diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1aea405..43ca3ae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,12 +5,12 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Set up go - uses: actions/setup-go@v4 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: - go-version: ">=1.24.3" + go-version-file: go.mod cache: false - name: Build diff --git a/.github/workflows/codesee-arch-diagram.yml b/.github/workflows/codesee-arch-diagram.yml index 806d41d..c97764d 100644 --- a/.github/workflows/codesee-arch-diagram.yml +++ b/.github/workflows/codesee-arch-diagram.yml @@ -17,7 +17,7 @@ jobs: continue-on-error: true name: Analyze the repo with CodeSee steps: - - uses: Codesee-io/codesee-action@v2 + - uses: Codesee-io/codesee-action@db076ce4b205a08da4d95bbefb3e278a958a4799 # v2 with: codesee-token: ${{ secrets.CODESEE_ARCH_DIAG_API_TOKEN }} codesee-url: https://app.codesee.io diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index fe15bbb..fee3f6e 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -5,13 +5,13 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: - go-version: ">=1.24.3" + go-version-file: go.mod cache: false - name: golangci-lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 continue-on-error: false with: - version: v1.64.3 + version: v2.9.0 diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index 889ebe2..22698df 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -30,7 +30,7 @@ jobs: steps: # Fetch project source with GitHub Actions Checkout. - - uses: actions/checkout@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 # Run the "semgrep ci" command on the command line of the docker image. - run: semgrep ci env: diff --git a/.golangci.yml b/.golangci.yml index bd0ee36..6e3e8a3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,11 +1,10 @@ -run: - timeout: 10m +version: "2" output: formats: - - format: colored-line-number + text: path: stdout linters: - enable-all: true + default: all disable: # Disabled because we use inits all over the place - gochecknoinits @@ -37,45 +36,76 @@ linters: - maintidx # We have legitimate use cases for returning an interface - ireturn - # deprecated - - tenv # Sometimes it's useful to return nil, nil - nilnil -linters-settings: - # See https://golangci-lint.run/usage/linters#revive - revive: + # Deprecated alias of wsl_v5; keep it off so only wsl_v5 runs (avoids a deprecation warning). + - wsl + settings: + ireturn: + allow: + - error + - generic + - stdlib + - empty + misspell: + mode: restricted + locale: US + # See https://golangci-lint.run/usage/linters#revive + revive: + rules: + # Use var-naming, but add a special rule to accept "Id" rather than forcing "ID" + - name: var-naming + arguments: + - - ID + - API + disabled: false + # v2 merged stylecheck into staticcheck; keep "all" checks but drop ST1003 (var-naming), matching the prior config. + staticcheck: + checks: + - all + - -ST1003 + varnamelen: + # The longest distance, in source lines, that is being considered a "small scope" where it is ok to use short variable names. + # The default is 5, which is quite strict. + max-distance: 15 + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling rules: - # Use var-naming, but add a special rule to accept "Id" rather than forcing "ID" - - name: var-naming - disabled: false - arguments: - - ["ID", "API"] - # See https://staticcheck.io/docs/configuration/options/#checks - staticcheck: - checks: ["all"] - stylecheck: - checks: ["all", "-ST1003"] - varnamelen: - # The longest distance, in source lines, that is being considered a "small scope" where it is ok to use short variable names. - # The default is 5, which is quite strict. - max-distance: 15 - ireturn: - allow: - - error - - generic - - stdlib - - empty - misspell: - locale: US - mode: restricted -issues: - exclude-rules: - - text: "don't use ALL_CAPS in Go names" - linters: - - revive - exclude-dirs: - - openapi - - scripts - - tmp - - playground - - testrun + - linters: + - revive + text: don't use ALL_CAPS in Go names + # `utils` is an established package name here; v2's revive flags it as "meaningless". + - linters: + - revive + text: avoid meaningless package names + paths: + - openapi + - scripts + - tmp + - playground + - testrun + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports + exclusions: + generated: lax + paths: + - openapi + - scripts + - tmp + - playground + - testrun + - third_party$ + - builtin$ + - examples$ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5061f3b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,109 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`amp` — the Ampersand CLI (Go, Cobra-based). It talks to the Ampersand API to manage +integrations, installations, connections, destinations, and projects, and provides local +webhook development tooling. User-facing docs: https://docs.withampersand.com/cli/overview + +## Build / lint / test + +Building requires [Task](https://taskfile.dev). The build bakes stage-specific config +(API URL, Clerk URL, login URL, version metadata) into the binary via `-ldflags -X`, so +you build *per stage* rather than with a plain `go build`. + +```sh +task build # alias for build-dev → produces bin/amp +task build-local # points API_URL at http://127.0.0.1:8080 +task build-staging +task build-prod +``` + +For local dev the README suggests symlinking `bin/amp` to `lamp` ("local amp") so it +coexists with an installed production `amp`. + +```sh +task lint # golangci-lint (config .golangci.yml) +task fix # wsl + gci + golangci-lint --fix (needs gci, wsl installed) +make fix-files FILES="cmd/a.go cmd/b.go" # format/vet specific files only + +go test ./... # run tests (only files/ has tests currently) +go test ./files -run TestGetRemovedReadObjects # run a single test +``` + +There is no `task test`; use `go test` directly. Linting is strict (`enable-all` minus a +curated disable list in `.golangci.yml`); the `openapi/` directory is excluded from lint. +Note revive is configured to accept `Id`/`Api` (not just `ID`/`API`). + +## Code generation + +`openapi/*.gen.go` are generated types (via `oapi-codegen`) pulled from remote specs in the +`amp-labs/openapi` repo — the manifest, problem, and catalog schemas. Regenerate with: + +```sh +cd openapi && make gen +``` + +Do not hand-edit the `.gen.go` files. The `Manifest`, `Integration`, `CatalogType`, etc. +types used throughout the code live in this generated `openapi` package. + +## Architecture + +**Command wiring (Cobra + Viper).** `main.go` → `cmd.Execute()` → `rootCmd` (in +`cmd/root.go`). Each command lives in its own file under `cmd/` and self-registers via an +`init()` that calls `rootCmd.AddCommand(...)`. Global persistent flags (`--debug/-d`, +`--project/-p`, `--key/-k`) are set up in `flags/config.go` (`flags.Init`) and bound to +Viper; `--key` also reads the `AMP_API_KEY` env var. Use the `flags` package helpers +(`GetProjectOrFail`, `GetAPIKey`, `GetOutputFormat`, `GetDebugMode`) rather than reading +Viper directly. + +**Stage configuration is build-time.** The `vars` package holds `ApiURL`, `ClerkRootURL`, +`LoginURL`, `Stage`, `Version`, etc., all defaulting to `"unset"` and overwritten at build +time by ldflags (see `Taskfile.yaml`). At runtime these can be overridden by env vars for +testing: `AMP_API_URL`, `AMP_CLERK_URL_OVERRIDE`, `AMP_STAGE_OVERRIDE`, `AMP_API_KEY`. + +**Authentication (two modes).** `request.APIClient.getAuthHeader` decides: +1. If an API key is present (`--key` / `AMP_API_KEY`) → sends `X-Api-Key`. +2. Otherwise → uses a Clerk JWT browser-login session. `amp login` performs the Clerk OAuth + flow and writes session data to the XDG config dir (`amp/jwt.json`, or + `amp/jwt-.json` for non-prod). The `clerk` package exchanges that stored session + for a fresh JWT on each request (`FetchJwt`). + +**API layer (`request/`).** `request.go` is the low-level HTTP client (`Client`) that adds +`X-Amp-Client*` headers, marshals/unmarshals JSON, and parses error responses. Non-2xx +responses with `application/problem+json` are decoded into `ProblemError` (RFC 7807). +`api.go` defines `APIClient` — one method per API endpoint (`ListIntegrations`, +`BatchUpsertIntegrations`, `GetPreSignedUploadURL`, etc.), each building a URL under +`{ApiURL}/v1/projects/{projectId}/...` and attaching the auth header. Response DTOs are in +`request/types.go`. + +**Deploy flow (`amp deploy`).** The most involved path, worth understanding end to end: +`files.Zip` locates and validates an `amp.yaml`/`amp.yml` manifest (parsed into the +generated `openapi.Manifest`, validated by `files/manifest.go` against `specVersion 1.0.0`), +zips it in memory → compute MD5 → `GetPreSignedUploadURL` → `storage.Upload` PUTs the zip to +GCS → `BatchUpsertIntegrations` is called with the resulting `gs://` URL. Before deploying, +`confirmReadObjectRemoval` compares the new manifest against existing integrations and, if +read objects were removed *and* there are live installations, interactively prompts (via +`promptui`) whether to pause reads (the `destructive` flag). + +**Local webhook development.** `amp listen` (hidden command) runs a local HTTP server that +pretty-prints and forwards incoming webhooks to your app, writing its chosen port to the OS +cache dir (`ampersand/webhook-port`). `amp trigger` sends fixture events (see +`internal/fixtures/`, e.g. hubspot/stripe payloads) to that listener; `sync-ngrok` bridges +to a public ngrok URL. Webhook helpers live in `internal/webhook/`. + +**Logging.** Use the `logger` package (`Info`, `Infof`, `Debugf`, `Fatal`, `FatalErr`). +`Debugf` output is gated on the `--debug` flag. Note: `logger` imports `flags`, so `flags` +must not import `logger` (there is an explicit circular-dependency workaround in +`flags/config.go`'s `GetProjectOrFail`). + +## Conventions + +- Command failures typically call `logger.Fatal`/`logger.FatalErr` (which `os.Exit(1)`), + rather than returning errors up the stack — follow the pattern of neighboring commands. +- Naming (`amp :`): list commands use `list:integrations`, + `list:installations`, etc.; deletes use `delete:integration`. Keep this colon style. +- Output format for list-style commands is controlled by a `--format/-f` flag (json|yaml); + wire it with `flags.InitAndBindFormatFlag` and render with `utils.WriteStruct`. diff --git a/appdata/appdata.go b/appdata/appdata.go index 57e833b..0be1ed5 100644 --- a/appdata/appdata.go +++ b/appdata/appdata.go @@ -11,9 +11,10 @@ import ( const fileName = "Ampersand/config.json" -// IMPORTANT: -// Do not modify the JSON labels in this struct without ensuring backwards compatibility, -// since those strings are written to the user's config file on their computer. +// Config is the user's persisted CLI configuration. +// +// IMPORTANT: Do not modify the JSON labels in this struct without ensuring backwards +// compatibility, since those strings are written to the user's config file on their computer. type Config struct { Token Token `json:"token"` } @@ -40,7 +41,9 @@ func Get() (Config, error) { } var c Config - if err := json.Unmarshal(data, &c); err != nil { + + err = json.Unmarshal(data, &c) + if err != nil { return Config{}, fmt.Errorf("can't parse config: %w", err) } @@ -56,7 +59,9 @@ func Set(config Config) error { } merged := config - if err := mergo.Merge(&merged, existing); err != nil { + + err = mergo.Merge(&merged, existing) + if err != nil { return fmt.Errorf("can't merge new config with existing config: %w", err) } diff --git a/clerk/clerk.go b/clerk/clerk.go index c893958..9a55cb8 100644 --- a/clerk/clerk.go +++ b/clerk/clerk.go @@ -162,7 +162,9 @@ func FetchJwt(ctx context.Context) (string, error) { //nolint:funlen,cyclop } data := &LoginData{} - if err := json.Unmarshal(contents, data); err != nil { + + err = json.Unmarshal(contents, data) + if err != nil { return "", fmt.Errorf("error unmarshalling jwt file: %w", err) } @@ -224,11 +226,13 @@ func FetchJwt(ctx context.Context) (string, error) { //nolint:funlen,cyclop } if rsp.StatusCode != http.StatusOK { - return "", fmt.Errorf("http %d (%s)", rsp.StatusCode, string(bb)) //nolint:goerr113 + return "", fmt.Errorf("http %d (%s)", rsp.StatusCode, string(bb)) //nolint:err113 } cr := &clientResponse{} - if err := json.Unmarshal(bb, cr); err != nil { + + err = json.Unmarshal(bb, cr) + if err != nil { return "", fmt.Errorf("error unmarshalling response body: %w", err) } @@ -274,7 +278,9 @@ func DecodeJWT(jwt string) (string, string, error) { func getHTML(emailStr string) (string, error) { // Render the HTML tmpl := mustache.New() - if err := tmpl.ParseString(HTML); err != nil { + + err := tmpl.ParseString(HTML) + if err != nil { return "", err } diff --git a/cmd/delete_installation.go b/cmd/delete_installation.go index 163b972..b64675c 100644 --- a/cmd/delete_installation.go +++ b/cmd/delete_installation.go @@ -14,7 +14,7 @@ var deleteInstallationCmd = &cobra.Command{ //nolint:gochecknoglobals Use: "delete:installation ", Short: "Delete installation", Long: "Delete installation", - Args: cobra.ExactArgs(2), //nolint:gomnd,mnd + Args: cobra.ExactArgs(2), //nolint:mnd Run: func(cmd *cobra.Command, args []string) { logger.Debug("Deleting installation") diff --git a/cmd/deploy.go b/cmd/deploy.go index f8722a8..0af5b5f 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -68,7 +68,8 @@ var deployCmd = &cobra.Command{ //nolint:gochecknoglobals logger.FatalErr("Unable to get pre-signed upload URL", err) } - if err := storage.Upload(cmd.Context(), zipResult.Data, signed.URL, md5String); err != nil { + err = storage.Upload(cmd.Context(), zipResult.Data, signed.URL, md5String) + if err != nil { logger.FatalErr("Unable to upload to Google Cloud Storage", err) } @@ -353,13 +354,17 @@ func formatGlobalPromptMessage(integrations []integrationRemovedObjectsInfo) str integrationWord := pluralizer.Pluralize("integration", len(integrations), false) message = fmt.Sprintf("⚠️ You are removing read action objects from %d %s:\n\n", len(integrations), integrationWord) + var lines strings.Builder + for _, info := range integrations { objectList := strings.Join(info.removedObjects, ", ") installationWord := pluralizer.Pluralize("installation", info.installationCount, false) - message += fmt.Sprintf(" • %s: %s (%d %s)\n", - info.integrationName, objectList, info.installationCount, installationWord) + lines.WriteString(fmt.Sprintf(" • %s: %s (%d %s)\n", + info.integrationName, objectList, info.installationCount, installationWord)) } + message += lines.String() + message += fmt.Sprintf( "\n\n❓ Do you want to stop reading these objects across all installations of these %d %s?\n\n"+ " Note: To stop reads for some integrations & not all, deploy changes to one integration at a time.", @@ -371,16 +376,16 @@ func formatGlobalPromptMessage(integrations []integrationRemovedObjectsInfo) str } func formatAffectedInstallations(groups []groupInfo, totalCount int) string { - var result string + var result strings.Builder for _, g := range groups { - result += fmt.Sprintf("\n - %s (%s)", g.name, g.ref) + result.WriteString(fmt.Sprintf("\n - %s (%s)", g.name, g.ref)) } if totalCount > len(groups) { - result += fmt.Sprintf("\n - and %d more", totalCount-len(groups)) + result.WriteString(fmt.Sprintf("\n - and %d more", totalCount-len(groups))) } - return result + return result.String() } func init() { diff --git a/cmd/deploy_destination.go b/cmd/deploy_destination.go index 3cabd48..93b3782 100644 --- a/cmd/deploy_destination.go +++ b/cmd/deploy_destination.go @@ -21,13 +21,16 @@ var deployDestinationCmd = &cobra.Command{ //nolint:gochecknoglobals Run: func(cmd *cobra.Command, args []string) { projectId := flags.GetProjectOrFail() apiKey := flags.GetAPIKey() + input := viper.GetString("input") if input == "" { logger.Fatal("Must provide an input file path") } var dest request.Destination - if _, err := utils.ReadStructFromFile(input, &dest); err != nil { + + _, err := utils.ReadStructFromFile(input, &dest) + if err != nil { logger.FatalErr("Unable to read destination file", err) } @@ -35,15 +38,15 @@ var deployDestinationCmd = &cobra.Command{ //nolint:gochecknoglobals oldDest := getOldDest(cmd.Context(), client, &dest) var output *request.Destination - var err error if oldDest == nil { output, err = client.CreateDestination(cmd.Context(), &dest) } else { patch := generatePatch(oldDest, &dest) if len(patch.UpdateMask) == 0 { - if err := utils.WriteStructToFile(viper.GetString("output"), - flags.GetOutputFormat(), oldDest); err != nil { + err := utils.WriteStructToFile(viper.GetString("output"), + flags.GetOutputFormat(), oldDest) + if err != nil { logger.FatalErr("Unable to write destination file", err) } @@ -57,8 +60,9 @@ var deployDestinationCmd = &cobra.Command{ //nolint:gochecknoglobals logger.FatalErr("Unable to deploy destination", err) } - if err := utils.WriteStructToFile(viper.GetString("output"), - flags.GetOutputFormat(), output); err != nil { + err = utils.WriteStructToFile(viper.GetString("output"), + flags.GetOutputFormat(), output) + if err != nil { logger.FatalErr("Unable to write destination file", err) } }, @@ -137,17 +141,20 @@ func findDestId(ctx context.Context, client *request.APIClient, dest *request.De func init() { deployDestinationCmd.Flags().StringP("input", "i", "", "The input file path") - if err := viper.BindPFlag("input", deployDestinationCmd.Flags().Lookup("input")); err != nil { + err := viper.BindPFlag("input", deployDestinationCmd.Flags().Lookup("input")) + if err != nil { logger.FatalErr("unable to bind flag", err) } deployDestinationCmd.Flags().StringP("output", "o", "-", "The output file path") - if err := viper.BindPFlag("output", deployDestinationCmd.Flags().Lookup("output")); err != nil { + err = viper.BindPFlag("output", deployDestinationCmd.Flags().Lookup("output")) + if err != nil { logger.FatalErr("unable to bind flag", err) } - if err := flags.InitAndBindFormatFlag(deployDestinationCmd); err != nil { + err = flags.InitAndBindFormatFlag(deployDestinationCmd) + if err != nil { logger.FatalErr("unable to bind flag", err) } diff --git a/cmd/init.go b/cmd/init.go index 720bcfd..b1d18bc 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -67,24 +67,28 @@ var initCmd = &cobra.Command{ //nolint:gochecknoglobals disp := "my integration" var integ openapi.Integration + integ.Name = name integ.DisplayName = disp integ.Provider = provider.Name if provider.Support.Read { - if err := setupRead(&integ, provider); err != nil { + err := setupRead(&integ, provider) + if err != nil { logger.FatalErr("Unable to setup read", err) } } if provider.Support.Write { - if err := setupWrite(&integ, provider); err != nil { + err := setupWrite(&integ, provider) + if err != nil { logger.FatalErr("Unable to setup write", err) } } if provider.Support.Proxy { - if err := setupProxy(&integ, provider); err != nil { + err := setupProxy(&integ, provider) + if err != nil { logger.FatalErr("Unable to setup proxy", err) } } @@ -104,7 +108,8 @@ var initCmd = &cobra.Command{ //nolint:gochecknoglobals logger.FatalErr("Unable to marshal manifest", err) } - if err := os.WriteFile("amp.yaml", ys, YamlFileMode); err != nil { //nolint:gosec + err = os.WriteFile("amp.yaml", ys, YamlFileMode) //nolint:gosec + if err != nil { logger.FatalErr("Unable to write manifest to file", err) } @@ -145,7 +150,8 @@ func setupWrite(integ *openapi.Integration, provider *openapi.ProviderInfo) erro write := &openapi.IntegrationWrite{} for { - if err := addWriteObject(write, provider); err != nil { + err := addWriteObject(write, provider) + if err != nil { return err } @@ -211,9 +217,10 @@ func getIntegrationField() (*openapi.IntegrationField, error) { //nolint:funlen, } if !remap { - if err := field.FromIntegrationFieldExistent(openapi.IntegrationFieldExistent{ + err := field.FromIntegrationFieldExistent(openapi.IntegrationFieldExistent{ FieldName: fieldName, - }); err != nil { + }) + if err != nil { return nil, err } @@ -264,7 +271,8 @@ func getIntegrationField() (*openapi.IntegrationField, error) { //nolint:funlen, mapping.Prompt = &promptText } - if err := field.FromIntegrationFieldMapping(mapping); err != nil { + err = field.FromIntegrationFieldMapping(mapping) + if err != nil { return nil, err } @@ -302,7 +310,8 @@ func addReadObject(read *openapi.IntegrationRead, provider *openapi.ProviderInfo } if wantBackfill { - if err := setupBackfill(obj); err != nil { + err := setupBackfill(obj) + if err != nil { return err } } @@ -416,7 +425,8 @@ func setupRead(integ *openapi.Integration, provider *openapi.ProviderInfo) error read := &openapi.IntegrationRead{} for { - if err := addReadObject(read, provider); err != nil { + err := addReadObject(read, provider) + if err != nil { return err } @@ -455,7 +465,8 @@ func promptString(prompt string, validate ...func(string) error) (string, error) Label: prompt, Validate: func(s string) error { for _, fn := range validate { - if err := fn(s); err != nil { + err := fn(s) + if err != nil { return err } } diff --git a/cmd/list_destinations.go b/cmd/list_destinations.go index e2285f1..32ba1ad 100644 --- a/cmd/list_destinations.go +++ b/cmd/list_destinations.go @@ -38,6 +38,7 @@ var listDestinationsCmd = &cobra.Command{ //nolint:gochecknoglobals if dest.Metadata.URL != "" { output += " (" + dest.Metadata.URL + ")" } + logger.Info(output) } }, diff --git a/cmd/list_installations.go b/cmd/list_installations.go index b603672..a37dcc1 100644 --- a/cmd/list_installations.go +++ b/cmd/list_installations.go @@ -16,7 +16,7 @@ var listInstallationsCmd = &cobra.Command{ //nolint:gochecknoglobals Use: "list:installations ", Short: "List installations", Long: "List installations", - Args: cobra.ExactArgs(1), //nolint:gomnd,mnd + Args: cobra.ExactArgs(1), //nolint:mnd Run: func(cmd *cobra.Command, args []string) { integrationId := args[0] projectId := flags.GetProjectOrFail() diff --git a/cmd/listen.go b/cmd/listen.go index cc8143d..a8af1f4 100644 --- a/cmd/listen.go +++ b/cmd/listen.go @@ -52,7 +52,9 @@ func runListen(cmd *cobra.Command, args []string) error { mux.HandleFunc("/", handleWebhook) // Start the server - listener, err := net.Listen("tcp", listenAddr) + var lc net.ListenConfig + + listener, err := lc.Listen(ctx, "tcp", listenAddr) if err != nil { return fmt.Errorf("failed to start listener: %w", err) } @@ -66,11 +68,14 @@ func runListen(cmd *cobra.Command, args []string) error { port := strconv.Itoa(addr.Port) // Save the port - if err := saveListenerPort(port); err != nil { + + err = saveListenerPort(port) + if err != nil { logger.FatalErr("failed to save port", err) } const serverTimeout = 10 * time.Second + srv := &http.Server{ Handler: mux, ReadHeaderTimeout: serverTimeout, @@ -80,7 +85,8 @@ func runListen(cmd *cobra.Command, args []string) error { go func() { logger.Info("starting webhook listener") - if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + err := srv.Serve(listener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { logger.FatalErr("webhook listener failed", err) } }() @@ -96,11 +102,13 @@ func runListen(cmd *cobra.Command, args []string) error { // Create a deadline to wait for current connections to complete const shutdownTimeout = 5 * time.Second + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer cancel() - if err := srv.Shutdown(shutdownCtx); err != nil { + err = srv.Shutdown(shutdownCtx) + if err != nil { logger.FatalErr("shutdown error", err) } @@ -127,7 +135,8 @@ func saveListenerPort(port string) error { // Create ampersand directory if it doesn't exist ampDir := filepath.Join(dir, "ampersand") - if err := os.MkdirAll(ampDir, mkdirPerm); err != nil { + err = os.MkdirAll(ampDir, mkdirPerm) + if err != nil { return err } @@ -168,7 +177,9 @@ func handleWebhook(writer http.ResponseWriter, req *http.Request) { req.Body.Close() // Log the webhook payload - if err := webhook.PrettyPrintJSON(body); err != nil { + + err = webhook.PrettyPrintJSON(body) + if err != nil { logger.FatalErr("error pretty printing JSON", err) } @@ -194,6 +205,7 @@ func handleWebhook(writer http.ResponseWriter, req *http.Request) { // Forward the request const clientTimeout = 5 * time.Second + client := &http.Client{Timeout: clientTimeout} resp, err := client.Do(forwardReq) @@ -216,7 +228,8 @@ func handleWebhook(writer http.ResponseWriter, req *http.Request) { writer.WriteHeader(resp.StatusCode) - if _, err := io.Copy(writer, resp.Body); err != nil { + _, err = io.Copy(writer, resp.Body) + if err != nil { logger.FatalErr("error copying response", err) } } diff --git a/cmd/login.go b/cmd/login.go index b24652e..f71d34b 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -79,13 +79,16 @@ const JwtFilePermissions = 0o600 // processLogin takes the JWT token, verifies it, and then stores it in the jwt.json file. func processLogin(ctx context.Context, payload []byte, write bool) (string, string, error) { //nolint:cyclop data := &clerk.LoginData{} - if err := json.Unmarshal(payload, data); err != nil { + + err := json.Unmarshal(payload, data) + if err != nil { return "", "", err } path := clerk.GetJwtPath() if write { - if err := os.WriteFile(path, pretty.Pretty(payload), JwtFilePermissions); err != nil { + err := os.WriteFile(path, pretty.Pretty(payload), JwtFilePermissions) + if err != nil { return "", "", err } } @@ -170,7 +173,8 @@ func canOpenBrowserLinux() bool { return false } - if _, err := exec.LookPath("xdg-open"); err != nil { + _, err := exec.LookPath("xdg-open") + if err != nil { logger.Info("'xdg-open' command not found, cannot open browser automatically.") return false @@ -185,7 +189,8 @@ func canOpenBrowserDarwin() bool { return false } - if _, err := exec.LookPath("open"); err != nil { + _, err := exec.LookPath("open") + if err != nil { logger.Info("'open' command not found, cannot open browser automatically.") return false @@ -200,7 +205,8 @@ func canOpenBrowserWindows() bool { return false } - if _, err := exec.LookPath("rundll32"); err != nil { + _, err := exec.LookPath("rundll32") + if err != nil { logger.Info("'rundll32' command not found, cannot open browser automatically.") return false @@ -213,15 +219,17 @@ func canOpenBrowserWindows() bool { func openBrowser(url string) { var err error + // The browser launch is intentionally detached from any request context: it must + // outlive this command, so we use context.Background() rather than a cancelable ctx. switch runtime.GOOS { case "linux": - err = exec.Command("xdg-open", url).Start() + err = exec.CommandContext(context.Background(), "xdg-open", url).Start() case OSWindows: - err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + err = exec.CommandContext(context.Background(), "rundll32", "url.dll,FileProtocolHandler", url).Start() case "darwin": - err = exec.Command("open", url).Start() + err = exec.CommandContext(context.Background(), "open", url).Start() default: - err = fmt.Errorf("unsupported platform: %s", runtime.GOOS) //nolint:goerr113 + err = fmt.Errorf("unsupported platform: %s", runtime.GOOS) //nolint:err113 } if err != nil { diff --git a/cmd/logout.go b/cmd/logout.go index 26c9663..916cdaf 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -35,7 +35,8 @@ func DoLogout(showLogs bool) { } } - if err := os.Remove(path); err != nil { + err = os.Remove(path) + if err != nil { logger.Fatal(err.Error()) } diff --git a/cmd/my_info.go b/cmd/my_info.go index e268283..4ce3c04 100644 --- a/cmd/my_info.go +++ b/cmd/my_info.go @@ -40,14 +40,17 @@ var myInfoCmd = &cobra.Command{ //nolint:gochecknoglobals } format := flags.GetOutputFormat() - if err := utils.WriteStruct(os.Stdout, format, info); err != nil { + + err = utils.WriteStruct(os.Stdout, format, info) + if err != nil { logger.FatalErr("Unable to write user info", err) } }, } func init() { - if err := flags.InitAndBindFormatFlag(myInfoCmd); err != nil { + err := flags.InitAndBindFormatFlag(myInfoCmd) + if err != nil { logger.FatalErr("unable to initialize flags", err) } diff --git a/cmd/sync_ngrok.go b/cmd/sync_ngrok.go index 5eb4b5f..0565786 100644 --- a/cmd/sync_ngrok.go +++ b/cmd/sync_ngrok.go @@ -232,7 +232,8 @@ func getPublicNgrokURL(ctx context.Context) (string, error) { // Ensure response body is properly closed to prevent resource leaks defer func() { - if closeErr := resp.Body.Close(); closeErr != nil { + closeErr := resp.Body.Close() + if closeErr != nil { _, _ = fmt.Fprintf(os.Stderr, "error closing response body: %v\n", closeErr) } }() @@ -248,7 +249,8 @@ func getPublicNgrokURL(ctx context.Context) (string, error) { decoder := json.NewDecoder(resp.Body) - if err := decoder.Decode(&ngrokResp); err != nil { + err = decoder.Decode(&ngrokResp) + if err != nil { // JSON parsing errors indicate malformed response from ngrok return "", fmt.Errorf("failed to parse ngrok response: %w", err) } @@ -309,7 +311,9 @@ func waitForNgrok(ctx context.Context) error { } // Perform initial connectivity check to see if ngrok is already running - conn, err := net.DialTimeout("tcp", address, connectTimeout) + dialer := net.Dialer{Timeout: connectTimeout} + + conn, err := dialer.DialContext(ctx, "tcp", address) if err == nil { // ngrok is already available, close connection and return immediately _ = conn.Close() @@ -349,7 +353,7 @@ func waitForNgrok(ctx context.Context) error { } // Attempt to connect to ngrok API endpoint - conn, err = net.DialTimeout("tcp", address, connectTimeout) + conn, err = dialer.DialContext(ctx, "tcp", address) if err == nil { // Success! ngrok is now available _ = conn.Close() @@ -370,7 +374,8 @@ func waitForNgrok(ctx context.Context) error { // This function is called by the Cobra framework when the ngrok command is invoked. func runSyncNgrok(cmd *cobra.Command, _ []string) error { // Validate protocol flag before proceeding - if err := validateProtocol(); err != nil { + err := validateProtocol() + if err != nil { return err } @@ -431,7 +436,8 @@ func getNgrokTunnelURL(ctx context.Context) (string, error) { // Step 1: Wait for ngrok service to become available logger.Info("waiting for ngrok to start...") - if err := waitForNgrok(ctx); err != nil { + err := waitForNgrok(ctx) + if err != nil { // Provide helpful context about ngrok not being available return "", fmt.Errorf("ngrok is not running: %w", err) } @@ -514,7 +520,9 @@ func updateDestinations(ctx context.Context, client *request.APIClient, } // Attempt to update the destination via API - if err := updateDestination(ctx, client, dest, publicURL); err != nil { + + err = updateDestination(ctx, client, dest, publicURL) + if err != nil { // Log API update failures but continue with other destinations logger.Infof("failed to update destination %s: %v", dest.NameOrId(), err) diff --git a/cmd/trigger.go b/cmd/trigger.go index 0abf5b7..443db71 100644 --- a/cmd/trigger.go +++ b/cmd/trigger.go @@ -71,8 +71,10 @@ func runTrigger(cmd *cobra.Command, args []string) error { payload = []byte(rawJSON) // Validate it's valid JSON - var jsonObj interface{} - if err := json.Unmarshal(payload, &jsonObj); err != nil { + var jsonObj any + + err := json.Unmarshal(payload, &jsonObj) + if err != nil { return fmt.Errorf("invalid JSON provided: %w", err) } case fixtureFile != "": @@ -91,7 +93,7 @@ func runTrigger(cmd *cobra.Command, args []string) error { // Let user edit the payload if requested if interactive { - payload, err = openInEditor(payload) + payload, err = openInEditor(cmd.Context(), payload) if err != nil { return fmt.Errorf("failed to edit payload: %w", err) } @@ -104,7 +106,7 @@ func runTrigger(cmd *cobra.Command, args []string) error { } // openInEditor opens the JSON payload in the default editor. -func openInEditor(data []byte) ([]byte, error) { +func openInEditor(ctx context.Context, data []byte) ([]byte, error) { // Create a temporary file tmpFile, err := os.CreateTemp("", "amp-webhook-*.json") if err != nil { @@ -113,11 +115,14 @@ func openInEditor(data []byte) ([]byte, error) { defer os.Remove(tmpFile.Name()) // Write the data to the file - if _, err := tmpFile.Write(data); err != nil { + + _, err = tmpFile.Write(data) + if err != nil { return nil, err } - if err := tmpFile.Close(); err != nil { + err = tmpFile.Close() + if err != nil { return nil, err } @@ -131,13 +136,17 @@ func openInEditor(data []byte) ([]byte, error) { } } - // Open the editor - cmd := exec.Command(editor, tmpFile.Name()) + // Open the editor. The command is the user's own $EDITOR (or a static + // vi/notepad fallback) and runs locally as the invoking user, so there is + // no untrusted input and no injection surface here. + // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command + cmd := exec.CommandContext(ctx, editor, tmpFile.Name()) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + err = cmd.Run() + if err != nil { return nil, err } @@ -159,6 +168,7 @@ func sendWebhook(payload []byte) error { // Send the request const clientTimeout = 5 * time.Second + client := &http.Client{Timeout: clientTimeout} resp, err := client.Do(req) diff --git a/cmd/validate.go b/cmd/validate.go new file mode 100644 index 0000000..e4a69f3 --- /dev/null +++ b/cmd/validate.go @@ -0,0 +1,201 @@ +package cmd + +import ( + "context" + "errors" + "os" + + ampyaml "github.com/amp-labs/amp-yaml-validator" + "github.com/amp-labs/amp-yaml-validator/catalog" + "github.com/amp-labs/cli/files" + "github.com/amp-labs/cli/flags" + "github.com/amp-labs/cli/logger" + "github.com/amp-labs/cli/request" + "github.com/amp-labs/cli/validate" + "github.com/spf13/cobra" +) + +var ( + validateStrict bool + validateSkipProvider bool + validateSkipAsync bool +) + +var validateCmd = &cobra.Command{ //nolint:gochecknoglobals + Use: "validate [ampYamlSourcePath]", + Short: "Validate an amp.yaml manifest", + Long: "Validate an amp.yaml manifest without deploying it.\n\n" + + "You can provide a path to the folder that contains amp.yaml or a path to the file " + + "itself; if omitted the current directory is used.\n\n" + + "When a project is configured (via --project), destinations and provider apps " + + "referenced by the manifest are checked against your Ampersand project. Without a " + + "project only schema and best-practice checks run.", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + source := "." + if len(args) > 0 { + source = args[0] + } + + manifestPath, err := files.FindManifestFile(source) + if err != nil { + if errors.Is(err, files.ErrBadManifest) { + logger.Fatal(err.Error()) + } + + logger.FatalErr("Unable to locate manifest", err) + } + + result, err := ampyaml.ValidateFile(cmd.Context(), manifestPath, buildValidateOptions(cmd.Context())...) + if err != nil { + logger.FatalErr("Unable to validate manifest", err) + } + + printValidationResult(manifestPath, result) + + if !result.Valid { + os.Exit(1) + } + }, +} + +// buildValidateOptions assembles the validator options from the command flags and, +// when a project is configured, the API-backed checkers. +func buildValidateOptions(ctx context.Context) []ampyaml.Option { + var opts []ampyaml.Option + + if validateStrict { + opts = append(opts, ampyaml.WithStrictMode(true)) + } + + if validateSkipProvider { + opts = append(opts, ampyaml.WithSkipProviderValidation()) + } + + if validateSkipAsync { + opts = append(opts, ampyaml.WithSkipAsyncValidation()) + } + + opts = append(opts, catalogOption(ctx)) + opts = append(opts, apiCheckerOptions(ctx)...) + + return opts +} + +// catalogOption backs provider/module/capability validation with the live ("dynamic") +// provider catalog fetched from the API, which changes several times a day. The +// catalog endpoint is public, so this runs regardless of whether a project is +// configured. If the fetch fails (e.g. offline), it degrades gracefully to the +// catalog embedded in the connectors library. +func catalogOption(ctx context.Context) ampyaml.Option { + catProvider, err := validate.NewCatalogProvider(ctx) + if err != nil { + logger.Debugf("Unable to fetch the live provider catalog, "+ + "falling back to the embedded catalog: %v", err) + + return ampyaml.WithCatalogProvider(catalog.NewDefaultCatalogProvider()) + } + + return ampyaml.WithCatalogProvider(catProvider) +} + +// apiCheckerOptions wires the destination and provider-app checkers to the Ampersand +// API. If no project is configured these checks are skipped, and the validator falls +// back to emitting reminder warnings instead of hard errors. Failures fetching either +// list are logged (debug) and treated as "checker unavailable" rather than fatal so +// that offline/schema validation still succeeds. +func apiCheckerOptions(ctx context.Context) []ampyaml.Option { + projectID := flags.GetProject() + if projectID == "" { + logger.Debugf("No project configured; skipping destination and provider-app checks. " + + "Pass --project to validate these against your Ampersand project.") + + return nil + } + + apiKey := flags.GetAPIKey() + client := request.NewAPIClient(projectID, &apiKey) + + var opts []ampyaml.Option + + destChecker, err := validate.NewDestinationChecker(ctx, client) + if err != nil { + logger.Debugf("Unable to fetch destinations for validation, skipping destination checks: %v", err) + } else { + opts = append(opts, ampyaml.WithDestinationChecker(destChecker)) + } + + appChecker, err := validate.NewProviderAppChecker(ctx, client) + if err != nil { + logger.Debugf("Unable to fetch provider apps for validation, skipping provider-app checks: %v", err) + } else { + opts = append(opts, ampyaml.WithProviderAppChecker(appChecker)) + } + + return opts +} + +func printValidationResult(manifestPath string, result *ampyaml.ValidationResult) { + logger.Infof("Validating: %s", manifestPath) + + if result.Valid && len(result.Warnings) == 0 { + logger.Info("✓ Validation passed with no issues!") + + return + } + + if len(result.Errors) > 0 { + logger.Infof("\n✗ Errors (%d):", len(result.Errors)) + + for i, issue := range result.Errors { + printValidationIssue(i+1, issue) + } + } + + if len(result.Warnings) > 0 { + logger.Infof("\n⚠ Warnings (%d):", len(result.Warnings)) + + for i, issue := range result.Warnings { + printValidationIssue(i+1, issue) + } + } + + logger.Info("") + + if result.Valid { + logger.Infof("✓ Validation passed with %d warning(s)", len(result.Warnings)) + } else { + logger.Infof("✗ Validation failed with %d error(s) and %d warning(s)", + len(result.Errors), len(result.Warnings)) + } +} + +func printValidationIssue(num int, issue ampyaml.ValidationIssue) { + logger.Infof("\n %d. [%s] %s", num, issue.Rule, issue.Message) + + if issue.Path != "" { + logger.Infof(" Path: %s", issue.Path) + } + + if issue.Line > 0 { + if issue.Column > 0 { + logger.Infof(" Location: line %d, column %d", issue.Line, issue.Column) + } else { + logger.Infof(" Location: line %d", issue.Line) + } + } + + if issue.Suggestion != "" { + logger.Infof(" Suggestion: %s", issue.Suggestion) + } +} + +func init() { + validateCmd.Flags().BoolVar(&validateStrict, "strict", false, "Treat warnings as errors") + validateCmd.Flags().BoolVar(&validateSkipProvider, "skip-provider", false, + "Skip provider-specific validation") + validateCmd.Flags().BoolVar(&validateSkipAsync, "skip-async", false, + "Skip async error-prevention validation") + + rootCmd.AddCommand(validateCmd) +} diff --git a/files/manifest.go b/files/manifest.go index e66732b..337df23 100644 --- a/files/manifest.go +++ b/files/manifest.go @@ -14,17 +14,18 @@ const manifestVersion = "1.0.0" func ParseManifest(yamlData []byte) (*openapi.Manifest, error) { manifest := &openapi.Manifest{} - if err := yaml.Unmarshal(yamlData, manifest); err != nil { + err := yaml.Unmarshal(yamlData, manifest) + if err != nil { return nil, fmt.Errorf("failed to parse yaml: %w", err) } return manifest, nil } -// nolint: goerr113 +// nolint: err113 func validationError(tracker *pathTracker, msg string, args ...any) error { err1 := fmt.Errorf(msg, args...) - err2 := fmt.Errorf("The validation error happened at the %s", tracker.String()) //nolint:stylecheck + err2 := fmt.Errorf("The validation error happened at the %s", tracker.String()) //nolint:staticcheck return errors.Join(ErrBadManifest, err1, err2) } @@ -47,7 +48,8 @@ func ValidateManifest(manifest *openapi.Manifest) error { } for idx, integ := range manifest.Integrations { - if err := validateIntegration(integ, tracker.PushObj("integrations").PushArr(idx)); err != nil { + err := validateIntegration(integ, tracker.PushObj("integrations").PushArr(idx)) + if err != nil { return err } } @@ -168,29 +170,33 @@ func validateSubscribe(sub *openapi.IntegrationSubscribe, path *pathTracker) err } if obj.AssociationChangeEvent != nil { - if err := validateSubscribeAssocChange(obj.AssociationChangeEvent, - path.PushArr(idx).PushObj("associationChangeEvent")); err != nil { + err := validateSubscribeAssocChange(obj.AssociationChangeEvent, + path.PushArr(idx).PushObj("associationChangeEvent")) + if err != nil { return err } } if obj.CreateEvent != nil { - if err := validateSubscribeCreateEvent(obj.CreateEvent, - path.PushArr(idx).PushObj("createEvent")); err != nil { + err := validateSubscribeCreateEvent(obj.CreateEvent, + path.PushArr(idx).PushObj("createEvent")) + if err != nil { return err } } if obj.UpdateEvent != nil { - if err := validateSubscribeUpdateEvent(obj.UpdateEvent, - path.PushArr(idx).PushObj("updateEvent")); err != nil { + err := validateSubscribeUpdateEvent(obj.UpdateEvent, + path.PushArr(idx).PushObj("updateEvent")) + if err != nil { return err } } if obj.DeleteEvent != nil { - if err := validateSubscribeDeleteEvent(obj.DeleteEvent, - path.PushArr(idx).PushObj("deleteEvent")); err != nil { + err := validateSubscribeDeleteEvent(obj.DeleteEvent, + path.PushArr(idx).PushObj("deleteEvent")) + if err != nil { return err } } @@ -213,25 +219,29 @@ func validateIntegration(integration openapi.Integration, path *pathTracker) err } if integration.Proxy != nil { - if err := validateProxy(integration.Proxy, path.PushObj("proxy")); err != nil { + err := validateProxy(integration.Proxy, path.PushObj("proxy")) + if err != nil { return err } } if integration.Read != nil { - if err := validateRead(integration.Read, path.PushObj("read")); err != nil { + err := validateRead(integration.Read, path.PushObj("read")) + if err != nil { return err } } if integration.Write != nil { - if err := validateWrite(integration.Write, path.PushObj("write")); err != nil { + err := validateWrite(integration.Write, path.PushObj("write")) + if err != nil { return err } } if integration.Subscribe != nil { - if err := validateSubscribe(integration.Subscribe, path.PushObj("subscribe")); err != nil { + err := validateSubscribe(integration.Subscribe, path.PushObj("subscribe")) + if err != nil { return err } } diff --git a/files/zip.go b/files/zip.go index 1ee55a4..8eb19cd 100644 --- a/files/zip.go +++ b/files/zip.go @@ -12,7 +12,7 @@ import ( "github.com/amp-labs/cli/openapi" ) -var ErrBadManifest = errors.New("Invalid manifest") //nolint:stylecheck +var ErrBadManifest = errors.New("Invalid manifest") //nolint:staticcheck const ( mode = 0o644 @@ -82,6 +82,38 @@ func getZipDir(source string) (string, error) { return sourceDir, nil } +// FindManifestFile resolves source to the path of a manifest file to validate. +// An explicit file path is returned as-is regardless of its name; a directory is +// searched for the conventional amp.yaml/amp.yml manifest. It returns +// ErrBadManifest if the source does not resolve to a manifest file. +func FindManifestFile(source string) (string, error) { + info, err := os.Stat(source) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("%w: source %q does not exist", ErrBadManifest, source) + } + + return "", fmt.Errorf("%w: stat %q: %w", ErrBadManifest, source, err) + } + + // An explicit file path is used as-is, whatever it's named. + if !info.IsDir() { + return source, nil + } + + // A directory is searched for the conventional manifest file names. + for _, name := range []string{yamlName, yamlAltName} { + candidate := filepath.Join(source, name) + + fi, statErr := os.Stat(candidate) + if statErr == nil && !fi.IsDir() { + return candidate, nil + } + } + + return "", fmt.Errorf("%w: no %s or %s found in %q", ErrBadManifest, yamlName, yamlAltName, source) +} + func statYaml() (os.FileInfo, error) { yamlStat, err := os.Stat(yamlName) if err != nil { //nolint:nestif @@ -132,7 +164,8 @@ func importYaml(writer *zip.Writer) (*openapi.Manifest, error) { return nil, fmt.Errorf("error parsing manifest: %w", err) } - if err := ValidateManifest(manifest); err != nil { + err = ValidateManifest(manifest) + if err != nil { return nil, err } @@ -170,7 +203,8 @@ func Zip(source string) (*ZipResult, error) { // nolint:funlen,cyclop manifest = m - if err := writer.Close(); err != nil { + err = writer.Close() + if err != nil { return fmt.Errorf("error closing zip writer: %w", err) } diff --git a/flags/config.go b/flags/config.go index 71a81cd..7f4ec32 100644 --- a/flags/config.go +++ b/flags/config.go @@ -20,19 +20,23 @@ func Init(rootCmd *cobra.Command) error { rootCmd.PersistentFlags().StringP("project", "p", "", "Ampersand project name or ID") rootCmd.PersistentFlags().StringP("key", "k", "", "Ampersand API key") - if err := viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug")); err != nil { + err := viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug")) + if err != nil { return err } - if err := viper.BindPFlag("project", rootCmd.PersistentFlags().Lookup("project")); err != nil { + err = viper.BindPFlag("project", rootCmd.PersistentFlags().Lookup("project")) + if err != nil { return err } - if err := viper.BindPFlag("key", rootCmd.PersistentFlags().Lookup("key")); err != nil { + err = viper.BindPFlag("key", rootCmd.PersistentFlags().Lookup("key")) + if err != nil { return err } - if err := viper.BindEnv("key", "AMP_API_KEY"); err != nil { + err = viper.BindEnv("key", "AMP_API_KEY") + if err != nil { panic(err) } @@ -43,7 +47,8 @@ func Init(rootCmd *cobra.Command) error { func InitAndBindFormatFlag(cmd *cobra.Command) error { cmd.Flags().StringP("format", "f", "json", "Output format, defaults to json. Options: json, yaml") - if err := viper.BindPFlag("format", cmd.Flags().Lookup("format")); err != nil { + err := viper.BindPFlag("format", cmd.Flags().Lookup("format")) + if err != nil { return err } @@ -65,6 +70,13 @@ func GetDebugMode() bool { return viper.GetBool("debug") } +// GetProject returns the configured project name or ID, or an empty string if +// none is set. Unlike GetProjectOrFail it does not exit, so callers that can +// operate without a project (e.g. offline validation) can degrade gracefully. +func GetProject() string { + return viper.GetString("project") +} + func GetProjectOrFail() string { p := viper.GetString("project") if p == "" { diff --git a/go.mod b/go.mod index 91b558a..1d5d0f5 100644 --- a/go.mod +++ b/go.mod @@ -1,47 +1,87 @@ module github.com/amp-labs/cli -go 1.24.3 +go 1.26.4 require ( github.com/adrg/xdg v0.5.3 github.com/alexkappa/mustache v1.0.0 + github.com/amp-labs/amp-yaml-validator v0.0.0-20260710012220-1b2da277247a + github.com/amp-labs/connectors v0.0.0-20260709182246-d7debc759a25 github.com/buildkite/shellwords v1.0.1 github.com/clerkinc/clerk-sdk-go v1.49.1 github.com/gertd/go-pluralize v0.2.1 github.com/imdario/mergo v0.3.15 github.com/manifoldco/promptui v0.9.0 - github.com/oapi-codegen/runtime v1.4.0 + github.com/oapi-codegen/runtime v1.4.2 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/tidwall/pretty v1.2.1 - golang.org/x/term v0.38.0 + golang.org/x/term v0.45.0 sigs.k8s.io/yaml v1.6.0 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect ) require ( + github.com/adhocore/gronx v1.20.0 // indirect + github.com/amp-labs/amp-common v0.0.0-20260707211901-527fea45abf2 // indirect + github.com/antchfx/xmlquery v1.5.1 // indirect + github.com/antchfx/xpath v1.3.6 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.42.1 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.29 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.28 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.4.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 // indirect + github.com/aws/smithy-go v1.27.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chzyer/readline v1.5.1 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/go-jose/go-jose/v3 v3.0.5 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator v9.31.0+incompatible // indirect + github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rogpeppe/go-internal v1.11.0 // indirect - github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/neilotoole/slogt v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect + github.com/spyzhov/ajson v0.9.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/log v0.20.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.46.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b517b39..6e11579 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,56 @@ github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/adhocore/gronx v1.20.0 h1:PD13Mo0wekkZ7ZZR9yb1TqeqTfybs7/K3ez9DmjQwEs= +github.com/adhocore/gronx v1.20.0/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/alexkappa/mustache v1.0.0 h1:GeF7AKKpKKVq8emIwYRQPsKPDWSGYQGWWSsddge62M4= github.com/alexkappa/mustache v1.0.0/go.mod h1:6v0WNoCZEQ8K5OZAv82ScIARg2bDqFD+Jl0LWxnApas= +github.com/amp-labs/amp-common v0.0.0-20260707211901-527fea45abf2 h1:dB/G6GhDAIgHd7/d+VXM/TJRrJNx4+FfwAOrKVmWWxY= +github.com/amp-labs/amp-common v0.0.0-20260707211901-527fea45abf2/go.mod h1:7O1Z5z2NC0E6o1NJ8fG58RqjKodzv6i1se8kMSo3H5U= +github.com/amp-labs/amp-yaml-validator v0.0.0-20260710012220-1b2da277247a h1:6w9YJarGWrN7vDCM5MTPkDQtXeMldxBfhH0x2rNiGnk= +github.com/amp-labs/amp-yaml-validator v0.0.0-20260710012220-1b2da277247a/go.mod h1:+L9UNaPZ/xkXqbEcejB90onOvy2rLS3IPjtFsEBXK8A= +github.com/amp-labs/connectors v0.0.0-20260709182246-d7debc759a25 h1:BB59qykwmWJjtvzC4943m33bjX7m3V/l8PJQb7WRLSQ= +github.com/amp-labs/connectors v0.0.0-20260709182246-d7debc759a25/go.mod h1:Cg9e7T0k1OF35+9vQtNlS5ADGTWfoOS6D95800dSV1k= +github.com/antchfx/xmlquery v1.5.1 h1:T9I4Ns1EXiWHy0IqKupGhnfTQtJwlGrpXtauYOoNv78= +github.com/antchfx/xmlquery v1.5.1/go.mod h1:bVqnl7TaDXSReKINrhZz+2E/PbCu2tUahb+wZ7WZNT8= +github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI= +github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= +github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= +github.com/aws/aws-sdk-go-v2/config v1.32.29 h1:BcMHHnpiWKogf+gGfpj3K1w+Sktz29XDo/cPSAPO3FU= +github.com/aws/aws-sdk-go-v2/config v1.32.29/go.mod h1:+Kbhn8Es4kPUph3F/0W7avykytc+Jh2Ld9/msv9ljV4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.28 h1:zTXJSsNcoO91/mTXsZoYf0AK8dvNPiA58/VtyGXR+wM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.28/go.mod h1:Kd9E0JzDBW/q1xbsHFrev/GnbAf5J0Ng8xoyc7HZ91Q= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk= +github.com/aws/aws-sdk-go-v2/service/signin v1.4.0 h1:sLzmJGCMv+C8KqiJgEqDLB6vxaJGmobRh4rr//ZpA3w= +github.com/aws/aws-sdk-go-v2/service/signin v1.4.0/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg= +github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 h1:qjMmry/cBDee1E/2gyvel0uRYCi3mwRZ2hf6N+GAodo= +github.com/aws/aws-sdk-go-v2/service/sso v1.32.0/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 h1:fpOlDPI55HdszaxapEGk6HsGosOUaM2YPWJpjMgp8UI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84= +github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 h1:bLZ0PolJ8J+HkJHztcXORUpHXBye2U8298lCEMi6ZCU= +github.com/aws/aws-sdk-go-v2/service/sts v1.44.0/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q= +github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= +github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/brianvoe/gofakeit/v6 v6.19.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8= github.com/buildkite/shellwords v1.0.1 h1:88OjMbEBf+EliVB0tizXJynpAM2CKOvYwepg5n8O70M= github.com/buildkite/shellwords v1.0.1/go.mod h1:so0eQnTxgbo58CTYX+4BCx5UuMzvRha9dcKdCKl6NV4= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= @@ -24,49 +66,80 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA= github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk= github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator v9.31.0+incompatible h1:UA72EPEogEnq76ehGdEDp4Mit+3FDh548oRqwVgNsHA= +github.com/go-playground/validator v9.31.0+incompatible/go.mod h1:yrEkQXlcI+PugkyDjY2bRrL/UBU4f3rvrgkN3V8JEig= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= -github.com/oapi-codegen/runtime v1.4.0 h1:KLOSFOp7UzkbS7Cs1ms6NBEKYr0WmH2wZG0KKbd2er4= -github.com/oapi-codegen/runtime v1.4.0/go.mod h1:5sw5fxCDmnOzKNYmkVNF8d34kyUeejJEY8HNT2WaPec= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/neilotoole/slogt v1.1.0 h1:c7qE92sq+V0yvCuaxph+RQ2jOKL61c4hqS1Bv9W7FZE= +github.com/neilotoole/slogt v1.1.0/go.mod h1:RCrGXkPc/hYybNulqQrMHRtvlQ7F6NktNVLuLwk6V+w= +github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= +github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= +github.com/oapi-codegen/runtime v1.4.2 h1:GMxFVYLzoYLua+/KvzgSphkyK1lLTReQI9Vf4hvATKE= +github.com/oapi-codegen/runtime v1.4.2/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= -github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -79,6 +152,8 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/spyzhov/ajson v0.9.6 h1:iJRDaLa+GjhCDAt1yFtU/LKMtLtsNVKkxqlpvrHHlpQ= +github.com/spyzhov/ajson v0.9.6/go.mod h1:a6oSw0MMb7Z5aD2tPoPO+jq11ETKgXUr2XktHdT8Wt8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -90,19 +165,39 @@ github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSW github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ= +go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs= +go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -110,9 +205,21 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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= @@ -124,35 +231,49 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= 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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go index 33c5e7f..39b488e 100644 --- a/internal/webhook/webhook.go +++ b/internal/webhook/webhook.go @@ -30,8 +30,10 @@ func LoadFixture(provider, event, customPath string) ([]byte, error) { data = bytes.ReplaceAll(data, []byte("{{NOW}}"), []byte(now)) // Validate it's valid JSON - var jsonObj interface{} - if err := json.Unmarshal(data, &jsonObj); err != nil { + var jsonObj any + + err = json.Unmarshal(data, &jsonObj) + if err != nil { return nil, fmt.Errorf("fixture contains invalid JSON: %w", err) } @@ -42,6 +44,7 @@ func LoadFixture(provider, event, customPath string) ([]byte, error) { // Format: provider.event_name (e.g., "stripe.payment_intent.created"). func ParseEvent(event string) (provider, eventName string) { const expectedParts = 2 + parts := strings.SplitN(event, ".", expectedParts) if len(parts) < expectedParts { diff --git a/request/api.go b/request/api.go index 3f5aa67..ea430cf 100644 --- a/request/api.go +++ b/request/api.go @@ -137,7 +137,8 @@ func (c *APIClient) DeleteIntegration(ctx context.Context, integrationId string) return err } - if _, err := c.Client.Delete(ctx, delURL, auth); err != nil { //nolint:bodyclose + _, err = c.Client.Delete(ctx, delURL, auth) //nolint:bodyclose + if err != nil { return fmt.Errorf("error deleting integration: %w", err) } @@ -200,6 +201,34 @@ func (c *APIClient) ListConnections(ctx context.Context) ([]*Connection, error) return connections, nil } +// providerCatalogURL returns the URL of the public provider catalog endpoint. +// The endpoint is not project-scoped and requires no authentication. +func providerCatalogURL() string { + rootURL, ok := os.LookupEnv("AMP_API_URL") + if !ok { + rootURL = vars.ApiURL + } + + return fmt.Sprintf("%s/%s/providers", rootURL, ApiVersion) +} + +// FetchProviderCatalog fetches the live ("dynamic") provider catalog from the +// Ampersand API and unmarshals it into target. The catalog changes several times a +// day, so callers should prefer it over any catalog compiled into a dependency. The +// endpoint is public (no auth) and not project-scoped, so this works without a +// configured project or an active login session. target is typically a +// *providers.CatalogType from the connectors package. +func FetchProviderCatalog(ctx context.Context, target any) error { + client := NewRequestClient() + + _, err := client.Get(ctx, providerCatalogURL(), target) //nolint:bodyclose + if err != nil { + return fmt.Errorf("error fetching provider catalog: %w", err) + } + + return nil +} + func (c *APIClient) GetCatalog(ctx context.Context) (openapi.CatalogType, error) { catalogURL := c.Root + "/providers" @@ -336,7 +365,8 @@ func (c *APIClient) DeleteInstallation(ctx context.Context, integrationId string return err } - if _, err := c.Client.Delete(ctx, delURL, auth); err != nil { //nolint:bodyclose + _, err = c.Client.Delete(ctx, delURL, auth) //nolint:bodyclose + if err != nil { return fmt.Errorf("error deleting installation: %w", err) } diff --git a/request/request.go b/request/request.go index c4328f8..86935ac 100644 --- a/request/request.go +++ b/request/request.go @@ -215,7 +215,9 @@ func (c *Client) makeRequestAndParseJSONResult(req *http.Request, result any) (* if err == nil { if mt == "application/problem+json" { prob := &ProblemError{} - if err := json.Unmarshal(payload, prob); err == nil { + + err := json.Unmarshal(payload, prob) + if err == nil { if prob.Status == http.StatusNotFound { return res, fmt.Errorf("%w: %w\n%w", ErrNon200Status, ErrNotFound, prob) } else { @@ -233,7 +235,8 @@ func (c *Client) makeRequestAndParseJSONResult(req *http.Request, result any) (* } } - if err := json.Unmarshal(payload, result); err != nil { + err = json.Unmarshal(payload, result) + if err != nil { return nil, err } @@ -381,7 +384,8 @@ func (c *Client) sendRequest(req *http.Request) (*http.Response, []byte, error) defer func() { if res != nil && res.Body != nil { - if closeErr := res.Body.Close(); closeErr != nil { + closeErr := res.Body.Close() + if closeErr != nil { logger.Debugf("unable to close response body %v", closeErr) } } diff --git a/utils/utils.go b/utils/utils.go index fe43bd5..918e67e 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -39,16 +39,20 @@ func ReadStruct(r io.Reader, out any) (Format, error) { return Unknown, err } - if err := json.Unmarshal(data, out); err != nil { - var se *json.SyntaxError - if !errors.As(err, &se) { - return Unknown, err - } - } else { + err = json.Unmarshal(data, out) + if err == nil { return JSON, nil } - if err := yaml.Unmarshal(data, out); err != nil { + // A JSON syntax error means the data may still be YAML, so fall through. Any other + // error means the data is JSON-shaped but invalid (e.g. a type mismatch); report it. + var se *json.SyntaxError + if !errors.As(err, &se) { + return Unknown, err + } + + err = yaml.Unmarshal(data, out) + if err != nil { return Unknown, err } @@ -69,7 +73,8 @@ func ReadStructFromFile(filePath string, out any) (Format, error) { _ = f.Close() }() - if format, err := ReadStruct(f, out); err == nil { + format, err := ReadStruct(f, out) + if err == nil { return format, nil } @@ -85,7 +90,8 @@ func WriteStruct(writer io.Writer, format Format, data any) error { enc.SetIndent("", " ") enc.SetEscapeHTML(false) - if err := enc.Encode(data); err != nil { + err := enc.Encode(data) + if err != nil { return err } diff --git a/validate/catalog.go b/validate/catalog.go new file mode 100644 index 0000000..52cacb3 --- /dev/null +++ b/validate/catalog.go @@ -0,0 +1,112 @@ +package validate + +import ( + "context" + "fmt" + + "github.com/amp-labs/amp-yaml-validator/catalog" + "github.com/amp-labs/cli/request" + "github.com/amp-labs/connectors/providers" +) + +// CatalogProvider implements the validator's catalog.CatalogProvider interface using +// the Ampersand API's live provider catalog (the "dynamic catalog"), which is updated +// several times a day. Backing validation with this keeps provider, module, and +// capability checks current instead of relying on the catalog compiled into the +// connectors library, which goes stale between releases. +// +// It holds a snapshot fetched once so repeated lookups during a single validation run +// don't re-hit the API. When the fetch fails, callers should fall back to the embedded +// catalog (catalog.NewDefaultCatalogProvider) so validation still works offline. +type CatalogProvider struct { + catalog map[string]providers.ProviderInfo +} + +// Ensure CatalogProvider satisfies the library interface. +var _ catalog.CatalogProvider = (*CatalogProvider)(nil) + +// NewCatalogProvider fetches the live provider catalog from the API and returns a +// provider backed by that snapshot. The catalog endpoint is public, so this works +// even without a configured project or an active login session. +func NewCatalogProvider(ctx context.Context) (*CatalogProvider, error) { + var cat providers.CatalogType + + err := request.FetchProviderCatalog(ctx, &cat) + if err != nil { + return nil, err + } + + return &CatalogProvider{catalog: cat}, nil +} + +// GetProviderInfo retrieves provider information from the fetched catalog. +func (c *CatalogProvider) GetProviderInfo( + _ context.Context, + providerName string, +) (*providers.ProviderInfo, error) { + info, ok := c.catalog[providerName] + if !ok { + return nil, fmt.Errorf("%w: %s", catalog.ErrProviderNotFound, providerName) + } + + return &info, nil +} + +// GetProviderSupport retrieves the provider's capabilities from the fetched catalog. +func (c *CatalogProvider) GetProviderSupport( + ctx context.Context, + providerName string, +) (*providers.Support, error) { + info, err := c.GetProviderInfo(ctx, providerName) + if err != nil { + return nil, err + } + + return &info.Support, nil +} + +// GetModuleInfo retrieves module information for a given provider and module ID. +func (c *CatalogProvider) GetModuleInfo( + ctx context.Context, + providerName string, + moduleID string, +) (*providers.ModuleInfo, error) { + info, err := c.GetProviderInfo(ctx, providerName) + if err != nil { + return nil, err + } + + if info.Modules == nil { + return nil, fmt.Errorf("%w: %s", catalog.ErrProviderNoModules, providerName) + } + + mods := *info.Modules + + mod, ok := mods[moduleID] + if !ok { + return nil, fmt.Errorf("%w: %s for provider %s", catalog.ErrModuleNotFound, moduleID, providerName) + } + + return &mod, nil +} + +// ListObjects reports ErrNotSupported: the provider catalog does not expose object +// schemas, matching the connectors default implementation. +func (c *CatalogProvider) ListObjects( + _ context.Context, + _ string, + _ string, +) ([]string, error) { + return nil, catalog.ErrNotSupported +} + +// Ping reports whether the fetched catalog is usable. The validator gates all +// provider-specific validation (including provider-app checks) on this, so a +// successfully fetched, non-empty catalog must report healthy. +func (c *CatalogProvider) Ping(_ context.Context) error { + if len(c.catalog) == 0 { + return catalog.ErrProviderNotFound + } + + return nil +} diff --git a/validate/checkers.go b/validate/checkers.go new file mode 100644 index 0000000..c2d66f5 --- /dev/null +++ b/validate/checkers.go @@ -0,0 +1,85 @@ +// Package validate supplies the amp-yaml-validator library with project-specific +// data (destinations, provider apps) fetched from the Ampersand API. The library +// exposes these as dependency-injected "checker" hooks so that the same validation +// logic can run client-side (here, against the API) or server-side (against the +// database). +package validate + +import ( + "context" + + "github.com/amp-labs/amp-yaml-validator/checker" + "github.com/amp-labs/cli/request" +) + +// DestinationChecker verifies that destinations referenced by a manifest exist in +// the project. It holds a snapshot of the project's destinations fetched once, so +// repeated CheckDestination calls during validation don't hit the API again. +type DestinationChecker struct { + names map[string]struct{} +} + +// Ensure DestinationChecker satisfies the library interface. +var _ checker.DestinationChecker = (*DestinationChecker)(nil) + +// NewDestinationChecker fetches the project's destinations and returns a checker +// backed by that snapshot. +func NewDestinationChecker(ctx context.Context, client *request.APIClient) (*DestinationChecker, error) { + destinations, err := client.ListDestinations(ctx) + if err != nil { + return nil, err + } + + names := make(map[string]struct{}, len(destinations)) + for _, dest := range destinations { + names[dest.Name] = struct{}{} + } + + return &DestinationChecker{names: names}, nil +} + +// CheckDestination reports checker.ErrDestinationNotFound if the named destination +// is not present in the project, and nil otherwise. +func (c *DestinationChecker) CheckDestination(_ context.Context, destinationName string) error { + if _, ok := c.names[destinationName]; ok { + return nil + } + + return checker.ErrDestinationNotFound +} + +// ProviderAppChecker verifies that a provider app (OAuth credentials) is configured +// for the providers referenced by a manifest. Like DestinationChecker it works off a +// one-time snapshot of the project's provider apps. +type ProviderAppChecker struct { + providers map[string]struct{} +} + +// Ensure ProviderAppChecker satisfies the library interface. +var _ checker.ProviderAppChecker = (*ProviderAppChecker)(nil) + +// NewProviderAppChecker fetches the project's provider apps and returns a checker +// backed by that snapshot. +func NewProviderAppChecker(ctx context.Context, client *request.APIClient) (*ProviderAppChecker, error) { + apps, err := client.ListProviderApps(ctx) + if err != nil { + return nil, err + } + + providers := make(map[string]struct{}, len(apps)) + for _, app := range apps { + providers[app.Provider] = struct{}{} + } + + return &ProviderAppChecker{providers: providers}, nil +} + +// CheckProviderApp reports checker.ErrProviderAppNotFound if no provider app is +// configured for the given provider, and nil otherwise. +func (c *ProviderAppChecker) CheckProviderApp(_ context.Context, providerName string) error { + if _, ok := c.providers[providerName]; ok { + return nil + } + + return checker.ErrProviderAppNotFound +}