diff --git a/packages/envsync-cli/AGENTS.md b/packages/envsync-cli/AGENTS.md index 6483435f..69b9b2de 100644 --- a/packages/envsync-cli/AGENTS.md +++ b/packages/envsync-cli/AGENTS.md @@ -44,6 +44,23 @@ internal/ - User config: `~/.envsyncrc.toml` (auth tokens and preferences) - Backend URL: compile-time injected via ldflags (`BACKEND_URL` make variable). Override in `~/.envsyncrc.toml`. Default local API is typically `http://localhost:4000` (origin only — no path prefix). +## Logging & telemetry + +- Log file: `~/.local/envsync/logs/envsync.log` (macOS/Linux) or `~/envsync/logs/envsync.log` (Windows), JSON, rotated (10 MB, 3 backups, 7 days). There is no console core: stdout/stderr belong to command output (`--json`) and the spinner/TUI. +- Logs carry `trace_id`/`span_id` fields and are exported as OTEL logs via otelzap when telemetry is enabled, so local records and telemetry signals correlate. +- Traces: every command action runs under a `cli/` span (`internal/features/commands/root.go` `observe`); each use case spans its operation via `telemetry.StartSpan`; each service method spans `svc..`; HTTP is auto-instrumented with `otelhttp` via `telemetry.NewInstrumentedTransport` (span name `METHOD /path`, one log record per API call with method/path/status/duration). +- Attribute rule: span attributes and log fields carry identifiers only (ids, names, paths) — never secret values, tokens, key material, or request/response bodies. +- Use-case convention (named `err` result required): + + ```go + ctx, end := telemetry.StartSpan(ctx, "app.create", trace.WithAttributes(attribute.String("app.name", app.Name))) + defer end(&err) + ``` + + `end` records failures on the span (exception + error status), logs them (`logger.FromContext(ctx)`), and ends the span. Logging elsewhere: `logger.FromContext(ctx)`. +- Telemetry config: `ENVSYNC_TELEMETRY_URL` (collector base or concrete signal URL), `ENVSYNC_OTEL_SERVICE`, `ENVSYNC_OTEL_DISABLED=true`; fallback `telemetry_url` in `~/.envsyncrc.toml`. Graceful no-op when unset. +- Failures exit non-zero: `FormatError`/`FormatJSONError` return `formatters.RenderedError` (message already displayed); `main` exits 1 without re-printing. `FormatWarning` stays non-fatal. + ## Release GoReleaser config in `.goreleaser.yml`. Release workflow in `.github/workflows/release.yml`. diff --git a/packages/envsync-cli/cmd/cli/main.go b/packages/envsync-cli/cmd/cli/main.go index c0c897dc..68159dfb 100644 --- a/packages/envsync-cli/cmd/cli/main.go +++ b/packages/envsync-cli/cmd/cli/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "log" "os" @@ -16,13 +17,11 @@ import ( genpem "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/gen_pem" gpgUseCases "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/gpg_key" inituc "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/init" - lfUseCases "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/log_forwarding" - oidcUseCases "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/oidc" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/run" - samlUseCases "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/saml" serviceTokenUseCases "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/service_token" syncUseCase "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/sync" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/formatters" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/factory" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" ) @@ -47,11 +46,8 @@ func main() { container.AccessHandler, container.RequestHandler, container.ServiceTokenHandler, - container.OidcHandler, - container.SamlHandler, container.RotationHandler, container.DynamicSecretHandler, - container.LogForwardingHandler, ) // Build CLI app @@ -59,32 +55,35 @@ func main() { // Run the application if err := app.Run(context.Background(), os.Args); err != nil { + var rendered formatters.RenderedError + if errors.As(err, &rendered) { + // The formatter already displayed the failure; exit non-zero + // without printing the message a second time. + os.Exit(1) + } log.Fatal(err) } } // Container holds the handler dependencies type Container struct { - AppHandler *handlers.AppHandler - AuthHandler *handlers.AuthHandler - ConfigHandler *handlers.ConfigHandler - EnvironmentHandler *handlers.EnvironmentHandler - SyncHandler *handlers.SyncHandler - ExportHandler *handlers.ExportHandler - InitHandler *handlers.InitHandler - RunHandler *handlers.RunHandler - GenPEMKeyHandler *handlers.GenPEMKeyHandler - GpgKeyHandler *handlers.GpgKeyHandler - CertificateHandler *handlers.CertificateHandler - TeamHandler *handlers.TeamHandler - AccessHandler *handlers.AccessHandler - RequestHandler *handlers.RequestHandler - ServiceTokenHandler *handlers.ServiceTokenHandler - OidcHandler *handlers.OidcHandler - SamlHandler *handlers.SamlHandler - RotationHandler *handlers.RotationHandler - DynamicSecretHandler *handlers.DynamicSecretHandler - LogForwardingHandler *handlers.LogForwardingHandler + AppHandler *handlers.AppHandler + AuthHandler *handlers.AuthHandler + ConfigHandler *handlers.ConfigHandler + EnvironmentHandler *handlers.EnvironmentHandler + SyncHandler *handlers.SyncHandler + ExportHandler *handlers.ExportHandler + InitHandler *handlers.InitHandler + RunHandler *handlers.RunHandler + GenPEMKeyHandler *handlers.GenPEMKeyHandler + GpgKeyHandler *handlers.GpgKeyHandler + CertificateHandler *handlers.CertificateHandler + TeamHandler *handlers.TeamHandler + AccessHandler *handlers.AccessHandler + RequestHandler *handlers.RequestHandler + ServiceTokenHandler *handlers.ServiceTokenHandler + RotationHandler *handlers.RotationHandler + DynamicSecretHandler *handlers.DynamicSecretHandler } // buildDependencyContainer creates and wires all handler dependencies @@ -98,10 +97,12 @@ func buildDependencyContainer() *Container { envFormatter := formatters.NewEnvFormatter() initFormatter := formatters.NewInitFormatter() syncFormatter := formatters.NewSyncFormatter() + runFormatter := formatters.NewRunFormatter() // Initialize use cases createAppUseCase := appUseCases.NewCreateAppUseCase() - deleteAppUseCase := appUseCases.NewDeleteAppUseCase() + tuiFactory := factory.NewAppFactory() + deleteAppUseCase := appUseCases.NewDeleteAppUseCase(tuiFactory) listAppsUseCase := appUseCases.NewListAppsUseCase() loginUseCase := authUseCases.NewLoginUseCase() @@ -120,7 +121,8 @@ func buildDependencyContainer() *Container { pushUseCase := syncUseCase.NewPushUseCase() exportUseCase := exportUseCases.NewExportUseCase() - initUC := inituc.NewInitUseCase() + initFactory := factory.NewInitFactory() + initUC := inituc.NewInitUseCase(initFactory) injectUseCase := run.NewInjectEnv() injectSecretUseCase := run.NewInjectSecretUseCase() @@ -157,6 +159,7 @@ func buildDependencyContainer() *Container { deleteAppUseCase, listAppsUseCase, appFormatter, + tuiFactory, ) c.AuthHandler = handlers.NewAuthHandler( @@ -178,6 +181,7 @@ func buildDependencyContainer() *Container { switchEnvironmentUseCase, deleteEnvironmentUseCase, envFormatter, + factory.NewEnvFactory(), ) c.SyncHandler = handlers.NewSyncHandler( @@ -201,6 +205,7 @@ func buildDependencyContainer() *Container { injectSecretUseCase, fetchAppUseCase, readConfigUseCase, + runFormatter, ) c.GenPEMKeyHandler = handlers.NewGenPEMKeyHandler( @@ -218,6 +223,7 @@ func buildDependencyContainer() *Container { gpgDeleteKeyUseCase, gpgService, gpgKeyFormatter, + factory.NewGpgKeyFactory(), ) certFormatter := formatters.NewCertificateFormatter() @@ -235,7 +241,7 @@ func buildDependencyContainer() *Container { ) c.TeamHandler = handlers.NewTeamHandler() - c.AccessHandler = handlers.NewAccessHandler() + c.AccessHandler = handlers.NewAccessHandler(factory.NewAccessFactory()) c.RequestHandler = handlers.NewRequestHandler() stCreateUseCase := serviceTokenUseCases.NewCreateServiceTokenUseCase() @@ -248,58 +254,14 @@ func buildDependencyContainer() *Container { stListUseCase, stGetUseCase, stDeleteUseCase, + listAppsUseCase, + getEnvironmentUseCase, stFormatter, - ) - - oidcCreateUseCase := oidcUseCases.NewCreateOidcProviderUseCase() - oidcListUseCase := oidcUseCases.NewListOidcProvidersUseCase() - oidcGetUseCase := oidcUseCases.NewGetOidcProviderUseCase() - oidcUpdateUseCase := oidcUseCases.NewUpdateOidcProviderUseCase() - oidcDeleteUseCase := oidcUseCases.NewDeleteOidcProviderUseCase() - oidcFormatter := formatters.NewOidcFormatter() - c.OidcHandler = handlers.NewOidcHandler( - oidcCreateUseCase, - oidcListUseCase, - oidcGetUseCase, - oidcUpdateUseCase, - oidcDeleteUseCase, - oidcFormatter, - ) - - samlCreateUseCase := samlUseCases.NewCreateSamlProviderUseCase() - samlListUseCase := samlUseCases.NewListSamlProvidersUseCase() - samlGetUseCase := samlUseCases.NewGetSamlProviderUseCase() - samlUpdateUseCase := samlUseCases.NewUpdateSamlProviderUseCase() - samlDeleteUseCase := samlUseCases.NewDeleteSamlProviderUseCase() - samlMetadataUseCase := samlUseCases.NewGetSamlMetadataUseCase() - samlSsoUseCase := samlUseCases.NewInitiateSamlSsoUseCase() - samlFormatter := formatters.NewSamlFormatter() - c.SamlHandler = handlers.NewSamlHandler( - samlCreateUseCase, - samlListUseCase, - samlGetUseCase, - samlUpdateUseCase, - samlDeleteUseCase, - samlMetadataUseCase, - samlSsoUseCase, - samlFormatter, + factory.NewServiceTokenFactory(), ) c.RotationHandler = handlers.NewRotationHandler() c.DynamicSecretHandler = handlers.NewDynamicSecretHandler() - lfCreateUseCase := lfUseCases.NewCreateLogForwardingConfigUseCase() - lfListUseCase := lfUseCases.NewListLogForwardingConfigsUseCase() - lfGetUseCase := lfUseCases.NewGetLogForwardingConfigUseCase() - lfDeleteUseCase := lfUseCases.NewDeleteLogForwardingConfigUseCase() - lfFormatter := formatters.NewLogForwardingFormatter() - c.LogForwardingHandler = handlers.NewLogForwardingHandler( - lfCreateUseCase, - lfListUseCase, - lfGetUseCase, - lfDeleteUseCase, - lfFormatter, - ) - return c } diff --git a/packages/envsync-cli/go.mod b/packages/envsync-cli/go.mod index 513a63a6..91b47757 100644 --- a/packages/envsync-cli/go.mod +++ b/packages/envsync-cli/go.mod @@ -1,30 +1,30 @@ module github.com/EnvSync-Cloud/envsync/packages/envsync-cli -go 1.25.0 +go 1.26.0 require ( - github.com/BurntSushi/toml v1.5.0 - github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk v0.0.0-00010101000000-000000000000 - github.com/aymanbagabas/go-pty v0.2.2 - github.com/charmbracelet/bubbles v0.21.0 - github.com/charmbracelet/bubbletea v1.3.5 - github.com/charmbracelet/huh v0.7.0 + github.com/BurntSushi/toml v1.6.0 + github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk v0.0.0-20260926232912-24fa54a7c668 + github.com/aymanbagabas/go-pty v0.2.3 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/joho/godotenv v1.5.1 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/savioxavier/termlink v1.4.3 - github.com/urfave/cli/v3 v3.3.8 - go.opentelemetry.io/contrib/bridges/otelzap v0.15.0 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 - go.opentelemetry.io/otel v1.45.0 - go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 - go.opentelemetry.io/otel/sdk v1.45.0 - go.opentelemetry.io/otel/sdk/log v0.19.0 - go.opentelemetry.io/otel/trace v1.45.0 - go.uber.org/zap v1.27.1 + github.com/urfave/cli/v3 v3.14.0 + go.opentelemetry.io/contrib/bridges/otelzap v0.21.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 + go.opentelemetry.io/otel v1.47.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.23.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 + go.opentelemetry.io/otel/sdk v1.47.0 + go.opentelemetry.io/otel/sdk/log v1.47.0 + go.opentelemetry.io/otel/trace v1.47.0 + go.uber.org/zap v1.28.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 - resty.dev/v3 v3.0.0-beta.3 + resty.dev/v3 v3.0.0-rc.4 ) replace github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk => ../../sdks/envsync-go-sdk @@ -35,44 +35,46 @@ require ( github.com/catppuccin/go v0.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/colorprofile v0.3.1 // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20250629123816-066ae234febc // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/x/ansi v0.11.8 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/strings v0.1.0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/creack/pty v1.1.24 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect + github.com/dustin/go-humanize v1.1.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.30 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sahilm/fuzzy v0.1.1 // indirect - github.com/u-root/u-root v0.14.0 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/sahilm/fuzzy v0.1.3 // indirect + github.com/u-root/u-root v0.16.0 // indirect + github.com/xo/terminfo v1.2.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect - go.opentelemetry.io/otel/log v0.19.0 // indirect - go.opentelemetry.io/otel/metric v1.45.0 // indirect - go.opentelemetry.io/proto/otlp v1.11.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect + go.opentelemetry.io/otel/metric v1.47.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.1 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.55.0 // indirect - golang.org/x/net v0.58.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.41.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect - google.golang.org/grpc v1.83.2 // indirect - google.golang.org/protobuf v1.36.11 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/net v0.59.0 // indirect + golang.org/x/oauth2 v0.37.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect + google.golang.org/grpc v1.84.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/packages/envsync-cli/go.sum b/packages/envsync-cli/go.sum index e5847ac0..b8c80d70 100644 --- a/packages/envsync-cli/go.sum +++ b/packages/envsync-cli/go.sum @@ -1,59 +1,61 @@ -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-pty v0.2.2 h1:YZREB4eSj+1xdbbItIokX0ekjjeifgJOA+ZvxU4/WM8= -github.com/aymanbagabas/go-pty v0.2.2/go.mod h1:gfvlwH+0U66BCwxJREjJaAOEs9H1OFf3YFjI9WSiZ04= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymanbagabas/go-pty v0.2.3 h1:hsqcTIUV8I4iTSh3HQl61CR2wh0YPS6gHOYLhAfWu/E= +github.com/aymanbagabas/go-pty v0.2.3/go.mod h1:GLkgQovzqN5A1xMB79yHWiG1rhcquZCjkwKQGKFPdPg= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= 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/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= -github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= -github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= -github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= -github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= -github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc= -github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/strings v0.0.0-20250629123816-066ae234febc h1:XFsX2G2Z1k1p9/52+7TYs2iYW//XCJXSD7xWlEeGvBM= -github.com/charmbracelet/x/exp/strings v0.0.0-20250629123816-066ae234febc/go.mod h1:Rgw3/F+xlcUc5XygUtimVSxAqCOsqyvJjqF5UHRvc5k= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= +github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/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/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= +github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -65,22 +67,22 @@ 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/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= -github.com/hugelgupf/vmtest v0.0.0-20240216064925-0561770280a1 h1:jWoR2Yqg8tzM0v6LAiP7i1bikZJu3gxpgvu3g1Lw+a0= -github.com/hugelgupf/vmtest v0.0.0-20240216064925-0561770280a1/go.mod h1:B63hDJMhTupLWCHwopAyEo7wRFowx9kOc8m8j1sfOqE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 h1:Bd7KaOxzULLxtZ/K5s1aLbWhR0+5RToO65TXHsf3bqQ= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0/go.mod h1:nN7ts3dFXKtCZWc//yfkpcQNKJABg16/uDVAZpLDalo= +github.com/hugelgupf/vmtest v0.0.0-20240307030256-5d9f3d34a58d h1:nP8SfQJqruIVSWYJTuYc37jLHEY1Z0fF+zKSrs3K/C8= +github.com/hugelgupf/vmtest v0.0.0-20240307030256-5d9f3d34a58d/go.mod h1:B63hDJMhTupLWCHwopAyEo7wRFowx9kOc8m8j1sfOqE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= +github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -91,97 +93,93 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= +github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= github.com/savioxavier/termlink v1.4.3 h1:Gh6vrG7jSn21cRiYdQqFXYcdXfM+Fg14aG487JTfKpA= github.com/savioxavier/termlink v1.4.3/go.mod h1:5T5ePUlWbxCHIwyF8/Ez1qufOoGM89RCg9NvG+3G3gc= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/u-root/gobusybox/src v0.0.0-20240225013946-a274a8d5d83a h1:eg5FkNoQp76ZsswyGZ+TjYqA/rhKefxK8BW7XOlQsxo= -github.com/u-root/gobusybox/src v0.0.0-20240225013946-a274a8d5d83a/go.mod h1:e/8TmrdreH0sZOw2DFKBaUV7bvDWRq6SeM9PzkuVM68= -github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= -github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE= -github.com/urfave/cli/v3 v3.3.8 h1:BzolUExliMdet9NlJ/u4m5vHSotJ3PzEqSAZ1oPMa/E= -github.com/urfave/cli/v3 v3.3.8/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/u-root/gobusybox/src v0.0.0-20250101170133-2e884e4509c7 h1:dtiVT4SeBUc/vHtwI2HjDZN+FCKTstQBxugIxJEGo9g= +github.com/u-root/gobusybox/src v0.0.0-20250101170133-2e884e4509c7/go.mod h1:PW3wGFCHjdHxAhra5FKvcARbCGqGfentYuPKmuhv8DY= +github.com/u-root/u-root v0.16.0 h1:wY40O83MBVks97+Is0WlFlOPSwKQMIrWP9R1IsrExg8= +github.com/u-root/u-root v0.16.0/go.mod h1:yL/XdSSW27PdGLgUh4MNRBy54mKM+TBLzpwiB4nwj90= +github.com/urfave/cli/v3 v3.14.0 h1:a8414NQlHJs0c/iBsulKLzlES0n/lEAskbL2LKpU4/s= +github.com/urfave/cli/v3 v3.14.0/go.mod h1:vXn6HxPNccJSzQr2QvwVncOKrgYGIHU0HY5h8B2nQj4= +github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4= +github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio= 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/otelzap v0.15.0 h1:x4qzjKkTl2hXmLl+IviSXvzaTyCJSYvpFZL5SRVLBxs= -go.opentelemetry.io/contrib/bridges/otelzap v0.15.0/go.mod h1:h7dZHJgqkzUiKFXCTJBrPWH0LEZaZXBFzKWstjWBRxw= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= -go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= -go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8= -go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= -go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= -go.opentelemetry.io/otel/log/logtest v0.16.0 h1:jr1CG3Z6FD9pwUaL/D0s0X4lY2ZVm1jP3JfCtzGxUmE= -go.opentelemetry.io/otel/log/logtest v0.16.0/go.mod h1:qeeZw+cI/rAtCzZ03Kq1ozq6C4z/PCa+K+bb0eJfKNs= -go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= -go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= -go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= -go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= -go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= -go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= -go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk= -go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk= -go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= -go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= -go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= -go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= -go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= -go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.opentelemetry.io/contrib/bridges/otelzap v0.21.0 h1:p23nklcy3VVRcfZ2ce5AXQa6RHQPQvIMM+fW3GzXCU0= +go.opentelemetry.io/contrib/bridges/otelzap v0.21.0/go.mod h1:7NPD4VToURMipQu1xgz6seTz8aXmbCqQNZs7bQADEjU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0/go.mod h1:tOsftB4SslBwwErVEPaenU2RpThXWPIU8DoJHEC4dyw= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.23.0 h1:5WVp40k2fJHuc5BtNTgKk+EugKFT8Eg3lywFfCPI/nE= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.23.0/go.mod h1:59nH0RYCGHRlwt15WyioeSYINBbuObeBpTowPhEKWHY= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 h1:aXZXsZ012wOgVkqaiQv+Z/d8V0xKjmg70F6m4CE94lc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0/go.mod h1:ziu1gUIJhAaxM3EV1e3Ralk5AAyL1UOYz3lJJ4VSI38= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/log/logtest v0.23.0 h1:XeI2D+/DjUMYzrfmMPXOxnTIkUTCXT/HwQ5QBJSEC/8= +go.opentelemetry.io/otel/log/logtest v0.23.0/go.mod h1:TOVBzNwEZG0vmQUuvE9UHaiKkUN7q3j1d+GeVGs/yQ8= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= +go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= +go.opentelemetry.io/otel/sdk/log v1.47.0 h1:W4N/ZfgpkRRnmARnqYoQ6BrWA0mrj8IzVDNLlpmUJZY= +go.opentelemetry.io/otel/sdk/log v1.47.0/go.mod h1:Wu74Z2EmOKXi2FleSNAb0Hyau2G+r5kubSEMJw5aAMs= +go.opentelemetry.io/otel/sdk/log/logtest v0.23.0 h1:j3pm42QCrKYYLLkI6OrV1QHFkUfDA6/4EP+Y1PzggLg= +go.opentelemetry.io/otel/sdk/log/logtest v0.23.0/go.mod h1:zt0gEnuTZgDvqvczHwYswz93eqMaIr6upTTsdV/HUlU= +go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= +go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= +go.opentelemetry.io/proto/otlp v1.11.1 h1:SCrPqH9NE5CmBrOqpjNCiT0X5mGwMyGERXdogfR7Yjw= +go.opentelemetry.io/proto/otlp v1.11.1/go.mod h1:wq8W4aSf6bOy8RZyESOeQFr9Pu7wydWX/TY4sgvrTPg= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= -golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= -golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -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.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= -google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= -google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE= +google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -resty.dev/v3 v3.0.0-beta.3 h1:3kEwzEgCnnS6Ob4Emlk94t+I/gClyoah7SnNi67lt+E= -resty.dev/v3 v3.0.0-beta.3/go.mod h1:OgkqiPvTDtOuV4MGZuUDhwOpkY8enjOsjjMzeOHefy4= +resty.dev/v3 v3.0.0-rc.4 h1:n3360yEcFiyrX5Vkc9XG3Y8kMM24alRBxJ9Ev1mLmqs= +resty.dev/v3 v3.0.0-rc.4/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM= diff --git a/packages/envsync-cli/internal/config/config.go b/packages/envsync-cli/internal/config/config.go index 8a32976d..b30d7fd2 100644 --- a/packages/envsync-cli/internal/config/config.go +++ b/packages/envsync-cli/internal/config/config.go @@ -6,21 +6,49 @@ import ( "os" "path/filepath" "sync" + + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/constants" ) type AppConfig struct { - AccessToken string `json:"access_token"` - BackendURL string `json:"backend_url"` - TelemetryURL string `json:"telemetry_url,omitempty"` - TelemetryToken string `json:"telemetry_token,omitempty"` + BackendURL string `json:"backend_url"` + AuthConfig AuthConfig `json:"auth_config"` + OTELConfig OTELConfig `json:"otel_config"` +} + +type AuthConfig struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt int `json:"expires_at"` + // ClientID and TokenURL identify the authorization server endpoint that + // issues tokens. They arrive with the device-code response and are needed + // again for the refresh grant. + ClientID string `json:"client_id"` + TokenURL string `json:"token_url"` +} + +type OTELConfig struct { + OtelDisabled bool `json:"disabled"` + Endpoint string `json:"endpoint"` + ServiceName string `json:"service_name"` +} + +// configFilePath resolves the single on-disk location of config.json. +func configFilePath() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("failed to get user config directory: %w", err) + } + return filepath.Join(configDir, "envsync", "config.json"), nil } var ( - cfg AppConfig - once sync.Once - initErr error - backendURL string = "https://api.envsync.cloud" - telemetryURL string + cfg AppConfig + once sync.Once + initErr error + // backendURL is the default API origin. The E2E harness overrides it with + // -X github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/config.backendURL=... + backendURL = constants.BackendURL ) func New() AppConfig { @@ -34,49 +62,28 @@ func New() AppConfig { func NewWithError() (AppConfig, error) { once.Do(func() { - configDir, err := os.UserConfigDir() + filePath, err := configFilePath() if err != nil { - initErr = fmt.Errorf("failed to get user config directory: %w", err) + initErr = err return } - filePath := filepath.Join(configDir, "envsync", "config.json") - - // Ensure directory exists - dirPath := filepath.Dir(filePath) - if err := os.MkdirAll(dirPath, os.ModePerm); err != nil { - initErr = fmt.Errorf("failed to create config directory: %w", err) - return - } - - // Create a file if it doesn't exist + // Create the file with defaults if it doesn't exist yet. if _, err := os.Stat(filePath); os.IsNotExist(err) { - file, err := os.Create(filePath) - if err != nil { + defaults := DefaultConfig() + if err := defaults.WriteConfigFile(); err != nil { initErr = fmt.Errorf("failed to create config file: %w", err) return } - file.Close() } + // Read config from file cfg, initErr = ReadConfigFile() if initErr != nil { return } - if envBackendURL := os.Getenv("ENVSYNC_API_URL"); envBackendURL != "" { - cfg.BackendURL = envBackendURL - } else if cfg.BackendURL == "" { - cfg.BackendURL = backendURL - } - - if envTelemetryURL := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"); envTelemetryURL != "" { - cfg.TelemetryURL = envTelemetryURL - } else if envTelemetryURL := os.Getenv("ENVSYNC_TELEMETRY_ENDPOINT"); envTelemetryURL != "" { - cfg.TelemetryURL = envTelemetryURL - } else if cfg.TelemetryURL == "" { - cfg.TelemetryURL = telemetryURL - } + cfg = overrideConfigWithEnv(cfg) }) return cfg, initErr @@ -85,41 +92,84 @@ func NewWithError() (AppConfig, error) { func (c *AppConfig) WriteConfigFile() error { data, err := json.MarshalIndent(c, "", " ") if err != nil { - return err + return fmt.Errorf("failed to marshal config: %w", err) } - configDir, err := os.UserConfigDir() + filePath, err := configFilePath() if err != nil { - return fmt.Errorf("failed to get user config directory: %w", err) + return err } - filePath := filepath.Join(configDir, "envsync", "config.json") + // The config directory may not exist yet on a fresh machine; the caller + // should never have to bootstrap it before persisting. + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } - return os.WriteFile(filePath, data, 0644) + return os.WriteFile(filePath, data, 0o644) } func ReadConfigFile() (AppConfig, error) { - configDir, err := os.UserConfigDir() + filePath, err := configFilePath() if err != nil { - return AppConfig{}, fmt.Errorf("failed to get user config directory: %w", err) + return AppConfig{}, err } - filePath := filepath.Join(configDir, "envsync", "config.json") - data, err := os.ReadFile(filePath) if err != nil { return AppConfig{}, err } + // A file that exists but holds nothing is a partially written config; + // fall back to defaults rather than handing back a zeroed AppConfig. if len(data) == 0 { - return AppConfig{}, nil + return DefaultConfig(), nil } var config AppConfig - err = json.Unmarshal(data, &config) - if err != nil { + if err := json.Unmarshal(data, &config); err != nil { return AppConfig{}, err } return config, nil } + +// defaultConfig returns the default configuration for the application. +func DefaultConfig() AppConfig { + return AppConfig{ + BackendURL: backendURL, + AuthConfig: AuthConfig{ + AccessToken: "", + RefreshToken: "", + ExpiresAt: 0, + }, + OTELConfig: OTELConfig{ + Endpoint: constants.OTELEndpoint, + ServiceName: constants.OTELService, + OtelDisabled: constants.OTELDisabled, + }, + } +} + +// overrideConfigWithEnv overrides the configuration with environment variables. +func overrideConfigWithEnv(cfg AppConfig) AppConfig { + if cfg.BackendURL == "" { + cfg.BackendURL = backendURL + } + if envBackendURL := os.Getenv(constants.EnvBackendURL); envBackendURL != "" { + cfg.BackendURL = envBackendURL + } else if envBackendURL := os.Getenv("ENVSYNC_API_URL"); envBackendURL != "" { + cfg.BackendURL = envBackendURL + } + if envTelemetryURL := os.Getenv(constants.EnvOTELURL); envTelemetryURL != "" { + cfg.OTELConfig.Endpoint = envTelemetryURL + } + if envOTELService := os.Getenv(constants.EnvOTELService); envOTELService != "" { + cfg.OTELConfig.ServiceName = envOTELService + } + if envOTELDisabled := os.Getenv(constants.EnvOTELDisabled); envOTELDisabled != "" { + cfg.OTELConfig.OtelDisabled = envOTELDisabled == "true" + } + + return cfg +} diff --git a/packages/envsync-cli/internal/config/config_test.go b/packages/envsync-cli/internal/config/config_test.go new file mode 100644 index 00000000..07ebd015 --- /dev/null +++ b/packages/envsync-cli/internal/config/config_test.go @@ -0,0 +1,120 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/constants" +) + +// isolateConfigDir points the config lookup at a throwaway HOME/XDG root and +// returns the resolved config.json path. Works on both darwin (Application +// Support) and Unix (~/.config) without hardcoding either. +func isolateConfigDir(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + + path, err := configFilePath() + if err != nil { + t.Fatalf("configFilePath() failed: %v", err) + } + return path +} + +// Regression: WriteConfigFile used to call os.WriteFile without MkdirAll, so +// the first write on a fresh machine failed with ENOENT and no config.json was +// ever produced. +func TestWriteConfigFileCreatesMissingDirectory(t *testing.T) { + path := isolateConfigDir(t) + + cfg := DefaultConfig() + cfg.AuthConfig.AccessToken = "token-value" + if err := cfg.WriteConfigFile(); err != nil { + t.Fatalf("WriteConfigFile() failed on a fresh machine: %v", err) + } + + if _, err := os.Stat(path); err != nil { + t.Fatalf("config.json was not created at %s: %v", path, err) + } + + got, err := ReadConfigFile() + if err != nil { + t.Fatalf("ReadConfigFile() failed: %v", err) + } + if got.AuthConfig.AccessToken != "token-value" { + t.Errorf("access_token round-trip: got %q, want %q", got.AuthConfig.AccessToken, "token-value") + } +} + +// Every field of the nested schema must survive a write/read round-trip. This +// pins the JSON tags: renaming or dropping one would otherwise silently detach +// that field from config.json without any compile error. +func TestConfigJSONRoundTrip(t *testing.T) { + isolateConfigDir(t) + + want := AppConfig{ + BackendURL: "https://new.example", + AuthConfig: AuthConfig{ + AccessToken: "NEW", + RefreshToken: "REFRESH", + ExpiresAt: 1735689600, + ClientID: "cli-client", + TokenURL: "https://idp.example/token", + }, + OTELConfig: OTELConfig{ + OtelDisabled: true, + Endpoint: "https://new-t.example", + ServiceName: "svc", + }, + } + + if err := want.WriteConfigFile(); err != nil { + t.Fatalf("WriteConfigFile() failed: %v", err) + } + + got, err := ReadConfigFile() + if err != nil { + t.Fatalf("ReadConfigFile() failed: %v", err) + } + + if got != want { + t.Errorf("round-trip mismatch:\n got %+v\n want %+v", got, want) + } +} + +func TestEnvsyncAPIURLOverridesSavedBackend(t *testing.T) { + t.Setenv(constants.EnvBackendURL, "") + t.Setenv("ENVSYNC_API_URL", "http://e2e.local") + + got := overrideConfigWithEnv(AppConfig{BackendURL: "https://invalid.example.test"}) + if got.BackendURL != "http://e2e.local" { + t.Fatalf("backend URL = %q, want ENVSYNC_API_URL", got.BackendURL) + } +} + +func TestDefaultBackendURLUsesLinkerVariable(t *testing.T) { + if got := DefaultConfig().BackendURL; got != backendURL { + t.Fatalf("DefaultConfig backend URL = %q, want linker value %q", got, backendURL) + } +} + +func TestReadConfigFileEmptyFallsBackToDefaults(t *testing.T) { + path := isolateConfigDir(t) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, nil, 0o644); err != nil { + t.Fatal(err) + } + + got, err := ReadConfigFile() + if err != nil { + t.Fatalf("ReadConfigFile() failed: %v", err) + } + if want := DefaultConfig().BackendURL; got.BackendURL != want { + t.Errorf("empty file backend_url: got %q, want %q", got.BackendURL, want) + } +} diff --git a/packages/envsync-cli/internal/constants/constants.go b/packages/envsync-cli/internal/constants/constants.go index f81dd9b4..bb65c133 100644 --- a/packages/envsync-cli/internal/constants/constants.go +++ b/packages/envsync-cli/internal/constants/constants.go @@ -1,8 +1,23 @@ package constants const ( - DefaultProjectConfig = "envsyncrc.toml" - LoggerKey = "logger" - TelemetryShutdownKey = "telemetry_shutdown" - RootSpanKey = "root_span" + DefaultProjectConfig = "envsyncrc.toml" + LoggerKey = "logger" + TelemetryShutdownKey = "telemetry_shutdown" +) + +// Environment variable keys +const ( + EnvBackendURL = "ENVSYNC_BACKEND_URL" + EnvOTELURL = "ENVSYNC_TELEMETRY_URL" + EnvOTELService = "ENVSYNC_OTEL_SERVICE" + EnvOTELDisabled = "ENVSYNC_OTEL_DISABLED" +) + +const ( + BackendURL = "https://api.envsync.cloud" + + OTELEndpoint = "https://t.envsync.cloud/obs/v1/traces" + OTELService = "envsync-cli" + OTELDisabled = false ) diff --git a/packages/envsync-cli/internal/domain/access.go b/packages/envsync-cli/internal/domain/access.go new file mode 100644 index 00000000..c9e6c9ca --- /dev/null +++ b/packages/envsync-cli/internal/domain/access.go @@ -0,0 +1,37 @@ +package domain + +import "strings" + +// AccessSubjectTypes lists the subject types `access app` accepts: a grant +// targets exactly one user or one team. +func AccessSubjectTypes() []string { + return []string{"user", "team"} +} + +// AccessRelations lists the access relations `access app` accepts, weakest +// first. +func AccessRelations() []string { + return []string{"viewer", "editor", "admin"} +} + +// ResolveAccessSubjectType matches user input against the accepted subject +// types. Matching ignores case and surrounding space. +func ResolveAccessSubjectType(input string) (string, bool) { + return resolveAccessOption(input, AccessSubjectTypes()) +} + +// ResolveAccessRelation matches user input against the accepted relations. +// Matching ignores case and surrounding space. +func ResolveAccessRelation(input string) (string, bool) { + return resolveAccessOption(input, AccessRelations()) +} + +func resolveAccessOption(input string, options []string) (string, bool) { + want := strings.ToLower(strings.TrimSpace(input)) + for _, o := range options { + if want == o { + return o, true + } + } + return "", false +} diff --git a/packages/envsync-cli/internal/domain/access_test.go b/packages/envsync-cli/internal/domain/access_test.go new file mode 100644 index 00000000..524bbafa --- /dev/null +++ b/packages/envsync-cli/internal/domain/access_test.go @@ -0,0 +1,47 @@ +package domain + +import "testing" + +func TestResolveAccessSubjectType(t *testing.T) { + tests := []struct { + input string + want string + wantOK bool + }{ + {input: "user", want: "user", wantOK: true}, + {input: "TEAM", want: "team", wantOK: true}, + {input: " Team ", want: "team", wantOK: true}, + {input: "admin", wantOK: false}, + {input: "group", wantOK: false}, + {input: "", wantOK: false}, + } + + for _, tt := range tests { + got, ok := ResolveAccessSubjectType(tt.input) + if ok != tt.wantOK || got != tt.want { + t.Errorf("ResolveAccessSubjectType(%q) = (%q, %v), want (%q, %v)", tt.input, got, ok, tt.want, tt.wantOK) + } + } +} + +func TestResolveAccessRelation(t *testing.T) { + tests := []struct { + input string + want string + wantOK bool + }{ + {input: "viewer", want: "viewer", wantOK: true}, + {input: "Editor", want: "editor", wantOK: true}, + {input: " ADMIN ", want: "admin", wantOK: true}, + {input: "owner", wantOK: false}, + {input: "read", wantOK: false}, + {input: "", wantOK: false}, + } + + for _, tt := range tests { + got, ok := ResolveAccessRelation(tt.input) + if ok != tt.wantOK || got != tt.want { + t.Errorf("ResolveAccessRelation(%q) = (%q, %v), want (%q, %v)", tt.input, got, ok, tt.want, tt.wantOK) + } + } +} diff --git a/packages/envsync-cli/internal/domain/application.go b/packages/envsync-cli/internal/domain/application.go index aee4bd32..70da1091 100644 --- a/packages/envsync-cli/internal/domain/application.go +++ b/packages/envsync-cli/internal/domain/application.go @@ -1,23 +1,59 @@ package domain -import "time" +import ( + "fmt" + "strings" + "time" +) + +// ParseMetadata turns repeated key=value entries into an application metadata +// map. Both the --metadata flag and the interactive form produce that shape. +func ParseMetadata(entries []string) (map[string]any, error) { + if len(entries) == 0 { + return nil, nil + } + + out := make(map[string]any, len(entries)) + for _, entry := range entries { + key, value, ok := strings.Cut(entry, "=") + if !ok { + return nil, fmt.Errorf("expected key=value, got %q", entry) + } + key = strings.TrimSpace(key) + if key == "" { + return nil, fmt.Errorf("empty key in %q", entry) + } + if _, duplicate := out[key]; duplicate { + return nil, fmt.Errorf("duplicate metadata key %q", key) + } + out[key] = strings.TrimSpace(value) + } + + return out, nil +} type Application struct { - ID string - Name string - Description string - Metadata map[string]any - OrgID string - EnvTypes []EnvType - EnvCount string - PublicKey string - EnableSecrets bool - IsManagedSecret bool - CreatedAt time.Time - UpdatedAt time.Time + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + OrgID string `json:"org_id,omitempty"` + EnvTypes []EnvType `json:"env_types,omitempty"` + EnvCount string `json:"env_count,omitempty"` + PublicKey string `json:"public_key,omitempty"` + EnableSecrets bool `json:"enable_secrets"` + IsManagedSecret bool `json:"is_managed_secret"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -func NewApplication(name, description, publicKey string, enableSecrets bool, metadata map[string]any) *Application { +func NewApplication( + name, + description, + publicKey string, + enableSecrets bool, + metadata map[string]any, +) *Application { return &Application{ Name: name, Description: description, @@ -26,3 +62,11 @@ func NewApplication(name, description, publicKey string, enableSecrets bool, met EnableSecrets: enableSecrets, } } + +var ( + DefaultEnvTypes = []EnvType{ + {Name: "Production", Color: "#ef4444", IsDefault: false}, + {Name: "Development", Color: "#22c55e", IsDefault: false}, + {Name: "Staging", Color: "#f59e0b", IsDefault: false}, + } +) diff --git a/packages/envsync-cli/internal/domain/envType.go b/packages/envsync-cli/internal/domain/envType.go index d3d7b3b1..761e671a 100644 --- a/packages/envsync-cli/internal/domain/envType.go +++ b/packages/envsync-cli/internal/domain/envType.go @@ -3,15 +3,15 @@ package domain import "time" type EnvType struct { - ID string - OrgID string - AppID string - Name string - IsDefault bool - IsProtected bool - Color string - CreatedAt time.Time - UpdatedAt time.Time + ID string `json:"id"` + OrgID string `json:"org_id,omitempty"` + AppID string `json:"app_id,omitempty"` + Name string `json:"name"` + IsDefault bool `json:"is_default"` + IsProtected bool `json:"is_protected"` + Color string `json:"color,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func NewEnvType(appID, name string, isDefault, isProtected bool, color string) *EnvType { diff --git a/packages/envsync-cli/internal/domain/gpg_key.go b/packages/envsync-cli/internal/domain/gpg_key.go index bc4fa250..95261be6 100644 --- a/packages/envsync-cli/internal/domain/gpg_key.go +++ b/packages/envsync-cli/internal/domain/gpg_key.go @@ -1,6 +1,73 @@ package domain -import "time" +import ( + "strings" + "time" +) + +// GpgAlgorithmOption is one selectable key algorithm for generation. The +// label is what users see; the value is what the API expects. +type GpgAlgorithmOption struct { + Label string + Value string + KeySize *int +} + +// GpgAlgorithmOptions lists the algorithms `gpg generate` accepts. RSA is +// pinned to 4096 bits; the ECC curves take no key size. +func GpgAlgorithmOptions() []GpgAlgorithmOption { + rsaKeySize := 4096 + return []GpgAlgorithmOption{ + {Label: "ECC Curve25519", Value: "ecc-curve25519"}, + {Label: "ECC P-256", Value: "ecc-p256"}, + {Label: "ECC P-384", Value: "ecc-p384"}, + {Label: "RSA 4096", Value: "rsa", KeySize: &rsaKeySize}, + } +} + +// ResolveGpgAlgorithm matches user input against the accepted algorithm list. +// Input may be a label ("RSA 4096") or an API value ("rsa"); matching ignores +// case and separator characters. +func ResolveGpgAlgorithm(input string) (GpgAlgorithmOption, bool) { + want := normalizeGpgAlgorithm(input) + if want == "" { + return GpgAlgorithmOption{}, false + } + for _, o := range GpgAlgorithmOptions() { + if want == normalizeGpgAlgorithm(o.Label) || want == normalizeGpgAlgorithm(o.Value) { + return o, true + } + } + return GpgAlgorithmOption{}, false +} + +func normalizeGpgAlgorithm(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + return strings.Map(func(r rune) rune { + switch r { + case ' ', '-', '_': + return -1 + } + return r + }, s) +} + +// SignModes lists the signing modes `gpg sign` accepts. +func SignModes() []string { + return []string{"binary", "text", "clearsign"} +} + +// ResolveSignMode matches user input against the accepted signing modes. +// Matching ignores case and surrounding space. +func ResolveSignMode(input string) (string, bool) { + got := strings.ToLower(strings.TrimSpace(input)) + for _, mode := range SignModes() { + if got == mode { + return mode, true + } + } + return "", false +} type GpgKey struct { ID string `json:"id"` diff --git a/packages/envsync-cli/internal/domain/gpg_key_test.go b/packages/envsync-cli/internal/domain/gpg_key_test.go new file mode 100644 index 00000000..67c34040 --- /dev/null +++ b/packages/envsync-cli/internal/domain/gpg_key_test.go @@ -0,0 +1,28 @@ +package domain + +import "testing" + +func TestResolveSignMode(t *testing.T) { + tests := []struct { + name string + input string + want string + wantOK bool + }{ + {name: "binary", input: "binary", want: "binary", wantOK: true}, + {name: "text", input: "text", want: "text", wantOK: true}, + {name: "clearsign", input: "clearsign", want: "clearsign", wantOK: true}, + {name: "case and padding are ignored", input: " ClearSign ", want: "clearsign", wantOK: true}, + {name: "unknown mode", input: "armored", want: "", wantOK: false}, + {name: "empty", input: "", want: "", wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ResolveSignMode(tt.input) + if ok != tt.wantOK || got != tt.want { + t.Errorf("ResolveSignMode(%q) = (%q, %v), want (%q, %v)", tt.input, got, ok, tt.want, tt.wantOK) + } + }) + } +} diff --git a/packages/envsync-cli/internal/domain/oidc_provider.go b/packages/envsync-cli/internal/domain/oidc_provider.go deleted file mode 100644 index 8326a8ed..00000000 --- a/packages/envsync-cli/internal/domain/oidc_provider.go +++ /dev/null @@ -1,28 +0,0 @@ -package domain - -type OidcProvider struct { - ID string - OrgID string - ProviderType string - IssuerURL string - Audience string - Enabled bool - AllowedSubjects []string - MachineUserID string - CreatedAt string - UpdatedAt string -} - -type CreateOidcProviderInput struct { - ProviderType string - IssuerURL string - Audience string - AllowedSubjects []string -} - -type UpdateOidcProviderInput struct { - ID string - Audience string - Enabled *bool - AllowedSubjects []string -} diff --git a/packages/envsync-cli/internal/domain/saml_provider.go b/packages/envsync-cli/internal/domain/saml_provider.go deleted file mode 100644 index 9d0d767b..00000000 --- a/packages/envsync-cli/internal/domain/saml_provider.go +++ /dev/null @@ -1,36 +0,0 @@ -package domain - -type SamlProvider struct { - ID string - OrgID string - ProviderType string - Name string - EntityID string - SsoURL string - Certificate string - Enabled bool - CreatedAt string - UpdatedAt string -} - -type CreateSamlProviderInput struct { - ProviderType string - Name string - EntityID string - SsoURL string - Certificate string -} - -type UpdateSamlProviderInput struct { - ID string - Name string - EntityID string - SsoURL string - Certificate string - Enabled *bool -} - -type SamlSsoResult struct { - RedirectURL string - RequestID string -} diff --git a/packages/envsync-cli/internal/domain/service_token.go b/packages/envsync-cli/internal/domain/service_token.go new file mode 100644 index 00000000..3323cf8c --- /dev/null +++ b/packages/envsync-cli/internal/domain/service_token.go @@ -0,0 +1,38 @@ +package domain + +import "time" + +type ServiceToken struct { + ID string `json:"id"` + // Name is the user-visible token label. + Name string `json:"name"` + // AppID / EnvTypeID scope the token; empty means unscoped. + AppID string `json:"app_id,omitempty"` + EnvTypeID string `json:"env_type_id,omitempty"` + // Permissions is nil when the user did not state any: the server default + // (read-only) then applies. Explicit answers are always sent, so a form + // "no/no" creates a token without permissions. + Permissions *ServiceTokenPermissions `json:"permissions,omitempty"` + Scopes []ServiceTokenScopes `json:"scopes"` + // Token holds the secret. It is only returned by create; list and get + // never see it again. + Token string `json:"token,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + RotatedFromID *string `json:"rotated_from_id,omitempty"` + GraceUntil *time.Time `json:"grace_until,omitempty"` + + // ExpiresInDays is create input only: 0 means "server default". + ExpiresInDays int `json:"-"` +} + +type ServiceTokenPermissions struct { + Read bool `json:"read"` + Write bool `json:"write"` +} + +type ServiceTokenScopes struct { + EnvTypeID string `json:"env_type_id"` + Path string `json:"path"` +} diff --git a/packages/envsync-cli/internal/features/commands/access_commands.go b/packages/envsync-cli/internal/features/commands/access_commands.go index 394942eb..fc64773f 100644 --- a/packages/envsync-cli/internal/features/commands/access_commands.go +++ b/packages/envsync-cli/internal/features/commands/access_commands.go @@ -19,7 +19,7 @@ func AccessCommands(handler *handlers.AccessHandler) *cli.Command { Name: "grant", Usage: "Grant direct user or team app access", Action: handler.GrantApp, - Flags: accessGrantFlags(), + Flags: accessGrantFormFlags(), }, { Name: "revoke", @@ -57,3 +57,14 @@ func accessGrantFlags() []cli.Flag { &cli.StringFlag{Name: "relation", Usage: "viewer, editor, or admin", Required: true}, } } + +// accessGrantFormFlags mirror accessGrantFlags but stay optional: `grant` +// collects missing values interactively. +func accessGrantFormFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{Name: "app-id", Usage: "Application ID (asked interactively when omitted)"}, + &cli.StringFlag{Name: "subject-id", Usage: "User or team ID (asked interactively when omitted)"}, + &cli.StringFlag{Name: "subject-type", Usage: "user or team (asked interactively when omitted)"}, + &cli.StringFlag{Name: "relation", Usage: "viewer, editor, or admin (asked interactively when omitted)"}, + } +} diff --git a/packages/envsync-cli/internal/features/commands/app_commands.go b/packages/envsync-cli/internal/features/commands/app_commands.go index 8f2e7832..8e2f165a 100644 --- a/packages/envsync-cli/internal/features/commands/app_commands.go +++ b/packages/envsync-cli/internal/features/commands/app_commands.go @@ -24,10 +24,23 @@ func CreateCommand(handler *handlers.AppHandler) *cli.Command { Name: "create", Usage: "Create a new application", Action: handler.Create, + Description: `Create a new application. + +Omit --name to be prompted interactively for the application details. + +Examples: + envsync app create --name my-app --description "Customer-facing API" + envsync app create -n my-app -d "Customer-facing API" --default-types + envsync app create -n my-app -d "Customer-facing API" --enable-secret + envsync app create -n my-app -d "Customer-facing API" -m team=core -m owner=platform + +Notes: + --metadata is repeatable and takes key=value pairs. + --public-key is only used together with --enable-secret.`, Flags: []cli.Flag{ &cli.StringFlag{ Name: "name", - Usage: "Application name", + Usage: "Application name; omit to be prompted interactively", Aliases: []string{"n"}, }, &cli.StringFlag{ @@ -37,23 +50,23 @@ func CreateCommand(handler *handlers.AppHandler) *cli.Command { }, &cli.StringSliceFlag{ Name: "metadata", - Usage: "Application metadata in key=value format", + Usage: "Metadata entry as a key=value pair (repeatable)", Aliases: []string{"m"}, }, &cli.BoolFlag{ Name: "default-types", - Usage: "Create PROD and DEV environment by default", - Aliases: []string{"dt"}, + Usage: "Also create the default PROD and DEV environment types", + Aliases: []string{"t"}, }, &cli.BoolFlag{ Name: "enable-secret", - Usage: "Its a boolean to confirm you want to enable secrets for your application", - Aliases: []string{"es"}, + Usage: "Enable encryption for secrets in this application", + Aliases: []string{"s"}, }, &cli.StringFlag{ Name: "public-key", - Usage: "Public key for the application, used for encryption.(Note: If not provided then a new key will be generated and will be managed by EnvSync)", - Aliases: []string{"pk"}, + Usage: "Public key for secret encryption; omit to let EnvSync generate and manage one. Requires --enable-secret", + Aliases: []string{"p"}, Value: "", }, }, diff --git a/packages/envsync-cli/internal/features/commands/certificate_commands.go b/packages/envsync-cli/internal/features/commands/certificate_commands.go index 5470d415..577f4e4e 100644 --- a/packages/envsync-cli/internal/features/commands/certificate_commands.go +++ b/packages/envsync-cli/internal/features/commands/certificate_commands.go @@ -56,8 +56,8 @@ func certCACommands(handler *handlers.CertificateHandler) *cli.Command { func certIssueCommand(handler *handlers.CertificateHandler) *cli.Command { return &cli.Command{ - Name: "issue", - Usage: "Issue a member or service certificate", + Name: "issue", + Usage: "Issue a member or service certificate", Action: handler.IssueCert, Flags: []cli.Flag{ &cli.StringFlag{ diff --git a/packages/envsync-cli/internal/features/commands/environment_commands.go b/packages/envsync-cli/internal/features/commands/environment_commands.go index 877e1167..d72fc1de 100644 --- a/packages/envsync-cli/internal/features/commands/environment_commands.go +++ b/packages/envsync-cli/internal/features/commands/environment_commands.go @@ -25,11 +25,6 @@ func SwitchEnvironmentCommand(handlers *handlers.EnvironmentHandler) *cli.Comman Usage: "Switch to a different environment", Action: handlers.SwitchEnvironment, Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "app-id", - Usage: "ID of the application to switch environment for", - Required: false, - }, &cli.StringFlag{ Name: "env-id", Usage: "ID of the environment to switch to", @@ -47,9 +42,8 @@ func GetAllEnvironmentsCommand(handlers *handlers.EnvironmentHandler) *cli.Comma Action: handlers.GetAllEnvironments, Flags: []cli.Flag{ &cli.StringFlag{ - Name: "app-id", - Usage: "ID of the application to list environments for", - Required: true, + Name: "app-id", + Usage: "ID of the application to list environments for (defaults to app_id in ./envsyncrc.toml)", }, }, } diff --git a/packages/envsync-cli/internal/features/commands/gpg_key_commands.go b/packages/envsync-cli/internal/features/commands/gpg_key_commands.go index 42ebdc11..b4a55e2e 100644 --- a/packages/envsync-cli/internal/features/commands/gpg_key_commands.go +++ b/packages/envsync-cli/internal/features/commands/gpg_key_commands.go @@ -39,24 +39,18 @@ func gpgGenerateCommand(handler *handlers.GpgKeyHandler) *cli.Command { Action: handler.Generate, Flags: []cli.Flag{ &cli.StringFlag{ - Name: "name", - Usage: "Key owner name", - Required: true, + Name: "name", + Usage: "Key owner name (asked interactively when omitted)", }, &cli.StringFlag{ - Name: "email", - Usage: "Key owner email", - Required: true, + Name: "email", + Usage: "Key owner email (asked interactively when omitted)", }, &cli.StringFlag{ Name: "algorithm", - Usage: "Key algorithm (ecc-curve25519, rsa, ecc-p256, ecc-p384)", + Usage: "Key algorithm (ECC Curve25519, ECC P-256, ECC P-384, RSA 4096)", Value: "ecc-curve25519", }, - &cli.IntFlag{ - Name: "key-size", - Usage: "Key size in bits (for RSA algorithm)", - }, &cli.IntFlag{ Name: "expires-in-days", Usage: "Key expiration in days", @@ -76,9 +70,8 @@ func gpgSignCommand(handler *handlers.GpgKeyHandler) *cli.Command { Action: handler.Sign, Flags: []cli.Flag{ &cli.StringFlag{ - Name: "key-id", - Usage: "GPG key ID to sign with", - Required: true, + Name: "key-id", + Usage: "GPG key ID to sign with (selected interactively when omitted)", }, &cli.StringFlag{ Name: "file", diff --git a/packages/envsync-cli/internal/features/commands/log_forwarding_commands.go b/packages/envsync-cli/internal/features/commands/log_forwarding_commands.go deleted file mode 100644 index 5b5178a4..00000000 --- a/packages/envsync-cli/internal/features/commands/log_forwarding_commands.go +++ /dev/null @@ -1,100 +0,0 @@ -package commands - -import ( - "github.com/urfave/cli/v3" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/handlers" -) - -// LogForwardingCommands returns all log-forwarding-related commands -func LogForwardingCommands(handler *handlers.LogForwardingHandler) *cli.Command { - return &cli.Command{ - Name: "log-forwarding", - Aliases: []string{"lf"}, - Usage: "Manage log forwarding configs", - Commands: []*cli.Command{ - logForwardingCreateCommand(handler), - logForwardingListCommand(handler), - logForwardingGetCommand(handler), - logForwardingDeleteCommand(handler), - }, - } -} - -func logForwardingCreateCommand(handler *handlers.LogForwardingHandler) *cli.Command { - return &cli.Command{ - Name: "create", - Usage: "Create a new log forwarding config", - Action: handler.Create, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "name", - Usage: "Config name", - Aliases: []string{"n"}, - Required: true, - }, - &cli.StringFlag{ - Name: "target", - Usage: "Log forwarding target (datadog|splunk|sumo-logic)", - Aliases: []string{"t"}, - Required: true, - }, - &cli.StringFlag{ - Name: "endpoint-url", - Usage: "Endpoint URL for the log forwarding target", - Aliases: []string{"eu"}, - Required: true, - }, - &cli.StringFlag{ - Name: "api-key", - Usage: "API key for the log forwarding target", - Aliases: []string{"ak"}, - Required: true, - }, - &cli.BoolFlag{ - Name: "enabled", - Usage: "Enable the log forwarding config", - Aliases: []string{"e"}, - Value: true, - }, - }, - } -} - -func logForwardingListCommand(handler *handlers.LogForwardingHandler) *cli.Command { - return &cli.Command{ - Name: "list", - Usage: "List all log forwarding configs", - Action: handler.List, - } -} - -func logForwardingGetCommand(handler *handlers.LogForwardingHandler) *cli.Command { - return &cli.Command{ - Name: "get", - Usage: "Get a specific log forwarding config", - Action: handler.Get, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "id", - Usage: "Log forwarding config ID", - Required: true, - }, - }, - } -} - -func logForwardingDeleteCommand(handler *handlers.LogForwardingHandler) *cli.Command { - return &cli.Command{ - Name: "delete", - Usage: "Delete a log forwarding config", - Action: handler.Delete, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "id", - Usage: "Log forwarding config ID", - Required: true, - }, - }, - } -} diff --git a/packages/envsync-cli/internal/features/commands/oidc_commands.go b/packages/envsync-cli/internal/features/commands/oidc_commands.go deleted file mode 100644 index d0fc7882..00000000 --- a/packages/envsync-cli/internal/features/commands/oidc_commands.go +++ /dev/null @@ -1,116 +0,0 @@ -package commands - -import ( - "github.com/urfave/cli/v3" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/handlers" -) - -func OidcCommands(handler *handlers.OidcHandler) *cli.Command { - return &cli.Command{ - Name: "oidc", - Usage: "Manage OIDC providers for machine auth", - Commands: []*cli.Command{ - oidcCreateCommand(handler), - oidcListCommand(handler), - oidcGetCommand(handler), - oidcUpdateCommand(handler), - oidcDeleteCommand(handler), - }, - } -} - -func oidcCreateCommand(handler *handlers.OidcHandler) *cli.Command { - return &cli.Command{ - Name: "create", - Usage: "Register a new OIDC provider", - Action: handler.Create, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "name", - Usage: "OIDC provider type (github_actions, gitlab_ci, kubernetes, generic)", - Aliases: []string{"n"}, - Required: true, - }, - &cli.StringFlag{ - Name: "issuer-url", - Usage: "OIDC issuer URL", - Required: true, - }, - &cli.StringFlag{ - Name: "audience", - Usage: "Expected audience claim value", - Required: true, - }, - &cli.StringFlag{ - Name: "allowed-subjects", - Usage: "Comma-separated subject patterns to allow (glob matching)", - }, - }, - } -} - -func oidcListCommand(handler *handlers.OidcHandler) *cli.Command { - return &cli.Command{ - Name: "list", - Usage: "List all OIDC providers", - Action: handler.List, - } -} - -func oidcGetCommand(handler *handlers.OidcHandler) *cli.Command { - return &cli.Command{ - Name: "get", - Usage: "Get details of a specific OIDC provider", - Action: handler.Get, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "id", - Usage: "OIDC provider ID", - Required: true, - }, - }, - } -} - -func oidcUpdateCommand(handler *handlers.OidcHandler) *cli.Command { - return &cli.Command{ - Name: "update", - Usage: "Update an existing OIDC provider", - Action: handler.Update, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "id", - Usage: "OIDC provider ID", - Required: true, - }, - &cli.StringFlag{ - Name: "audience", - Usage: "Updated audience claim value", - }, - &cli.BoolFlag{ - Name: "enabled", - Usage: "Enable or disable the provider", - }, - &cli.StringFlag{ - Name: "allowed-subjects", - Usage: "Comma-separated subject patterns to allow", - }, - }, - } -} - -func oidcDeleteCommand(handler *handlers.OidcHandler) *cli.Command { - return &cli.Command{ - Name: "delete", - Usage: "Delete an OIDC provider", - Action: handler.Delete, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "id", - Usage: "OIDC provider ID", - Required: true, - }, - }, - } -} diff --git a/packages/envsync-cli/internal/features/commands/root.go b/packages/envsync-cli/internal/features/commands/root.go index effa2185..860ed039 100644 --- a/packages/envsync-cli/internal/features/commands/root.go +++ b/packages/envsync-cli/internal/features/commands/root.go @@ -9,6 +9,7 @@ import ( "go.opentelemetry.io/otel/trace" "go.uber.org/zap" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/config" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/constants" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/handlers" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/logger" @@ -24,26 +25,23 @@ const ( ) type CommandRegistry struct { - appHandler *handlers.AppHandler - authHandler *handlers.AuthHandler - configHandler *handlers.ConfigHandler - environmentHandler *handlers.EnvironmentHandler - syncHandler *handlers.SyncHandler - exportHandler *handlers.ExportHandler - initHandler *handlers.InitHandler - runHandler *handlers.RunHandler - genPEMKeyHandler *handlers.GenPEMKeyHandler - gpgKeyHandler *handlers.GpgKeyHandler - certificateHandler *handlers.CertificateHandler - teamHandler *handlers.TeamHandler - accessHandler *handlers.AccessHandler - requestHandler *handlers.RequestHandler - serviceTokenHandler *handlers.ServiceTokenHandler - oidcHandler *handlers.OidcHandler - samlHandler *handlers.SamlHandler - rotationHandler *handlers.RotationHandler - dynamicSecretHandler *handlers.DynamicSecretHandler - logForwardingHandler *handlers.LogForwardingHandler + appHandler *handlers.AppHandler + authHandler *handlers.AuthHandler + configHandler *handlers.ConfigHandler + environmentHandler *handlers.EnvironmentHandler + syncHandler *handlers.SyncHandler + exportHandler *handlers.ExportHandler + initHandler *handlers.InitHandler + runHandler *handlers.RunHandler + genPEMKeyHandler *handlers.GenPEMKeyHandler + gpgKeyHandler *handlers.GpgKeyHandler + certificateHandler *handlers.CertificateHandler + teamHandler *handlers.TeamHandler + accessHandler *handlers.AccessHandler + requestHandler *handlers.RequestHandler + serviceTokenHandler *handlers.ServiceTokenHandler + rotationHandler *handlers.RotationHandler + dynamicSecretHandler *handlers.DynamicSecretHandler } func NewCommandRegistry( @@ -62,40 +60,34 @@ func NewCommandRegistry( accessHandler *handlers.AccessHandler, requestHandler *handlers.RequestHandler, serviceTokenHandler *handlers.ServiceTokenHandler, - oidcHandler *handlers.OidcHandler, - samlHandler *handlers.SamlHandler, rotationHandler *handlers.RotationHandler, dynamicSecretHandler *handlers.DynamicSecretHandler, - logForwardingHandler *handlers.LogForwardingHandler, ) *CommandRegistry { return &CommandRegistry{ - appHandler: appHandler, - authHandler: authHandler, - configHandler: configHandler, - environmentHandler: environmentHandler, - syncHandler: syncHandler, - exportHandler: exportHandler, - initHandler: initHandler, - runHandler: runHandler, - genPEMKeyHandler: genPEMKeyHandler, - gpgKeyHandler: gpgKeyHandler, - certificateHandler: certificateHandler, - teamHandler: teamHandler, - accessHandler: accessHandler, - requestHandler: requestHandler, - serviceTokenHandler: serviceTokenHandler, - oidcHandler: oidcHandler, - samlHandler: samlHandler, - rotationHandler: rotationHandler, - dynamicSecretHandler: dynamicSecretHandler, - logForwardingHandler: logForwardingHandler, + appHandler: appHandler, + authHandler: authHandler, + configHandler: configHandler, + environmentHandler: environmentHandler, + syncHandler: syncHandler, + exportHandler: exportHandler, + initHandler: initHandler, + runHandler: runHandler, + genPEMKeyHandler: genPEMKeyHandler, + gpgKeyHandler: gpgKeyHandler, + certificateHandler: certificateHandler, + teamHandler: teamHandler, + accessHandler: accessHandler, + requestHandler: requestHandler, + serviceTokenHandler: serviceTokenHandler, + rotationHandler: rotationHandler, + dynamicSecretHandler: dynamicSecretHandler, } } var version = "dev" func (r *CommandRegistry) RegisterCLI() *cli.Command { - return &cli.Command{ + root := &cli.Command{ Name: "envsync", Usage: "EnvSync CLI for managing applications and configurations", Version: version, @@ -129,42 +121,61 @@ func (r *CommandRegistry) RegisterCLI() *cli.Command { AccessCommands(r.accessHandler), RequestCommands(r.requestHandler), ServiceTokenCommands(r.serviceTokenHandler), - OidcCommands(r.oidcHandler), - SamlCommands(r.samlHandler), RotationCommands(r.rotationHandler), DynamicSecretCommands(r.dynamicSecretHandler), - LogForwardingCommands(r.logForwardingHandler), }, } + return observe(root, "") +} + +// observe wraps every command action in the tree with a root span named after +// the full command path ("cli/app/create") plus command start/finish logs, and +// records any error the action returns on that span before it becomes the exit +// status. Child spans created by use cases nest underneath because the span +// context flows through the action ctx. +func observe(c *cli.Command, parentPath string) *cli.Command { + full := c.Name + if parentPath != "" { + full = parentPath + "/" + c.Name + } + if c.Action != nil { + action := c.Action + c.Action = func(ctx context.Context, cmd *cli.Command) (err error) { + ctx, end := telemetry.StartSpan(ctx, "cli/"+full, + trace.WithAttributes(attribute.String("cli.command", full))) + defer end(&err) + + l := logger.FromContext(ctx) + l.Info("command started", zap.String("command", full)) + err = action(ctx, cmd) + if err == nil { + l.Info("command finished", zap.String("command", full)) + } + return err + } + } + for _, sub := range c.Commands { + observe(sub, full) + } + return c } func (r *CommandRegistry) beforeHook(ctx context.Context, cmd *cli.Command) (context.Context, error) { + // Initialise app config + _ = config.New() + // Initialise OpenTelemetry (graceful degradation on failure) shutdown, lp, _ := telemetry.Init(ctx) ctx = context.WithValue(ctx, constants.TelemetryShutdownKey, shutdown) - // Start root span for the CLI command - cmdName := "cli" - if cmd.Name != "" { - cmdName = "cli/" + cmd.Name - } - var span trace.Span - ctx, span = telemetry.Tracer().Start(ctx, cmdName, - trace.WithAttributes(attribute.String("cli.command", cmd.Name)), - ) - ctx = context.WithValue(ctx, constants.RootSpanKey, span) - l := logger.NewLogger(lp) return context.WithValue(ctx, constants.LoggerKey, l), nil } func (r *CommandRegistry) afterHook(ctx context.Context, cmd *cli.Command) error { - // End root span - if span, ok := ctx.Value(constants.RootSpanKey).(trace.Span); ok && span != nil { - span.End() - } - - // Sync logger + // Sync logger. This runs after the command action's span has ended, and + // before the exporters shut down, so nothing written on the way out is + // lost. if l, ok := ctx.Value(constants.LoggerKey).(*zap.Logger); ok && l != nil { l.Sync() } diff --git a/packages/envsync-cli/internal/features/commands/rotation_commands.go b/packages/envsync-cli/internal/features/commands/rotation_commands.go index e407f9af..c3033370 100644 --- a/packages/envsync-cli/internal/features/commands/rotation_commands.go +++ b/packages/envsync-cli/internal/features/commands/rotation_commands.go @@ -11,11 +11,8 @@ func RotationCommands(handler *handlers.RotationHandler) *cli.Command { Name: "rotation", Usage: "Manage secret rotation policies", Commands: []*cli.Command{ - rotationCreateCommand(handler), rotationListCommand(handler), rotationGetCommand(handler), - rotationUpdateCommand(handler), - rotationDeleteCommand(handler), rotationTriggerCommand(handler), rotationStatesCommand(handler), rotationRevokeExpiredCommand(handler), @@ -23,37 +20,6 @@ func RotationCommands(handler *handlers.RotationHandler) *cli.Command { } } -func rotationCreateCommand(handler *handlers.RotationHandler) *cli.Command { - return &cli.Command{ - Name: "create", - Usage: "Create a new rotation policy", - Action: handler.Create, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "name", - Usage: "Variable key name for the rotation policy", - Aliases: []string{"n"}, - }, - &cli.StringFlag{ - Name: "engine", - Usage: "Engine type (postgres|mysql|aws-iam|azure-sp|gcp-service-account|cloudflare-pages|sendgrid|twilio)", - }, - &cli.StringFlag{ - Name: "secret-id", - Usage: "Application ID for the secret to rotate", - }, - &cli.StringFlag{ - Name: "schedule", - Usage: "Cron expression for rotation schedule", - }, - &cli.StringFlag{ - Name: "config", - Usage: "Connection config as JSON string", - }, - }, - } -} - func rotationListCommand(handler *handlers.RotationHandler) *cli.Command { return &cli.Command{ Name: "list", @@ -76,51 +42,6 @@ func rotationGetCommand(handler *handlers.RotationHandler) *cli.Command { } } -func rotationUpdateCommand(handler *handlers.RotationHandler) *cli.Command { - return &cli.Command{ - Name: "update", - Usage: "Update a rotation policy", - Action: handler.Update, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "id", - Usage: "Rotation policy ID (required)", - }, - &cli.StringFlag{ - Name: "name", - Usage: "New variable key name", - Aliases: []string{"n"}, - }, - &cli.StringFlag{ - Name: "schedule", - Usage: "New cron expression for rotation schedule", - }, - &cli.BoolFlag{ - Name: "enabled", - Usage: "Enable or disable the rotation policy", - }, - &cli.StringFlag{ - Name: "config", - Usage: "Updated connection config as JSON string", - }, - }, - } -} - -func rotationDeleteCommand(handler *handlers.RotationHandler) *cli.Command { - return &cli.Command{ - Name: "delete", - Usage: "Delete a rotation policy", - Action: handler.Delete, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "id", - Usage: "Rotation policy ID (required)", - }, - }, - } -} - func rotationTriggerCommand(handler *handlers.RotationHandler) *cli.Command { return &cli.Command{ Name: "trigger", diff --git a/packages/envsync-cli/internal/features/commands/saml_commands.go b/packages/envsync-cli/internal/features/commands/saml_commands.go deleted file mode 100644 index 2853f4a8..00000000 --- a/packages/envsync-cli/internal/features/commands/saml_commands.go +++ /dev/null @@ -1,166 +0,0 @@ -package commands - -import ( - "github.com/urfave/cli/v3" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/handlers" -) - -func SamlCommands(handler *handlers.SamlHandler) *cli.Command { - return &cli.Command{ - Name: "saml", - Usage: "Manage SAML SSO providers", - Commands: []*cli.Command{ - samlCreateCommand(handler), - samlListCommand(handler), - samlGetCommand(handler), - samlUpdateCommand(handler), - samlDeleteCommand(handler), - samlMetadataCommand(handler), - samlSsoCommand(handler), - }, - } -} - -func samlCreateCommand(handler *handlers.SamlHandler) *cli.Command { - return &cli.Command{ - Name: "create", - Usage: "Register a new SAML identity provider", - Action: handler.Create, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "name", - Usage: "Human-readable name for the provider", - Aliases: []string{"n"}, - Required: true, - }, - &cli.StringFlag{ - Name: "provider-type", - Usage: "SAML IdP type (okta, onelogin, azure-ad, google-workspace, duo, rippling, oracle, ping-identity)", - Required: true, - }, - &cli.StringFlag{ - Name: "entity-id", - Usage: "SAML entity ID (issuer) from the IdP metadata", - Required: true, - }, - &cli.StringFlag{ - Name: "sso-url", - Usage: "IdP SSO login URL", - Required: true, - }, - &cli.StringFlag{ - Name: "certificate", - Usage: "IdP X.509 certificate (PEM format) for signature validation", - Required: true, - }, - }, - } -} - -func samlListCommand(handler *handlers.SamlHandler) *cli.Command { - return &cli.Command{ - Name: "list", - Usage: "List all SAML providers", - Action: handler.List, - } -} - -func samlGetCommand(handler *handlers.SamlHandler) *cli.Command { - return &cli.Command{ - Name: "get", - Usage: "Get details of a specific SAML provider", - Action: handler.Get, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "id", - Usage: "SAML provider ID", - Required: true, - }, - }, - } -} - -func samlUpdateCommand(handler *handlers.SamlHandler) *cli.Command { - return &cli.Command{ - Name: "update", - Usage: "Update an existing SAML provider", - Action: handler.Update, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "id", - Usage: "SAML provider ID", - Required: true, - }, - &cli.StringFlag{ - Name: "name", - Usage: "Updated provider name", - }, - &cli.StringFlag{ - Name: "entity-id", - Usage: "Updated entity ID", - }, - &cli.StringFlag{ - Name: "sso-url", - Usage: "Updated SSO URL", - }, - &cli.StringFlag{ - Name: "certificate", - Usage: "Updated X.509 certificate (PEM format)", - }, - &cli.BoolFlag{ - Name: "enabled", - Usage: "Enable or disable the provider", - }, - }, - } -} - -func samlDeleteCommand(handler *handlers.SamlHandler) *cli.Command { - return &cli.Command{ - Name: "delete", - Usage: "Delete a SAML provider", - Action: handler.Delete, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "id", - Usage: "SAML provider ID", - Required: true, - }, - }, - } -} - -func samlMetadataCommand(handler *handlers.SamlHandler) *cli.Command { - return &cli.Command{ - Name: "metadata", - Usage: "Retrieve public SAML Service Provider metadata XML (requires a logged-in session to resolve --org-slug to an org id)", - Action: handler.Metadata, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "org-slug", - Usage: "Organization slug (resolved via whoami; login required)", - Required: true, - }, - }, - } -} - -func samlSsoCommand(handler *handlers.SamlHandler) *cli.Command { - return &cli.Command{ - Name: "sso", - Usage: "Initiate public SP-initiated SAML SSO flow", - Action: handler.Sso, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "org-slug", - Usage: "Organization slug", - Required: true, - }, - &cli.StringFlag{ - Name: "provider-id", - Usage: "Optional SAML provider ID (must belong to the organization and be enabled)", - }, - }, - } -} diff --git a/packages/envsync-cli/internal/features/commands/service_token_commands.go b/packages/envsync-cli/internal/features/commands/service_token_commands.go index 86c2d04c..5b990a6a 100644 --- a/packages/envsync-cli/internal/features/commands/service_token_commands.go +++ b/packages/envsync-cli/internal/features/commands/service_token_commands.go @@ -27,18 +27,17 @@ func serviceTokenCreateCommand(handler *handlers.ServiceTokenHandler) *cli.Comma Action: handler.Create, Flags: []cli.Flag{ &cli.StringFlag{ - Name: "name", - Usage: "Service token name", - Aliases: []string{"n"}, - Required: true, + Name: "name", + Usage: "Service token name (asked interactively when omitted)", + Aliases: []string{"n"}, }, &cli.StringFlag{ Name: "app-id", - Usage: "Application ID to scope the token to", + Usage: "Application ID to scope the token to (selected interactively when omitted)", }, &cli.StringFlag{ Name: "env-type-id", - Usage: "Environment type ID to scope the token to", + Usage: "Environment type ID to scope the token to (selected interactively when omitted)", }, &cli.BoolFlag{ Name: "read", @@ -71,9 +70,8 @@ func serviceTokenGetCommand(handler *handlers.ServiceTokenHandler) *cli.Command Action: handler.Get, Flags: []cli.Flag{ &cli.StringFlag{ - Name: "id", - Usage: "Service token ID", - Required: true, + Name: "id", + Usage: "Service token ID (selected interactively when omitted)", }, }, } @@ -86,9 +84,8 @@ func serviceTokenDeleteCommand(handler *handlers.ServiceTokenHandler) *cli.Comma Action: handler.Delete, Flags: []cli.Flag{ &cli.StringFlag{ - Name: "id", - Usage: "Service token ID", - Required: true, + Name: "id", + Usage: "Service token ID (selected interactively when omitted)", }, }, } diff --git a/packages/envsync-cli/internal/features/handlers/access_handler.go b/packages/envsync-cli/internal/features/handlers/access_handler.go index cccff98b..2a403dc8 100644 --- a/packages/envsync-cli/internal/features/handlers/access_handler.go +++ b/packages/envsync-cli/internal/features/handlers/access_handler.go @@ -2,36 +2,217 @@ package handlers import ( "context" + "errors" "fmt" + "os" "github.com/urfave/cli/v3" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/formatters" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/spinner" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/factory" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository" ) type AccessHandler struct { repo repository.AccessRepository + appRepo repository.ApplicationRepository + userRepo repository.UserRepository + teamRepo repository.TeamRepository formatter *formatters.BaseFormatter + tui *factory.AccessFactory } -func NewAccessHandler() *AccessHandler { +func NewAccessHandler(tui *factory.AccessFactory) *AccessHandler { return &AccessHandler{ repo: repository.NewAccessRepository(), + appRepo: repository.NewApplicationRepository(), + userRepo: repository.NewUserRepository(), + teamRepo: repository.NewTeamRepository(), formatter: formatters.NewBaseFormatter(), + tui: tui, } } func (h *AccessHandler) GrantApp(ctx context.Context, cmd *cli.Command) error { - if err := h.repo.GrantAppAccess(ctx, cmd.String("app-id"), cmd.String("subject-id"), cmd.String("subject-type"), cmd.String("relation")); err != nil { - return h.formatter.FormatError(cmd.Writer, err.Error()) + appID := cmd.String("app-id") + subjectID := cmd.String("subject-id") + subjectType := cmd.String("subject-type") + relation := cmd.String("relation") + + // Flag values are held to the same list the TUI steps offer. + if subjectType != "" { + resolved, ok := domain.ResolveAccessSubjectType(subjectType) + if !ok { + return h.formatter.FormatError(cmd.ErrWriter, fmt.Sprintf("invalid subject-type %q: must be user or team", subjectType)) + } + subjectType = resolved + } + if relation != "" { + resolved, ok := domain.ResolveAccessRelation(relation) + if !ok { + return h.formatter.FormatError(cmd.ErrWriter, fmt.Sprintf("invalid relation %q: must be viewer, editor or admin", relation)) + } + relation = resolved + } + + // Flags alone can carry a run when they cover every field; the wizard + // collects what is missing: application, then the subject (its type first, + // then the user or team table), then the relation. JSON output is for + // scripts: it takes flag values only and never opens the TUI. + if appID == "" || subjectID == "" || subjectType == "" || relation == "" { + if cmd.Bool("json") { + return h.formatter.FormatError(cmd.ErrWriter, "app-id, subject-id, subject-type and relation must be provided with json flag") + } + if !spinner.IsTerminal(os.Stdin) { + return h.formatter.FormatError(cmd.ErrWriter, "app-id, subject-id, subject-type and relation are required; provide --app-id, --subject-id, --subject-type and --relation flags") + } + + if appID == "" { + apps, err := h.fetchApps(ctx) + if err != nil { + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) + } + id, ok, err := h.tui.PickApplication(apps, "Select the application") + if err != nil { + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) + } + if !ok { + return h.formatter.FormatError(cmd.ErrWriter, "access grant cancelled by user") + } + appID = id + } + + if subjectType == "" { + value, ok, err := h.tui.SelectSubjectType() + if err != nil { + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) + } + if !ok { + return h.formatter.FormatError(cmd.ErrWriter, "access grant cancelled by user") + } + subjectType = value + } + + if subjectID == "" { + if subjectType == "team" { + teams, err := h.fetchTeams(ctx) + if err != nil { + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) + } + id, ok, err := h.tui.PickTeam(teams, "Select the team") + if err != nil { + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) + } + if !ok { + return h.formatter.FormatError(cmd.ErrWriter, "access grant cancelled by user") + } + subjectID = id + } else { + users, err := h.fetchUsers(ctx) + if err != nil { + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) + } + id, ok, err := h.tui.PickUser(users, "Select the user") + if err != nil { + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) + } + if !ok { + return h.formatter.FormatError(cmd.ErrWriter, "access grant cancelled by user") + } + subjectID = id + } + } + + if relation == "" { + value, ok, err := h.tui.SelectRelation() + if err != nil { + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) + } + if !ok { + return h.formatter.FormatError(cmd.ErrWriter, "access grant cancelled by user") + } + relation = value + } + } + + if err := h.repo.GrantAppAccess(ctx, appID, subjectID, subjectType, relation); err != nil { + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } return h.formatter.FormatSuccess(cmd.Writer, "App access granted") } +// fetchApps loads the applications with the shared loader presentation. +func (h *AccessHandler) fetchApps(ctx context.Context) ([]factory.AccessChoice, error) { + sp := spinner.New("Fetching applications…", spinner.IsTerminal(os.Stderr), os.Stderr) + sp.Start() + defer sp.Stop() + + apps, err := h.appRepo.GetAll(ctx) + if err != nil { + return nil, err + } + if len(apps) == 0 { + return nil, errors.New("no applications available; create one with 'envsync app create'") + } + + choices := make([]factory.AccessChoice, 0, len(apps)) + for _, a := range apps { + choices = append(choices, factory.AccessChoice{ID: a.ID, Cells: []string{a.Name, a.Description}}) + } + return choices, nil +} + +// fetchUsers loads the organization users with the shared loader presentation. +func (h *AccessHandler) fetchUsers(ctx context.Context) ([]factory.AccessChoice, error) { + sp := spinner.New("Fetching users…", spinner.IsTerminal(os.Stderr), os.Stderr) + sp.Start() + defer sp.Stop() + + users, err := h.userRepo.GetAll(ctx) + if err != nil { + return nil, err + } + if len(users) == 0 { + return nil, errors.New("no users available") + } + + choices := make([]factory.AccessChoice, 0, len(users)) + for _, u := range users { + choices = append(choices, factory.AccessChoice{ID: u.ID, Cells: []string{u.Email}}) + } + return choices, nil +} + +// fetchTeams loads the organization teams with the shared loader presentation. +func (h *AccessHandler) fetchTeams(ctx context.Context) ([]factory.AccessChoice, error) { + sp := spinner.New("Fetching teams…", spinner.IsTerminal(os.Stderr), os.Stderr) + sp.Start() + defer sp.Stop() + + teams, err := h.teamRepo.List(ctx) + if err != nil { + return nil, err + } + if len(teams) == 0 { + return nil, errors.New("no teams available") + } + + choices := make([]factory.AccessChoice, 0, len(teams)) + for _, t := range teams { + description := "" + if t.Description != nil { + description = *t.Description + } + choices = append(choices, factory.AccessChoice{ID: t.ID, Cells: []string{t.Name, description}}) + } + return choices, nil +} + func (h *AccessHandler) RevokeApp(ctx context.Context, cmd *cli.Command) error { if err := h.repo.RevokeAppAccess(ctx, cmd.String("app-id"), cmd.String("subject-id"), cmd.String("subject-type"), cmd.String("relation")); err != nil { - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } return h.formatter.FormatSuccess(cmd.Writer, "App access revoked") } @@ -39,7 +220,7 @@ func (h *AccessHandler) RevokeApp(ctx context.Context, cmd *cli.Command) error { func (h *AccessHandler) GrantsApp(ctx context.Context, cmd *cli.Command) error { grants, err := h.repo.ListAppGrants(ctx, cmd.String("app-id")) if err != nil { - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } if cmd.Bool("json") { return h.formatter.FormatJSON(cmd.Writer, grants) @@ -53,7 +234,7 @@ func (h *AccessHandler) GrantsApp(ctx context.Context, cmd *cli.Command) error { func (h *AccessHandler) EffectiveApp(ctx context.Context, cmd *cli.Command) error { entries, err := h.repo.EffectiveAppAccess(ctx, cmd.String("app-id")) if err != nil { - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } if cmd.Bool("json") { return h.formatter.FormatJSON(cmd.Writer, entries) diff --git a/packages/envsync-cli/internal/features/handlers/app_handler.go b/packages/envsync-cli/internal/features/handlers/app_handler.go index 79e0641c..439c7370 100644 --- a/packages/envsync-cli/internal/features/handlers/app_handler.go +++ b/packages/envsync-cli/internal/features/handlers/app_handler.go @@ -4,12 +4,15 @@ import ( "context" "errors" "fmt" + "os" "github.com/urfave/cli/v3" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/app" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/formatters" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/spinner" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/factory" ) type AppHandler struct { @@ -17,6 +20,7 @@ type AppHandler struct { deleteUseCase app.DeleteAppUseCase listUseCase app.ListAppsUseCase formatter *formatters.AppFormatter + tui *factory.AppFactory } func NewAppHandler( @@ -24,12 +28,14 @@ func NewAppHandler( deleteUseCase app.DeleteAppUseCase, listUseCase app.ListAppsUseCase, formatter *formatters.AppFormatter, + tui *factory.AppFactory, ) *AppHandler { return &AppHandler{ createUseCase: createUseCase, deleteUseCase: deleteUseCase, listUseCase: listUseCase, formatter: formatter, + tui: tui, } } @@ -41,49 +47,56 @@ func (h *AppHandler) Create(ctx context.Context, cmd *cli.Command) error { if cmd.IsSet("description") { application.Description = cmd.String("description") } - if cmd.IsSet("metadata") { - metadata := cmd.String("metadata") - if metadata != "" { - metadataMap := make(map[string]any) - application.Metadata = metadataMap - } + + metadata, err := domain.ParseMetadata(cmd.StringSlice("metadata")) + if err != nil { + return h.formatter.FormatError(cmd.ErrWriter, "Invalid metadata: "+err.Error()) } + application.Metadata = metadata - setDefaultEnv := cmd.Bool("default-types") enableSecret := cmd.Bool("enable-secret") publicKey := cmd.String("public-key") application.EnableSecrets = enableSecret application.PublicKey = publicKey + application.IsManagedSecret = enableSecret && publicKey == "" - if publicKey == "" && enableSecret { - application.IsManagedSecret = true - } else { - application.IsManagedSecret = false + if cmd.Bool("default-types") { + application.EnvTypes = append(application.EnvTypes, domain.DefaultEnvTypes...) } - ctx = context.WithValue(ctx, "setDefaultEnv", setDefaultEnv) - createdApp, err := h.createUseCase.Execute(ctx, application) if err != nil { return h.formatUseCaseError(cmd, err) } - if cmd.Bool("json") { - if application.EnableSecrets && createdApp.PublicKey == "" { - return h.formatter.FormatWarningJSON(cmd.Writer, "secrets are enabled but no public key was provided. A self managed key will be generated!!!") - } - - return h.formatter.FormatJSON(cmd.Writer, createdApp) + // A key is only used when secrets are enabled, so say so rather than + // dropping it without a word. + if publicKey != "" && !enableSecret { + h.warn(cmd, "--public-key was ignored because --enable-secret is not set") + } + if application.IsManagedSecret { + h.warn(cmd, "secrets are enabled but no public key was provided. A self managed key will be generated!!!") } - if application.EnableSecrets && application.PublicKey == "" { - h.formatter.FormatWarning(cmd.Writer, "Secrets are enabled but no public key was provided. A self managed key will be generated!!!") + if cmd.Bool("json") { + return h.formatter.FormatJSON(cmd.Writer, createdApp) } return h.formatter.FormatCreateSuccessMessage(cmd.Writer, *createdApp) } +// warn reports a non-fatal problem. Under --json it goes to stderr so stdout +// stays a single parseable document. A failed warning must not fail the +// command, so the write error is deliberately dropped. +func (h *AppHandler) warn(cmd *cli.Command, message string) { + out := cmd.Writer + if cmd.Bool("json") { + out = cmd.ErrWriter + } + _ = h.formatter.FormatWarning(out, message) +} + func (h *AppHandler) Delete(ctx context.Context, cmd *cli.Command) error { if cmd.IsSet("json") && (!cmd.IsSet("id") && !cmd.IsSet("name")) { return h.formatter.FormatJSONError(cmd.Writer, errors.New("Application ID or Name is required for deletion.")) @@ -121,9 +134,15 @@ func (h *AppHandler) Delete(ctx context.Context, cmd *cli.Command) error { } func (h *AppHandler) List(ctx context.Context, cmd *cli.Command) error { - ctx = context.WithValue(ctx, "json", cmd.Bool("json")) - + // Show progress while the apps load, then hand the screen over to the view. + sp := spinner.New("Fetching applications…", spinner.IsTerminal(os.Stderr), os.Stderr) + if !cmd.Bool("json") { + sp.Start() + } apps, err := h.listUseCase.Execute(ctx) + if !cmd.Bool("json") { + sp.Stop() + } if err != nil { return h.formatUseCaseError(cmd, err) } @@ -132,9 +151,13 @@ func (h *AppHandler) List(ctx context.Context, cmd *cli.Command) error { return h.formatter.FormatJSON(cmd.Writer, apps) } - h.formatter.FormatListTable(cmd.Writer, apps) + // A terminal gets the interactive list. Pipes and scripts keep the plain + // table so existing pipelines are unaffected. + if len(apps) > 0 && spinner.IsTerminal(cmd.Writer) { + return h.tui.ListAppsInteractive(apps) + } - return nil + return h.formatter.FormatListTable(cmd.Writer, apps) } func (h *AppHandler) formatUseCaseError(cmd *cli.Command, err error) error { @@ -146,23 +169,23 @@ func (h *AppHandler) formatUseCaseError(cmd *cli.Command, err error) error { case *app.AppError: switch e.Code { case app.AppErrorCodeNotFound: - return h.formatter.FormatError(cmd.Writer, "Application not found: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Application not found: "+e.Message) case app.AppErrorCodeAlreadyExists: - return h.formatter.FormatError(cmd.Writer, "Application already exists: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Application already exists: "+e.Message) case app.AppErrorCodeValidation: - return h.formatter.FormatError(cmd.Writer, "Validation error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Validation error: "+e.Message) case app.AppErrorCodeAccessDenied: - return h.formatter.FormatError(cmd.Writer, "Access denied: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Access denied: "+e.Message) case app.AppErrorCodeInUse: return h.formatter.FormatWarning(cmd.Writer, "Cannot complete operation: "+e.Message) case app.AppErrorCodeCancelled: return h.formatter.FormatWarning(cmd.Writer, "Operation cancelled: "+e.Message) case app.AppErrorTUI: - return h.formatter.FormatError(cmd.Writer, "TUI error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "TUI error: "+e.Message) default: - return h.formatter.FormatError(cmd.Writer, "Service error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Service error: "+e.Message) } default: - return h.formatter.FormatError(cmd.Writer, "Unexpected error: "+err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, "Unexpected error: "+err.Error()) } } diff --git a/packages/envsync-cli/internal/features/handlers/auth_handler.go b/packages/envsync-cli/internal/features/handlers/auth_handler.go index e112f039..d8c2ed43 100644 --- a/packages/envsync-cli/internal/features/handlers/auth_handler.go +++ b/packages/envsync-cli/internal/features/handlers/auth_handler.go @@ -37,7 +37,11 @@ func (h *AuthHandler) Login(ctx context.Context, cmd *cli.Command) error { noWait := cmd.Bool("no-wait") jsonOutput := cmd.Bool("json") - response, err := h.loginUseCase.ExecuteWithOptions(ctx, noBrowser, noWait) + response, err := h.loginUseCase.ExecuteWithOptions(ctx, auth.LoginOptions{ + NoBrowser: noBrowser, + NoWait: noWait, + JSON: jsonOutput, + }) if err != nil { return h.formatUseCaseError(cmd, err) } @@ -130,21 +134,21 @@ func (h *AuthHandler) formatUseCaseError(cmd *cli.Command, err error) error { case auth.AuthErrorCodeNotLoggedIn: return h.formatter.FormatWarning(cmd.Writer, "Not logged in: "+e.Message) case auth.AuthErrorCodeLoginFailed: - return h.formatter.FormatError(cmd.Writer, "Login failed: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Login failed: "+e.Message) case auth.AuthErrorCodeTokenInvalid: - return h.formatter.FormatError(cmd.Writer, "Token invalid: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Token invalid: "+e.Message) case auth.AuthErrorCodeTokenExpired: - return h.formatter.FormatError(cmd.Writer, "Token expired: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Token expired: "+e.Message) case auth.AuthErrorCodeTimeout: - return h.formatter.FormatError(cmd.Writer, "Authentication timeout: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Authentication timeout: "+e.Message) case auth.AuthErrorCodeCancelled: return h.formatter.FormatWarning(cmd.Writer, "Authentication cancelled: "+e.Message) case auth.AuthErrorCodeNetworkError: - return h.formatter.FormatError(cmd.Writer, "Network error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Network error: "+e.Message) default: - return h.formatter.FormatError(cmd.Writer, "Authentication error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Authentication error: "+e.Message) } default: - return h.formatter.FormatError(cmd.Writer, "Unexpected error: "+err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, "Unexpected error: "+err.Error()) } } diff --git a/packages/envsync-cli/internal/features/handlers/certificate_handler.go b/packages/envsync-cli/internal/features/handlers/certificate_handler.go index f803aec4..b5fa522d 100644 --- a/packages/envsync-cli/internal/features/handlers/certificate_handler.go +++ b/packages/envsync-cli/internal/features/handlers/certificate_handler.go @@ -127,12 +127,12 @@ func (h *CertificateHandler) IssueCert(ctx context.Context, cmd *cli.Command) er if certPath != "" && cert.CertPEM != "" { if err := os.WriteFile(certPath, []byte(cert.CertPEM+"\n"), 0644); err != nil { - return h.formatter.FormatError(cmd.Writer, "Failed to write certificate: "+err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, "Failed to write certificate: "+err.Error()) } } if keyPath != "" && cert.KeyPEM != "" { if err := os.WriteFile(keyPath, []byte(cert.KeyPEM+"\n"), 0600); err != nil { - return h.formatter.FormatError(cmd.Writer, "Failed to write key: "+err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, "Failed to write key: "+err.Error()) } } @@ -216,7 +216,7 @@ func (h *CertificateHandler) GetCRL(ctx context.Context, cmd *cli.Command) error outputPath := cmd.String("output") if outputPath != "" { if err := os.WriteFile(outputPath, []byte(result.CRLPEM+"\n"), 0644); err != nil { - return h.formatter.FormatError(cmd.Writer, "Failed to write CRL: "+err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, "Failed to write CRL: "+err.Error()) } return h.formatter.FormatSuccess(cmd.Writer, "CRL written to "+outputPath) } @@ -238,7 +238,7 @@ func (h *CertificateHandler) GetRootCA(ctx context.Context, cmd *cli.Command) er outputPath := cmd.String("output") if outputPath != "" { if err := os.WriteFile(outputPath, []byte(certPEM+"\n"), 0644); err != nil { - return h.formatter.FormatError(cmd.Writer, "Failed to write root CA: "+err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, "Failed to write root CA: "+err.Error()) } return h.formatter.FormatSuccess(cmd.Writer, "Root CA written to "+outputPath) } @@ -292,13 +292,13 @@ func (h *CertificateHandler) formatError(cmd *cli.Command, err error) error { case *certUC.CertError: switch e.Code { case certUC.CertErrorCodeNotFound: - return h.formatter.FormatError(cmd.Writer, "Certificate not found: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Certificate not found: "+e.Message) case certUC.CertErrorCodeValidation: - return h.formatter.FormatError(cmd.Writer, "Validation error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Validation error: "+e.Message) default: - return h.formatter.FormatError(cmd.Writer, "Error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Error: "+e.Message) } default: - return h.formatter.FormatError(cmd.Writer, "Unexpected error: "+err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, "Unexpected error: "+err.Error()) } } diff --git a/packages/envsync-cli/internal/features/handlers/config_handler.go b/packages/envsync-cli/internal/features/handlers/config_handler.go index 899b4610..ef0d328c 100644 --- a/packages/envsync-cli/internal/features/handlers/config_handler.go +++ b/packages/envsync-cli/internal/features/handlers/config_handler.go @@ -86,37 +86,27 @@ func (h *ConfigHandler) Get(ctx context.Context, cmd *cli.Command) error { return h.formatUseCaseError(cmd, err) } - // Format output based on requested format - if cmd.Bool("json") { - return h.formatter.FormatJSON(cmd.Writer, response.Config) - } - - // If specific keys were requested, show only those - if len(keys) > 0 { - for _, key := range keys { - if value, exists := response.Values[key]; exists { - if cmd.Bool("json") { - jsonOutput := map[string]any{ - "key": key, - "value": value, - } - if err := h.formatter.FormatJSON(cmd.Writer, jsonOutput); err != nil { - return err - } - } - - if err := h.formatter.FormatSingleValue(cmd.Writer, key, value); err != nil { - return err - } - } else { - if !cmd.Bool("json") { - if err := h.formatter.FormatWarning(cmd.Writer, "Key '"+key+"' not found"); err != nil { - return err - } - } - } + jsonOut := cmd.Bool("json") + + // No keys requested: report the whole configuration. + if len(keys) == 0 { + if jsonOut { + return h.formatter.FormatJSON(cmd.Writer, response.Config) + } + return h.formatter.FormatKeyValueList(cmd.Writer, "Config", response.Values) + } + + // Exactly one JSON document per invocation. Calling FormatJSON inside the + // per-key loop emitted a separate object per key, which is not parseable as + // a single JSON value. Keys are emitted like the human-readable output. + if jsonOut { + return h.formatter.FormatJSON(cmd.Writer, response.Values) + } + + for _, key := range keys { + if err := h.formatter.FormatSingleValue(cmd.Writer, key, response.Values[key]); err != nil { + return err } - return nil } return nil @@ -165,11 +155,7 @@ func (h *ConfigHandler) Reset(ctx context.Context, cmd *cli.Command) error { func (h *ConfigHandler) formatUseCaseError(cmd *cli.Command, err error) error { if cmd.Bool("json") { - // If JSON output is requested, format the error as JSON - jsonOutput := map[string]any{ - "error": err.Error(), - } - return h.formatter.FormatJSON(cmd.Writer, jsonOutput) + return h.formatter.FormatJSONError(cmd.Writer, err) } // Handle different types of use case errors @@ -177,20 +163,20 @@ func (h *ConfigHandler) formatUseCaseError(cmd *cli.Command, err error) error { case *configUseCase.ConfigError: switch e.Code { case configUseCase.ConfigErrorCodeValidation: - return h.formatter.FormatError(cmd.Writer, "Validation error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Validation error \n"+e.Error()) case configUseCase.ConfigErrorCodeFileSystem: - return h.formatter.FormatError(cmd.Writer, "File system error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "File system error \n"+e.Error()) case configUseCase.ConfigErrorCodePermission: - return h.formatter.FormatError(cmd.Writer, "Permission error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Permission error \n"+e.Error()) case configUseCase.ConfigErrorCodeNotFound: - return h.formatter.FormatError(cmd.Writer, "Configuration not found: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Configuration not found \n"+e.Error()) case configUseCase.ConfigErrorCodeCorrupted: - return h.formatter.FormatError(cmd.Writer, "Configuration corrupted: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Configuration corrupted \n"+e.Error()) default: - return h.formatter.FormatError(cmd.Writer, "Service error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Service error \n"+e.Error()) } default: - return h.formatter.FormatError(cmd.Writer, "Unexpected error: "+err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, "Unexpected error\n "+err.Error()) } } diff --git a/packages/envsync-cli/internal/features/handlers/dynamic_secret_handler.go b/packages/envsync-cli/internal/features/handlers/dynamic_secret_handler.go index 49578572..845c6ce8 100644 --- a/packages/envsync-cli/internal/features/handlers/dynamic_secret_handler.go +++ b/packages/envsync-cli/internal/features/handlers/dynamic_secret_handler.go @@ -14,17 +14,17 @@ import ( ) type DynamicSecretHandler struct { - listEnginesUseCase dynamic_secret.ListEnginesUseCase - createEngineUseCase dynamic_secret.CreateEngineUseCase - getEngineUseCase dynamic_secret.GetEngineUseCase - updateEngineUseCase dynamic_secret.UpdateEngineUseCase - deleteEngineUseCase dynamic_secret.DeleteEngineUseCase - listLeasesUseCase dynamic_secret.ListLeasesUseCase - createLeaseUseCase dynamic_secret.CreateLeaseUseCase - getLeaseUseCase dynamic_secret.GetLeaseUseCase - revokeLeaseUseCase dynamic_secret.RevokeLeaseUseCase - cleanupUseCase dynamic_secret.CleanupUseCase - formatter *formatters.DynamicSecretFormatter + listEnginesUseCase dynamic_secret.ListEnginesUseCase + createEngineUseCase dynamic_secret.CreateEngineUseCase + getEngineUseCase dynamic_secret.GetEngineUseCase + updateEngineUseCase dynamic_secret.UpdateEngineUseCase + deleteEngineUseCase dynamic_secret.DeleteEngineUseCase + listLeasesUseCase dynamic_secret.ListLeasesUseCase + createLeaseUseCase dynamic_secret.CreateLeaseUseCase + getLeaseUseCase dynamic_secret.GetLeaseUseCase + revokeLeaseUseCase dynamic_secret.RevokeLeaseUseCase + cleanupUseCase dynamic_secret.CleanupUseCase + formatter *formatters.DynamicSecretFormatter } func NewDynamicSecretHandler() *DynamicSecretHandler { @@ -61,7 +61,7 @@ func (h *DynamicSecretHandler) ListEngines(ctx context.Context, cmd *cli.Command func (h *DynamicSecretHandler) CreateEngine(ctx context.Context, cmd *cli.Command) error { engineType, err := sdk.NewCreateDynamicSecretEngineRequestEngineTypeFromString(cmd.String("engine")) if err != nil { - return h.formatter.FormatError(cmd.Writer, "Invalid engine type: "+cmd.String("engine")) + return h.formatter.FormatError(cmd.ErrWriter, "Invalid engine type: "+cmd.String("engine")) } req := &sdk.CreateDynamicSecretEngineRequest{ @@ -72,7 +72,7 @@ func (h *DynamicSecretHandler) CreateEngine(ctx context.Context, cmd *cli.Comman if cmd.IsSet("config") { var configMap map[string]interface{} if err := json.Unmarshal([]byte(cmd.String("config")), &configMap); err != nil { - return h.formatter.FormatError(cmd.Writer, "Invalid config JSON: "+err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, "Invalid config JSON: "+err.Error()) } // For CLI simplicity, we pass config as a raw map // The SDK union type requires specific struct, so we skip it here @@ -94,7 +94,7 @@ func (h *DynamicSecretHandler) CreateEngine(ctx context.Context, cmd *cli.Comman func (h *DynamicSecretHandler) GetEngine(ctx context.Context, cmd *cli.Command) error { id := cmd.String("id") if id == "" { - return h.formatter.FormatError(cmd.Writer, "Engine ID is required (--id)") + return h.formatter.FormatError(cmd.ErrWriter, "Engine ID is required (--id)") } engine, err := h.getEngineUseCase.Execute(ctx, id) @@ -112,7 +112,7 @@ func (h *DynamicSecretHandler) GetEngine(ctx context.Context, cmd *cli.Command) func (h *DynamicSecretHandler) UpdateEngine(ctx context.Context, cmd *cli.Command) error { id := cmd.String("id") if id == "" { - return h.formatter.FormatError(cmd.Writer, "Engine ID is required (--id)") + return h.formatter.FormatError(cmd.ErrWriter, "Engine ID is required (--id)") } req := &sdk.UpdateDynamicSecretEngineRequest{} @@ -141,7 +141,7 @@ func (h *DynamicSecretHandler) UpdateEngine(ctx context.Context, cmd *cli.Comman func (h *DynamicSecretHandler) DeleteEngine(ctx context.Context, cmd *cli.Command) error { id := cmd.String("id") if id == "" { - return h.formatter.FormatError(cmd.Writer, "Engine ID is required (--id)") + return h.formatter.FormatError(cmd.ErrWriter, "Engine ID is required (--id)") } if err := h.deleteEngineUseCase.Execute(ctx, id); err != nil { @@ -160,7 +160,7 @@ func (h *DynamicSecretHandler) DeleteEngine(ctx context.Context, cmd *cli.Comman func (h *DynamicSecretHandler) ListLeases(ctx context.Context, cmd *cli.Command) error { engineID := cmd.String("engine-id") if engineID == "" { - return h.formatter.FormatError(cmd.Writer, "Engine ID is required (--engine-id)") + return h.formatter.FormatError(cmd.ErrWriter, "Engine ID is required (--engine-id)") } leases, err := h.listLeasesUseCase.Execute(ctx, engineID) @@ -178,7 +178,7 @@ func (h *DynamicSecretHandler) ListLeases(ctx context.Context, cmd *cli.Command) func (h *DynamicSecretHandler) CreateLease(ctx context.Context, cmd *cli.Command) error { engineID := cmd.String("engine-id") if engineID == "" { - return h.formatter.FormatError(cmd.Writer, "Engine ID is required (--engine-id)") + return h.formatter.FormatError(cmd.ErrWriter, "Engine ID is required (--engine-id)") } req := &sdk.CreateDynamicSecretLeaseRequest{} @@ -187,7 +187,7 @@ func (h *DynamicSecretHandler) CreateLease(ctx context.Context, cmd *cli.Command ttlStr := cmd.String("ttl") duration, err := time.ParseDuration(ttlStr) if err != nil { - return h.formatter.FormatError(cmd.Writer, "Invalid TTL duration: "+ttlStr) + return h.formatter.FormatError(cmd.ErrWriter, "Invalid TTL duration: "+ttlStr) } ttlSeconds := int(duration.Seconds()) req.TtlSeconds = &ttlSeconds @@ -208,7 +208,7 @@ func (h *DynamicSecretHandler) CreateLease(ctx context.Context, cmd *cli.Command func (h *DynamicSecretHandler) GetLease(ctx context.Context, cmd *cli.Command) error { id := cmd.String("id") if id == "" { - return h.formatter.FormatError(cmd.Writer, "Lease ID is required (--id)") + return h.formatter.FormatError(cmd.ErrWriter, "Lease ID is required (--id)") } lease, err := h.getLeaseUseCase.Execute(ctx, id) @@ -226,7 +226,7 @@ func (h *DynamicSecretHandler) GetLease(ctx context.Context, cmd *cli.Command) e func (h *DynamicSecretHandler) RevokeLease(ctx context.Context, cmd *cli.Command) error { id := cmd.String("id") if id == "" { - return h.formatter.FormatError(cmd.Writer, "Lease ID is required (--id)") + return h.formatter.FormatError(cmd.ErrWriter, "Lease ID is required (--id)") } result, err := h.revokeLeaseUseCase.Execute(ctx, id) @@ -258,5 +258,5 @@ func (h *DynamicSecretHandler) formatError(cmd *cli.Command, err error) error { if cmd.Bool("json") { return h.formatter.FormatJSONError(cmd.Writer, err) } - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } diff --git a/packages/envsync-cli/internal/features/handlers/environment_handler.go b/packages/envsync-cli/internal/features/handlers/environment_handler.go index 8621a79a..f53bd5ee 100644 --- a/packages/envsync-cli/internal/features/handlers/environment_handler.go +++ b/packages/envsync-cli/internal/features/handlers/environment_handler.go @@ -3,12 +3,15 @@ package handlers import ( "context" "errors" + "os" "github.com/urfave/cli/v3" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/environment" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/formatters" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/spinner" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/factory" ) type EnvironmentHandler struct { @@ -16,6 +19,7 @@ type EnvironmentHandler struct { switchEnvUseCase environment.SwitchEnvUseCase deleteEnvUseCase environment.DeleteEnvUseCase formatter *formatters.EnvFormatter + tui *factory.EnvFactory } func NewEnvironmentHandler( @@ -23,40 +27,56 @@ func NewEnvironmentHandler( switchEnvUseCase environment.SwitchEnvUseCase, deleteEnvUseCase environment.DeleteEnvUseCase, formatter *formatters.EnvFormatter, + tui *factory.EnvFactory, ) *EnvironmentHandler { return &EnvironmentHandler{ getEnvUseCase: getEnvUseCase, switchEnvUseCase: switchEnvUseCase, deleteEnvUseCase: deleteEnvUseCase, formatter: formatter, + tui: tui, } } func (h *EnvironmentHandler) SwitchEnvironment(ctx context.Context, cmd *cli.Command) error { - if cmd.Bool("json") && (!cmd.IsSet("app-id") && cmd.IsSet("env-id")) { - return h.formatUseCaseError(cmd, errors.New("app-id or env-id must be provided with json flag")) + if cmd.Bool("json") && !cmd.IsSet("env-id") { + return h.formatUseCaseError(cmd, errors.New("env-id must be provided with json flag")) } env := domain.EnvType{ - AppID: cmd.String("app-id"), - ID: cmd.String("env-id"), + ID: cmd.String("env-id"), } - if err := h.switchEnvUseCase.Execute(ctx, env); err != nil { + selected, err := h.switchEnvUseCase.Execute(ctx, env) + if err != nil { return h.formatUseCaseError(cmd, err) } - return nil + if cmd.Bool("json") { + return h.formatter.FormatJSON(cmd.Writer, map[string]any{ + "message": "Switched to " + selected.Name, + "app_id": selected.AppID, + "env_type_id": selected.ID, + "name": selected.Name, + }) + } + + return h.formatter.FormatSuccess(cmd.Writer, "Switched to "+selected.Name) } func (h *EnvironmentHandler) GetAllEnvironments(ctx context.Context, cmd *cli.Command) error { - if cmd.Bool("json") && !cmd.IsSet("app-id") { + if cmd.Bool("json") && cmd.String("app-id") == "" { return h.formatUseCaseError(cmd, errors.New("app-id must be provided with json flag")) } - appID := cmd.String("app-id") - - envs, err := h.getEnvUseCase.ExecuteByAppID(ctx, appID) + sp := spinner.New("Fetching environments…", spinner.IsTerminal(os.Stderr), os.Stderr) + if !cmd.Bool("json") { + sp.Start() + } + envs, err := h.getEnvUseCase.ExecuteByAppID(ctx, cmd.String("app-id")) + if !cmd.Bool("json") { + sp.Stop() + } if err != nil { return h.formatUseCaseError(cmd, err) } @@ -65,7 +85,13 @@ func (h *EnvironmentHandler) GetAllEnvironments(ctx context.Context, cmd *cli.Co return h.formatter.FormatJSON(cmd.Writer, envs) } - return h.formatter.FormatEnvList(cmd.Writer, envs) + // A terminal gets the interactive table. Pipes and scripts keep the plain + // table so existing pipelines are unaffected. + if len(envs) > 0 && spinner.IsTerminal(cmd.Writer) { + return h.tui.ListEnvsInteractive(envs) + } + + return h.formatter.FormatEnvTable(cmd.Writer, envs) } func (h *EnvironmentHandler) DeleteEnvironment(ctx context.Context, cmd *cli.Command) error { @@ -102,31 +128,33 @@ func (h *EnvironmentHandler) DeleteEnvironment(ctx context.Context, cmd *cli.Com func (h *EnvironmentHandler) formatUseCaseError(cmd *cli.Command, err error) error { if cmd.Bool("json") { - jsonOutput := map[string]any{ - "error": err.Error(), - } - return h.formatter.FormatJSON(cmd.Writer, jsonOutput) + return h.formatter.FormatJSONError(cmd.Writer, err) } switch e := err.(type) { case *environment.EnvError: switch e.Code { case environment.EnvErrorCodeValidation: - return h.formatter.FormatError(cmd.Writer, "Validation error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Validation error: "+e.Message) case environment.EnvErrorCodeServiceError: - return h.formatter.FormatError(cmd.Writer, "Service error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Service error: "+e.Message) case environment.EnvErrorCodeNotFound: - return h.formatter.FormatError(cmd.Writer, "Environment not found: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Environment not found: "+e.Message) case environment.EnvErrorCodeCorrupted: - return h.formatter.FormatError(cmd.Writer, "Environment data is corrupted: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Environment data is corrupted: "+e.Message) case environment.EnvErrorCodePermission: - return h.formatter.FormatError(cmd.Writer, "Permission error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Permission error: "+e.Message) case environment.EnvErrorCodeFileSystem: - return h.formatter.FormatError(cmd.Writer, "File system error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "File system error: "+e.Message) + case environment.EnvErrorCodeTUIError: + return h.formatter.FormatError(cmd.ErrWriter, "TUI error: "+e.Message) + case environment.EnvErrorCodeCancelled: + // Backing out is a choice, not a failure. + return h.formatter.FormatWarning(cmd.ErrWriter, e.Message) default: - return h.formatter.FormatError(cmd.Writer, "Service error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Service error: "+e.Message) } default: - return h.formatter.FormatError(cmd.Writer, "Unexpected error: "+err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, "Unexpected error: "+err.Error()) } } diff --git a/packages/envsync-cli/internal/features/handlers/gpg_key_handler.go b/packages/envsync-cli/internal/features/handlers/gpg_key_handler.go index 6e0d1511..4af65dc9 100644 --- a/packages/envsync-cli/internal/features/handlers/gpg_key_handler.go +++ b/packages/envsync-cli/internal/features/handlers/gpg_key_handler.go @@ -3,11 +3,15 @@ package handlers import ( "context" "os" + "slices" "github.com/urfave/cli/v3" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" gpg_key "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/gpg_key" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/formatters" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/spinner" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/factory" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" ) @@ -21,6 +25,7 @@ type GpgKeyHandler struct { deleteUseCase gpg_key.DeleteKeyUseCase service services.GpgKeyService formatter *formatters.GpgKeyFormatter + tui *factory.GpgKeyFactory } func NewGpgKeyHandler( @@ -33,6 +38,7 @@ func NewGpgKeyHandler( deleteUseCase gpg_key.DeleteKeyUseCase, service services.GpgKeyService, formatter *formatters.GpgKeyFormatter, + tui *factory.GpgKeyFactory, ) *GpgKeyHandler { return &GpgKeyHandler{ listUseCase: listUseCase, @@ -44,11 +50,20 @@ func NewGpgKeyHandler( deleteUseCase: deleteUseCase, service: service, formatter: formatter, + tui: tui, } } func (h *GpgKeyHandler) List(ctx context.Context, cmd *cli.Command) error { + // Show progress while the keys load, then hand the screen over to the view. + sp := spinner.New("Fetching GPG keys…", spinner.IsTerminal(os.Stderr), os.Stderr) + if !cmd.Bool("json") { + sp.Start() + } keys, err := h.listUseCase.Execute(ctx) + if !cmd.Bool("json") { + sp.Stop() + } if err != nil { return h.formatError(cmd, err) } @@ -57,35 +72,79 @@ func (h *GpgKeyHandler) List(ctx context.Context, cmd *cli.Command) error { return h.formatter.FormatJSON(cmd.Writer, keys) } - return h.formatter.FormatKeyList(cmd.Writer, keys) + // A terminal gets the interactive table. Pipes and scripts keep the plain + // table so existing pipelines are unaffected. + if len(keys) > 0 && spinner.IsTerminal(cmd.Writer) { + return h.tui.ListKeysInteractive(keys) + } + + return h.formatter.FormatListTable(cmd.Writer, keys) } func (h *GpgKeyHandler) Generate(ctx context.Context, cmd *cli.Command) error { + jsonOutput := cmd.Bool("json") + + if cmd.IsSet("algorithm") { + if _, ok := domain.ResolveGpgAlgorithm(cmd.String("algorithm")); !ok { + return h.formatError(cmd, gpg_key.NewValidationError("algorithm must be one of ECC Curve25519, ECC P-256, ECC P-384, RSA 4096", gpg_key.ErrInvalidAlgorithm)) + } + } + name := cmd.String("name") email := cmd.String("email") algorithm := cmd.String("algorithm") - var keySize *int - if cmd.IsSet("key-size") { - ks := int(cmd.Int("key-size")) - keySize = &ks - } - var expiresInDays *int if cmd.IsSet("expires-in-days") { - eid := int(cmd.Int("expires-in-days")) - expiresInDays = &eid + days := int(cmd.Int("expires-in-days")) + expiresInDays = &days } - usageFlags := []string{"sign"} - isDefault := cmd.Bool("default") + // Flags alone can carry a run when they cover the required fields. The + // form picks up whatever is missing. JSON output is for scripts: it takes + // flag values only and never opens the form. + switch { + case jsonOutput: + if name == "" { + return h.formatError(cmd, gpg_key.NewValidationError("name must be provided with json flag", gpg_key.ErrNameRequired)) + } + if email == "" { + return h.formatError(cmd, gpg_key.NewValidationError("email must be provided with json flag", gpg_key.ErrEmailRequired)) + } + case !cmd.IsSet("name") || !cmd.IsSet("email") || !cmd.IsSet("algorithm"): + if !spinner.IsTerminal(os.Stdin) { + return h.formatError(cmd, gpg_key.NewValidationError("name, email and algorithm are required; provide --name, --email and --algorithm flags", gpg_key.ErrNameRequired)) + } - key, err := h.generateUseCase.Execute(ctx, name, email, algorithm, keySize, expiresInDays, usageFlags, isDefault) + values, err := h.tui.GenerateKeyTUI(factory.GenerateKeyValues{ + Name: name, + Email: email, + Algorithm: algorithm, + ExpiresInDays: expiresInDays, + }) + if err != nil { + return h.formatError(cmd, err) + } + name, email, algorithm, expiresInDays = values.Name, values.Email, values.Algorithm, values.ExpiresInDays + case name == "" || email == "": + return h.formatError(cmd, gpg_key.NewValidationError("name and email are required; provide --name and --email flags", gpg_key.ErrNameRequired)) + } + + // Show progress while the key generates, then hand the screen over to the + // success message. + sp := spinner.New("Generating GPG key…", spinner.IsTerminal(os.Stderr), os.Stderr) + if !jsonOutput { + sp.Start() + } + key, err := h.generateUseCase.Execute(ctx, name, email, algorithm, expiresInDays, []string{"sign"}, cmd.Bool("default")) + if !jsonOutput { + sp.Stop() + } if err != nil { return h.formatError(cmd, err) } - if cmd.Bool("json") { + if jsonOutput { return h.formatter.FormatJSON(cmd.Writer, key) } @@ -93,10 +152,16 @@ func (h *GpgKeyHandler) Generate(ctx context.Context, cmd *cli.Command) error { } func (h *GpgKeyHandler) Sign(ctx context.Context, cmd *cli.Command) error { + jsonOutput := cmd.Bool("json") + keyID := cmd.String("key-id") filePath := cmd.String("file") - mode := cmd.String("mode") + mode, ok := domain.ResolveSignMode(cmd.String("mode")) + if !ok { + return h.formatError(cmd, gpg_key.NewValidationError("mode must be one of binary, text, clearsign", gpg_key.ErrInvalidSignMode)) + } detached := cmd.Bool("detached") + output := cmd.String("output") // Check if stdin has data useStdin := false @@ -107,27 +172,107 @@ func (h *GpgKeyHandler) Sign(ctx context.Context, cmd *cli.Command) error { } } + // Flags alone can carry a run when they cover the key and the input. The + // picker and form collect what is missing. JSON output is for scripts: it + // takes flag values only and never opens the picker or the form. + switch { + case jsonOutput: + if keyID == "" { + return h.formatError(cmd, gpg_key.NewValidationError("key-id must be provided with json flag", gpg_key.ErrKeyIDRequired)) + } + if filePath == "" && !useStdin { + return h.formatError(cmd, gpg_key.NewValidationError("file must be provided with json flag (or pipe the data via stdin)", gpg_key.ErrNoInputProvided)) + } + case keyID == "" || (filePath == "" && !useStdin): + if !spinner.IsTerminal(os.Stdin) { + return h.formatError(cmd, gpg_key.NewValidationError("key-id and file are required; provide --key-id and --file flags", gpg_key.ErrKeyIDRequired)) + } + + if keyID == "" { + keys, err := h.fetchKeys(ctx) + if err != nil { + return h.formatError(cmd, err) + } + + // Filter out only active keys + activeKeys := slices.Collect(func(yield func(domain.GpgKey) bool) { + for _, key := range keys { + if formatters.GpgKeyStatus(key) != "active" { + continue + } + if !yield(key) { + return + } + } + }) + + key, ok, err := h.tui.PickGpgKey(activeKeys, "Select the key to sign with") + if err != nil { + return h.formatError(cmd, err) + } + if !ok { + return h.formatError(cmd, gpg_key.ErrSignCancelled) + } + keyID = key.ID + } + + values, err := h.tui.SignKeyTUI(factory.SignKeyValues{ + FilePath: filePath, + Mode: mode, + Detached: detached, + Output: output, + }) + if err != nil { + return h.formatError(cmd, err) + } + filePath, mode, detached, output = values.FilePath, values.Mode, values.Detached, values.Output + } + + // Show progress while the signature is produced, then hand the screen over + // to the result. + sp := spinner.New("Signing…", spinner.IsTerminal(os.Stderr), os.Stderr) + if !jsonOutput { + sp.Start() + } result, err := h.signUseCase.Execute(ctx, keyID, filePath, mode, detached, useStdin) + if !jsonOutput { + sp.Stop() + } if err != nil { return h.formatError(cmd, err) } // Write to output file if specified - outputPath := cmd.String("output") - if outputPath != "" { - if err := os.WriteFile(outputPath, []byte(result.Signature+"\n"), 0644); err != nil { - return h.formatter.FormatError(cmd.Writer, "Failed to write output: "+err.Error()) + if output != "" { + if err := os.WriteFile(output, []byte(result.Signature+"\n"), 0644); err != nil { + return h.formatter.FormatError(cmd.ErrWriter, "Failed to write output: "+err.Error()) } - return h.formatter.FormatSuccess(cmd.Writer, "Signature written to "+outputPath) + return h.formatter.FormatSuccess(cmd.Writer, "Signature written to "+output) } - if cmd.Bool("json") { + if jsonOutput { return h.formatter.FormatJSON(cmd.Writer, result) } return h.formatter.FormatSignResult(cmd.Writer, *result) } +// fetchKeys loads the available GPG keys with the shared loader presentation. +func (h *GpgKeyHandler) fetchKeys(ctx context.Context) ([]domain.GpgKey, error) { + sp := spinner.New("Fetching GPG keys…", spinner.IsTerminal(os.Stderr), os.Stderr) + sp.Start() + defer sp.Stop() + + keys, err := h.listUseCase.Execute(ctx) + if err != nil { + return nil, err + } + if len(keys) == 0 { + return nil, gpg_key.NewNotFoundError("no GPG keys available; generate one with 'envsync gpg generate'", nil) + } + return keys, nil +} + func (h *GpgKeyHandler) Verify(ctx context.Context, cmd *cli.Command) error { filePath := cmd.String("file") signaturePath := cmd.String("signature") @@ -157,7 +302,7 @@ func (h *GpgKeyHandler) Export(ctx context.Context, cmd *cli.Command) error { outputPath := cmd.String("output") if outputPath != "" { if err := os.WriteFile(outputPath, []byte(publicKey+"\n"), 0644); err != nil { - return h.formatter.FormatError(cmd.Writer, "Failed to write output: "+err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, "Failed to write output: "+err.Error()) } return h.formatter.FormatSuccess(cmd.Writer, "Public key written to "+outputPath) } @@ -256,13 +401,13 @@ func (h *GpgKeyHandler) formatError(cmd *cli.Command, err error) error { case *gpg_key.GpgKeyError: switch e.Code { case gpg_key.GpgKeyErrorCodeNotFound: - return h.formatter.FormatError(cmd.Writer, "GPG key not found: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "GPG key not found: "+e.Message) case gpg_key.GpgKeyErrorCodeValidation: - return h.formatter.FormatError(cmd.Writer, "Validation error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Validation error: "+e.Message) default: - return h.formatter.FormatError(cmd.Writer, "Error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Error: "+e.Message) } default: - return h.formatter.FormatError(cmd.Writer, "Unexpected error: "+err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, "Unexpected error: "+err.Error()) } } diff --git a/packages/envsync-cli/internal/features/handlers/init_handler.go b/packages/envsync-cli/internal/features/handlers/init_handler.go index 6931b981..75edc880 100644 --- a/packages/envsync-cli/internal/features/handlers/init_handler.go +++ b/packages/envsync-cli/internal/features/handlers/init_handler.go @@ -24,47 +24,61 @@ func (h *InitHandler) Init(ctx context.Context, cmd *cli.Command) error { appID := cmd.String("app-id") envTypeID := cmd.String("env-type-id") - if err := h.initUseCase.ExecuteWithOptions(ctx, cmd.String("config"), appID, envTypeID); err != nil { - return h.formatUseCaseError(cmd, err) + if cmd.Bool("json") { + if appID == "" || envTypeID == "" { + return h.formatter.FormatError(cmd.Writer, "app-id and env-type-id are required when using JSON output") + } } - return nil -} -func (h *InitHandler) formatUseCaseError(cmd *cli.Command, err error) error { if cmd.Bool("json") { jsonOutput := map[string]any{ - "error": err.Error(), + "path": "envsyncrc.toml", + "app-id": appID, + "env-type-id": envTypeID, } return h.formatter.FormatJSON(cmd.Writer, jsonOutput) } + result, err := h.initUseCase.ExecuteWithOptions(ctx, cmd.String("config"), appID, envTypeID) + if err != nil { + return h.formatUseCaseError(cmd, err) + } + + return h.formatter.FormatSuccess(cmd.Writer, "Configuration written to "+result.Path) +} + +func (h *InitHandler) formatUseCaseError(cmd *cli.Command, err error) error { + if cmd.Bool("json") { + return h.formatter.FormatJSONError(cmd.Writer, err) + } + switch e := err.(type) { case *inituc.InitError: switch e.Code { case inituc.InitErrorCodeValidation: - return h.formatter.FormatError(cmd.Writer, "Validation error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Validation error: "+e.Message) case inituc.InitErrorCodeFileSystem: - return h.formatter.FormatError(cmd.Writer, "File system error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "File system error: "+e.Message) case inituc.InitErrorCodePermission: - return h.formatter.FormatError(cmd.Writer, "Permission error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Permission error: "+e.Message) case inituc.InitErrorCodeAlreadyExists: - return h.formatter.FormatError(cmd.Writer, "Configuration already exists: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Configuration already exists: "+e.Message) case inituc.InitErrorCodeNotFound: - return h.formatter.FormatError(cmd.Writer, "Not found error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Not found error: "+e.Message) case inituc.InitErrorCodeServiceError: - return h.formatter.FormatError(cmd.Writer, "Service error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Service error: "+e.Message) case inituc.InitErrorCodeNetworkError: - return h.formatter.FormatError(cmd.Writer, "Network error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Network error: "+e.Message) case inituc.InitErrorCodeTUIError: - return h.formatter.FormatError(cmd.Writer, "TUI error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "TUI error: "+e.Message) case inituc.InitErrorCodeCancelled: - return h.formatter.FormatError(cmd.Writer, "Operation cancelled: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Operation cancelled: "+e.Message) case inituc.InitErrorCodeTimeout: - return h.formatter.FormatError(cmd.Writer, "Operation timed out: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Operation timed out: "+e.Message) default: - return h.formatter.FormatError(cmd.Writer, "Service error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Service error: "+e.Message) } default: - return h.formatter.FormatError(cmd.Writer, "Unexpected error: "+err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, "Unexpected error: "+err.Error()) } } diff --git a/packages/envsync-cli/internal/features/handlers/log_forwarding_handler.go b/packages/envsync-cli/internal/features/handlers/log_forwarding_handler.go deleted file mode 100644 index e4da803e..00000000 --- a/packages/envsync-cli/internal/features/handlers/log_forwarding_handler.go +++ /dev/null @@ -1,131 +0,0 @@ -package handlers - -import ( - "context" - "errors" - "fmt" - - "github.com/urfave/cli/v3" - - lf "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/log_forwarding" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/formatters" -) - -type LogForwardingHandler struct { - createUseCase lf.CreateLogForwardingConfigUseCase - listUseCase lf.ListLogForwardingConfigsUseCase - getUseCase lf.GetLogForwardingConfigUseCase - deleteUseCase lf.DeleteLogForwardingConfigUseCase - formatter *formatters.LogForwardingFormatter -} - -func NewLogForwardingHandler( - createUseCase lf.CreateLogForwardingConfigUseCase, - listUseCase lf.ListLogForwardingConfigsUseCase, - getUseCase lf.GetLogForwardingConfigUseCase, - deleteUseCase lf.DeleteLogForwardingConfigUseCase, - formatter *formatters.LogForwardingFormatter, -) *LogForwardingHandler { - return &LogForwardingHandler{ - createUseCase: createUseCase, - listUseCase: listUseCase, - getUseCase: getUseCase, - deleteUseCase: deleteUseCase, - formatter: formatter, - } -} - -func (h *LogForwardingHandler) Create(ctx context.Context, cmd *cli.Command) error { - name := cmd.String("name") - target := cmd.String("target") - endpointURL := cmd.String("endpoint-url") - apiKey := cmd.String("api-key") - enabled := cmd.Bool("enabled") - - config, err := h.createUseCase.Execute(ctx, name, target, endpointURL, apiKey, enabled) - if err != nil { - return h.formatUseCaseError(cmd, err) - } - - if cmd.Bool("json") { - return h.formatter.FormatJSON(cmd.Writer, config) - } - - return h.formatter.FormatCreateSuccess(cmd.Writer, config) -} - -func (h *LogForwardingHandler) List(ctx context.Context, cmd *cli.Command) error { - configs, err := h.listUseCase.Execute(ctx) - if err != nil { - return h.formatUseCaseError(cmd, err) - } - - if cmd.Bool("json") { - return h.formatter.FormatJSON(cmd.Writer, configs) - } - - if len(configs) == 0 { - h.formatter.FormatWarning(cmd.Writer, "No log forwarding configs found.") - return nil - } - - return h.formatter.FormatListTable(cmd.Writer, configs) -} - -func (h *LogForwardingHandler) Get(ctx context.Context, cmd *cli.Command) error { - id := cmd.String("id") - - config, err := h.getUseCase.Execute(ctx, id) - if err != nil { - return h.formatUseCaseError(cmd, err) - } - - if cmd.Bool("json") { - return h.formatter.FormatJSON(cmd.Writer, config) - } - - return h.formatter.FormatGetDetail(cmd.Writer, config) -} - -func (h *LogForwardingHandler) Delete(ctx context.Context, cmd *cli.Command) error { - id := cmd.String("id") - - resp, err := h.deleteUseCase.Execute(ctx, id) - if err != nil { - return h.formatUseCaseError(cmd, err) - } - - if cmd.Bool("json") { - jsonData := map[string]any{ - "message": fmt.Sprintf("Log forwarding config '%s' deleted successfully", id), - } - if resp != nil { - jsonData["response"] = resp - } - return h.formatter.FormatJSON(cmd.Writer, jsonData) - } - - return h.formatter.FormatDeleteSuccess(cmd.Writer, id) -} - -func (h *LogForwardingHandler) formatUseCaseError(cmd *cli.Command, err error) error { - if cmd.Bool("json") { - return h.formatter.FormatJSONError(cmd.Writer, err) - } - - var lfErr *lf.LogForwardingError - if errors.As(err, &lfErr) { - switch lfErr.Code { - case lf.ErrorCodeNotFound: - return h.formatter.FormatError(cmd.Writer, "Config not found: "+lfErr.Message) - case lf.ErrorCodeValidation: - return h.formatter.FormatError(cmd.Writer, "Validation error: "+lfErr.Message) - case lf.ErrorCodeAccessDenied: - return h.formatter.FormatError(cmd.Writer, "Access denied: "+lfErr.Message) - default: - return h.formatter.FormatError(cmd.Writer, "Service error: "+lfErr.Message) - } - } - - return h.formatter.FormatError(cmd.Writer, "Unexpected error: "+err.Error()) -} diff --git a/packages/envsync-cli/internal/features/handlers/oidc_handler.go b/packages/envsync-cli/internal/features/handlers/oidc_handler.go deleted file mode 100644 index b7c73f19..00000000 --- a/packages/envsync-cli/internal/features/handlers/oidc_handler.go +++ /dev/null @@ -1,153 +0,0 @@ -package handlers - -import ( - "context" - "strings" - - "github.com/urfave/cli/v3" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/oidc" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/formatters" -) - -type OidcHandler struct { - createUseCase oidc.CreateOidcProviderUseCase - listUseCase oidc.ListOidcProvidersUseCase - getUseCase oidc.GetOidcProviderUseCase - updateUseCase oidc.UpdateOidcProviderUseCase - deleteUseCase oidc.DeleteOidcProviderUseCase - formatter *formatters.OidcFormatter -} - -func NewOidcHandler( - createUseCase oidc.CreateOidcProviderUseCase, - listUseCase oidc.ListOidcProvidersUseCase, - getUseCase oidc.GetOidcProviderUseCase, - updateUseCase oidc.UpdateOidcProviderUseCase, - deleteUseCase oidc.DeleteOidcProviderUseCase, - formatter *formatters.OidcFormatter, -) *OidcHandler { - return &OidcHandler{ - createUseCase: createUseCase, - listUseCase: listUseCase, - getUseCase: getUseCase, - updateUseCase: updateUseCase, - deleteUseCase: deleteUseCase, - formatter: formatter, - } -} - -func (h *OidcHandler) Create(ctx context.Context, cmd *cli.Command) error { - input := domain.CreateOidcProviderInput{ - ProviderType: cmd.String("name"), - IssuerURL: cmd.String("issuer-url"), - Audience: cmd.String("audience"), - } - - if cmd.IsSet("allowed-subjects") { - input.AllowedSubjects = parseCommaSeparated(cmd.String("allowed-subjects")) - } - - provider, err := h.createUseCase.Execute(ctx, input) - if err != nil { - return h.formatError(cmd, err) - } - - if cmd.Bool("json") { - return h.formatter.FormatJSON(cmd.Writer, provider) - } - - return h.formatter.FormatCreateSuccess(cmd.Writer, *provider) -} - -func (h *OidcHandler) List(ctx context.Context, cmd *cli.Command) error { - providers, err := h.listUseCase.Execute(ctx) - if err != nil { - return h.formatError(cmd, err) - } - - if cmd.Bool("json") { - return h.formatter.FormatJSON(cmd.Writer, providers) - } - - return h.formatter.FormatList(cmd.Writer, providers) -} - -func (h *OidcHandler) Get(ctx context.Context, cmd *cli.Command) error { - id := cmd.String("id") - - provider, err := h.getUseCase.Execute(ctx, id) - if err != nil { - return h.formatError(cmd, err) - } - - if cmd.Bool("json") { - return h.formatter.FormatJSON(cmd.Writer, provider) - } - - return h.formatter.FormatDetail(cmd.Writer, *provider) -} - -func (h *OidcHandler) Update(ctx context.Context, cmd *cli.Command) error { - input := domain.UpdateOidcProviderInput{ - ID: cmd.String("id"), - } - - if cmd.IsSet("audience") { - input.Audience = cmd.String("audience") - } - if cmd.IsSet("enabled") { - enabled := cmd.Bool("enabled") - input.Enabled = &enabled - } - if cmd.IsSet("allowed-subjects") { - input.AllowedSubjects = parseCommaSeparated(cmd.String("allowed-subjects")) - } - - if err := h.updateUseCase.Execute(ctx, input); err != nil { - return h.formatError(cmd, err) - } - - if cmd.Bool("json") { - return h.formatter.FormatJSON(cmd.Writer, map[string]string{"message": "OIDC provider updated successfully"}) - } - - return h.formatter.FormatSuccess(cmd.Writer, "OIDC provider updated successfully") -} - -func (h *OidcHandler) Delete(ctx context.Context, cmd *cli.Command) error { - id := cmd.String("id") - - if err := h.deleteUseCase.Execute(ctx, id); err != nil { - return h.formatError(cmd, err) - } - - if cmd.Bool("json") { - return h.formatter.FormatJSON(cmd.Writer, map[string]string{"message": "OIDC provider deleted successfully"}) - } - - return h.formatter.FormatDeleteSuccess(cmd.Writer, id) -} - -func (h *OidcHandler) formatError(cmd *cli.Command, err error) error { - if cmd.Bool("json") { - return h.formatter.FormatJSONError(cmd.Writer, err) - } - return h.formatter.FormatError(cmd.Writer, err.Error()) -} - -func parseCommaSeparated(s string) []string { - if s == "" { - return nil - } - parts := strings.Split(s, ",") - result := make([]string, 0, len(parts)) - for _, p := range parts { - trimmed := strings.TrimSpace(p) - if trimmed != "" { - result = append(result, trimmed) - } - } - return result -} diff --git a/packages/envsync-cli/internal/features/handlers/request_handler.go b/packages/envsync-cli/internal/features/handlers/request_handler.go index e02a5588..fa68e684 100644 --- a/packages/envsync-cli/internal/features/handlers/request_handler.go +++ b/packages/envsync-cli/internal/features/handlers/request_handler.go @@ -45,7 +45,7 @@ func (h *RequestHandler) CreateDirect(ctx context.Context, cmd *cli.Command) err } result, err := h.repo.CreateDirect(ctx, payload) if err != nil { - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } if cmd.Bool("json") { return h.formatter.FormatJSON(cmd.Writer, result) @@ -62,7 +62,7 @@ func (h *RequestHandler) CreatePromotion(ctx context.Context, cmd *cli.Command) "message": cmd.String("message"), }) if err != nil { - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } if cmd.Bool("json") { return h.formatter.FormatJSON(cmd.Writer, result) @@ -73,7 +73,7 @@ func (h *RequestHandler) CreatePromotion(ctx context.Context, cmd *cli.Command) func (h *RequestHandler) List(ctx context.Context, cmd *cli.Command) error { items, err := h.repo.List(ctx) if err != nil { - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } if cmd.Bool("json") { return h.formatter.FormatJSON(cmd.Writer, items) @@ -87,7 +87,7 @@ func (h *RequestHandler) List(ctx context.Context, cmd *cli.Command) error { func (h *RequestHandler) Get(ctx context.Context, cmd *cli.Command) error { item, err := h.repo.Get(ctx, cmd.String("id")) if err != nil { - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } if cmd.Bool("json") { return h.formatter.FormatJSON(cmd.Writer, item) @@ -98,7 +98,7 @@ func (h *RequestHandler) Get(ctx context.Context, cmd *cli.Command) error { func (h *RequestHandler) Approve(ctx context.Context, cmd *cli.Command) error { item, err := h.repo.Approve(ctx, cmd.String("id")) if err != nil { - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } if cmd.Bool("json") { return h.formatter.FormatJSON(cmd.Writer, item) @@ -109,7 +109,7 @@ func (h *RequestHandler) Approve(ctx context.Context, cmd *cli.Command) error { func (h *RequestHandler) Reject(ctx context.Context, cmd *cli.Command) error { item, err := h.repo.Reject(ctx, cmd.String("id"), cmd.String("reason")) if err != nil { - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } if cmd.Bool("json") { return h.formatter.FormatJSON(cmd.Writer, item) @@ -120,7 +120,7 @@ func (h *RequestHandler) Reject(ctx context.Context, cmd *cli.Command) error { func (h *RequestHandler) Cancel(ctx context.Context, cmd *cli.Command) error { item, err := h.repo.Cancel(ctx, cmd.String("id")) if err != nil { - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } if cmd.Bool("json") { return h.formatter.FormatJSON(cmd.Writer, item) diff --git a/packages/envsync-cli/internal/features/handlers/rotation_handler.go b/packages/envsync-cli/internal/features/handlers/rotation_handler.go index 9d131bf3..156e723a 100644 --- a/packages/envsync-cli/internal/features/handlers/rotation_handler.go +++ b/packages/envsync-cli/internal/features/handlers/rotation_handler.go @@ -2,7 +2,6 @@ package handlers import ( "context" - "encoding/json" "github.com/urfave/cli/v3" @@ -13,24 +12,18 @@ import ( ) type RotationHandler struct { - listUseCase rotation.ListPoliciesUseCase - createUseCase rotation.CreatePolicyUseCase - getUseCase rotation.GetPolicyUseCase - updateUseCase rotation.UpdatePolicyUseCase - deleteUseCase rotation.DeletePolicyUseCase - triggerUseCase rotation.TriggerRotationUseCase - statesUseCase rotation.GetRotationStatesUseCase - revokeUseCase rotation.RevokeExpiredCredentialsUseCase - formatter *formatters.RotationFormatter + listUseCase rotation.ListPoliciesUseCase + getUseCase rotation.GetPolicyUseCase + triggerUseCase rotation.TriggerRotationUseCase + statesUseCase rotation.GetRotationStatesUseCase + revokeUseCase rotation.RevokeExpiredCredentialsUseCase + formatter *formatters.RotationFormatter } func NewRotationHandler() *RotationHandler { return &RotationHandler{ listUseCase: rotation.NewListPoliciesUseCase(), - createUseCase: rotation.NewCreatePolicyUseCase(), getUseCase: rotation.NewGetPolicyUseCase(), - updateUseCase: rotation.NewUpdatePolicyUseCase(), - deleteUseCase: rotation.NewDeletePolicyUseCase(), triggerUseCase: rotation.NewTriggerRotationUseCase(), statesUseCase: rotation.NewGetRotationStatesUseCase(), revokeUseCase: rotation.NewRevokeExpiredCredentialsUseCase(), @@ -53,50 +46,10 @@ func (h *RotationHandler) List(ctx context.Context, cmd *cli.Command) error { return h.formatter.FormatPolicyList(cmd.Writer, policies) } -func (h *RotationHandler) Create(ctx context.Context, cmd *cli.Command) error { - engineType, err := sdk.NewCreateRotationPolicyRequestEngineTypeFromString(cmd.String("engine")) - if err != nil { - return h.formatter.FormatError(cmd.Writer, "Invalid engine type: "+cmd.String("engine")) - } - - req := &sdk.CreateRotationPolicyRequest{ - EngineType: engineType, - ScheduleCron: cmd.String("schedule"), - } - - if cmd.IsSet("name") { - // name maps to variable_key for display purposes - req.VariableKey = cmd.String("name") - } - if cmd.IsSet("secret-id") { - // secret-id is a composite: app_id/env_type_id/variable_key - // For simplicity, we treat it as app_id - req.AppId = cmd.String("secret-id") - } - if cmd.IsSet("config") { - var configMap map[string]interface{} - if err := json.Unmarshal([]byte(cmd.String("config")), &configMap); err != nil { - return h.formatter.FormatError(cmd.Writer, "Invalid config JSON: "+err.Error()) - } - req.ConnectionConfig = configMap - } - - policy, err := h.createUseCase.Execute(ctx, req) - if err != nil { - return h.formatError(cmd, err) - } - - if cmd.Bool("json") { - return h.formatter.FormatJSON(cmd.Writer, policy) - } - - return h.formatter.FormatCreateSuccess(cmd.Writer, policy) -} - func (h *RotationHandler) Get(ctx context.Context, cmd *cli.Command) error { id := cmd.String("id") if id == "" { - return h.formatter.FormatError(cmd.Writer, "Rotation policy ID is required (--id)") + return h.formatter.FormatError(cmd.ErrWriter, "Rotation policy ID is required (--id)") } policy, err := h.getUseCase.Execute(ctx, id) @@ -111,63 +64,10 @@ func (h *RotationHandler) Get(ctx context.Context, cmd *cli.Command) error { return h.formatter.FormatPolicyDetail(cmd.Writer, policy) } -func (h *RotationHandler) Update(ctx context.Context, cmd *cli.Command) error { - id := cmd.String("id") - if id == "" { - return h.formatter.FormatError(cmd.Writer, "Rotation policy ID is required (--id)") - } - - req := &sdk.UpdateRotationPolicyRequest{} - - if cmd.IsSet("schedule") { - schedule := cmd.String("schedule") - req.ScheduleCron = &schedule - } - if cmd.IsSet("enabled") { - enabled := cmd.Bool("enabled") - req.Enabled = &enabled - } - if cmd.IsSet("config") { - var configMap map[string]interface{} - if err := json.Unmarshal([]byte(cmd.String("config")), &configMap); err != nil { - return h.formatter.FormatError(cmd.Writer, "Invalid config JSON: "+err.Error()) - } - req.ConnectionConfig = configMap - } - - policy, err := h.updateUseCase.Execute(ctx, id, req) - if err != nil { - return h.formatError(cmd, err) - } - - if cmd.Bool("json") { - return h.formatter.FormatJSON(cmd.Writer, policy) - } - - return h.formatter.FormatUpdateSuccess(cmd.Writer, policy) -} - -func (h *RotationHandler) Delete(ctx context.Context, cmd *cli.Command) error { - id := cmd.String("id") - if id == "" { - return h.formatter.FormatError(cmd.Writer, "Rotation policy ID is required (--id)") - } - - if err := h.deleteUseCase.Execute(ctx, id); err != nil { - return h.formatError(cmd, err) - } - - if cmd.Bool("json") { - return h.formatter.FormatJSON(cmd.Writer, map[string]string{"message": "Rotation policy deleted successfully", "id": id}) - } - - return h.formatter.FormatSuccess(cmd.Writer, "Rotation policy deleted: "+id) -} - func (h *RotationHandler) Trigger(ctx context.Context, cmd *cli.Command) error { id := cmd.String("id") if id == "" { - return h.formatter.FormatError(cmd.Writer, "Rotation policy ID is required (--id)") + return h.formatter.FormatError(cmd.ErrWriter, "Rotation policy ID is required (--id)") } result, err := h.triggerUseCase.Execute(ctx, id) @@ -185,7 +85,7 @@ func (h *RotationHandler) Trigger(ctx context.Context, cmd *cli.Command) error { func (h *RotationHandler) States(ctx context.Context, cmd *cli.Command) error { id := cmd.String("id") if id == "" { - return h.formatter.FormatError(cmd.Writer, "Rotation policy ID is required (--id)") + return h.formatter.FormatError(cmd.ErrWriter, "Rotation policy ID is required (--id)") } states, err := h.statesUseCase.Execute(ctx, id) @@ -217,5 +117,5 @@ func (h *RotationHandler) formatError(cmd *cli.Command, err error) error { if cmd.Bool("json") { return h.formatter.FormatJSONError(cmd.Writer, err) } - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } diff --git a/packages/envsync-cli/internal/features/handlers/run_handler.go b/packages/envsync-cli/internal/features/handlers/run_handler.go index 1283886e..7d8eaa5d 100644 --- a/packages/envsync-cli/internal/features/handlers/run_handler.go +++ b/packages/envsync-cli/internal/features/handlers/run_handler.go @@ -3,9 +3,13 @@ package handlers import ( "context" "errors" + "os" + "strconv" "strings" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/run" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/formatters" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/spinner" "github.com/urfave/cli/v3" ) @@ -15,6 +19,7 @@ type RunHandler struct { injectSecretUseCase run.InjectSecretsUseCase appUseCase run.FetchAppUseCase readConfigUseCase run.ReadConfigUseCase + formatter *formatters.RunFormatter } func NewRunHandler( @@ -23,6 +28,7 @@ func NewRunHandler( isuc run.InjectSecretsUseCase, auc run.FetchAppUseCase, rcuc run.ReadConfigUseCase, + formatter *formatters.RunFormatter, ) *RunHandler { return &RunHandler{ redactUseCase: ruc, @@ -30,27 +36,57 @@ func NewRunHandler( injectSecretUseCase: isuc, appUseCase: auc, readConfigUseCase: rcuc, + formatter: formatter, } } func (h *RunHandler) Run(ctx context.Context, cmd *cli.Command) error { c := strings.Split(cmd.String("command"), " ") + s := spinner.New("Reading config file...", true, os.Stdout) + + if !cmd.Bool("json") { + s.Start() + } configData, err := h.readConfigUseCase.Execute(ctx) + if !cmd.Bool("json") { + s.Stop() + } if err != nil { return err } + if err := h.formatter.FormatLog(cmd.Writer, "Fetched project configuration."); err != nil { + return err + } + + s = spinner.New("Loading...", true, os.Stdout) + if !cmd.Bool("json") { + s.Start() + } app, err := h.appUseCase.Execute(ctx, configData.AppID) + if !cmd.Bool("json") { + s.Stop() + } if err != nil { return err } + if !cmd.Bool("json") { + s.Start() + } envs, err := h.injectEnvUseCase.Execute(ctx) + if !cmd.Bool("json") { + s.Stop() + } if err != nil { return err } + if err := h.formatter.FormatLog(cmd.Writer, "Injected "+strconv.Itoa(len(envs))+" environment variable(s)."); err != nil { + return err + } + if app.EnableSecrets { if !cmd.IsSet("private-key") && !app.IsManagedSecret { return errors.New("private-key flag is required when secrets are enabled") @@ -61,16 +97,29 @@ func (h *RunHandler) Run(ctx context.Context, cmd *cli.Command) error { ctx = context.WithValue(ctx, "appID", configData.AppID) ctx = context.WithValue(ctx, "envTypeID", configData.EnvTypeID) + if !cmd.Bool("json") { + s.Start() + } secrets, err := h.injectSecretUseCase.Execute(ctx) + if !cmd.Bool("json") { + s.Stop() + } if err != nil { return err } + if err := h.formatter.FormatLog(cmd.Writer, "Injected "+strconv.Itoa(len(secrets))+" secret(s)."); err != nil { + return err + } + for key, value := range secrets { envs[key] = value } } + if err := h.formatter.FormatRunningStatus(cmd.Writer); err != nil { + return err + } _ = h.redactUseCase.Execute(ctx, c, envs) return nil diff --git a/packages/envsync-cli/internal/features/handlers/saml_handler.go b/packages/envsync-cli/internal/features/handlers/saml_handler.go deleted file mode 100644 index 654f5549..00000000 --- a/packages/envsync-cli/internal/features/handlers/saml_handler.go +++ /dev/null @@ -1,179 +0,0 @@ -package handlers - -import ( - "context" - - "github.com/urfave/cli/v3" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/saml" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/formatters" -) - -type SamlHandler struct { - createUseCase saml.CreateSamlProviderUseCase - listUseCase saml.ListSamlProvidersUseCase - getUseCase saml.GetSamlProviderUseCase - updateUseCase saml.UpdateSamlProviderUseCase - deleteUseCase saml.DeleteSamlProviderUseCase - metadataUseCase saml.GetSamlMetadataUseCase - ssoUseCase saml.InitiateSamlSsoUseCase - formatter *formatters.SamlFormatter -} - -func NewSamlHandler( - createUseCase saml.CreateSamlProviderUseCase, - listUseCase saml.ListSamlProvidersUseCase, - getUseCase saml.GetSamlProviderUseCase, - updateUseCase saml.UpdateSamlProviderUseCase, - deleteUseCase saml.DeleteSamlProviderUseCase, - metadataUseCase saml.GetSamlMetadataUseCase, - ssoUseCase saml.InitiateSamlSsoUseCase, - formatter *formatters.SamlFormatter, -) *SamlHandler { - return &SamlHandler{ - createUseCase: createUseCase, - listUseCase: listUseCase, - getUseCase: getUseCase, - updateUseCase: updateUseCase, - deleteUseCase: deleteUseCase, - metadataUseCase: metadataUseCase, - ssoUseCase: ssoUseCase, - formatter: formatter, - } -} - -func (h *SamlHandler) Create(ctx context.Context, cmd *cli.Command) error { - input := domain.CreateSamlProviderInput{ - ProviderType: cmd.String("provider-type"), - Name: cmd.String("name"), - EntityID: cmd.String("entity-id"), - SsoURL: cmd.String("sso-url"), - Certificate: cmd.String("certificate"), - } - - provider, err := h.createUseCase.Execute(ctx, input) - if err != nil { - return h.formatError(cmd, err) - } - - if cmd.Bool("json") { - return h.formatter.FormatJSON(cmd.Writer, provider) - } - - return h.formatter.FormatCreateSuccess(cmd.Writer, *provider) -} - -func (h *SamlHandler) List(ctx context.Context, cmd *cli.Command) error { - providers, err := h.listUseCase.Execute(ctx) - if err != nil { - return h.formatError(cmd, err) - } - - if cmd.Bool("json") { - return h.formatter.FormatJSON(cmd.Writer, providers) - } - - return h.formatter.FormatList(cmd.Writer, providers) -} - -func (h *SamlHandler) Get(ctx context.Context, cmd *cli.Command) error { - id := cmd.String("id") - - provider, err := h.getUseCase.Execute(ctx, id) - if err != nil { - return h.formatError(cmd, err) - } - - if cmd.Bool("json") { - return h.formatter.FormatJSON(cmd.Writer, provider) - } - - return h.formatter.FormatDetail(cmd.Writer, *provider) -} - -func (h *SamlHandler) Update(ctx context.Context, cmd *cli.Command) error { - input := domain.UpdateSamlProviderInput{ - ID: cmd.String("id"), - } - - if cmd.IsSet("name") { - input.Name = cmd.String("name") - } - if cmd.IsSet("entity-id") { - input.EntityID = cmd.String("entity-id") - } - if cmd.IsSet("sso-url") { - input.SsoURL = cmd.String("sso-url") - } - if cmd.IsSet("certificate") { - input.Certificate = cmd.String("certificate") - } - if cmd.IsSet("enabled") { - enabled := cmd.Bool("enabled") - input.Enabled = &enabled - } - - if err := h.updateUseCase.Execute(ctx, input); err != nil { - return h.formatError(cmd, err) - } - - if cmd.Bool("json") { - return h.formatter.FormatJSON(cmd.Writer, map[string]string{"message": "SAML provider updated successfully"}) - } - - return h.formatter.FormatSuccess(cmd.Writer, "SAML provider updated successfully") -} - -func (h *SamlHandler) Delete(ctx context.Context, cmd *cli.Command) error { - id := cmd.String("id") - - if err := h.deleteUseCase.Execute(ctx, id); err != nil { - return h.formatError(cmd, err) - } - - if cmd.Bool("json") { - return h.formatter.FormatJSON(cmd.Writer, map[string]string{"message": "SAML provider deleted successfully"}) - } - - return h.formatter.FormatDeleteSuccess(cmd.Writer, id) -} - -func (h *SamlHandler) Metadata(ctx context.Context, cmd *cli.Command) error { - orgSlug := cmd.String("org-slug") - - xml, err := h.metadataUseCase.Execute(ctx, orgSlug) - if err != nil { - return h.formatError(cmd, err) - } - - if cmd.Bool("json") { - return h.formatter.FormatJSON(cmd.Writer, map[string]string{"metadata": xml}) - } - - _, err = cmd.Writer.Write([]byte(xml)) - return err -} - -func (h *SamlHandler) Sso(ctx context.Context, cmd *cli.Command) error { - orgSlug := cmd.String("org-slug") - providerID := cmd.String("provider-id") - - result, err := h.ssoUseCase.Execute(ctx, orgSlug, providerID) - if err != nil { - return h.formatError(cmd, err) - } - - if cmd.Bool("json") { - return h.formatter.FormatJSON(cmd.Writer, result) - } - - return h.formatter.FormatSsoResult(cmd.Writer, *result) -} - -func (h *SamlHandler) formatError(cmd *cli.Command, err error) error { - if cmd.Bool("json") { - return h.formatter.FormatJSONError(cmd.Writer, err) - } - return h.formatter.FormatError(cmd.Writer, err.Error()) -} diff --git a/packages/envsync-cli/internal/features/handlers/service_token_handler.go b/packages/envsync-cli/internal/features/handlers/service_token_handler.go index 05787873..6ba00992 100644 --- a/packages/envsync-cli/internal/features/handlers/service_token_handler.go +++ b/packages/envsync-cli/internal/features/handlers/service_token_handler.go @@ -3,13 +3,17 @@ package handlers import ( "context" "errors" + "os" "github.com/urfave/cli/v3" - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" - + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/app" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/environment" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/service_token" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/formatters" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/spinner" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/factory" ) type ServiceTokenHandler struct { @@ -17,7 +21,10 @@ type ServiceTokenHandler struct { listUseCase service_token.ListServiceTokensUseCase getUseCase service_token.GetServiceTokenUseCase deleteUseCase service_token.DeleteServiceTokenUseCase + listApps app.ListAppsUseCase + getEnvs environment.GetEnvUseCase formatter *formatters.ServiceTokenFormatter + tui *factory.ServiceTokenFactory } func NewServiceTokenHandler( @@ -25,63 +32,177 @@ func NewServiceTokenHandler( listUseCase service_token.ListServiceTokensUseCase, getUseCase service_token.GetServiceTokenUseCase, deleteUseCase service_token.DeleteServiceTokenUseCase, + listApps app.ListAppsUseCase, + getEnvs environment.GetEnvUseCase, formatter *formatters.ServiceTokenFormatter, + tui *factory.ServiceTokenFactory, ) *ServiceTokenHandler { return &ServiceTokenHandler{ createUseCase: createUseCase, listUseCase: listUseCase, getUseCase: getUseCase, deleteUseCase: deleteUseCase, + listApps: listApps, + getEnvs: getEnvs, formatter: formatter, + tui: tui, } } func (h *ServiceTokenHandler) Create(ctx context.Context, cmd *cli.Command) error { - req := &sdk.CreateServiceTokenRequest{} + jsonOutput := cmd.Bool("json") - if cmd.IsSet("name") { - req.Name = cmd.String("name") - } - if cmd.IsSet("app-id") { - appID := cmd.String("app-id") - req.AppId = &appID - } - if cmd.IsSet("env-type-id") { - envTypeID := cmd.String("env-type-id") - req.EnvTypeId = &envTypeID + name := cmd.String("name") + appID := cmd.String("app-id") + envTypeID := cmd.String("env-type-id") + read := cmd.Bool("read") + write := cmd.Bool("write") + + // Permissions stay nil while the user states none: the API then applies + // its default (read-only). Once a flag is set or the form answers, the + // explicit values are sent — that is what keeps form "no/no" a real no. + var permissions *domain.ServiceTokenPermissions + if cmd.IsSet("read") || cmd.IsSet("write") { + permissions = &domain.ServiceTokenPermissions{Read: read, Write: write} } + + expiresInDays := 0 if cmd.IsSet("expires-in-days") { - days := int(cmd.Int("expires-in-days")) - req.ExpiresInDays = &days + expiresInDays = int(cmd.Int("expires-in-days")) } - if cmd.IsSet("read") || cmd.IsSet("write") { - permissions := &sdk.ServiceTokenPermissions{} - if cmd.IsSet("read") { - read := cmd.Bool("read") - permissions.Read = read + // Flags alone can carry a run when they cover the key and the scope. The + // pickers and form collect what is missing. JSON output is for scripts: it + // takes flag values only and never opens the TUI. + switch { + case jsonOutput: + if name == "" { + return h.formatUseCaseError(cmd, service_token.NewServiceTokenValidationError("name must be provided with json flag", service_token.ErrTokenNameRequired)) + } + case name == "" || appID == "" || envTypeID == "": + if !spinner.IsTerminal(os.Stdin) { + return h.formatUseCaseError(cmd, service_token.NewServiceTokenValidationError("name, app-id and env-type-id are required; provide --name, --app-id and --env-type-id flags", service_token.ErrTokenNameRequired)) } - if cmd.IsSet("write") { - write := cmd.Bool("write") - permissions.Write = write + + if appID == "" { + apps, err := h.fetchApps(ctx) + if err != nil { + return h.formatUseCaseError(cmd, err) + } + + application, ok, err := h.tui.PickApplication(apps, "Select the application") + if err != nil { + return h.formatUseCaseError(cmd, err) + } + if !ok { + return h.formatUseCaseError(cmd, service_token.ErrTokenCreateCancelled) + } + appID = application.ID + } + + if envTypeID == "" { + envs, err := h.fetchEnvs(ctx, appID) + if err != nil { + return h.formatUseCaseError(cmd, err) + } + + env, ok, err := h.tui.PickEnvironment(envs, "Select the environment") + if err != nil { + return h.formatUseCaseError(cmd, err) + } + if !ok { + return h.formatUseCaseError(cmd, service_token.ErrTokenCreateCancelled) + } + envTypeID = env.ID + } + + values, err := h.tui.CreateTokenTUI(factory.ServiceTokenCreateValues{ + Name: name, + Read: read, + Write: write, + ExpiresInDays: expiresInDays, + }) + if err != nil { + return h.formatUseCaseError(cmd, err) } - req.Permissions = permissions + name = values.Name + permissions = &domain.ServiceTokenPermissions{Read: values.Read, Write: values.Write} + expiresInDays = values.ExpiresInDays + } + + req := &domain.ServiceToken{ + Name: name, + AppID: appID, + EnvTypeID: envTypeID, + Permissions: permissions, + ExpiresInDays: expiresInDays, } + // Show progress while the token is created, then hand the screen over to + // the success message. + sp := spinner.New("Creating service token…", spinner.IsTerminal(os.Stderr), os.Stderr) + + if !jsonOutput { + sp.Start() + } token, err := h.createUseCase.Execute(ctx, req) + if !jsonOutput { + sp.Stop() + } if err != nil { return h.formatUseCaseError(cmd, err) } - if cmd.Bool("json") { + if jsonOutput { return h.formatter.FormatJSON(cmd.Writer, token) } return h.formatter.FormatCreateSuccessMessage(cmd.Writer, token) } +// fetchApps loads the applications with the shared loader presentation. +func (h *ServiceTokenHandler) fetchApps(ctx context.Context) ([]domain.Application, error) { + sp := spinner.New("Fetching applications…", spinner.IsTerminal(os.Stderr), os.Stderr) + sp.Start() + defer sp.Stop() + + apps, err := h.listApps.Execute(ctx) + if err != nil { + return nil, err + } + if len(apps) == 0 { + return nil, service_token.NewServiceTokenValidationError("no applications available; create one with 'envsync app create'", service_token.ErrNoApplications) + } + return apps, nil +} + +// fetchEnvs loads the application's environments with the shared loader +// presentation. +func (h *ServiceTokenHandler) fetchEnvs(ctx context.Context, appID string) ([]domain.EnvType, error) { + sp := spinner.New("Fetching environments…", spinner.IsTerminal(os.Stderr), os.Stderr) + sp.Start() + defer sp.Stop() + + envs, err := h.getEnvs.ExecuteByAppID(ctx, appID) + if err != nil { + return nil, err + } + if len(envs) == 0 { + return nil, service_token.NewServiceTokenValidationError("no environments available for this application", service_token.ErrNoEnvironments) + } + return envs, nil +} + func (h *ServiceTokenHandler) List(ctx context.Context, cmd *cli.Command) error { + // Show progress while the tokens load, then hand the screen over to the view. + sp := spinner.New("Fetching service tokens…", spinner.IsTerminal(os.Stderr), os.Stderr) + if !cmd.Bool("json") { + sp.Start() + } tokens, err := h.listUseCase.Execute(ctx) + if !cmd.Bool("json") { + sp.Stop() + } if err != nil { return h.formatUseCaseError(cmd, err) } @@ -90,32 +211,106 @@ func (h *ServiceTokenHandler) List(ctx context.Context, cmd *cli.Command) error return h.formatter.FormatJSON(cmd.Writer, tokens) } + // A terminal gets the table TUI. Pipes and scripts keep the plain table so + // existing pipelines are unaffected. + if len(tokens) > 0 && spinner.IsTerminal(cmd.Writer) { + return h.tui.ListTokensInteractive(tokens) + } + return h.formatter.FormatListTable(cmd.Writer, tokens) } func (h *ServiceTokenHandler) Get(ctx context.Context, cmd *cli.Command) error { + jsonOutput := cmd.Bool("json") id := cmd.String("id") + // Without --id a terminal picks the token from a summary table. JSON is + // for scripts: it takes flag values only and never opens the TUI. + if id == "" { + if jsonOutput { + return h.formatUseCaseError(cmd, service_token.NewServiceTokenValidationError("id must be provided with json flag", service_token.ErrTokenIDRequired)) + } + if !spinner.IsTerminal(os.Stdin) { + return h.formatUseCaseError(cmd, service_token.NewServiceTokenValidationError("id is required; provide --id flag", service_token.ErrTokenIDRequired)) + } + + tokens, err := h.fetchTokens(ctx) + if err != nil { + return h.formatUseCaseError(cmd, err) + } + + token, ok, err := h.tui.PickToken(tokens, "Select the service token") + if err != nil { + return h.formatUseCaseError(cmd, err) + } + if !ok { + return h.formatUseCaseError(cmd, service_token.ErrTokenSelectionCancelled) + } + id = token.ID + } + token, err := h.getUseCase.Execute(ctx, id) if err != nil { return h.formatUseCaseError(cmd, err) } - if cmd.Bool("json") { + if jsonOutput { return h.formatter.FormatJSON(cmd.Writer, token) } return h.formatter.FormatGetSuccessMessage(cmd.Writer, token) } +// fetchTokens loads the service tokens with the shared loader presentation. +func (h *ServiceTokenHandler) fetchTokens(ctx context.Context) ([]domain.ServiceToken, error) { + sp := spinner.New("Fetching service tokens…", spinner.IsTerminal(os.Stderr), os.Stderr) + sp.Start() + defer sp.Stop() + + tokens, err := h.listUseCase.Execute(ctx) + if err != nil { + return nil, err + } + if len(tokens) == 0 { + return nil, service_token.NewServiceTokenValidationError("no service tokens available; create one with 'envsync service-token create'", service_token.ErrNoTokens) + } + return tokens, nil +} + func (h *ServiceTokenHandler) Delete(ctx context.Context, cmd *cli.Command) error { + jsonOutput := cmd.Bool("json") id := cmd.String("id") + // Without --id a terminal picks the token to delete. JSON is for scripts: + // it takes flag values only and never opens the TUI. + if id == "" { + if jsonOutput { + return h.formatUseCaseError(cmd, service_token.NewServiceTokenValidationError("id must be provided with json flag", service_token.ErrTokenIDRequired)) + } + if !spinner.IsTerminal(os.Stdin) { + return h.formatUseCaseError(cmd, service_token.NewServiceTokenValidationError("id is required; provide --id flag", service_token.ErrTokenIDRequired)) + } + + tokens, err := h.fetchTokens(ctx) + if err != nil { + return h.formatUseCaseError(cmd, err) + } + + token, ok, err := h.tui.PickTokenToDelete(tokens, "Select the service token to delete") + if err != nil { + return h.formatUseCaseError(cmd, err) + } + if !ok { + return h.formatUseCaseError(cmd, service_token.ErrTokenSelectionCancelled) + } + id = token.ID + } + if err := h.deleteUseCase.Execute(ctx, id); err != nil { return h.formatUseCaseError(cmd, err) } - if cmd.Bool("json") { + if jsonOutput { jsonData := map[string]any{ "message": "Service token deleted successfully", "id": id, @@ -135,15 +330,15 @@ func (h *ServiceTokenHandler) formatUseCaseError(cmd *cli.Command, err error) er if errors.As(err, &stErr) { switch stErr.Code { case service_token.ServiceTokenErrorCodeValidation: - return h.formatter.FormatError(cmd.Writer, "Validation error: "+stErr.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Validation error: "+stErr.Message) case service_token.ServiceTokenErrorCodeNotFound: - return h.formatter.FormatError(cmd.Writer, "Service token not found: "+stErr.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Service token not found: "+stErr.Message) case service_token.ServiceTokenErrorCodeAccessDenied: - return h.formatter.FormatError(cmd.Writer, "Access denied: "+stErr.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Access denied: "+stErr.Message) default: - return h.formatter.FormatError(cmd.Writer, "Service error: "+stErr.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Service error: "+stErr.Message) } } - return h.formatter.FormatError(cmd.Writer, "Unexpected error: "+err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, "Unexpected error: "+err.Error()) } diff --git a/packages/envsync-cli/internal/features/handlers/sync.go b/packages/envsync-cli/internal/features/handlers/sync.go index ebdc77f9..25fc51a7 100644 --- a/packages/envsync-cli/internal/features/handlers/sync.go +++ b/packages/envsync-cli/internal/features/handlers/sync.go @@ -3,11 +3,13 @@ package handlers import ( "context" "fmt" + "os" "github.com/urfave/cli/v3" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/features/usecases/sync" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/formatters" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/spinner" ) type SyncHandler struct { @@ -31,9 +33,16 @@ func NewSyncHandler( func (h *SyncHandler) Pull(ctx context.Context, cmd *cli.Command) error { config := cmd.String("config") + s := spinner.New("Fetching variables...", true, os.Stdout) + if !cmd.Bool("json") { + s.Start() + } diff, err := h.pullUseCase.Execute(ctx, config) + if !cmd.Bool("json") { + s.Stop() + } if err != nil { - return err + return h.formatUseCaseError(cmd, err) } if len(diff.Warnings) > 0 { @@ -44,13 +53,7 @@ func (h *SyncHandler) Pull(ctx context.Context, cmd *cli.Command) error { return nil } - if len(diff.Added) > 0 || len(diff.Updated) > 0 || len(diff.Deleted) > 0 { - // Handle the sync response, e.g., print or log the changes - fmt.Printf("Sync completed with %d added, %d updated, and %d deleted variables.\n", - len(diff.Added), len(diff.Updated), len(diff.Deleted)) - } else { - fmt.Printf("No changes detected during sync.\n") - } + h.formatter.FormatVariableDetails(len(diff.Added), len(diff.Updated), len(diff.Deleted)) return nil } @@ -84,32 +87,28 @@ func (h *SyncHandler) Push(ctx context.Context, cmd *cli.Command) error { func (h *SyncHandler) formatUseCaseError(cmd *cli.Command, err error) error { if cmd.Bool("json") { - // If JSON output is requested, format the error as JSON - jsonOutput := map[string]any{ - "error": err.Error(), - } - return h.formatter.FormatJSON(cmd.Writer, jsonOutput) + return h.formatter.FormatJSONError(cmd.Writer, err) } // Handle different types of use case errors switch e := err.(type) { case *sync.SyncError: switch e.Code { case sync.SyncErrorCodeValidation: - return h.formatter.FormatError(cmd.Writer, "Validation error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Validation error: "+e.Message) case sync.SyncErrorCodeFileSystem: - return h.formatter.FormatError(cmd.Writer, "File system error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "File system error: "+e.Message) case sync.SyncErrorCodePermission: - return h.formatter.FormatError(cmd.Writer, "Permission error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Permission error: "+e.Message) case sync.SyncErrorCodeNotFound: - return h.formatter.FormatError(cmd.Writer, "Not found error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Not found error: "+e.Message) case sync.SyncErrorCodeCorrupted: - return h.formatter.FormatError(cmd.Writer, "Corrupted file error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Corrupted file error: "+e.Message) case sync.SyncErrorCodeServiceError: - return h.formatter.FormatError(cmd.Writer, "Service error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Service error: "+e.Message) default: - return h.formatter.FormatError(cmd.Writer, "Service error: "+e.Message) + return h.formatter.FormatError(cmd.ErrWriter, "Service error: "+e.Message) } default: - return h.formatter.FormatError(cmd.Writer, "Unexpected error: "+err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, "Unexpected error: "+err.Error()) } } diff --git a/packages/envsync-cli/internal/features/handlers/team_handler.go b/packages/envsync-cli/internal/features/handlers/team_handler.go index 21cc602a..42bdc7a6 100644 --- a/packages/envsync-cli/internal/features/handlers/team_handler.go +++ b/packages/envsync-cli/internal/features/handlers/team_handler.go @@ -25,7 +25,7 @@ func NewTeamHandler() *TeamHandler { func (h *TeamHandler) List(ctx context.Context, cmd *cli.Command) error { teams, err := h.repo.List(ctx) if err != nil { - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } if cmd.Bool("json") { return h.formatter.FormatJSON(cmd.Writer, teams) @@ -47,7 +47,7 @@ func (h *TeamHandler) Create(ctx context.Context, cmd *cli.Command) error { "color": cmd.String("color"), }) if err != nil { - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } if cmd.Bool("json") { return h.formatter.FormatJSON(cmd.Writer, team) @@ -67,42 +67,42 @@ func (h *TeamHandler) Update(ctx context.Context, cmd *cli.Command) error { payload["color"] = cmd.String("color") } if err := h.repo.Update(ctx, cmd.String("id"), payload); err != nil { - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } return h.formatter.FormatSuccess(cmd.Writer, "Team updated: "+cmd.String("id")) } func (h *TeamHandler) Delete(ctx context.Context, cmd *cli.Command) error { if err := h.repo.Delete(ctx, cmd.String("id")); err != nil { - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } return h.formatter.FormatSuccess(cmd.Writer, "Team deleted: "+cmd.String("id")) } func (h *TeamHandler) AddMember(ctx context.Context, cmd *cli.Command) error { if err := h.repo.AddMember(ctx, cmd.String("id"), cmd.String("user-id")); err != nil { - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } return h.formatter.FormatSuccess(cmd.Writer, "Member added to team") } func (h *TeamHandler) RemoveMember(ctx context.Context, cmd *cli.Command) error { if err := h.repo.RemoveMember(ctx, cmd.String("id"), cmd.String("user-id")); err != nil { - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } return h.formatter.FormatSuccess(cmd.Writer, "Member removed from team") } func (h *TeamHandler) AssignRole(ctx context.Context, cmd *cli.Command) error { if err := h.repo.AssignRole(ctx, cmd.String("id"), cmd.String("role-id")); err != nil { - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } return h.formatter.FormatSuccess(cmd.Writer, "Role assigned to team") } func (h *TeamHandler) UnassignRole(ctx context.Context, cmd *cli.Command) error { if err := h.repo.UnassignRole(ctx, cmd.String("id")); err != nil { - return h.formatter.FormatError(cmd.Writer, err.Error()) + return h.formatter.FormatError(cmd.ErrWriter, err.Error()) } return h.formatter.FormatSuccess(cmd.Writer, "Role removed from team") } diff --git a/packages/envsync-cli/internal/features/usecases/app/create_app.go b/packages/envsync-cli/internal/features/usecases/app/create_app.go index 357b9fba..2bc731c5 100644 --- a/packages/envsync-cli/internal/features/usecases/app/create_app.go +++ b/packages/envsync-cli/internal/features/usecases/app/create_app.go @@ -2,12 +2,18 @@ package app import ( "context" + "errors" "fmt" + "os" "regexp" "strings" "sync" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/spinner" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/factory" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" @@ -30,23 +36,13 @@ func NewCreateAppUseCase() CreateAppUseCase { } } -func (uc *createAppUseCase) Execute(ctx context.Context, app domain.Application) (*domain.Application, error) { - ctx, span := telemetry.Tracer().Start(ctx, "app.create") - defer span.End() - - if app.Name != "" { - // Check if application with same name already exists - if exists, err := uc.checkApplicationExists(ctx, app.Name); err != nil { - return nil, NewServiceError("failed to check application existence", err) - } else if exists { - return nil, NewAlreadyExistsError( - fmt.Sprintf("application with name '%s' already exists", app.Name), - ErrAppAlreadyExists, - ) - } - } +func (uc *createAppUseCase) Execute(ctx context.Context, app domain.Application) (created *domain.Application, err error) { + ctx, end := telemetry.StartSpan(ctx, "app.create", trace.WithAttributes(attribute.String("app.name", app.Name))) + defer end(&err) - // var inputApp *domain.Application + // A missing name means the TUI supplies it, so duplicate detection has to + // wait until the name is final — otherwise apps created interactively were + // never checked at all. if app.Name == "" { a, err := uc.tui.CreateAppTUI(ctx, &app) if err != nil { @@ -55,6 +51,22 @@ func (uc *createAppUseCase) Execute(ctx context.Context, app domain.Application) app = *a } + // The form is complete at this point. Keep a visible status while the + // request runs so the command does not look hung, and clear it on the way + // out so the caller's success output takes its place. + sp := spinner.New("Creating application…", spinner.IsTerminal(os.Stderr), os.Stderr) + sp.Start() + defer sp.Stop() + + if exists, err := uc.checkApplicationExists(ctx, app.Name); err != nil { + return nil, NewServiceError("failed to check application existence", err) + } else if exists { + return nil, NewAlreadyExistsError( + fmt.Sprintf("application with name '%s' already exists", app.Name), + ErrAppAlreadyExists, + ) + } + // Validate business validation if err := uc.validateBusinessRules(app); err != nil { return nil, err @@ -66,35 +78,40 @@ func (uc *createAppUseCase) Execute(ctx context.Context, app domain.Application) return nil, NewServiceError("failed to create application", err) } - setDefaultEnv := ctx.Value("setDefaultEnv") - if setDefaultEnv != nil && setDefaultEnv.(bool) { - var wg sync.WaitGroup - var prodErr, devErr error - - wg.Add(2) - - // Create PROD environment type - go func() { - defer wg.Done() - prodEnvType := domain.NewEnvType(createdApp.ID, "PROD", false, false, "") - _, prodErr = uc.envService.CreateEnvType(ctx, prodEnvType) - }() + // Create one environment type per requested entry, concurrently. + if len(app.EnvTypes) > 0 { + created := make([]domain.EnvType, len(app.EnvTypes)) + errs := make([]error, len(app.EnvTypes)) - // Create DEV environment type - go func() { - defer wg.Done() - devEnvType := domain.NewEnvType(createdApp.ID, "DEV", false, false, "") - _, devErr = uc.envService.CreateEnvType(ctx, devEnvType) - }() + var wg sync.WaitGroup + wg.Add(len(app.EnvTypes)) + + for i, envType := range app.EnvTypes { + // Each goroutine owns exactly one index, so there is no sharing to + // synchronise; wg.Wait() below publishes the results. + go func(i int, envType domain.EnvType) { + defer wg.Done() + created[i], errs[i] = uc.envService.CreateEnvType( + ctx, + domain.NewEnvType(createdApp.ID, envType.Name, envType.IsDefault, envType.IsProtected, envType.Color), + ) + }(i, envType) + } wg.Wait() - if prodErr != nil { - return nil, NewServiceError("failed to create PROD environment type", prodErr) + // Report every failure, not just the first, and name the one that failed. + for i, err := range errs { + if err != nil { + errs[i] = fmt.Errorf("%s: %w", app.EnvTypes[i].Name, err) + } } - if devErr != nil { - return nil, NewServiceError("failed to create DEV environment type", devErr) + if err := errors.Join(errs...); err != nil { + return nil, NewServiceError("failed to create environment types", err) } + + // Report what was actually created so the caller can show it. + createdApp.EnvTypes = append(createdApp.EnvTypes, created...) } return &createdApp, nil diff --git a/packages/envsync-cli/internal/features/usecases/app/delete_app.go b/packages/envsync-cli/internal/features/usecases/app/delete_app.go index fd7a1e6f..9f643cb0 100644 --- a/packages/envsync-cli/internal/features/usecases/app/delete_app.go +++ b/packages/envsync-cli/internal/features/usecases/app/delete_app.go @@ -1,37 +1,42 @@ package app import ( - "bufio" "context" - "fmt" "os" - "strings" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/spinner" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/factory" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type deleteAppUseCase struct { appService services.ApplicationService + tui *factory.AppFactory } -func NewDeleteAppUseCase() DeleteAppUseCase { - service := services.NewAppService() +func NewDeleteAppUseCase(tui *factory.AppFactory) DeleteAppUseCase { return &deleteAppUseCase{ - appService: service, + appService: services.NewAppService(), + tui: tui, } } -func (uc *deleteAppUseCase) Execute(ctx context.Context) ([]domain.Application, error) { - ctx, span := telemetry.Tracer().Start(ctx, "app.delete") - defer span.End() - +func (uc *deleteAppUseCase) Execute(ctx context.Context) (deleted []domain.Application, err error) { appID, _ := ctx.Value("appID").(string) appName, _ := ctx.Value("appName").(string) + ctx, end := telemetry.StartSpan(ctx, "app.delete", trace.WithAttributes( + attribute.String("app.id", appID), + attribute.String("app.name", appName), + )) + defer end(&err) + var deletedApps []domain.Application - var err error switch { case appID == "" && appName == "": @@ -59,44 +64,25 @@ func (uc *deleteAppUseCase) deleteAppsInteractive(ctx context.Context) ([]domain return nil, NewNotFoundError("no applications found", nil) } - reader := bufio.NewReader(os.Stdin) - - fmt.Println("\n🗑️ Available Applications:") - fmt.Println(strings.Repeat("-", 60)) - for i, app := range apps { - fmt.Printf(" %d) %s (ID: %s)\n", i+1, app.Name, app.ID) - } - fmt.Println(strings.Repeat("-", 60)) - - fmt.Print("\nSelect application to delete (enter number or ID): ") - input, _ := reader.ReadString('\n') - input = strings.TrimSpace(input) - - var selectedApp *domain.Application - for i, app := range apps { - if input == fmt.Sprintf("%d", i+1) || input == app.ID || strings.EqualFold(input, app.Name) { - selectedApp = &apps[i] - break - } - } - - if selectedApp == nil { - return nil, NewNotFoundError("application not found: "+input, nil) + selected, ok, err := uc.tui.PickApp(apps) + if err != nil { + return nil, NewServiceError("failed to select application", err) } - - fmt.Printf("\n⚠️ Are you sure you want to delete '%s'? (y/N): ", selectedApp.Name) - confirm, _ := reader.ReadString('\n') - confirm = strings.TrimSpace(strings.ToLower(confirm)) - - if confirm != "y" && confirm != "yes" { + if !ok { return nil, NewCancelledError("deletion cancelled by user", nil) } - if err := uc.appService.DeleteApp(ctx, *selectedApp); err != nil { + // Deleting can take a moment; keep a visible status and clear it on the way + // out so the caller's success output takes its place. + sp := spinner.New("Deleting application…", spinner.IsTerminal(os.Stderr), os.Stderr) + sp.Start() + defer sp.Stop() + + if err := uc.appService.DeleteApp(ctx, selected); err != nil { return nil, NewServiceError("failed to delete application", err) } - return []domain.Application{*selectedApp}, nil + return []domain.Application{selected}, nil } func (uc *deleteAppUseCase) deleteAppByID(ctx context.Context, appID string) ([]domain.Application, error) { diff --git a/packages/envsync-cli/internal/features/usecases/app/list_apps.go b/packages/envsync-cli/internal/features/usecases/app/list_apps.go index 194abf1f..44f03b73 100644 --- a/packages/envsync-cli/internal/features/usecases/app/list_apps.go +++ b/packages/envsync-cli/internal/features/usecases/app/list_apps.go @@ -19,9 +19,9 @@ func NewListAppsUseCase() ListAppsUseCase { } } -func (uc *listAppsUseCase) Execute(ctx context.Context) ([]domain.Application, error) { - ctx, span := telemetry.Tracer().Start(ctx, "app.list") - defer span.End() +func (uc *listAppsUseCase) Execute(ctx context.Context) (result []domain.Application, err error) { + ctx, end := telemetry.StartSpan(ctx, "app.list") + defer end(&err) apps, err := uc.appService.GetAllApps(ctx) if err != nil { diff --git a/packages/envsync-cli/internal/features/usecases/auth/interfaces.go b/packages/envsync-cli/internal/features/usecases/auth/interfaces.go index a508e264..a6aacf2e 100644 --- a/packages/envsync-cli/internal/features/usecases/auth/interfaces.go +++ b/packages/envsync-cli/internal/features/usecases/auth/interfaces.go @@ -9,7 +9,14 @@ import ( // LoginUseCase defines the interface for user authentication type LoginUseCase interface { Execute(context.Context) (*LoginResponse, error) - ExecuteWithOptions(ctx context.Context, noBrowser bool, noWait bool) (*LoginResponse, error) + ExecuteWithOptions(ctx context.Context, opts LoginOptions) (*LoginResponse, error) +} + +// LoginOptions controls how a login attempt is presented to the user. +type LoginOptions struct { + NoBrowser bool // do not launch the system browser + NoWait bool // return as soon as the device code is issued + JSON bool // machine-readable output: suppress the spinner } // LogoutUseCase defines the interface for user logout diff --git a/packages/envsync-cli/internal/features/usecases/auth/login.go b/packages/envsync-cli/internal/features/usecases/auth/login.go index 9a72ec6c..b501dc3b 100644 --- a/packages/envsync-cli/internal/features/usecases/auth/login.go +++ b/packages/envsync-cli/internal/features/usecases/auth/login.go @@ -3,12 +3,14 @@ package auth import ( "context" "fmt" + "os" "strings" "github.com/pkg/browser" "github.com/savioxavier/termlink" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/spinner" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/style" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" @@ -26,12 +28,12 @@ func NewLoginUseCase() LoginUseCase { } func (uc *loginUseCase) Execute(ctx context.Context) (*LoginResponse, error) { - return uc.ExecuteWithOptions(ctx, false, false) + return uc.ExecuteWithOptions(ctx, LoginOptions{}) } -func (uc *loginUseCase) ExecuteWithOptions(ctx context.Context, noBrowser bool, noWait bool) (*LoginResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "auth.login") - defer span.End() +func (uc *loginUseCase) ExecuteWithOptions(ctx context.Context, opts LoginOptions) (response *LoginResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "auth.login") + defer end(&err) userInfo, err := uc.checkCurrentLoginStatus(ctx) if err == nil && userInfo != nil { @@ -47,7 +49,7 @@ func (uc *loginUseCase) ExecuteWithOptions(ctx context.Context, noBrowser bool, return nil, NewLoginFailedError("failed to initiate login process", err) } - if noWait { + if opts.NoWait { return &LoginResponse{ Success: true, Message: "Device code generated. Complete authentication in your browser, then run 'envsync auth whoami' to verify.", @@ -63,17 +65,27 @@ func (uc *loginUseCase) ExecuteWithOptions(ctx context.Context, noBrowser bool, if err := uc.displayLoginInstructions(credentials); err != nil { } - if !noBrowser { + if !opts.NoBrowser { if err := uc.openBrowserForLogin(credentials.GetVerificationUri()); err != nil { } } + // The poll can run for as long as the device code is valid, so keep a + // visible status on stderr instead of looking hung. Stop clears the line on + // both paths, so whatever the caller prints next replaces the spinner. + sp := spinner.New( + fmt.Sprintf("Waiting for you to sign in in your browser… (code: %s)", credentials.GetUserCode()), + !opts.JSON && spinner.IsTerminal(os.Stderr), + os.Stderr, + ) + sp.Start() token, err := uc.authService.PollForToken(ctx, credentials) + sp.Stop() if err != nil { return nil, uc.handlePollingError(err) } - if err := uc.authService.SaveToken(token); err != nil { + if err := uc.authService.SaveToken(token, credentials); err != nil { return nil, NewServiceError("failed to save authentication token", err) } diff --git a/packages/envsync-cli/internal/features/usecases/auth/logout.go b/packages/envsync-cli/internal/features/usecases/auth/logout.go index 3c360fdf..682a9d4a 100644 --- a/packages/envsync-cli/internal/features/usecases/auth/logout.go +++ b/packages/envsync-cli/internal/features/usecases/auth/logout.go @@ -20,9 +20,9 @@ func NewLogoutUseCase() LogoutUseCase { } } -func (uc *logoutUseCase) Execute(ctx context.Context) error { - _, span := telemetry.Tracer().Start(ctx, "auth.logout") - defer span.End() +func (uc *logoutUseCase) Execute(ctx context.Context) (err error) { + _, end := telemetry.StartSpan(ctx, "auth.logout") + defer end(&err) // Perform logout if err := uc.authService.Logout(); err != nil { @@ -39,10 +39,10 @@ func (uc *logoutUseCase) Execute(ctx context.Context) error { func (uc *logoutUseCase) cleanupLocalState() error { cfg := config.New() - cfg.AccessToken = "" + cfg.AuthConfig = config.AuthConfig{} if err := cfg.WriteConfigFile(); err != nil { - return fmt.Errorf("failed to clear access token: %w", err) + return fmt.Errorf("failed to clear auth config: %w", err) } return nil diff --git a/packages/envsync-cli/internal/features/usecases/auth/whoami.go b/packages/envsync-cli/internal/features/usecases/auth/whoami.go index 13712672..2946bad5 100644 --- a/packages/envsync-cli/internal/features/usecases/auth/whoami.go +++ b/packages/envsync-cli/internal/features/usecases/auth/whoami.go @@ -18,9 +18,9 @@ func NewWhoamiUseCase() WhoamiUseCase { } } -func (uc *whoamiUseCase) Execute(ctx context.Context) (*WhoamiResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "auth.whoami") - defer span.End() +func (uc *whoamiUseCase) Execute(ctx context.Context) (result *WhoamiResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "auth.whoami") + defer end(&err) // Initialize response with configuration info response := &WhoamiResponse{ diff --git a/packages/envsync-cli/internal/features/usecases/certificate/ca_status.go b/packages/envsync-cli/internal/features/usecases/certificate/ca_status.go index 2c804c62..42356f05 100644 --- a/packages/envsync-cli/internal/features/usecases/certificate/ca_status.go +++ b/packages/envsync-cli/internal/features/usecases/certificate/ca_status.go @@ -5,6 +5,7 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type caStatusUseCase struct { @@ -16,7 +17,10 @@ func NewCAStatusUseCase() CAStatusUseCase { return &caStatusUseCase{service: service} } -func (uc *caStatusUseCase) Execute(ctx context.Context) (*domain.Certificate, error) { +func (uc *caStatusUseCase) Execute(ctx context.Context) (result *domain.Certificate, err error) { + ctx, end := telemetry.StartSpan(ctx, "certificate.ca_status") + defer end(&err) + cert, err := uc.service.GetCA(ctx) if err != nil { return nil, NewServiceError("failed to get organization CA status", err) diff --git a/packages/envsync-cli/internal/features/usecases/certificate/check_ocsp.go b/packages/envsync-cli/internal/features/usecases/certificate/check_ocsp.go index 456291cc..50cb92d8 100644 --- a/packages/envsync-cli/internal/features/usecases/certificate/check_ocsp.go +++ b/packages/envsync-cli/internal/features/usecases/certificate/check_ocsp.go @@ -5,6 +5,9 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type checkOCSPUseCase struct { @@ -16,7 +19,11 @@ func NewCheckOCSPUseCase() CheckOCSPUseCase { return &checkOCSPUseCase{service: service} } -func (uc *checkOCSPUseCase) Execute(ctx context.Context, serialHex string) (*domain.OCSPResult, error) { +func (uc *checkOCSPUseCase) Execute(ctx context.Context, serialHex string) (ocspResult *domain.OCSPResult, err error) { + ctx, end := telemetry.StartSpan(ctx, "certificate.check_ocsp", + trace.WithAttributes(attribute.String("cert.serial", serialHex))) + defer end(&err) + if serialHex == "" { return nil, NewValidationError("certificate serial number is required", ErrSerialRequired) } diff --git a/packages/envsync-cli/internal/features/usecases/certificate/errors.go b/packages/envsync-cli/internal/features/usecases/certificate/errors.go index c7afc26b..a01bdfbe 100644 --- a/packages/envsync-cli/internal/features/usecases/certificate/errors.go +++ b/packages/envsync-cli/internal/features/usecases/certificate/errors.go @@ -3,13 +3,13 @@ package certificate import "errors" var ( - ErrOrgNameRequired = errors.New("organization name is required") - ErrEmailRequired = errors.New("member email is required") - ErrRoleRequired = errors.New("role is required") - ErrCNRequired = errors.New("common name is required") - ErrSerialRequired = errors.New("certificate serial number is required") - ErrCANotInitialized = errors.New("organization CA not initialized") - ErrCertNotFound = errors.New("certificate not found") + ErrOrgNameRequired = errors.New("organization name is required") + ErrEmailRequired = errors.New("member email is required") + ErrRoleRequired = errors.New("role is required") + ErrCNRequired = errors.New("common name is required") + ErrSerialRequired = errors.New("certificate serial number is required") + ErrCANotInitialized = errors.New("organization CA not initialized") + ErrCertNotFound = errors.New("certificate not found") ) type CertError struct { diff --git a/packages/envsync-cli/internal/features/usecases/certificate/get_crl.go b/packages/envsync-cli/internal/features/usecases/certificate/get_crl.go index 5e5fd335..006d57c4 100644 --- a/packages/envsync-cli/internal/features/usecases/certificate/get_crl.go +++ b/packages/envsync-cli/internal/features/usecases/certificate/get_crl.go @@ -5,6 +5,7 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type getCRLUseCase struct { @@ -16,7 +17,10 @@ func NewGetCRLUseCase() GetCRLUseCase { return &getCRLUseCase{service: service} } -func (uc *getCRLUseCase) Execute(ctx context.Context) (*domain.CRLResult, error) { +func (uc *getCRLUseCase) Execute(ctx context.Context) (crlResult *domain.CRLResult, err error) { + ctx, end := telemetry.StartSpan(ctx, "certificate.get_crl") + defer end(&err) + result, err := uc.service.GetCRL(ctx) if err != nil { return nil, NewServiceError("failed to get CRL", err) diff --git a/packages/envsync-cli/internal/features/usecases/certificate/get_root_ca.go b/packages/envsync-cli/internal/features/usecases/certificate/get_root_ca.go index 4878b557..3c52de1d 100644 --- a/packages/envsync-cli/internal/features/usecases/certificate/get_root_ca.go +++ b/packages/envsync-cli/internal/features/usecases/certificate/get_root_ca.go @@ -4,6 +4,7 @@ import ( "context" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type getRootCAUseCase struct { @@ -15,7 +16,10 @@ func NewGetRootCAUseCase() GetRootCAUseCase { return &getRootCAUseCase{service: service} } -func (uc *getRootCAUseCase) Execute(ctx context.Context) (string, error) { +func (uc *getRootCAUseCase) Execute(ctx context.Context) (result string, err error) { + ctx, end := telemetry.StartSpan(ctx, "certificate.get_root_ca") + defer end(&err) + certPEM, err := uc.service.GetRootCA(ctx) if err != nil { return "", NewServiceError("failed to get root CA", err) diff --git a/packages/envsync-cli/internal/features/usecases/certificate/init_ca.go b/packages/envsync-cli/internal/features/usecases/certificate/init_ca.go index 9cf53b1f..9bd587e3 100644 --- a/packages/envsync-cli/internal/features/usecases/certificate/init_ca.go +++ b/packages/envsync-cli/internal/features/usecases/certificate/init_ca.go @@ -6,6 +6,9 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository/requests" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type initCAUseCase struct { @@ -17,7 +20,11 @@ func NewInitCAUseCase() InitCAUseCase { return &initCAUseCase{service: service} } -func (uc *initCAUseCase) Execute(ctx context.Context, orgName, description string) (*domain.Certificate, error) { +func (uc *initCAUseCase) Execute(ctx context.Context, orgName, description string) (result *domain.Certificate, err error) { + ctx, end := telemetry.StartSpan(ctx, "certificate.init_ca", + trace.WithAttributes(attribute.String("cert.org_name", orgName))) + defer end(&err) + if orgName == "" { return nil, NewValidationError("organization name is required", ErrOrgNameRequired) } diff --git a/packages/envsync-cli/internal/features/usecases/certificate/issue_cert.go b/packages/envsync-cli/internal/features/usecases/certificate/issue_cert.go index 942a1a82..84d177e3 100644 --- a/packages/envsync-cli/internal/features/usecases/certificate/issue_cert.go +++ b/packages/envsync-cli/internal/features/usecases/certificate/issue_cert.go @@ -7,6 +7,9 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository/requests" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type issueCertUseCase struct { @@ -18,7 +21,17 @@ func NewIssueCertUseCase() IssueCertUseCase { return &issueCertUseCase{service: service} } -func (uc *issueCertUseCase) Execute(ctx context.Context, input IssueCertInput) (*domain.Certificate, error) { +func (uc *issueCertUseCase) Execute(ctx context.Context, input IssueCertInput) (result *domain.Certificate, err error) { + ctx, end := telemetry.StartSpan(ctx, "certificate.issue_cert", + trace.WithAttributes( + attribute.String("app.id", input.AppID), + attribute.String("env.id", input.EnvTypeID), + attribute.String("cert.common_name", input.CommonName), + attribute.String("cert.email", input.Email), + attribute.String("file.path", input.CSRPath), + )) + defer end(&err) + if input.CSRPath != "" { pemBytes, err := os.ReadFile(input.CSRPath) if err != nil { diff --git a/packages/envsync-cli/internal/features/usecases/certificate/list_certs.go b/packages/envsync-cli/internal/features/usecases/certificate/list_certs.go index 96a94963..0bfad29b 100644 --- a/packages/envsync-cli/internal/features/usecases/certificate/list_certs.go +++ b/packages/envsync-cli/internal/features/usecases/certificate/list_certs.go @@ -5,6 +5,7 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type listCertsUseCase struct { @@ -16,7 +17,10 @@ func NewListCertsUseCase() ListCertsUseCase { return &listCertsUseCase{service: service} } -func (uc *listCertsUseCase) Execute(ctx context.Context) ([]domain.Certificate, error) { +func (uc *listCertsUseCase) Execute(ctx context.Context) (result []domain.Certificate, err error) { + ctx, end := telemetry.StartSpan(ctx, "certificate.list_certs") + defer end(&err) + certs, err := uc.service.ListCerts(ctx) if err != nil { return nil, NewServiceError("failed to list certificates", err) diff --git a/packages/envsync-cli/internal/features/usecases/certificate/revoke_cert.go b/packages/envsync-cli/internal/features/usecases/certificate/revoke_cert.go index 0c2e35a2..93cf2216 100644 --- a/packages/envsync-cli/internal/features/usecases/certificate/revoke_cert.go +++ b/packages/envsync-cli/internal/features/usecases/certificate/revoke_cert.go @@ -5,6 +5,9 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository/responses" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type revokeCertUseCase struct { @@ -16,7 +19,11 @@ func NewRevokeCertUseCase() RevokeCertUseCase { return &revokeCertUseCase{service: service} } -func (uc *revokeCertUseCase) Execute(ctx context.Context, serialHex string, reason int) (*responses.RevokeCertResponse, error) { +func (uc *revokeCertUseCase) Execute(ctx context.Context, serialHex string, reason int) (revokeResult *responses.RevokeCertResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "certificate.revoke_cert", + trace.WithAttributes(attribute.String("cert.serial", serialHex))) + defer end(&err) + if serialHex == "" { return nil, NewValidationError("certificate serial number is required", ErrSerialRequired) } diff --git a/packages/envsync-cli/internal/features/usecases/config/errors.go b/packages/envsync-cli/internal/features/usecases/config/errors.go index 2825bc4e..17adf991 100644 --- a/packages/envsync-cli/internal/features/usecases/config/errors.go +++ b/packages/envsync-cli/internal/features/usecases/config/errors.go @@ -9,8 +9,12 @@ var ( ErrEmptyConfigKey = errors.New("configuration key cannot be empty") ErrInvalidConfigKey = errors.New("invalid configuration key") - ErrEmptyBackendURL = errors.New("backend URL cannot be empty") - ErrInvalidBackendURL = errors.New("backend URL is invalid") + ErrEmptyBackendURL = errors.New("backend URL cannot be empty") + ErrInvalidBackendURL = errors.New("backend URL is invalid") + ErrEmptyServiceName = errors.New("service name cannot be empty") + ErrInvalidDisabledValue = errors.New("disabled value must be true or false") + ErrEmptyEndpoint = errors.New("endpoint cannot be empty") + ErrInvalidEndpoint = errors.New("endpoint is invalid") // File system errors ErrConfigFileNotFound = errors.New("configuration file not found") diff --git a/packages/envsync-cli/internal/features/usecases/config/get_config.go b/packages/envsync-cli/internal/features/usecases/config/get_config.go index fba3efcb..f53d29c4 100644 --- a/packages/envsync-cli/internal/features/usecases/config/get_config.go +++ b/packages/envsync-cli/internal/features/usecases/config/get_config.go @@ -2,9 +2,13 @@ package config import ( "context" + "strconv" "strings" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/config" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type getConfigUseCase struct{} @@ -13,7 +17,11 @@ func NewGetConfigUseCase() GetConfigUseCase { return &getConfigUseCase{} } -func (uc *getConfigUseCase) Execute(ctx context.Context, req GetConfigRequest) (*GetConfigResponse, error) { +func (uc *getConfigUseCase) Execute(ctx context.Context, req GetConfigRequest) (result *GetConfigResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "config.get", + trace.WithAttributes(attribute.StringSlice("config.keys", req.Keys))) + defer end(&err) + // Validate request if err := req.Validate(); err != nil { return nil, NewValidationError("invalid get config request", "", err) @@ -33,14 +41,13 @@ func (uc *getConfigUseCase) Execute(ctx context.Context, req GetConfigRequest) ( Warnings: []string{}, } - // If specific keys were requested, extract only those values + // If specific keys were requested, extract only those values. A key that is + // known resolves even when its value is empty, so callers can tell "unset" + // (present, "") apart from "unknown" (absent). if len(req.Keys) > 0 { for _, key := range req.Keys { - value, exists := uc.getConfigValue(cfg, key) - if exists { + if value, known := uc.getConfigValue(cfg, key); known { response.Values[key] = value - } else { - response.Warnings = append(response.Warnings, "Key '"+key+"' not found in configuration") } } } else { @@ -55,13 +62,19 @@ func (uc *getConfigUseCase) Execute(ctx context.Context, req GetConfigRequest) ( return response, nil } +// getConfigValue resolves a key against the configuration. The second result +// reports whether the key is known at all — it must not depend on the value, +// or a false boolean or an unset string is indistinguishable from a typo. func (uc *getConfigUseCase) getConfigValue(cfg config.AppConfig, key string) (string, bool) { - // Normalize key to lowercase for comparison - normalizedKey := strings.ToLower(key) - - switch normalizedKey { - case "backend_url", "backendurl": - return cfg.BackendURL, cfg.BackendURL != "" + switch key { + case "backend_url": + return cfg.BackendURL, true + case "otel_config.endpoint": + return cfg.OTELConfig.Endpoint, true + case "otel_config.service_name": + return cfg.OTELConfig.ServiceName, true + case "otel_config.disabled": + return strconv.FormatBool(cfg.OTELConfig.OtelDisabled), true default: return "", false } @@ -70,9 +83,10 @@ func (uc *getConfigUseCase) getConfigValue(cfg config.AppConfig, key string) (st func (uc *getConfigUseCase) getAllConfigValues(cfg config.AppConfig) map[string]string { values := make(map[string]string) - if cfg.BackendURL != "" { - values["backend_url"] = cfg.BackendURL - } + values["backend_url"] = cfg.BackendURL + values["otel_config.endpoint"] = cfg.OTELConfig.Endpoint + values["otel_config.service_name"] = cfg.OTELConfig.ServiceName + values["otel_config.disabled"] = strconv.FormatBool(cfg.OTELConfig.OtelDisabled) return values } diff --git a/packages/envsync-cli/internal/features/usecases/config/get_config_test.go b/packages/envsync-cli/internal/features/usecases/config/get_config_test.go new file mode 100644 index 00000000..de0339b9 --- /dev/null +++ b/packages/envsync-cli/internal/features/usecases/config/get_config_test.go @@ -0,0 +1,71 @@ +package config + +import ( + "context" + "path/filepath" + "testing" + + appconfig "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/config" +) + +// isolateConfig points config.json at a throwaway HOME so tests never touch the +// developer's real configuration. +func isolateConfig(t *testing.T, cfg appconfig.AppConfig) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + if err := cfg.WriteConfigFile(); err != nil { + t.Fatalf("WriteConfigFile() failed: %v", err) + } +} + +// Regression: key presence was derived from the value, so a false boolean or an +// empty string was indistinguishable from an unknown key. `config get +// otel_config.disabled --json` printed nothing at all, and unset strings were +// reported as "key not found". +func TestGetConfigResolvesKeysIndependentlyOfValue(t *testing.T) { + isolateConfig(t, appconfig.AppConfig{ + BackendURL: "https://api.envsync.cloud", + OTELConfig: appconfig.OTELConfig{ + OtelDisabled: false, + Endpoint: "", + ServiceName: "", + }, + }) + + uc := NewGetConfigUseCase() + resp, err := uc.Execute(context.Background(), GetConfigRequest{ + Keys: []string{ + "backend_url", + "otel_config.disabled", + "otel_config.service_name", + "otel_config.endpoint", + }, + }) + if err != nil { + t.Fatalf("Execute() failed: %v", err) + } + + want := map[string]string{ + "backend_url": "https://api.envsync.cloud", + "otel_config.disabled": "false", + "otel_config.service_name": "", + "otel_config.endpoint": "", + } + + for key, wantVal := range want { + got, ok := resp.Values[key] + if !ok { + t.Errorf("key %q was dropped from the result although it is a known key", key) + continue + } + if got != wantVal { + t.Errorf("key %q = %q, want %q", key, got, wantVal) + } + } + + if len(resp.Values) != len(want) { + t.Errorf("got %d keys, want %d: %v", len(resp.Values), len(want), resp.Values) + } +} diff --git a/packages/envsync-cli/internal/features/usecases/config/interfaces.go b/packages/envsync-cli/internal/features/usecases/config/interfaces.go index 4ac80375..cec1b883 100644 --- a/packages/envsync-cli/internal/features/usecases/config/interfaces.go +++ b/packages/envsync-cli/internal/features/usecases/config/interfaces.go @@ -111,14 +111,10 @@ func (r ResetConfigRequest) Validate() error { // Helper functions for validation func isValidConfigKey(key string) bool { validKeys := map[string]bool{ - "backend_url": true, - "backendurl": true, - "telemetry_url": true, - "telemetryurl": true, - "access_token": true, - "accesstoken": true, - "telemetry_token": true, - "telemetrytoken": true, + "backend_url": true, + "otel_config.service_name": true, + "otel_config.disabled": true, + "otel_config.endpoint": true, } return validKeys[key] @@ -126,13 +122,23 @@ func isValidConfigKey(key string) bool { func validateConfigValue(key, value string) error { switch key { - case "backend_url", "backendurl": + case "backend_url": if value == "" { return ErrEmptyBackendURL } if !isValidURL(value) { return ErrInvalidBackendURL } + case "otel_config.disabled": + if value != "true" && value != "false" { + return ErrInvalidDisabledValue + } + case "otel_config.endpoint": + if !isValidURL(value) { + return ErrInvalidEndpoint + } + default: + return nil } return nil diff --git a/packages/envsync-cli/internal/features/usecases/config/reset_config.go b/packages/envsync-cli/internal/features/usecases/config/reset_config.go index 927caafd..63db79e0 100644 --- a/packages/envsync-cli/internal/features/usecases/config/reset_config.go +++ b/packages/envsync-cli/internal/features/usecases/config/reset_config.go @@ -6,6 +6,10 @@ import ( "strings" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/config" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/constants" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type resetConfigUseCase struct{} @@ -14,7 +18,11 @@ func NewResetConfigUseCase() ResetConfigUseCase { return &resetConfigUseCase{} } -func (uc *resetConfigUseCase) Execute(ctx context.Context, req ResetConfigRequest) error { +func (uc *resetConfigUseCase) Execute(ctx context.Context, req ResetConfigRequest) (err error) { + ctx, end := telemetry.StartSpan(ctx, "config.reset", + trace.WithAttributes(attribute.StringSlice("config.keys", req.Keys))) + defer end(&err) + // Validate request if err := req.Validate(); err != nil { return NewValidationError("invalid reset config request", "", err) @@ -28,13 +36,10 @@ func (uc *resetConfigUseCase) Execute(ctx context.Context, req ResetConfigReques // Reset configuration based on request if len(req.Keys) == 0 { - // Reset all configuration err = uc.resetAllConfig() } else { - // Reset specific keys err = uc.resetSpecificKeys(cfg, req.Keys) } - if err != nil { return err } @@ -43,12 +48,9 @@ func (uc *resetConfigUseCase) Execute(ctx context.Context, req ResetConfigReques } func (uc *resetConfigUseCase) resetAllConfig() error { - emptyCfg := config.AppConfig{ - BackendURL: "https://api.envsync.cloud", - TelemetryURL: "https://t.envsync.cloud/obs", - } + cfg := config.DefaultConfig() - if err := emptyCfg.WriteConfigFile(); err != nil { + if err := cfg.WriteConfigFile(); err != nil { return NewFileSystemError("failed to write reset config file", err) } @@ -76,13 +78,21 @@ func (uc *resetConfigUseCase) resetConfigKey(cfg *config.AppConfig, key string) case "backend_url", "backendurl": cfg.BackendURL = "https://api.envsync.cloud" case "telemetry_url", "telemetryurl": - cfg.TelemetryURL = "https://t.envsync.cloud/obs" + cfg.OTELConfig.Endpoint = constants.OTELEndpoint + case "service_name", "servicename": + cfg.OTELConfig.ServiceName = "" case "access_token", "accesstoken": - cfg.AccessToken = "" - case "telemetry_token", "telemetrytoken": - cfg.TelemetryToken = "" + cfg.AuthConfig.AccessToken = "" + case "refresh_token", "refreshtoken": + cfg.AuthConfig.RefreshToken = "" + case "expires_at", "expiresat": + cfg.AuthConfig.ExpiresAt = 0 + case "client_id", "clientid": + cfg.AuthConfig.ClientID = "" + case "token_url", "tokenurl": + cfg.AuthConfig.TokenURL = "" default: - return fmt.Errorf("unknown configuration key: '%s'. Valid keys are: backend_url, telemetry_url, access_token, telemetry_token", key) + return fmt.Errorf("unknown configuration key: '%s'. Valid keys are: backend_url, telemetry_url, access_token, refresh_token, expires_at, client_id, token_url", key) } return nil diff --git a/packages/envsync-cli/internal/features/usecases/config/set_config.go b/packages/envsync-cli/internal/features/usecases/config/set_config.go index 9b4149c1..7b583553 100644 --- a/packages/envsync-cli/internal/features/usecases/config/set_config.go +++ b/packages/envsync-cli/internal/features/usecases/config/set_config.go @@ -3,9 +3,14 @@ package config import ( "context" "fmt" + "maps" + "slices" "strings" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/config" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type setConfigUseCase struct{} @@ -14,7 +19,11 @@ func NewSetConfigUseCase() SetConfigUseCase { return &setConfigUseCase{} } -func (uc *setConfigUseCase) Execute(ctx context.Context, req SetConfigRequest) error { +func (uc *setConfigUseCase) Execute(ctx context.Context, req SetConfigRequest) (err error) { + ctx, end := telemetry.StartSpan(ctx, "config.set", + trace.WithAttributes(attribute.StringSlice("config.keys", slices.Sorted(maps.Keys(req.KeyValuePairs))))) + defer end(&err) + // Validate request if err := req.Validate(); err != nil { return NewValidationError("invalid set config request", "", err) @@ -52,8 +61,14 @@ func (uc *setConfigUseCase) setConfigValue(cfg *config.AppConfig, key, value str normalizedKey := strings.ToLower(key) switch normalizedKey { - case "backend_url", "backendurl": + case "backend_url": cfg.BackendURL = value + case "otel_config.disabled": + cfg.OTELConfig.OtelDisabled = value == "true" + case "otel_config.endpoint": + cfg.OTELConfig.Endpoint = value + case "otel_config.service_name": + cfg.OTELConfig.ServiceName = value default: return fmt.Errorf("unknown configuration key: '%s'. Valid keys are: backend_url", key) } diff --git a/packages/envsync-cli/internal/features/usecases/dynamic_secret/cleanup.go b/packages/envsync-cli/internal/features/usecases/dynamic_secret/cleanup.go index 20ffd643..fa118a4b 100644 --- a/packages/envsync-cli/internal/features/usecases/dynamic_secret/cleanup.go +++ b/packages/envsync-cli/internal/features/usecases/dynamic_secret/cleanup.go @@ -19,9 +19,9 @@ func NewCleanupUseCase() CleanupUseCase { } } -func (uc *cleanupUseCase) Execute(ctx context.Context) (*sdk.CleanupResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "dynamic_secret.cleanup") - defer span.End() +func (uc *cleanupUseCase) Execute(ctx context.Context) (response *sdk.CleanupResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "dynamic_secret.cleanup") + defer end(&err) result, err := uc.dynamicSecretService.CleanupExpiredLeases(ctx) if err != nil { diff --git a/packages/envsync-cli/internal/features/usecases/dynamic_secret/create_engine.go b/packages/envsync-cli/internal/features/usecases/dynamic_secret/create_engine.go index be3cf789..bd3b87a0 100644 --- a/packages/envsync-cli/internal/features/usecases/dynamic_secret/create_engine.go +++ b/packages/envsync-cli/internal/features/usecases/dynamic_secret/create_engine.go @@ -4,6 +4,8 @@ import ( "context" sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" @@ -19,9 +21,12 @@ func NewCreateEngineUseCase() CreateEngineUseCase { } } -func (uc *createEngineUseCase) Execute(ctx context.Context, req *sdk.CreateDynamicSecretEngineRequest) (*sdk.DynamicSecretEngineResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "dynamic_secret.create_engine") - defer span.End() +func (uc *createEngineUseCase) Execute(ctx context.Context, req *sdk.CreateDynamicSecretEngineRequest) (result *sdk.DynamicSecretEngineResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "dynamic_secret.create_engine", trace.WithAttributes( + attribute.String("engine.name", req.Name), + attribute.String("engine.type", string(req.EngineType)), + )) + defer end(&err) if err := uc.validateCreateRequest(req); err != nil { return nil, err diff --git a/packages/envsync-cli/internal/features/usecases/dynamic_secret/create_lease.go b/packages/envsync-cli/internal/features/usecases/dynamic_secret/create_lease.go index 7220aac7..e417220c 100644 --- a/packages/envsync-cli/internal/features/usecases/dynamic_secret/create_lease.go +++ b/packages/envsync-cli/internal/features/usecases/dynamic_secret/create_lease.go @@ -4,6 +4,8 @@ import ( "context" sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" @@ -19,9 +21,14 @@ func NewCreateLeaseUseCase() CreateLeaseUseCase { } } -func (uc *createLeaseUseCase) Execute(ctx context.Context, engineID string, req *sdk.CreateDynamicSecretLeaseRequest) (*sdk.DynamicSecretLeaseResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "dynamic_secret.create_lease") - defer span.End() +func (uc *createLeaseUseCase) Execute(ctx context.Context, engineID string, req *sdk.CreateDynamicSecretLeaseRequest) (result *sdk.DynamicSecretLeaseResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "dynamic_secret.create_lease", trace.WithAttributes( + attribute.String("engine.id", engineID), + attribute.String("app.id", req.AppId), + attribute.String("env.id", req.EnvTypeId), + attribute.String("config.key", req.VariableKey), + )) + defer end(&err) if engineID == "" { return nil, NewValidationError("engine ID is required", ErrEngineIDRequired) diff --git a/packages/envsync-cli/internal/features/usecases/dynamic_secret/delete_engine.go b/packages/envsync-cli/internal/features/usecases/dynamic_secret/delete_engine.go index 4a249b97..1cda257f 100644 --- a/packages/envsync-cli/internal/features/usecases/dynamic_secret/delete_engine.go +++ b/packages/envsync-cli/internal/features/usecases/dynamic_secret/delete_engine.go @@ -3,6 +3,9 @@ package dynamic_secret import ( "context" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) @@ -17,15 +20,15 @@ func NewDeleteEngineUseCase() DeleteEngineUseCase { } } -func (uc *deleteEngineUseCase) Execute(ctx context.Context, id string) error { - ctx, span := telemetry.Tracer().Start(ctx, "dynamic_secret.delete_engine") - defer span.End() +func (uc *deleteEngineUseCase) Execute(ctx context.Context, id string) (err error) { + ctx, end := telemetry.StartSpan(ctx, "dynamic_secret.delete_engine", trace.WithAttributes(attribute.String("engine.id", id))) + defer end(&err) if id == "" { return NewValidationError("engine ID is required", ErrIDRequired) } - _, err := uc.dynamicSecretService.DeleteEngine(ctx, id) + _, err = uc.dynamicSecretService.DeleteEngine(ctx, id) if err != nil { return NewServiceError("failed to delete dynamic secret engine", err) } diff --git a/packages/envsync-cli/internal/features/usecases/dynamic_secret/errors.go b/packages/envsync-cli/internal/features/usecases/dynamic_secret/errors.go index 3ea5bade..97463267 100644 --- a/packages/envsync-cli/internal/features/usecases/dynamic_secret/errors.go +++ b/packages/envsync-cli/internal/features/usecases/dynamic_secret/errors.go @@ -3,18 +3,18 @@ package dynamic_secret import "errors" var ( - ErrIDRequired = errors.New("engine or lease ID is required") - ErrEngineIDRequired = errors.New("engine ID is required") - ErrNameRequired = errors.New("engine name is required") - ErrEngineTypeRequired = errors.New("engine type is required") - ErrConfigRequired = errors.New("engine config is required") - ErrEngineNotFound = errors.New("dynamic secret engine not found") - ErrLeaseNotFound = errors.New("dynamic secret lease not found") - ErrFailedToList = errors.New("failed to list dynamic secret engines") - ErrFailedToCreate = errors.New("failed to create dynamic secret engine") - ErrFailedToGet = errors.New("failed to get dynamic secret engine") - ErrFailedToUpdate = errors.New("failed to update dynamic secret engine") - ErrFailedToDelete = errors.New("failed to delete dynamic secret engine") + ErrIDRequired = errors.New("engine or lease ID is required") + ErrEngineIDRequired = errors.New("engine ID is required") + ErrNameRequired = errors.New("engine name is required") + ErrEngineTypeRequired = errors.New("engine type is required") + ErrConfigRequired = errors.New("engine config is required") + ErrEngineNotFound = errors.New("dynamic secret engine not found") + ErrLeaseNotFound = errors.New("dynamic secret lease not found") + ErrFailedToList = errors.New("failed to list dynamic secret engines") + ErrFailedToCreate = errors.New("failed to create dynamic secret engine") + ErrFailedToGet = errors.New("failed to get dynamic secret engine") + ErrFailedToUpdate = errors.New("failed to update dynamic secret engine") + ErrFailedToDelete = errors.New("failed to delete dynamic secret engine") ErrFailedToListLeases = errors.New("failed to list dynamic secret leases") ErrFailedToCreateLease = errors.New("failed to create dynamic secret lease") ErrFailedToGetLease = errors.New("failed to get dynamic secret lease") diff --git a/packages/envsync-cli/internal/features/usecases/dynamic_secret/get_engine.go b/packages/envsync-cli/internal/features/usecases/dynamic_secret/get_engine.go index 7eb61261..47c58c56 100644 --- a/packages/envsync-cli/internal/features/usecases/dynamic_secret/get_engine.go +++ b/packages/envsync-cli/internal/features/usecases/dynamic_secret/get_engine.go @@ -4,6 +4,8 @@ import ( "context" sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" @@ -19,9 +21,9 @@ func NewGetEngineUseCase() GetEngineUseCase { } } -func (uc *getEngineUseCase) Execute(ctx context.Context, id string) (*sdk.DynamicSecretEngineResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "dynamic_secret.get_engine") - defer span.End() +func (uc *getEngineUseCase) Execute(ctx context.Context, id string) (result *sdk.DynamicSecretEngineResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "dynamic_secret.get_engine", trace.WithAttributes(attribute.String("engine.id", id))) + defer end(&err) if id == "" { return nil, NewValidationError("engine ID is required", ErrIDRequired) diff --git a/packages/envsync-cli/internal/features/usecases/dynamic_secret/get_lease.go b/packages/envsync-cli/internal/features/usecases/dynamic_secret/get_lease.go index 6626838d..ddf4d40e 100644 --- a/packages/envsync-cli/internal/features/usecases/dynamic_secret/get_lease.go +++ b/packages/envsync-cli/internal/features/usecases/dynamic_secret/get_lease.go @@ -4,6 +4,8 @@ import ( "context" sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" @@ -19,9 +21,9 @@ func NewGetLeaseUseCase() GetLeaseUseCase { } } -func (uc *getLeaseUseCase) Execute(ctx context.Context, leaseID string) (*sdk.DynamicSecretLeaseResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "dynamic_secret.get_lease") - defer span.End() +func (uc *getLeaseUseCase) Execute(ctx context.Context, leaseID string) (result *sdk.DynamicSecretLeaseResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "dynamic_secret.get_lease", trace.WithAttributes(attribute.String("lease.id", leaseID))) + defer end(&err) if leaseID == "" { return nil, NewValidationError("lease ID is required", ErrIDRequired) diff --git a/packages/envsync-cli/internal/features/usecases/dynamic_secret/list_engines.go b/packages/envsync-cli/internal/features/usecases/dynamic_secret/list_engines.go index 1ca8e9cf..86b15aa2 100644 --- a/packages/envsync-cli/internal/features/usecases/dynamic_secret/list_engines.go +++ b/packages/envsync-cli/internal/features/usecases/dynamic_secret/list_engines.go @@ -19,9 +19,9 @@ func NewListEnginesUseCase() ListEnginesUseCase { } } -func (uc *listEnginesUseCase) Execute(ctx context.Context) (sdk.DynamicSecretEnginesResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "dynamic_secret.list_engines") - defer span.End() +func (uc *listEnginesUseCase) Execute(ctx context.Context) (result sdk.DynamicSecretEnginesResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "dynamic_secret.list_engines") + defer end(&err) engines, err := uc.dynamicSecretService.ListEngines(ctx) if err != nil { diff --git a/packages/envsync-cli/internal/features/usecases/dynamic_secret/list_leases.go b/packages/envsync-cli/internal/features/usecases/dynamic_secret/list_leases.go index d48a42b6..98503e63 100644 --- a/packages/envsync-cli/internal/features/usecases/dynamic_secret/list_leases.go +++ b/packages/envsync-cli/internal/features/usecases/dynamic_secret/list_leases.go @@ -4,6 +4,8 @@ import ( "context" sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" @@ -19,9 +21,9 @@ func NewListLeasesUseCase() ListLeasesUseCase { } } -func (uc *listLeasesUseCase) Execute(ctx context.Context, engineID string) (sdk.DynamicSecretLeasesResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "dynamic_secret.list_leases") - defer span.End() +func (uc *listLeasesUseCase) Execute(ctx context.Context, engineID string) (result sdk.DynamicSecretLeasesResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "dynamic_secret.list_leases", trace.WithAttributes(attribute.String("engine.id", engineID))) + defer end(&err) if engineID == "" { return nil, NewValidationError("engine ID is required", ErrEngineIDRequired) diff --git a/packages/envsync-cli/internal/features/usecases/dynamic_secret/revoke_lease.go b/packages/envsync-cli/internal/features/usecases/dynamic_secret/revoke_lease.go index 560bbd4b..5dc4ad35 100644 --- a/packages/envsync-cli/internal/features/usecases/dynamic_secret/revoke_lease.go +++ b/packages/envsync-cli/internal/features/usecases/dynamic_secret/revoke_lease.go @@ -4,6 +4,8 @@ import ( "context" sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" @@ -19,9 +21,9 @@ func NewRevokeLeaseUseCase() RevokeLeaseUseCase { } } -func (uc *revokeLeaseUseCase) Execute(ctx context.Context, leaseID string) (*sdk.RevokeLeaseResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "dynamic_secret.revoke_lease") - defer span.End() +func (uc *revokeLeaseUseCase) Execute(ctx context.Context, leaseID string) (response *sdk.RevokeLeaseResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "dynamic_secret.revoke_lease", trace.WithAttributes(attribute.String("lease.id", leaseID))) + defer end(&err) if leaseID == "" { return nil, NewValidationError("lease ID is required", ErrIDRequired) diff --git a/packages/envsync-cli/internal/features/usecases/dynamic_secret/update_engine.go b/packages/envsync-cli/internal/features/usecases/dynamic_secret/update_engine.go index 859126ae..7558a010 100644 --- a/packages/envsync-cli/internal/features/usecases/dynamic_secret/update_engine.go +++ b/packages/envsync-cli/internal/features/usecases/dynamic_secret/update_engine.go @@ -4,6 +4,8 @@ import ( "context" sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" @@ -19,9 +21,9 @@ func NewUpdateEngineUseCase() UpdateEngineUseCase { } } -func (uc *updateEngineUseCase) Execute(ctx context.Context, id string, req *sdk.UpdateDynamicSecretEngineRequest) (*sdk.DynamicSecretEngineResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "dynamic_secret.update_engine") - defer span.End() +func (uc *updateEngineUseCase) Execute(ctx context.Context, id string, req *sdk.UpdateDynamicSecretEngineRequest) (result *sdk.DynamicSecretEngineResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "dynamic_secret.update_engine", trace.WithAttributes(attribute.String("engine.id", id))) + defer end(&err) if id == "" { return nil, NewValidationError("engine ID is required", ErrIDRequired) diff --git a/packages/envsync-cli/internal/features/usecases/dynamic_secret/usecases_test.go b/packages/envsync-cli/internal/features/usecases/dynamic_secret/usecases_test.go index c0fc705b..b6bbb9a6 100644 --- a/packages/envsync-cli/internal/features/usecases/dynamic_secret/usecases_test.go +++ b/packages/envsync-cli/internal/features/usecases/dynamic_secret/usecases_test.go @@ -10,15 +10,15 @@ import ( // mockDynamicSecretService implements services.DynamicSecretService for testing. type mockDynamicSecretService struct { - listEnginesFn func(ctx context.Context) (sdk.DynamicSecretEnginesResponse, error) - createEngineFn func(ctx context.Context, req *sdk.CreateDynamicSecretEngineRequest) (*sdk.DynamicSecretEngineResponse, error) - getEngineFn func(ctx context.Context, id string) (*sdk.DynamicSecretEngineResponse, error) - deleteEngineFn func(ctx context.Context, id string) (*sdk.ErrorResponse, error) - updateEngineFn func(ctx context.Context, id string, req *sdk.UpdateDynamicSecretEngineRequest) (*sdk.DynamicSecretEngineResponse, error) - listLeasesFn func(ctx context.Context, engineID string) (sdk.DynamicSecretLeasesResponse, error) - createLeaseFn func(ctx context.Context, engineID string, req *sdk.CreateDynamicSecretLeaseRequest) (*sdk.DynamicSecretLeaseResponse, error) - getLeaseFn func(ctx context.Context, leaseID string) (*sdk.DynamicSecretLeaseResponse, error) - revokeLeaseFn func(ctx context.Context, leaseID string) (*sdk.RevokeLeaseResponse, error) + listEnginesFn func(ctx context.Context) (sdk.DynamicSecretEnginesResponse, error) + createEngineFn func(ctx context.Context, req *sdk.CreateDynamicSecretEngineRequest) (*sdk.DynamicSecretEngineResponse, error) + getEngineFn func(ctx context.Context, id string) (*sdk.DynamicSecretEngineResponse, error) + deleteEngineFn func(ctx context.Context, id string) (*sdk.ErrorResponse, error) + updateEngineFn func(ctx context.Context, id string, req *sdk.UpdateDynamicSecretEngineRequest) (*sdk.DynamicSecretEngineResponse, error) + listLeasesFn func(ctx context.Context, engineID string) (sdk.DynamicSecretLeasesResponse, error) + createLeaseFn func(ctx context.Context, engineID string, req *sdk.CreateDynamicSecretLeaseRequest) (*sdk.DynamicSecretLeaseResponse, error) + getLeaseFn func(ctx context.Context, leaseID string) (*sdk.DynamicSecretLeaseResponse, error) + revokeLeaseFn func(ctx context.Context, leaseID string) (*sdk.RevokeLeaseResponse, error) cleanupExpiredLeasesFn func(ctx context.Context) (*sdk.CleanupResponse, error) } diff --git a/packages/envsync-cli/internal/features/usecases/environment/delete_env.go b/packages/envsync-cli/internal/features/usecases/environment/delete_env.go index d93965b2..0c0ada7b 100644 --- a/packages/envsync-cli/internal/features/usecases/environment/delete_env.go +++ b/packages/envsync-cli/internal/features/usecases/environment/delete_env.go @@ -4,6 +4,9 @@ import ( "context" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type deleteEnvUseCase struct { @@ -17,7 +20,11 @@ func NewDeleteEnvUseCase() DeleteEnvUseCase { } } -func (uc *deleteEnvUseCase) Execute(ctx context.Context, id string) error { +func (uc *deleteEnvUseCase) Execute(ctx context.Context, id string) (err error) { + ctx, end := telemetry.StartSpan(ctx, "environment.delete", + trace.WithAttributes(attribute.String("env.id", id))) + defer end(&err) + if err := uc.envService.DeleteEnvType(ctx, id); err != nil { return NewServiceError("failed to delete environment", err) } diff --git a/packages/envsync-cli/internal/features/usecases/environment/errors.go b/packages/envsync-cli/internal/features/usecases/environment/errors.go index b739daaf..c8025e3c 100644 --- a/packages/envsync-cli/internal/features/usecases/environment/errors.go +++ b/packages/envsync-cli/internal/features/usecases/environment/errors.go @@ -66,6 +66,8 @@ const ( EnvErrorCodeNotFound = "ENV_NOT_FOUND" EnvErrorCodeCorrupted = "ENV_CORRUPTED" EnvErrorCodeServiceError = "SERVICE_ERROR" + EnvErrorCodeTUIError = "TUI_ERROR" + EnvErrorCodeCancelled = "CANCELLED" ) // Helper functions to create structured errors @@ -118,6 +120,22 @@ func NewServiceError(message string, cause error) *EnvError { } } +func NewTUIError(message string, cause error) *EnvError { + return &EnvError{ + Code: EnvErrorCodeTUIError, + Message: message, + Cause: cause, + } +} + +func NewCancelledError(message string, cause error) *EnvError { + return &EnvError{ + Code: EnvErrorCodeCancelled, + Message: message, + Cause: cause, + } +} + // Validation severity levels const ( ValidationSeverityError = "error" diff --git a/packages/envsync-cli/internal/features/usecases/environment/get_env.go b/packages/envsync-cli/internal/features/usecases/environment/get_env.go index b0ea7d1f..b7594a97 100644 --- a/packages/envsync-cli/internal/features/usecases/environment/get_env.go +++ b/packages/envsync-cli/internal/features/usecases/environment/get_env.go @@ -5,29 +5,75 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type getEnvUseCase struct { - envService services.EnvTypeService + envService services.EnvTypeService + syncService services.SyncService } func NewGetEnvUseCase() GetEnvUseCase { - service := services.NewEnvTypeService() return &getEnvUseCase{ - envService: service, + envService: services.NewEnvTypeService(), + syncService: services.NewSyncService(), } } -func (uc *getEnvUseCase) ExecuteByAppID(ctx context.Context, appID string) ([]domain.EnvType, error) { - env, err := uc.envService.GetEnvTypesByAppID(ctx, appID) +func (uc *getEnvUseCase) ExecuteByAppID(ctx context.Context, appID string) (result []domain.EnvType, err error) { + ctx, end := telemetry.StartSpan(ctx, "environment.get_by_app_id", + trace.WithAttributes(attribute.String("app.id", appID))) + defer end(&err) + + appID, err = uc.resolveAppID(appID) + if err != nil { + return nil, err + } + + envTypes, err := uc.envService.GetEnvTypesByAppID(ctx, appID) if err != nil { return nil, NewServiceError("failed to get environment by app ID", err) } - return env, nil + return envTypes, nil +} + +// resolveAppID honours an explicit app ID and otherwise falls back to the +// app_id written to ./envsyncrc.toml by `envsync init`. Without either, the +// user gets pointed at --app-id instead of a cryptic fetch failure. +func (uc *getEnvUseCase) resolveAppID(appID string) (string, error) { + if appID != "" { + return appID, nil + } + + if err := uc.syncService.SyncConfigExist(); err != nil { + return "", NewValidationError( + "no app ID given and no envsyncrc.toml in the current directory; provide the application ID with the --app-id flag", + "", + nil, + ) + } + cfg, err := uc.syncService.ReadConfigData() + if err != nil { + return "", NewFileSystemError("failed to read envsyncrc.toml", err) + } + if cfg.AppID == "" { + return "", NewValidationError( + "envsyncrc.toml in the current directory has no app_id; provide the application ID with the --app-id flag", + "", + nil, + ) + } + return cfg.AppID, nil } -func (uc *getEnvUseCase) ExecuteByID(ctx context.Context, id string) (domain.EnvType, error) { +func (uc *getEnvUseCase) ExecuteByID(ctx context.Context, id string) (result domain.EnvType, err error) { + ctx, end := telemetry.StartSpan(ctx, "environment.get_by_id", + trace.WithAttributes(attribute.String("env.id", id))) + defer end(&err) + env, err := uc.envService.GetEnvTypeByID(ctx, id) if err != nil { return domain.EnvType{}, NewServiceError("failed to get environment by ID", err) diff --git a/packages/envsync-cli/internal/features/usecases/environment/get_env_test.go b/packages/envsync-cli/internal/features/usecases/environment/get_env_test.go new file mode 100644 index 00000000..f9597e9b --- /dev/null +++ b/packages/envsync-cli/internal/features/usecases/environment/get_env_test.go @@ -0,0 +1,193 @@ +package environment + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" +) + +// mockEnvTypeService is a test double implementing services.EnvTypeService. +type mockEnvTypeService struct { + getByAppIDFn func(ctx context.Context, appID string) ([]domain.EnvType, error) + getByIDFn func(ctx context.Context, id string) (domain.EnvType, error) +} + +var _ services.EnvTypeService = (*mockEnvTypeService)(nil) + +func (m *mockEnvTypeService) CreateEnvType(ctx context.Context, envType *domain.EnvType) (domain.EnvType, error) { + return domain.EnvType{}, nil +} + +func (m *mockEnvTypeService) GetEnvTypeByID(ctx context.Context, id string) (domain.EnvType, error) { + if m.getByIDFn != nil { + return m.getByIDFn(ctx, id) + } + return domain.EnvType{}, nil +} + +func (m *mockEnvTypeService) GetEnvTypesByAppID(ctx context.Context, appID string) ([]domain.EnvType, error) { + if m.getByAppIDFn != nil { + return m.getByAppIDFn(ctx, appID) + } + return nil, nil +} + +func (m *mockEnvTypeService) DeleteEnvType(ctx context.Context, id string) error { + return nil +} + +// mockSyncService is a test double implementing services.SyncService. Only the +// project-config accessors matter for app ID resolution; the rest are inert. +type mockSyncService struct { + configExistErr error + config domain.SyncConfig + configReadErr error + configReads int +} + +var _ services.SyncService = (*mockSyncService)(nil) + +func (m *mockSyncService) ReadConfigData() (domain.SyncConfig, error) { + m.configReads++ + return m.config, m.configReadErr +} + +func (m *mockSyncService) ReadConfigDataFromPath(string) (domain.SyncConfig, error) { + return m.config, m.configReadErr +} + +func (m *mockSyncService) WriteConfigData(cfg domain.SyncConfig) error { + return nil +} + +func (m *mockSyncService) SyncConfigExist() error { + return m.configExistErr +} + +func (m *mockSyncService) ReadLocalEnv() (map[string]string, error) { return nil, nil } + +func (m *mockSyncService) ReadRemoteEnv(ctx context.Context) ([]*domain.EnvironmentVariable, error) { + return nil, nil +} + +func (m *mockSyncService) CalculateEnvDiff(local map[string]string, remote map[string]string) *domain.EnvironmentSync { + return nil +} + +func (m *mockSyncService) WriteLocalEnv(env map[string]string) error { return nil } + +func (m *mockSyncService) WriteRemoteEnv(ctx context.Context, env *domain.EnvironmentSync) error { + return nil +} + +func TestExecuteByAppID_ExplicitIDWins(t *testing.T) { + syncSvc := &mockSyncService{} + var gotAppID string + uc := &getEnvUseCase{ + envService: &mockEnvTypeService{ + getByAppIDFn: func(ctx context.Context, appID string) ([]domain.EnvType, error) { + gotAppID = appID + return []domain.EnvType{{ID: "env-1"}}, nil + }, + }, + syncService: syncSvc, + } + + envs, err := uc.ExecuteByAppID(context.Background(), "app-explicit") + if err != nil { + t.Fatalf("ExecuteByAppID() error = %v", err) + } + if gotAppID != "app-explicit" { + t.Fatalf("service called with appID %q, want %q", gotAppID, "app-explicit") + } + if len(envs) != 1 { + t.Fatalf("got %d envs, want 1", len(envs)) + } + if syncSvc.configReads != 0 { + t.Fatalf("config read %d times for explicit app ID, want 0", syncSvc.configReads) + } +} + +func TestExecuteByAppID_FallsBackToProjectConfig(t *testing.T) { + var gotAppID string + uc := &getEnvUseCase{ + envService: &mockEnvTypeService{ + getByAppIDFn: func(ctx context.Context, appID string) ([]domain.EnvType, error) { + gotAppID = appID + return nil, nil + }, + }, + syncService: &mockSyncService{config: domain.SyncConfig{AppID: "app-from-config"}}, + } + + if _, err := uc.ExecuteByAppID(context.Background(), ""); err != nil { + t.Fatalf("ExecuteByAppID() error = %v", err) + } + if gotAppID != "app-from-config" { + t.Fatalf("service called with appID %q, want %q", gotAppID, "app-from-config") + } +} + +func TestExecuteByAppID_MissingConfigFile(t *testing.T) { + uc := &getEnvUseCase{ + envService: &mockEnvTypeService{}, + syncService: &mockSyncService{configExistErr: errors.New("project configuration file not found")}, + } + + _, err := uc.ExecuteByAppID(context.Background(), "") + if err == nil { + t.Fatal("ExecuteByAppID() error = nil, want error") + } + var envErr *EnvError + if !errors.As(err, &envErr) { + t.Fatalf("error type = %T, want *EnvError", err) + } + if envErr.Code != EnvErrorCodeValidation { + t.Fatalf("error code = %q, want %q", envErr.Code, EnvErrorCodeValidation) + } + if !strings.Contains(err.Error(), "--app-id") { + t.Fatalf("error %q should tell the user about --app-id", err) + } +} + +func TestExecuteByAppID_ConfigWithoutAppID(t *testing.T) { + uc := &getEnvUseCase{ + envService: &mockEnvTypeService{}, + syncService: &mockSyncService{config: domain.SyncConfig{}}, + } + + _, err := uc.ExecuteByAppID(context.Background(), "") + if err == nil { + t.Fatal("ExecuteByAppID() error = nil, want error") + } + if !strings.Contains(err.Error(), "--app-id") { + t.Fatalf("error %q should tell the user about --app-id", err) + } +} + +func TestExecuteByAppID_ServiceError(t *testing.T) { + uc := &getEnvUseCase{ + envService: &mockEnvTypeService{ + getByAppIDFn: func(ctx context.Context, appID string) ([]domain.EnvType, error) { + return nil, errors.New("boom") + }, + }, + syncService: &mockSyncService{}, + } + + _, err := uc.ExecuteByAppID(context.Background(), "app-1") + if err == nil { + t.Fatal("ExecuteByAppID() error = nil, want error") + } + var envErr *EnvError + if !errors.As(err, &envErr) { + t.Fatalf("error type = %T, want *EnvError", err) + } + if envErr.Code != EnvErrorCodeServiceError { + t.Fatalf("error code = %q, want %q", envErr.Code, EnvErrorCodeServiceError) + } +} diff --git a/packages/envsync-cli/internal/features/usecases/environment/interface.go b/packages/envsync-cli/internal/features/usecases/environment/interface.go index 8efb2f4a..f21b0943 100644 --- a/packages/envsync-cli/internal/features/usecases/environment/interface.go +++ b/packages/envsync-cli/internal/features/usecases/environment/interface.go @@ -12,7 +12,7 @@ type GetEnvUseCase interface { } type SwitchEnvUseCase interface { - Execute(context.Context, domain.EnvType) error + Execute(context.Context, domain.EnvType) (domain.EnvType, error) } type DeleteEnvUseCase interface { diff --git a/packages/envsync-cli/internal/features/usecases/environment/switch_env.go b/packages/envsync-cli/internal/features/usecases/environment/switch_env.go index bea7637a..f6d57480 100644 --- a/packages/envsync-cli/internal/features/usecases/environment/switch_env.go +++ b/packages/envsync-cli/internal/features/usecases/environment/switch_env.go @@ -1,14 +1,16 @@ package environment import ( - "bufio" "context" - "fmt" "os" - "strings" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/spinner" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/factory" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type switchEnvUseCase struct { @@ -26,32 +28,35 @@ func NewSwitchEnvUseCase() SwitchEnvUseCase { } } -func (uc *switchEnvUseCase) Execute(ctx context.Context, envType domain.EnvType) error { +func (uc *switchEnvUseCase) Execute(ctx context.Context, envType domain.EnvType) (result domain.EnvType, err error) { + ctx, end := telemetry.StartSpan(ctx, "environment.switch", + trace.WithAttributes( + attribute.String("env.id", envType.ID), + attribute.String("env.name", envType.Name), + attribute.String("app.id", envType.AppID), + )) + defer end(&err) + syncConfig, err := uc.readSyncConfig() if err != nil { - return err + return domain.EnvType{}, err } envs, err := uc.fetchAvailableEnvs(ctx, syncConfig.AppID) if err != nil { - return err + return domain.EnvType{}, err } - if envType.ID != "" { - for _, env := range envs { - if env.ID == envType.ID { - return uc.updateSyncConfigWithEnv(syncConfig, env.ID) - } - } - return NewNotFoundError("environment type not found: "+envType.ID, nil) + selected, err := uc.resolveSelection(envs, envType) + if err != nil { + return domain.EnvType{}, err } - selectedEnv, err := uc.selectEnvironmentInteractive(envs) - if err != nil { - return err + if err := uc.updateSyncConfigWithEnv(syncConfig, selected.ID); err != nil { + return domain.EnvType{}, err } - return uc.updateSyncConfigWithEnv(syncConfig, selectedEnv.ID) + return selected, nil } func (uc *switchEnvUseCase) readSyncConfig() (*domain.SyncConfig, error) { @@ -63,7 +68,10 @@ func (uc *switchEnvUseCase) readSyncConfig() (*domain.SyncConfig, error) { } func (uc *switchEnvUseCase) fetchAvailableEnvs(ctx context.Context, appID string) ([]domain.EnvType, error) { + sp := spinner.New("Fetching environments…", spinner.IsTerminal(os.Stderr), os.Stderr) + sp.Start() envs, err := uc.envTypeService.GetEnvTypesByAppID(ctx, appID) + sp.Stop() if err != nil { return nil, NewServiceError("failed to fetch environment types", err) } @@ -73,27 +81,27 @@ func (uc *switchEnvUseCase) fetchAvailableEnvs(ctx context.Context, appID string return envs, nil } -func (uc *switchEnvUseCase) selectEnvironmentInteractive(envs []domain.EnvType) (*domain.EnvType, error) { - reader := bufio.NewReader(os.Stdin) - - fmt.Println("\n🌍 Available Environments:") - fmt.Println(strings.Repeat("-", 60)) - for i, env := range envs { - fmt.Printf(" %d) %s (ID: %s)\n", i+1, env.Name, env.ID) +// resolveSelection honours an explicit --env-id, and otherwise asks the user +// to pick one from a table. +func (uc *switchEnvUseCase) resolveSelection(envs []domain.EnvType, want domain.EnvType) (domain.EnvType, error) { + if want.ID != "" { + for _, env := range envs { + if env.ID == want.ID { + return env, nil + } + } + return domain.EnvType{}, NewNotFoundError("environment type not found: "+want.ID, nil) } - fmt.Println(strings.Repeat("-", 60)) - - fmt.Print("\nSelect environment (enter number or ID): ") - input, _ := reader.ReadString('\n') - input = strings.TrimSpace(input) - for i, env := range envs { - if input == fmt.Sprintf("%d", i+1) || input == env.ID || strings.EqualFold(input, env.Name) { - return &envs[i], nil - } + selected, ok, err := factory.PickEnvType(envs, "Switch environment") + if err != nil { + return domain.EnvType{}, NewTUIError("failed to select environment", err) + } + if !ok { + return domain.EnvType{}, NewCancelledError("environment switch cancelled by user", nil) } - return nil, NewNotFoundError("environment not found: "+input, nil) + return selected, nil } func (uc *switchEnvUseCase) updateSyncConfigWithEnv(syncConfig *domain.SyncConfig, envTypeID string) error { diff --git a/packages/envsync-cli/internal/features/usecases/gen_pem/gen_key_pair.go b/packages/envsync-cli/internal/features/usecases/gen_pem/gen_key_pair.go index 71272041..769a91b6 100644 --- a/packages/envsync-cli/internal/features/usecases/gen_pem/gen_key_pair.go +++ b/packages/envsync-cli/internal/features/usecases/gen_pem/gen_key_pair.go @@ -3,7 +3,10 @@ package genpem import ( "context" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/utils" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) var ( @@ -18,7 +21,11 @@ func NewGenKeyPairUseCase() GenKeyPairUseCase { } // GenerateKeyPair implements GenKeyPairUseCase. -func (g *genKeyPairUseCase) GenerateKeyPair(ctx context.Context, output string) error { +func (g *genKeyPairUseCase) GenerateKeyPair(ctx context.Context, output string) (err error) { + ctx, end := telemetry.StartSpan(ctx, "gen_pem.generate_key_pair", + trace.WithAttributes(attribute.String("file.path", output))) + defer end(&err) + keyPair, err := utils.GenerateKeyPair() if err != nil { return err diff --git a/packages/envsync-cli/internal/features/usecases/gpg_key/delete_key.go b/packages/envsync-cli/internal/features/usecases/gpg_key/delete_key.go index f82aab03..dc73474f 100644 --- a/packages/envsync-cli/internal/features/usecases/gpg_key/delete_key.go +++ b/packages/envsync-cli/internal/features/usecases/gpg_key/delete_key.go @@ -4,6 +4,9 @@ import ( "context" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type deleteKeyUseCase struct { @@ -15,7 +18,11 @@ func NewDeleteKeyUseCase() DeleteKeyUseCase { return &deleteKeyUseCase{service: service} } -func (uc *deleteKeyUseCase) Execute(ctx context.Context, keyID string) error { +func (uc *deleteKeyUseCase) Execute(ctx context.Context, keyID string) (err error) { + ctx, end := telemetry.StartSpan(ctx, "gpg_key.delete_key", + trace.WithAttributes(attribute.String("key.id", keyID))) + defer end(&err) + if keyID == "" { return NewValidationError("key ID is required", ErrKeyIDRequired) } diff --git a/packages/envsync-cli/internal/features/usecases/gpg_key/errors.go b/packages/envsync-cli/internal/features/usecases/gpg_key/errors.go index 4c42ccbe..1b0136d7 100644 --- a/packages/envsync-cli/internal/features/usecases/gpg_key/errors.go +++ b/packages/envsync-cli/internal/features/usecases/gpg_key/errors.go @@ -10,6 +10,9 @@ var ( ErrVerifyFailed = errors.New("verification operation failed") ErrNameRequired = errors.New("name is required") ErrEmailRequired = errors.New("email is required") + ErrInvalidAlgorithm = errors.New("invalid algorithm") + ErrInvalidSignMode = errors.New("invalid sign mode") + ErrSignCancelled = errors.New("signing cancelled by user") ErrNoInputProvided = errors.New("no input provided (use --file or pipe via stdin)") ) diff --git a/packages/envsync-cli/internal/features/usecases/gpg_key/export_key.go b/packages/envsync-cli/internal/features/usecases/gpg_key/export_key.go index 29877a70..23f4db4f 100644 --- a/packages/envsync-cli/internal/features/usecases/gpg_key/export_key.go +++ b/packages/envsync-cli/internal/features/usecases/gpg_key/export_key.go @@ -4,6 +4,9 @@ import ( "context" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type exportUseCase struct { @@ -15,7 +18,11 @@ func NewExportUseCase() ExportUseCase { return &exportUseCase{service: service} } -func (uc *exportUseCase) Execute(ctx context.Context, keyID string) (string, string, error) { +func (uc *exportUseCase) Execute(ctx context.Context, keyID string) (exportedPublicKey, exportedFingerprint string, err error) { + ctx, end := telemetry.StartSpan(ctx, "gpg_key.export_key", + trace.WithAttributes(attribute.String("key.id", keyID))) + defer end(&err) + if keyID == "" { return "", "", NewValidationError("key ID is required", ErrKeyIDRequired) } diff --git a/packages/envsync-cli/internal/features/usecases/gpg_key/generate_key.go b/packages/envsync-cli/internal/features/usecases/gpg_key/generate_key.go index 278fe973..e42a5767 100644 --- a/packages/envsync-cli/internal/features/usecases/gpg_key/generate_key.go +++ b/packages/envsync-cli/internal/features/usecases/gpg_key/generate_key.go @@ -6,6 +6,9 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository/requests" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type generateKeyUseCase struct { @@ -17,7 +20,15 @@ func NewGenerateKeyUseCase() GenerateKeyUseCase { return &generateKeyUseCase{service: service} } -func (uc *generateKeyUseCase) Execute(ctx context.Context, name, email, algorithm string, keySize, expiresInDays *int, usageFlags []string, isDefault bool) (*domain.GpgKey, error) { +func (uc *generateKeyUseCase) Execute(ctx context.Context, name, email, algorithm string, expiresInDays *int, usageFlags []string, isDefault bool) (result *domain.GpgKey, err error) { + ctx, end := telemetry.StartSpan(ctx, "gpg_key.generate_key", + trace.WithAttributes( + attribute.String("key.name", name), + attribute.String("key.email", email), + attribute.String("key.algorithm", algorithm), + )) + defer end(&err) + if name == "" { return nil, NewValidationError("name is required", ErrNameRequired) } @@ -25,6 +36,11 @@ func (uc *generateKeyUseCase) Execute(ctx context.Context, name, email, algorith return nil, NewValidationError("email is required", ErrEmailRequired) } + opt, ok := domain.ResolveGpgAlgorithm(algorithm) + if !ok { + return nil, NewValidationError("algorithm must be one of ECC Curve25519, ECC P-256, ECC P-384, RSA 4096", ErrInvalidAlgorithm) + } + if usageFlags == nil { usageFlags = []string{"sign"} } @@ -32,8 +48,8 @@ func (uc *generateKeyUseCase) Execute(ctx context.Context, name, email, algorith req := requests.GenerateGpgKeyRequest{ Name: name, Email: email, - Algorithm: algorithm, - KeySize: keySize, + Algorithm: opt.Value, + KeySize: opt.KeySize, UsageFlags: usageFlags, ExpiresInDays: expiresInDays, IsDefault: isDefault, diff --git a/packages/envsync-cli/internal/features/usecases/gpg_key/generate_key_test.go b/packages/envsync-cli/internal/features/usecases/gpg_key/generate_key_test.go new file mode 100644 index 00000000..65925e3a --- /dev/null +++ b/packages/envsync-cli/internal/features/usecases/gpg_key/generate_key_test.go @@ -0,0 +1,168 @@ +package gpg_key + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository/requests" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" +) + +// mockGpgKeyService is a test double implementing services.GpgKeyService. +// Each method delegates to an assignable function field so tests control +// behavior. +type mockGpgKeyService struct { + generateFn func(ctx context.Context, req requests.GenerateGpgKeyRequest) (domain.GpgKey, error) +} + +var _ services.GpgKeyService = (*mockGpgKeyService)(nil) + +func (m *mockGpgKeyService) ListKeys(ctx context.Context) ([]domain.GpgKey, error) { + return nil, nil +} + +func (m *mockGpgKeyService) GetKey(ctx context.Context, id string) (domain.GpgKey, error) { + return domain.GpgKey{}, nil +} + +func (m *mockGpgKeyService) GenerateKey(ctx context.Context, req requests.GenerateGpgKeyRequest) (domain.GpgKey, error) { + if m.generateFn != nil { + return m.generateFn(ctx, req) + } + return domain.GpgKey{}, nil +} + +func (m *mockGpgKeyService) DeleteKey(ctx context.Context, id string) error { return nil } + +func (m *mockGpgKeyService) RevokeKey(ctx context.Context, id string, reason string) (domain.GpgKey, error) { + return domain.GpgKey{}, nil +} + +func (m *mockGpgKeyService) ExportKey(ctx context.Context, id string) (string, string, error) { + return "", "", nil +} + +func (m *mockGpgKeyService) Sign(ctx context.Context, req requests.SignDataRequest) (domain.GpgSignatureResult, error) { + return domain.GpgSignatureResult{}, nil +} + +func (m *mockGpgKeyService) Verify(ctx context.Context, req requests.VerifySignatureRequest) (domain.GpgVerifyResult, error) { + return domain.GpgVerifyResult{}, nil +} + +func (m *mockGpgKeyService) RotateKey(ctx context.Context, id string, payload map[string]any) (domain.GpgKey, error) { + return domain.GpgKey{}, nil +} + +func (m *mockGpgKeyService) ExtendExpiry(ctx context.Context, id string, expiresInDays int) (domain.GpgKey, error) { + return domain.GpgKey{}, nil +} + +func TestGenerateKey_AlgorithmOptions(t *testing.T) { + rsaKeySize := 4096 + tests := []struct { + name string + algorithm string + wantAlgorithm string + wantKeySize *int + }{ + { + name: "label ECC Curve25519", + algorithm: "ECC Curve25519", + wantAlgorithm: "ecc-curve25519", + }, + { + name: "slug ecc-p256", + algorithm: "ecc-p256", + wantAlgorithm: "ecc-p256", + }, + { + name: "label ECC P-384", + algorithm: "ECC P-384", + wantAlgorithm: "ecc-p384", + }, + { + name: "RSA 4096 pins the key size", + algorithm: "RSA 4096", + wantAlgorithm: "rsa", + wantKeySize: &rsaKeySize, + }, + { + name: "slug rsa maps to RSA 4096", + algorithm: "rsa", + wantAlgorithm: "rsa", + wantKeySize: &rsaKeySize, + }, + { + name: "separators and case are ignored", + algorithm: "ecc curve25519", + wantAlgorithm: "ecc-curve25519", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got requests.GenerateGpgKeyRequest + uc := &generateKeyUseCase{ + service: &mockGpgKeyService{ + generateFn: func(ctx context.Context, req requests.GenerateGpgKeyRequest) (domain.GpgKey, error) { + got = req + return domain.GpgKey{ID: "key-1"}, nil + }, + }, + } + + key, err := uc.Execute(context.Background(), "release-bot", "bot@example.com", tt.algorithm, nil, nil, false) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if key.ID != "key-1" { + t.Fatalf("key.ID = %q, want %q", key.ID, "key-1") + } + if got.Algorithm != tt.wantAlgorithm { + t.Errorf("request algorithm = %q, want %q", got.Algorithm, tt.wantAlgorithm) + } + switch { + case tt.wantKeySize == nil && got.KeySize != nil: + t.Errorf("request key size = %d, want none", *got.KeySize) + case tt.wantKeySize != nil && got.KeySize == nil: + t.Errorf("request key size = none, want %d", *tt.wantKeySize) + case tt.wantKeySize != nil && *got.KeySize != *tt.wantKeySize: + t.Errorf("request key size = %d, want %d", *got.KeySize, *tt.wantKeySize) + } + }) + } +} + +func TestGenerateKey_RejectsUnknownAlgorithm(t *testing.T) { + uc := &generateKeyUseCase{service: &mockGpgKeyService{}} + + _, err := uc.Execute(context.Background(), "release-bot", "bot@example.com", "dsa", nil, nil, false) + if err == nil { + t.Fatal("Execute() error = nil, want error") + } + var gpgErr *GpgKeyError + if !errors.As(err, &gpgErr) { + t.Fatalf("error type = %T, want *GpgKeyError", err) + } + if gpgErr.Code != GpgKeyErrorCodeValidation { + t.Fatalf("error code = %q, want %q", gpgErr.Code, GpgKeyErrorCodeValidation) + } + if !strings.Contains(err.Error(), "ECC Curve25519") { + t.Fatalf("error %q should list the accepted algorithms", err) + } +} + +func TestGenerateKey_RequiresNameAndEmail(t *testing.T) { + uc := &generateKeyUseCase{service: &mockGpgKeyService{}} + + if _, err := uc.Execute(context.Background(), "", "bot@example.com", "rsa", nil, nil, false); err == nil { + t.Error("Execute() without name = nil error, want error") + } + if _, err := uc.Execute(context.Background(), "release-bot", "", "rsa", nil, nil, false); err == nil { + t.Error("Execute() without email = nil error, want error") + } +} diff --git a/packages/envsync-cli/internal/features/usecases/gpg_key/interfaces.go b/packages/envsync-cli/internal/features/usecases/gpg_key/interfaces.go index 9ed5039f..689ab054 100644 --- a/packages/envsync-cli/internal/features/usecases/gpg_key/interfaces.go +++ b/packages/envsync-cli/internal/features/usecases/gpg_key/interfaces.go @@ -11,7 +11,7 @@ type ListKeysUseCase interface { } type GenerateKeyUseCase interface { - Execute(ctx context.Context, name, email, algorithm string, keySize, expiresInDays *int, usageFlags []string, isDefault bool) (*domain.GpgKey, error) + Execute(ctx context.Context, name, email, algorithm string, expiresInDays *int, usageFlags []string, isDefault bool) (*domain.GpgKey, error) } type SignUseCase interface { diff --git a/packages/envsync-cli/internal/features/usecases/gpg_key/list_keys.go b/packages/envsync-cli/internal/features/usecases/gpg_key/list_keys.go index 3493a5f6..89531a3f 100644 --- a/packages/envsync-cli/internal/features/usecases/gpg_key/list_keys.go +++ b/packages/envsync-cli/internal/features/usecases/gpg_key/list_keys.go @@ -5,6 +5,7 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type listKeysUseCase struct { @@ -16,7 +17,10 @@ func NewListKeysUseCase() ListKeysUseCase { return &listKeysUseCase{service: service} } -func (uc *listKeysUseCase) Execute(ctx context.Context) ([]domain.GpgKey, error) { +func (uc *listKeysUseCase) Execute(ctx context.Context) (result []domain.GpgKey, err error) { + ctx, end := telemetry.StartSpan(ctx, "gpg_key.list_keys") + defer end(&err) + keys, err := uc.service.ListKeys(ctx) if err != nil { return nil, NewServiceError("failed to list GPG keys", err) diff --git a/packages/envsync-cli/internal/features/usecases/gpg_key/revoke_key.go b/packages/envsync-cli/internal/features/usecases/gpg_key/revoke_key.go index 24944e6a..49e6f5f8 100644 --- a/packages/envsync-cli/internal/features/usecases/gpg_key/revoke_key.go +++ b/packages/envsync-cli/internal/features/usecases/gpg_key/revoke_key.go @@ -5,6 +5,9 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type revokeUseCase struct { @@ -16,7 +19,11 @@ func NewRevokeUseCase() RevokeUseCase { return &revokeUseCase{service: service} } -func (uc *revokeUseCase) Execute(ctx context.Context, keyID, reason string) (*domain.GpgKey, error) { +func (uc *revokeUseCase) Execute(ctx context.Context, keyID, reason string) (result *domain.GpgKey, err error) { + ctx, end := telemetry.StartSpan(ctx, "gpg_key.revoke_key", + trace.WithAttributes(attribute.String("key.id", keyID))) + defer end(&err) + if keyID == "" { return nil, NewValidationError("key ID is required", ErrKeyIDRequired) } diff --git a/packages/envsync-cli/internal/features/usecases/gpg_key/sign.go b/packages/envsync-cli/internal/features/usecases/gpg_key/sign.go index e319d375..ef2e7813 100644 --- a/packages/envsync-cli/internal/features/usecases/gpg_key/sign.go +++ b/packages/envsync-cli/internal/features/usecases/gpg_key/sign.go @@ -9,6 +9,9 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository/requests" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type signUseCase struct { @@ -20,13 +23,20 @@ func NewSignUseCase() SignUseCase { return &signUseCase{service: service} } -func (uc *signUseCase) Execute(ctx context.Context, keyID, filePath, mode string, detached bool, useStdin bool) (*domain.GpgSignatureResult, error) { +func (uc *signUseCase) Execute(ctx context.Context, keyID, filePath, mode string, detached bool, useStdin bool) (signed *domain.GpgSignatureResult, err error) { + ctx, end := telemetry.StartSpan(ctx, "gpg_key.sign", + trace.WithAttributes( + attribute.String("key.id", keyID), + attribute.String("file.path", filePath), + attribute.String("sign.mode", mode), + )) + defer end(&err) + if keyID == "" { return nil, NewValidationError("key ID is required", ErrKeyIDRequired) } var data []byte - var err error if filePath != "" { data, err = os.ReadFile(filePath) diff --git a/packages/envsync-cli/internal/features/usecases/gpg_key/verify.go b/packages/envsync-cli/internal/features/usecases/gpg_key/verify.go index e93d6d2f..ee8e445c 100644 --- a/packages/envsync-cli/internal/features/usecases/gpg_key/verify.go +++ b/packages/envsync-cli/internal/features/usecases/gpg_key/verify.go @@ -9,6 +9,9 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository/requests" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type verifyUseCase struct { @@ -20,7 +23,15 @@ func NewVerifyUseCase() VerifyUseCase { return &verifyUseCase{service: service} } -func (uc *verifyUseCase) Execute(ctx context.Context, filePath, signaturePath, keyID string) (*domain.GpgVerifyResult, error) { +func (uc *verifyUseCase) Execute(ctx context.Context, filePath, signaturePath, keyID string) (verified *domain.GpgVerifyResult, err error) { + ctx, end := telemetry.StartSpan(ctx, "gpg_key.verify", + trace.WithAttributes( + attribute.String("file.path", filePath), + attribute.String("signature.path", signaturePath), + attribute.String("key.id", keyID), + )) + defer end(&err) + if filePath == "" { return nil, NewValidationError("file path is required for verification", ErrFileNotFound) } diff --git a/packages/envsync-cli/internal/features/usecases/init/init.go b/packages/envsync-cli/internal/features/usecases/init/init.go index d6515618..9407f7ba 100644 --- a/packages/envsync-cli/internal/features/usecases/init/init.go +++ b/packages/envsync-cli/internal/features/usecases/init/init.go @@ -1,49 +1,62 @@ package init import ( - "bufio" "context" "fmt" "os" - "strings" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "github.com/BurntSushi/toml" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/constants" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/spinner" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/factory" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type initCaseUse struct { appService services.ApplicationService + envService services.EnvTypeService + tui *factory.InitFactory } -func NewInitUseCase() InitUseCase { - appService := services.NewAppService() +func NewInitUseCase(tui *factory.InitFactory) InitUseCase { return &initCaseUse{ - appService: appService, + appService: services.NewAppService(), + envService: services.NewEnvTypeService(), + tui: tui, } } -func (uc *initCaseUse) Execute(ctx context.Context, config string) error { +func (uc *initCaseUse) Execute(ctx context.Context, config string) (InitResult, error) { return uc.ExecuteWithOptions(ctx, config, "", "") } -func (uc *initCaseUse) ExecuteWithOptions(ctx context.Context, config string, appID string, envTypeID string) error { - ctx, span := telemetry.Tracer().Start(ctx, "project.init") - defer span.End() +func (uc *initCaseUse) ExecuteWithOptions(ctx context.Context, config string, appID string, envTypeID string) (result InitResult, err error) { + ctx, end := telemetry.StartSpan(ctx, "project.init", trace.WithAttributes( + attribute.String("config.path", config), + attribute.String("app.id", appID), + attribute.String("env.id", envTypeID), + )) + defer end(&err) if err := uc.checkConfigExists(config); err == nil { - return err + return InitResult{}, err } + sp := spinner.New("Fetching applications…", spinner.IsTerminal(os.Stderr), os.Stderr) + sp.Start() apps, err := uc.appService.GetAllApps(ctx) + sp.Stop() if err != nil { - return NewServiceError("failed to retrieve applications", err) + return InitResult{}, NewServiceError("failed to retrieve applications", err) } if len(apps) == 0 { - return NewNotFoundError("no applications found. Create an application first with 'envsync app create'", nil) + return InitResult{}, NewNotFoundError("no applications found. Create an application first with 'envsync app create'", nil) } var selectedAppID, selectedEnvID string @@ -53,22 +66,23 @@ func (uc *initCaseUse) ExecuteWithOptions(ctx context.Context, config string, ap selectedEnvID = envTypeID if envTypeID == "" { - for _, app := range apps { - if app.ID == appID { - if len(app.EnvTypes) > 0 { - selectedEnvID, err = uc.selectEnvType(app) - if err != nil { - return err - } - } + appName := "" + for _, a := range apps { + if a.ID == appID { + appName = a.Name break } } + + selectedEnvID, err = uc.selectEnvType(ctx, appID, appName) + if err != nil { + return InitResult{}, err + } } } else { selectedAppID, selectedEnvID, err = uc.selectAppAndEnv(ctx, apps) if err != nil { - return err + return InitResult{}, err } } @@ -77,80 +91,76 @@ func (uc *initCaseUse) ExecuteWithOptions(ctx context.Context, config string, ap EnvTypeID: selectedEnvID, } - return uc.saveConfig(syncConfig) -} - -func (uc *initCaseUse) selectAppAndEnv(ctx context.Context, apps []domain.Application) (string, string, error) { - reader := bufio.NewReader(os.Stdin) - - fmt.Println("\n📋 Available Applications:") - fmt.Println(strings.Repeat("-", 60)) - for i, app := range apps { - fmt.Printf(" %d) %s (ID: %s)\n", i+1, app.Name, app.ID) + if err := uc.saveConfig(syncConfig); err != nil { + return InitResult{}, err } - fmt.Println(strings.Repeat("-", 60)) - fmt.Print("\nSelect application (enter number or ID): ") - input, _ := reader.ReadString('\n') - input = strings.TrimSpace(input) + return InitResult{ + Path: constants.DefaultProjectConfig, + AppID: selectedAppID, + EnvTypeID: selectedEnvID, + }, nil +} - var selectedApp *domain.Application - for i, app := range apps { - if input == fmt.Sprintf("%d", i+1) || input == app.ID || strings.EqualFold(input, app.Name) { - selectedApp = &apps[i] - break - } +// fetchEnvTypes loads an application's environment types. The application list +// does not carry them, so they have to be fetched per application. +func (uc *initCaseUse) fetchEnvTypes(ctx context.Context, appID, appName string) ([]domain.EnvType, error) { + sp := spinner.New(fmt.Sprintf("Fetching environments for %s…", appName), spinner.IsTerminal(os.Stderr), os.Stderr) + sp.Start() + envTypes, err := uc.envService.GetEnvTypesByAppID(ctx, appID) + sp.Stop() + if err != nil { + return nil, NewServiceError("failed to retrieve environment types", err) } + return envTypes, nil +} - if selectedApp == nil { - return "", "", NewNotFoundError("application not found: "+input, nil) +func (uc *initCaseUse) selectAppAndEnv(ctx context.Context, apps []domain.Application) (string, string, error) { + selected, ok, err := uc.tui.PickApp(apps) + if err != nil { + return "", "", NewTUIError("failed to select application", err) } - - if len(selectedApp.EnvTypes) == 0 { - return selectedApp.ID, "", nil + if !ok { + return "", "", NewCancelledError("initialisation cancelled by user", nil) } - fmt.Printf("\n🌍 Available Environments for %s:\n", selectedApp.Name) - fmt.Println(strings.Repeat("-", 60)) - for i, env := range selectedApp.EnvTypes { - fmt.Printf(" %d) %s (ID: %s)\n", i+1, env.Name, env.ID) + envTypes, err := uc.fetchEnvTypes(ctx, selected.ID, selected.Name) + if err != nil { + return "", "", err + } + if len(envTypes) == 0 { + return "", "", NewNotFoundError("no environment types found \n Create one using 'envsync env create'", nil) } - fmt.Println(strings.Repeat("-", 60)) - - fmt.Print("\nSelect environment (enter number or ID): ") - envInput, _ := reader.ReadString('\n') - envInput = strings.TrimSpace(envInput) - for i, env := range selectedApp.EnvTypes { - if envInput == fmt.Sprintf("%d", i+1) || envInput == env.ID || strings.EqualFold(envInput, env.Name) { - return selectedApp.ID, env.ID, nil - } + env, ok, err := factory.PickEnvType(envTypes, "Select an environment for "+selected.Name) + if err != nil { + return "", "", NewTUIError("failed to select environment", err) + } + if !ok { + return "", "", NewCancelledError("initialisation cancelled by user", nil) } - return "", "", NewNotFoundError("environment not found: "+envInput, nil) + return selected.ID, env.ID, nil } -func (uc *initCaseUse) selectEnvType(app domain.Application) (string, error) { - reader := bufio.NewReader(os.Stdin) - - fmt.Printf("\n🌍 Available Environments for %s:\n", app.Name) - fmt.Println(strings.Repeat("-", 60)) - for i, env := range app.EnvTypes { - fmt.Printf(" %d) %s (ID: %s)\n", i+1, env.Name, env.ID) +func (uc *initCaseUse) selectEnvType(ctx context.Context, appID, appName string) (string, error) { + envTypes, err := uc.fetchEnvTypes(ctx, appID, appName) + if err != nil { + return "", err + } + if len(envTypes) == 0 { + return "", NewNotFoundError("no environment types found \n Create one using 'envsync env create'", nil) } - fmt.Println(strings.Repeat("-", 60)) - - fmt.Print("\nSelect environment (enter number or ID): ") - envInput, _ := reader.ReadString('\n') - envInput = strings.TrimSpace(envInput) - for i, env := range app.EnvTypes { - if envInput == fmt.Sprintf("%d", i+1) || envInput == env.ID || strings.EqualFold(envInput, env.Name) { - return env.ID, nil - } + env, ok, err := factory.PickEnvType(envTypes, "Select an environment for "+appName) + if err != nil { + return "", NewTUIError("failed to select environment", err) + } + if !ok { + return "", NewCancelledError("initialisation cancelled by user", nil) } - return "", NewNotFoundError("environment not found: "+envInput, nil) + return env.ID, nil } func (uc *initCaseUse) checkConfigExists(configPath string) error { diff --git a/packages/envsync-cli/internal/features/usecases/init/interface.go b/packages/envsync-cli/internal/features/usecases/init/interface.go index 44659645..370b7870 100644 --- a/packages/envsync-cli/internal/features/usecases/init/interface.go +++ b/packages/envsync-cli/internal/features/usecases/init/interface.go @@ -3,6 +3,13 @@ package init import "context" type InitUseCase interface { - Execute(context.Context, string) error - ExecuteWithOptions(ctx context.Context, config string, appID string, envTypeID string) error + Execute(context.Context, string) (InitResult, error) + ExecuteWithOptions(ctx context.Context, config string, appID string, envTypeID string) (InitResult, error) +} + +// InitResult describes the configuration a successful init wrote. +type InitResult struct { + Path string `json:"path"` + AppID string `json:"app_id"` + EnvTypeID string `json:"env_type_id"` } diff --git a/packages/envsync-cli/internal/features/usecases/log_forwarding/create_config.go b/packages/envsync-cli/internal/features/usecases/log_forwarding/create_config.go deleted file mode 100644 index fc78d079..00000000 --- a/packages/envsync-cli/internal/features/usecases/log_forwarding/create_config.go +++ /dev/null @@ -1,87 +0,0 @@ -package log_forwarding - -import ( - "context" - "fmt" - "strings" - - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" -) - -type createLogForwardingConfigUseCase struct { - service services.LogForwardingService -} - -func NewCreateLogForwardingConfigUseCase() CreateLogForwardingConfigUseCase { - return &createLogForwardingConfigUseCase{ - service: services.NewLogForwardingService(), - } -} - -func (uc *createLogForwardingConfigUseCase) Execute( - ctx context.Context, - name, providerType, endpointURL, apiKey string, - enabled bool, -) (*sdk.LogForwardingResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "log_forwarding.create") - defer span.End() - - // Validate inputs - if err := uc.validateInputs(name, providerType, endpointURL, apiKey); err != nil { - return nil, err - } - - // Build the SDK request - req := &sdk.CreateLogForwardingRequest{ - Name: name, - ProviderType: sdk.CreateLogForwardingRequestProviderType(providerType), - Config: map[string]interface{}{ - "endpoint_url": endpointURL, - "api_key": apiKey, - }, - Enabled: &enabled, - } - - // Create via service - resp, err := uc.service.CreateLogForwardingConfig(ctx, req) - if err != nil { - return nil, NewServiceError("failed to create log forwarding config", err) - } - - return resp, nil -} - -func (uc *createLogForwardingConfigUseCase) validateInputs(name, providerType, endpointURL, apiKey string) error { - if strings.TrimSpace(name) == "" { - return NewValidationError("config name is required", ErrConfigNameRequired) - } - - if strings.TrimSpace(providerType) == "" { - return NewValidationError("target (provider type) is required", ErrTargetRequired) - } - - // Validate provider type - validTargets := map[string]bool{ - "datadog": true, - "splunk": true, - "sumo-logic": true, - } - if !validTargets[providerType] { - return NewValidationError( - fmt.Sprintf("invalid target '%s'. Must be one of: datadog, splunk, sumo-logic", providerType), - ErrInvalidTarget, - ) - } - - if strings.TrimSpace(endpointURL) == "" { - return NewValidationError("endpoint URL is required", ErrEndpointURLRequired) - } - - if strings.TrimSpace(apiKey) == "" { - return NewValidationError("API key is required", ErrAPIKeyRequired) - } - - return nil -} diff --git a/packages/envsync-cli/internal/features/usecases/log_forwarding/delete_config.go b/packages/envsync-cli/internal/features/usecases/log_forwarding/delete_config.go deleted file mode 100644 index 5d7e7ccd..00000000 --- a/packages/envsync-cli/internal/features/usecases/log_forwarding/delete_config.go +++ /dev/null @@ -1,36 +0,0 @@ -package log_forwarding - -import ( - "context" - "strings" - - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" -) - -type deleteLogForwardingConfigUseCase struct { - service services.LogForwardingService -} - -func NewDeleteLogForwardingConfigUseCase() DeleteLogForwardingConfigUseCase { - return &deleteLogForwardingConfigUseCase{ - service: services.NewLogForwardingService(), - } -} - -func (uc *deleteLogForwardingConfigUseCase) Execute(ctx context.Context, id string) (*sdk.ErrorResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "log_forwarding.delete") - defer span.End() - - if strings.TrimSpace(id) == "" { - return nil, NewValidationError("config ID is required", ErrConfigIDRequired) - } - - resp, err := uc.service.DeleteLogForwardingConfig(ctx, id) - if err != nil { - return nil, NewServiceError("failed to delete log forwarding config", err) - } - - return resp, nil -} diff --git a/packages/envsync-cli/internal/features/usecases/log_forwarding/errors.go b/packages/envsync-cli/internal/features/usecases/log_forwarding/errors.go deleted file mode 100644 index 192da671..00000000 --- a/packages/envsync-cli/internal/features/usecases/log_forwarding/errors.go +++ /dev/null @@ -1,77 +0,0 @@ -package log_forwarding - -import "errors" - -// Log forwarding use case errors -var ( - // Validation errors - ErrConfigIDRequired = errors.New("log forwarding config ID is required") - ErrTargetRequired = errors.New("log forwarding target (provider type) is required") - ErrEndpointURLRequired = errors.New("endpoint URL is required") - ErrAPIKeyRequired = errors.New("API key is required") - ErrInvalidTarget = errors.New("invalid target. Must be one of: datadog, splunk, sumo-logic") - ErrConfigNameRequired = errors.New("log forwarding config name is required") - - // Business logic errors - ErrConfigNotFound = errors.New("log forwarding config not found") - ErrConfigAlreadyExists = errors.New("log forwarding config already exists") - - // External service errors - ErrServiceUnavailable = errors.New("log forwarding service is currently unavailable") - ErrCreateFailed = errors.New("failed to create log forwarding config") - ErrDeleteFailed = errors.New("failed to delete log forwarding config") - ErrListFailed = errors.New("failed to list log forwarding configs") - ErrGetFailed = errors.New("failed to get log forwarding config") -) - -// Error types for structured error handling -type LogForwardingError struct { - Code string - Message string - Cause error -} - -func (e LogForwardingError) Error() string { - if e.Cause != nil { - return e.Message + ": " + e.Cause.Error() - } - return e.Message -} - -func (e LogForwardingError) Unwrap() error { - return e.Cause -} - -// Error codes -const ( - ErrorCodeValidation = "VALIDATION_ERROR" - ErrorCodeNotFound = "CONFIG_NOT_FOUND" - ErrorCodeAlreadyExists = "CONFIG_ALREADY_EXISTS" - ErrorCodeAccessDenied = "ACCESS_DENIED" - ErrorCodeServiceError = "SERVICE_ERROR" -) - -// Helper functions to create structured errors -func NewValidationError(message string, cause error) *LogForwardingError { - return &LogForwardingError{ - Code: ErrorCodeValidation, - Message: message, - Cause: cause, - } -} - -func NewNotFoundError(message string, cause error) *LogForwardingError { - return &LogForwardingError{ - Code: ErrorCodeNotFound, - Message: message, - Cause: cause, - } -} - -func NewServiceError(message string, cause error) *LogForwardingError { - return &LogForwardingError{ - Code: ErrorCodeServiceError, - Message: message, - Cause: cause, - } -} diff --git a/packages/envsync-cli/internal/features/usecases/log_forwarding/get_config.go b/packages/envsync-cli/internal/features/usecases/log_forwarding/get_config.go deleted file mode 100644 index 900c2a21..00000000 --- a/packages/envsync-cli/internal/features/usecases/log_forwarding/get_config.go +++ /dev/null @@ -1,36 +0,0 @@ -package log_forwarding - -import ( - "context" - "strings" - - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" -) - -type getLogForwardingConfigUseCase struct { - service services.LogForwardingService -} - -func NewGetLogForwardingConfigUseCase() GetLogForwardingConfigUseCase { - return &getLogForwardingConfigUseCase{ - service: services.NewLogForwardingService(), - } -} - -func (uc *getLogForwardingConfigUseCase) Execute(ctx context.Context, id string) (*sdk.LogForwardingResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "log_forwarding.get") - defer span.End() - - if strings.TrimSpace(id) == "" { - return nil, NewValidationError("config ID is required", ErrConfigIDRequired) - } - - config, err := uc.service.GetLogForwardingConfig(ctx, id) - if err != nil { - return nil, NewServiceError("failed to get log forwarding config", err) - } - - return config, nil -} diff --git a/packages/envsync-cli/internal/features/usecases/log_forwarding/interfaces.go b/packages/envsync-cli/internal/features/usecases/log_forwarding/interfaces.go deleted file mode 100644 index 0e659ab6..00000000 --- a/packages/envsync-cli/internal/features/usecases/log_forwarding/interfaces.go +++ /dev/null @@ -1,27 +0,0 @@ -package log_forwarding - -import ( - "context" - - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" -) - -// CreateLogForwardingConfigUseCase defines the interface for creating log forwarding configs -type CreateLogForwardingConfigUseCase interface { - Execute(ctx context.Context, name, providerType, endpointURL, apiKey string, enabled bool) (*sdk.LogForwardingResponse, error) -} - -// ListLogForwardingConfigsUseCase defines the interface for listing log forwarding configs -type ListLogForwardingConfigsUseCase interface { - Execute(ctx context.Context) (sdk.LogForwardingsResponse, error) -} - -// GetLogForwardingConfigUseCase defines the interface for getting a single log forwarding config -type GetLogForwardingConfigUseCase interface { - Execute(ctx context.Context, id string) (*sdk.LogForwardingResponse, error) -} - -// DeleteLogForwardingConfigUseCase defines the interface for deleting a log forwarding config -type DeleteLogForwardingConfigUseCase interface { - Execute(ctx context.Context, id string) (*sdk.ErrorResponse, error) -} diff --git a/packages/envsync-cli/internal/features/usecases/log_forwarding/list_configs.go b/packages/envsync-cli/internal/features/usecases/log_forwarding/list_configs.go deleted file mode 100644 index fc551774..00000000 --- a/packages/envsync-cli/internal/features/usecases/log_forwarding/list_configs.go +++ /dev/null @@ -1,31 +0,0 @@ -package log_forwarding - -import ( - "context" - - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" -) - -type listLogForwardingConfigsUseCase struct { - service services.LogForwardingService -} - -func NewListLogForwardingConfigsUseCase() ListLogForwardingConfigsUseCase { - return &listLogForwardingConfigsUseCase{ - service: services.NewLogForwardingService(), - } -} - -func (uc *listLogForwardingConfigsUseCase) Execute(ctx context.Context) (sdk.LogForwardingsResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "log_forwarding.list") - defer span.End() - - configs, err := uc.service.GetLogForwardingConfigs(ctx) - if err != nil { - return nil, NewServiceError("failed to list log forwarding configs", err) - } - - return configs, nil -} diff --git a/packages/envsync-cli/internal/features/usecases/log_forwarding/usecases_test.go b/packages/envsync-cli/internal/features/usecases/log_forwarding/usecases_test.go deleted file mode 100644 index ed67e5bb..00000000 --- a/packages/envsync-cli/internal/features/usecases/log_forwarding/usecases_test.go +++ /dev/null @@ -1,732 +0,0 @@ -package log_forwarding - -import ( - "context" - "errors" - "testing" - - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" -) - -// mockLogForwardingService implements services.LogForwardingService for testing. -type mockLogForwardingService struct { - createResp *sdk.LogForwardingResponse - createErr error - - listResp sdk.LogForwardingsResponse - listErr error - - getResp *sdk.LogForwardingResponse - getErr error - - deleteResp *sdk.ErrorResponse - deleteErr error -} - -var _ services.LogForwardingService = (*mockLogForwardingService)(nil) - -func (m *mockLogForwardingService) CreateLogForwardingConfig(_ context.Context, _ *sdk.CreateLogForwardingRequest) (*sdk.LogForwardingResponse, error) { - return m.createResp, m.createErr -} - -func (m *mockLogForwardingService) GetLogForwardingConfigs(_ context.Context) (sdk.LogForwardingsResponse, error) { - return m.listResp, m.listErr -} - -func (m *mockLogForwardingService) GetLogForwardingConfig(_ context.Context, _ string) (*sdk.LogForwardingResponse, error) { - return m.getResp, m.getErr -} - -func (m *mockLogForwardingService) DeleteLogForwardingConfig(_ context.Context, _ string) (*sdk.ErrorResponse, error) { - return m.deleteResp, m.deleteErr -} - -// --- CreateLogForwardingConfigUseCase tests --- - -func TestCreateLogForwardingConfigUseCase_EmptyName(t *testing.T) { - mock := &mockLogForwardingService{} - uc := &createLogForwardingConfigUseCase{service: mock} - - tests := []struct { - name string - inputName string - }{ - {name: "empty string", inputName: ""}, - {name: "whitespace only", inputName: " "}, - {name: "tab only", inputName: "\t"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resp, err := uc.Execute(context.Background(), tt.inputName, "datadog", "https://example.com", "key123", true) - if err == nil { - t.Fatal("expected error for empty name, got nil") - } - if resp != nil { - t.Errorf("expected nil response, got %v", resp) - } - - var lfErr *LogForwardingError - if !errors.As(err, &lfErr) { - t.Fatalf("expected *LogForwardingError, got %T", err) - } - if lfErr.Code != ErrorCodeValidation { - t.Errorf("expected code %q, got %q", ErrorCodeValidation, lfErr.Code) - } - if !errors.Is(lfErr.Cause, ErrConfigNameRequired) { - t.Errorf("expected cause ErrConfigNameRequired, got %v", lfErr.Cause) - } - }) - } -} - -func TestCreateLogForwardingConfigUseCase_EmptyProviderType(t *testing.T) { - mock := &mockLogForwardingService{} - uc := &createLogForwardingConfigUseCase{service: mock} - - tests := []struct { - name string - providerType string - }{ - {name: "empty string", providerType: ""}, - {name: "whitespace only", providerType: " "}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resp, err := uc.Execute(context.Background(), "my-config", tt.providerType, "https://example.com", "key123", true) - if err == nil { - t.Fatal("expected error for empty provider type, got nil") - } - if resp != nil { - t.Errorf("expected nil response, got %v", resp) - } - - var lfErr *LogForwardingError - if !errors.As(err, &lfErr) { - t.Fatalf("expected *LogForwardingError, got %T", err) - } - if lfErr.Code != ErrorCodeValidation { - t.Errorf("expected code %q, got %q", ErrorCodeValidation, lfErr.Code) - } - if !errors.Is(lfErr.Cause, ErrTargetRequired) { - t.Errorf("expected cause ErrTargetRequired, got %v", lfErr.Cause) - } - }) - } -} - -func TestCreateLogForwardingConfigUseCase_InvalidProviderType(t *testing.T) { - mock := &mockLogForwardingService{} - uc := &createLogForwardingConfigUseCase{service: mock} - - tests := []struct { - name string - providerType string - }{ - {name: "unknown provider", providerType: "unknown"}, - {name: "datadog typo", providerType: "Datadog"}, - {name: "splunk typo", providerType: "SPLUNK"}, - {name: "empty_map_provider", providerType: "newrelic"}, - {name: "numeric_string", providerType: "123"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resp, err := uc.Execute(context.Background(), "my-config", tt.providerType, "https://example.com", "key123", true) - if err == nil { - t.Fatalf("expected error for invalid provider type %q, got nil", tt.providerType) - } - if resp != nil { - t.Errorf("expected nil response, got %v", resp) - } - - var lfErr *LogForwardingError - if !errors.As(err, &lfErr) { - t.Fatalf("expected *LogForwardingError, got %T", err) - } - if lfErr.Code != ErrorCodeValidation { - t.Errorf("expected code %q, got %q", ErrorCodeValidation, lfErr.Code) - } - if !errors.Is(lfErr.Cause, ErrInvalidTarget) { - t.Errorf("expected cause ErrInvalidTarget, got %v", lfErr.Cause) - } - }) - } -} - -func TestCreateLogForwardingConfigUseCase_EmptyEndpointURL(t *testing.T) { - mock := &mockLogForwardingService{} - uc := &createLogForwardingConfigUseCase{service: mock} - - tests := []struct { - name string - endpointURL string - }{ - {name: "empty string", endpointURL: ""}, - {name: "whitespace only", endpointURL: " "}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resp, err := uc.Execute(context.Background(), "my-config", "datadog", tt.endpointURL, "key123", true) - if err == nil { - t.Fatal("expected error for empty endpoint URL, got nil") - } - if resp != nil { - t.Errorf("expected nil response, got %v", resp) - } - - var lfErr *LogForwardingError - if !errors.As(err, &lfErr) { - t.Fatalf("expected *LogForwardingError, got %T", err) - } - if lfErr.Code != ErrorCodeValidation { - t.Errorf("expected code %q, got %q", ErrorCodeValidation, lfErr.Code) - } - if !errors.Is(lfErr.Cause, ErrEndpointURLRequired) { - t.Errorf("expected cause ErrEndpointURLRequired, got %v", lfErr.Cause) - } - }) - } -} - -func TestCreateLogForwardingConfigUseCase_EmptyAPIKey(t *testing.T) { - mock := &mockLogForwardingService{} - uc := &createLogForwardingConfigUseCase{service: mock} - - tests := []struct { - name string - apiKey string - }{ - {name: "empty string", apiKey: ""}, - {name: "whitespace only", apiKey: " "}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resp, err := uc.Execute(context.Background(), "my-config", "datadog", "https://example.com", tt.apiKey, true) - if err == nil { - t.Fatal("expected error for empty API key, got nil") - } - if resp != nil { - t.Errorf("expected nil response, got %v", resp) - } - - var lfErr *LogForwardingError - if !errors.As(err, &lfErr) { - t.Fatalf("expected *LogForwardingError, got %T", err) - } - if lfErr.Code != ErrorCodeValidation { - t.Errorf("expected code %q, got %q", ErrorCodeValidation, lfErr.Code) - } - if !errors.Is(lfErr.Cause, ErrAPIKeyRequired) { - t.Errorf("expected cause ErrAPIKeyRequired, got %v", lfErr.Cause) - } - }) - } -} - -func TestCreateLogForwardingConfigUseCase_ServiceError(t *testing.T) { - svcErr := errors.New("network timeout") - mock := &mockLogForwardingService{ - createErr: svcErr, - } - uc := &createLogForwardingConfigUseCase{service: mock} - - tests := []struct { - name string - providerType string - }{ - {name: "datadog service error", providerType: "datadog"}, - {name: "splunk service error", providerType: "splunk"}, - {name: "sumo-logic service error", providerType: "sumo-logic"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resp, err := uc.Execute(context.Background(), "my-config", tt.providerType, "https://example.com", "key123", true) - if err == nil { - t.Fatal("expected error from service, got nil") - } - if resp != nil { - t.Errorf("expected nil response, got %v", resp) - } - - var lfErr *LogForwardingError - if !errors.As(err, &lfErr) { - t.Fatalf("expected *LogForwardingError, got %T", err) - } - if lfErr.Code != ErrorCodeServiceError { - t.Errorf("expected code %q, got %q", ErrorCodeServiceError, lfErr.Code) - } - if !errors.Is(lfErr, svcErr) { - t.Errorf("expected wrapped service error, got %v", err) - } - }) - } -} - -func TestCreateLogForwardingConfigUseCase_Success(t *testing.T) { - expectedResp := &sdk.LogForwardingResponse{ - Id: "cfg-123", - OrgId: "org-456", - Name: "my-datadog", - ProviderType: sdk.LogForwardingResponseProviderTypeDatadog, - Enabled: true, - CreatedAt: "2024-01-15T10:00:00Z", - UpdatedAt: "2024-01-15T10:00:00Z", - } - - tests := []struct { - name string - configName string - providerType string - endpointURL string - apiKey string - enabled bool - }{ - { - name: "datadog enabled", - configName: "my-datadog", - providerType: "datadog", - endpointURL: "https://http-intake.logs.datadoghq.com", - apiKey: "dd-key-123", - enabled: true, - }, - { - name: "splunk disabled", - configName: "my-splunk", - providerType: "splunk", - endpointURL: "https://splunk.example.com:8088", - apiKey: "splunk-token-456", - enabled: false, - }, - { - name: "sumo-logic enabled", - configName: "my-sumo", - providerType: "sumo-logic", - endpointURL: "https://endpoint.sumologic.com", - apiKey: "sumo-key-789", - enabled: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mock := &mockLogForwardingService{ - createResp: expectedResp, - } - uc := &createLogForwardingConfigUseCase{service: mock} - - resp, err := uc.Execute(context.Background(), tt.configName, tt.providerType, tt.endpointURL, tt.apiKey, tt.enabled) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp == nil { - t.Fatal("expected non-nil response") - } - if resp.Id != expectedResp.Id { - t.Errorf("expected ID %q, got %q", expectedResp.Id, resp.Id) - } - if resp.Name != expectedResp.Name { - t.Errorf("expected Name %q, got %q", expectedResp.Name, resp.Name) - } - }) - } -} - -// --- ListLogForwardingConfigsUseCase tests --- - -func TestListLogForwardingConfigsUseCase_ServiceError(t *testing.T) { - svcErr := errors.New("connection refused") - mock := &mockLogForwardingService{ - listErr: svcErr, - } - uc := &listLogForwardingConfigsUseCase{service: mock} - - resp, err := uc.Execute(context.Background()) - if err == nil { - t.Fatal("expected error from service, got nil") - } - if resp != nil { - t.Errorf("expected nil response, got %v", resp) - } - - var lfErr *LogForwardingError - if !errors.As(err, &lfErr) { - t.Fatalf("expected *LogForwardingError, got %T", err) - } - if lfErr.Code != ErrorCodeServiceError { - t.Errorf("expected code %q, got %q", ErrorCodeServiceError, lfErr.Code) - } - if !errors.Is(lfErr, svcErr) { - t.Errorf("expected wrapped service error, got %v", err) - } -} - -func TestListLogForwardingConfigsUseCase_EmptyList(t *testing.T) { - mock := &mockLogForwardingService{ - listResp: sdk.LogForwardingsResponse{}, - } - uc := &listLogForwardingConfigsUseCase{service: mock} - - resp, err := uc.Execute(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp == nil { - t.Fatal("expected non-nil response") - } - if len(resp) != 0 { - t.Errorf("expected empty list, got %d items", len(resp)) - } -} - -func TestListLogForwardingConfigsUseCase_Success(t *testing.T) { - expectedConfigs := sdk.LogForwardingsResponse{ - &sdk.LogForwardingResponse{ - Id: "cfg-1", - Name: "datadog-prod", - ProviderType: sdk.LogForwardingResponseProviderTypeDatadog, - Enabled: true, - }, - &sdk.LogForwardingResponse{ - Id: "cfg-2", - Name: "splunk-staging", - ProviderType: sdk.LogForwardingResponseProviderTypeSplunk, - Enabled: false, - }, - &sdk.LogForwardingResponse{ - Id: "cfg-3", - Name: "sumo-dev", - ProviderType: sdk.LogForwardingResponseProviderTypeSumoLogic, - Enabled: true, - }, - } - - mock := &mockLogForwardingService{ - listResp: expectedConfigs, - } - uc := &listLogForwardingConfigsUseCase{service: mock} - - resp, err := uc.Execute(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp == nil { - t.Fatal("expected non-nil response") - } - if len(resp) != len(expectedConfigs) { - t.Fatalf("expected %d configs, got %d", len(expectedConfigs), len(resp)) - } - - for i, cfg := range resp { - if cfg.Id != expectedConfigs[i].Id { - t.Errorf("config[%d]: expected ID %q, got %q", i, expectedConfigs[i].Id, cfg.Id) - } - if cfg.Name != expectedConfigs[i].Name { - t.Errorf("config[%d]: expected Name %q, got %q", i, expectedConfigs[i].Name, cfg.Name) - } - if cfg.ProviderType != expectedConfigs[i].ProviderType { - t.Errorf("config[%d]: expected ProviderType %q, got %q", i, expectedConfigs[i].ProviderType, cfg.ProviderType) - } - if cfg.Enabled != expectedConfigs[i].Enabled { - t.Errorf("config[%d]: expected Enabled %v, got %v", i, expectedConfigs[i].Enabled, cfg.Enabled) - } - } -} - -// --- GetLogForwardingConfigUseCase tests --- - -func TestGetLogForwardingConfigUseCase_EmptyID(t *testing.T) { - mock := &mockLogForwardingService{} - uc := &getLogForwardingConfigUseCase{service: mock} - - tests := []struct { - name string - id string - }{ - {name: "empty string", id: ""}, - {name: "whitespace only", id: " "}, - {name: "tab only", id: "\t"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resp, err := uc.Execute(context.Background(), tt.id) - if err == nil { - t.Fatal("expected error for empty ID, got nil") - } - if resp != nil { - t.Errorf("expected nil response, got %v", resp) - } - - var lfErr *LogForwardingError - if !errors.As(err, &lfErr) { - t.Fatalf("expected *LogForwardingError, got %T", err) - } - if lfErr.Code != ErrorCodeValidation { - t.Errorf("expected code %q, got %q", ErrorCodeValidation, lfErr.Code) - } - if !errors.Is(lfErr.Cause, ErrConfigIDRequired) { - t.Errorf("expected cause ErrConfigIDRequired, got %v", lfErr.Cause) - } - }) - } -} - -func TestGetLogForwardingConfigUseCase_ServiceError(t *testing.T) { - svcErr := errors.New("HTTP 500") - mock := &mockLogForwardingService{ - getErr: svcErr, - } - uc := &getLogForwardingConfigUseCase{service: mock} - - resp, err := uc.Execute(context.Background(), "cfg-123") - if err == nil { - t.Fatal("expected error from service, got nil") - } - if resp != nil { - t.Errorf("expected nil response, got %v", resp) - } - - var lfErr *LogForwardingError - if !errors.As(err, &lfErr) { - t.Fatalf("expected *LogForwardingError, got %T", err) - } - if lfErr.Code != ErrorCodeServiceError { - t.Errorf("expected code %q, got %q", ErrorCodeServiceError, lfErr.Code) - } - if !errors.Is(lfErr, svcErr) { - t.Errorf("expected wrapped service error, got %v", err) - } -} - -func TestGetLogForwardingConfigUseCase_Success(t *testing.T) { - expectedResp := &sdk.LogForwardingResponse{ - Id: "cfg-abc", - OrgId: "org-xyz", - Name: "prod-datadog", - ProviderType: sdk.LogForwardingResponseProviderTypeDatadog, - Config: map[string]interface{}{"endpoint_url": "https://example.com"}, - Enabled: true, - CreatedAt: "2024-01-15T10:00:00Z", - UpdatedAt: "2024-01-16T12:00:00Z", - } - - mock := &mockLogForwardingService{ - getResp: expectedResp, - } - uc := &getLogForwardingConfigUseCase{service: mock} - - resp, err := uc.Execute(context.Background(), "cfg-abc") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp == nil { - t.Fatal("expected non-nil response") - } - if resp.Id != expectedResp.Id { - t.Errorf("expected ID %q, got %q", expectedResp.Id, resp.Id) - } - if resp.Name != expectedResp.Name { - t.Errorf("expected Name %q, got %q", expectedResp.Name, resp.Name) - } - if resp.OrgId != expectedResp.OrgId { - t.Errorf("expected OrgId %q, got %q", expectedResp.OrgId, resp.OrgId) - } - if resp.ProviderType != expectedResp.ProviderType { - t.Errorf("expected ProviderType %q, got %q", expectedResp.ProviderType, resp.ProviderType) - } - if resp.Enabled != expectedResp.Enabled { - t.Errorf("expected Enabled %v, got %v", expectedResp.Enabled, resp.Enabled) - } -} - -// --- DeleteLogForwardingConfigUseCase tests --- - -func TestDeleteLogForwardingConfigUseCase_EmptyID(t *testing.T) { - mock := &mockLogForwardingService{} - uc := &deleteLogForwardingConfigUseCase{service: mock} - - tests := []struct { - name string - id string - }{ - {name: "empty string", id: ""}, - {name: "whitespace only", id: " "}, - {name: "tab only", id: "\t"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resp, err := uc.Execute(context.Background(), tt.id) - if err == nil { - t.Fatal("expected error for empty ID, got nil") - } - if resp != nil { - t.Errorf("expected nil response, got %v", resp) - } - - var lfErr *LogForwardingError - if !errors.As(err, &lfErr) { - t.Fatalf("expected *LogForwardingError, got %T", err) - } - if lfErr.Code != ErrorCodeValidation { - t.Errorf("expected code %q, got %q", ErrorCodeValidation, lfErr.Code) - } - if !errors.Is(lfErr.Cause, ErrConfigIDRequired) { - t.Errorf("expected cause ErrConfigIDRequired, got %v", lfErr.Cause) - } - }) - } -} - -func TestDeleteLogForwardingConfigUseCase_ServiceError(t *testing.T) { - svcErr := errors.New("timeout") - mock := &mockLogForwardingService{ - deleteErr: svcErr, - } - uc := &deleteLogForwardingConfigUseCase{service: mock} - - resp, err := uc.Execute(context.Background(), "cfg-123") - if err == nil { - t.Fatal("expected error from service, got nil") - } - if resp != nil { - t.Errorf("expected nil response, got %v", resp) - } - - var lfErr *LogForwardingError - if !errors.As(err, &lfErr) { - t.Fatalf("expected *LogForwardingError, got %T", err) - } - if lfErr.Code != ErrorCodeServiceError { - t.Errorf("expected code %q, got %q", ErrorCodeServiceError, lfErr.Code) - } - if !errors.Is(lfErr, svcErr) { - t.Errorf("expected wrapped service error, got %v", err) - } -} - -func TestDeleteLogForwardingConfigUseCase_Success(t *testing.T) { - expectedResp := &sdk.ErrorResponse{ - Error: "log forwarding config deleted", - } - - mock := &mockLogForwardingService{ - deleteResp: expectedResp, - } - uc := &deleteLogForwardingConfigUseCase{service: mock} - - resp, err := uc.Execute(context.Background(), "cfg-123") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp == nil { - t.Fatal("expected non-nil response") - } - if resp.GetError() != expectedResp.GetError() { - t.Errorf("expected error message %q, got %q", expectedResp.GetError(), resp.GetError()) - } -} - -// --- Error type tests --- - -func TestLogForwardingError_Error(t *testing.T) { - tests := []struct { - name string - err *LogForwardingError - expected string - }{ - { - name: "without cause", - err: &LogForwardingError{ - Code: ErrorCodeValidation, - Message: "name is required", - }, - expected: "name is required", - }, - { - name: "with cause", - err: &LogForwardingError{ - Code: ErrorCodeServiceError, - Message: "failed to create", - Cause: errors.New("network error"), - }, - expected: "failed to create: network error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.err.Error() - if result != tt.expected { - t.Errorf("expected %q, got %q", tt.expected, result) - } - }) - } -} - -func TestLogForwardingError_Unwrap(t *testing.T) { - cause := errors.New("root cause") - lfErr := &LogForwardingError{ - Code: ErrorCodeServiceError, - Message: "wrapped", - Cause: cause, - } - - if !errors.Is(lfErr, cause) { - t.Error("expected Unwrap to return the cause") - } - - // nil cause - lfErrNoCause := &LogForwardingError{ - Code: ErrorCodeValidation, - Message: "no cause", - } - if lfErrNoCause.Unwrap() != nil { - t.Error("expected nil from Unwrap when cause is nil") - } -} - -func TestNewValidationError(t *testing.T) { - cause := ErrConfigNameRequired - err := NewValidationError("name is required", cause) - - if err.Code != ErrorCodeValidation { - t.Errorf("expected code %q, got %q", ErrorCodeValidation, err.Code) - } - if err.Message != "name is required" { - t.Errorf("expected message %q, got %q", "name is required", err.Message) - } - if err.Cause != cause { - t.Errorf("expected cause %v, got %v", cause, err.Cause) - } -} - -func TestNewNotFoundError(t *testing.T) { - cause := ErrConfigNotFound - err := NewNotFoundError("config not found", cause) - - if err.Code != ErrorCodeNotFound { - t.Errorf("expected code %q, got %q", ErrorCodeNotFound, err.Code) - } - if err.Cause != cause { - t.Errorf("expected cause %v, got %v", cause, err.Cause) - } -} - -func TestNewServiceError(t *testing.T) { - cause := errors.New("connection refused") - err := NewServiceError("service unavailable", cause) - - if err.Code != ErrorCodeServiceError { - t.Errorf("expected code %q, got %q", ErrorCodeServiceError, err.Code) - } - if err.Cause != cause { - t.Errorf("expected cause %v, got %v", cause, err.Cause) - } -} diff --git a/packages/envsync-cli/internal/features/usecases/oidc/create.go b/packages/envsync-cli/internal/features/usecases/oidc/create.go deleted file mode 100644 index e6313678..00000000 --- a/packages/envsync-cli/internal/features/usecases/oidc/create.go +++ /dev/null @@ -1,60 +0,0 @@ -package oidc - -import ( - "context" - "strings" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" -) - -type createOidcProviderUseCase struct { - service services.OidcService -} - -func NewCreateOidcProviderUseCase() CreateOidcProviderUseCase { - return &createOidcProviderUseCase{ - service: services.NewOidcService(), - } -} - -func (uc *createOidcProviderUseCase) Execute(ctx context.Context, input domain.CreateOidcProviderInput) (*domain.OidcProvider, error) { - ctx, span := telemetry.Tracer().Start(ctx, "oidc.create") - defer span.End() - - if err := uc.validateInput(input); err != nil { - return nil, err - } - - provider, err := uc.service.CreateProvider(ctx, input) - if err != nil { - return nil, NewServiceError("failed to create OIDC provider", err) - } - - return provider, nil -} - -func (uc *createOidcProviderUseCase) validateInput(input domain.CreateOidcProviderInput) error { - if strings.TrimSpace(input.ProviderType) == "" { - return NewValidationError("provider type is required", ErrProviderTypeRequired) - } - if strings.TrimSpace(input.IssuerURL) == "" { - return NewValidationError("issuer URL is required", ErrIssuerURLRequired) - } - if strings.TrimSpace(input.Audience) == "" { - return NewValidationError("audience is required", ErrAudienceRequired) - } - - validTypes := map[string]bool{ - "github_actions": true, - "gitlab_ci": true, - "kubernetes": true, - "generic": true, - } - if !validTypes[input.ProviderType] { - return NewValidationError("invalid provider type", ErrInvalidProviderType) - } - - return nil -} diff --git a/packages/envsync-cli/internal/features/usecases/oidc/delete.go b/packages/envsync-cli/internal/features/usecases/oidc/delete.go deleted file mode 100644 index e1b20600..00000000 --- a/packages/envsync-cli/internal/features/usecases/oidc/delete.go +++ /dev/null @@ -1,34 +0,0 @@ -package oidc - -import ( - "context" - "strings" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" -) - -type deleteOidcProviderUseCase struct { - service services.OidcService -} - -func NewDeleteOidcProviderUseCase() DeleteOidcProviderUseCase { - return &deleteOidcProviderUseCase{ - service: services.NewOidcService(), - } -} - -func (uc *deleteOidcProviderUseCase) Execute(ctx context.Context, id string) error { - ctx, span := telemetry.Tracer().Start(ctx, "oidc.delete") - defer span.End() - - if strings.TrimSpace(id) == "" { - return NewValidationError("provider ID is required", ErrProviderIDRequired) - } - - if err := uc.service.DeleteProvider(ctx, id); err != nil { - return NewServiceError("failed to delete OIDC provider", err) - } - - return nil -} diff --git a/packages/envsync-cli/internal/features/usecases/oidc/errors.go b/packages/envsync-cli/internal/features/usecases/oidc/errors.go deleted file mode 100644 index 28692e85..00000000 --- a/packages/envsync-cli/internal/features/usecases/oidc/errors.go +++ /dev/null @@ -1,53 +0,0 @@ -package oidc - -import "errors" - -var ( - ErrProviderIDRequired = errors.New("provider ID is required") - ErrProviderTypeRequired = errors.New("provider type is required") - ErrIssuerURLRequired = errors.New("issuer URL is required") - ErrAudienceRequired = errors.New("audience is required") - ErrProviderNotFound = errors.New("OIDC provider not found") - ErrProviderAlreadyExists = errors.New("OIDC provider already exists") - ErrInvalidProviderType = errors.New("invalid provider type (must be github_actions, gitlab_ci, kubernetes, or generic)") - ErrProviderCreateFailed = errors.New("failed to create OIDC provider") - ErrProviderUpdateFailed = errors.New("failed to update OIDC provider") - ErrProviderDeleteFailed = errors.New("failed to delete OIDC provider") - ErrProviderListFailed = errors.New("failed to list OIDC providers") - ErrProviderGetFailed = errors.New("failed to get OIDC provider") -) - -type OidcError struct { - Code string - Message string - Cause error -} - -func (e OidcError) Error() string { - if e.Cause != nil { - return e.Message + ": " + e.Cause.Error() - } - return e.Message -} - -func (e OidcError) Unwrap() error { - return e.Cause -} - -const ( - OidcErrorCodeValidation = "VALIDATION_ERROR" - OidcErrorCodeNotFound = "PROVIDER_NOT_FOUND" - OidcErrorCodeServiceError = "SERVICE_ERROR" -) - -func NewValidationError(message string, cause error) *OidcError { - return &OidcError{Code: OidcErrorCodeValidation, Message: message, Cause: cause} -} - -func NewNotFoundError(message string, cause error) *OidcError { - return &OidcError{Code: OidcErrorCodeNotFound, Message: message, Cause: cause} -} - -func NewServiceError(message string, cause error) *OidcError { - return &OidcError{Code: OidcErrorCodeServiceError, Message: message, Cause: cause} -} diff --git a/packages/envsync-cli/internal/features/usecases/oidc/get.go b/packages/envsync-cli/internal/features/usecases/oidc/get.go deleted file mode 100644 index d1e1bac3..00000000 --- a/packages/envsync-cli/internal/features/usecases/oidc/get.go +++ /dev/null @@ -1,36 +0,0 @@ -package oidc - -import ( - "context" - "strings" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" -) - -type getOidcProviderUseCase struct { - service services.OidcService -} - -func NewGetOidcProviderUseCase() GetOidcProviderUseCase { - return &getOidcProviderUseCase{ - service: services.NewOidcService(), - } -} - -func (uc *getOidcProviderUseCase) Execute(ctx context.Context, id string) (*domain.OidcProvider, error) { - ctx, span := telemetry.Tracer().Start(ctx, "oidc.get") - defer span.End() - - if strings.TrimSpace(id) == "" { - return nil, NewValidationError("provider ID is required", ErrProviderIDRequired) - } - - provider, err := uc.service.GetProvider(ctx, id) - if err != nil { - return nil, NewServiceError("failed to get OIDC provider", err) - } - - return provider, nil -} diff --git a/packages/envsync-cli/internal/features/usecases/oidc/interfaces.go b/packages/envsync-cli/internal/features/usecases/oidc/interfaces.go deleted file mode 100644 index aab3c190..00000000 --- a/packages/envsync-cli/internal/features/usecases/oidc/interfaces.go +++ /dev/null @@ -1,27 +0,0 @@ -package oidc - -import ( - "context" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" -) - -type CreateOidcProviderUseCase interface { - Execute(ctx context.Context, input domain.CreateOidcProviderInput) (*domain.OidcProvider, error) -} - -type ListOidcProvidersUseCase interface { - Execute(ctx context.Context) ([]domain.OidcProvider, error) -} - -type GetOidcProviderUseCase interface { - Execute(ctx context.Context, id string) (*domain.OidcProvider, error) -} - -type UpdateOidcProviderUseCase interface { - Execute(ctx context.Context, input domain.UpdateOidcProviderInput) error -} - -type DeleteOidcProviderUseCase interface { - Execute(ctx context.Context, id string) error -} diff --git a/packages/envsync-cli/internal/features/usecases/oidc/list.go b/packages/envsync-cli/internal/features/usecases/oidc/list.go deleted file mode 100644 index b27b743b..00000000 --- a/packages/envsync-cli/internal/features/usecases/oidc/list.go +++ /dev/null @@ -1,31 +0,0 @@ -package oidc - -import ( - "context" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" -) - -type listOidcProvidersUseCase struct { - service services.OidcService -} - -func NewListOidcProvidersUseCase() ListOidcProvidersUseCase { - return &listOidcProvidersUseCase{ - service: services.NewOidcService(), - } -} - -func (uc *listOidcProvidersUseCase) Execute(ctx context.Context) ([]domain.OidcProvider, error) { - ctx, span := telemetry.Tracer().Start(ctx, "oidc.list") - defer span.End() - - providers, err := uc.service.ListProviders(ctx) - if err != nil { - return nil, NewServiceError("failed to list OIDC providers", err) - } - - return providers, nil -} diff --git a/packages/envsync-cli/internal/features/usecases/oidc/update.go b/packages/envsync-cli/internal/features/usecases/oidc/update.go deleted file mode 100644 index 3fa0afbd..00000000 --- a/packages/envsync-cli/internal/features/usecases/oidc/update.go +++ /dev/null @@ -1,35 +0,0 @@ -package oidc - -import ( - "context" - "strings" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" -) - -type updateOidcProviderUseCase struct { - service services.OidcService -} - -func NewUpdateOidcProviderUseCase() UpdateOidcProviderUseCase { - return &updateOidcProviderUseCase{ - service: services.NewOidcService(), - } -} - -func (uc *updateOidcProviderUseCase) Execute(ctx context.Context, input domain.UpdateOidcProviderInput) error { - ctx, span := telemetry.Tracer().Start(ctx, "oidc.update") - defer span.End() - - if strings.TrimSpace(input.ID) == "" { - return NewValidationError("provider ID is required", ErrProviderIDRequired) - } - - if err := uc.service.UpdateProvider(ctx, input); err != nil { - return NewServiceError("failed to update OIDC provider", err) - } - - return nil -} diff --git a/packages/envsync-cli/internal/features/usecases/oidc/usecases_test.go b/packages/envsync-cli/internal/features/usecases/oidc/usecases_test.go deleted file mode 100644 index 5c4f6e2e..00000000 --- a/packages/envsync-cli/internal/features/usecases/oidc/usecases_test.go +++ /dev/null @@ -1,800 +0,0 @@ -package oidc - -import ( - "context" - "errors" - "fmt" - "testing" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" -) - -// Ensure mockOidcService satisfies the interface at compile time. -var _ services.OidcService = (*mockOidcService)(nil) - -type mockOidcService struct { - createProviderFn func(ctx context.Context, input domain.CreateOidcProviderInput) (*domain.OidcProvider, error) - listProvidersFn func(ctx context.Context) ([]domain.OidcProvider, error) - getProviderFn func(ctx context.Context, id string) (*domain.OidcProvider, error) - updateProviderFn func(ctx context.Context, input domain.UpdateOidcProviderInput) error - deleteProviderFn func(ctx context.Context, id string) error -} - -func (m *mockOidcService) CreateProvider(ctx context.Context, input domain.CreateOidcProviderInput) (*domain.OidcProvider, error) { - return m.createProviderFn(ctx, input) -} - -func (m *mockOidcService) ListProviders(ctx context.Context) ([]domain.OidcProvider, error) { - return m.listProvidersFn(ctx) -} - -func (m *mockOidcService) GetProvider(ctx context.Context, id string) (*domain.OidcProvider, error) { - return m.getProviderFn(ctx, id) -} - -func (m *mockOidcService) UpdateProvider(ctx context.Context, input domain.UpdateOidcProviderInput) error { - return m.updateProviderFn(ctx, input) -} - -func (m *mockOidcService) DeleteProvider(ctx context.Context, id string) error { - return m.deleteProviderFn(ctx, id) -} - -// --- CreateOidcProviderUseCase --- - -func TestCreateOidcProviderUseCase_Execute(t *testing.T) { - tests := []struct { - name string - input domain.CreateOidcProviderInput - mockFn func(ctx context.Context, input domain.CreateOidcProviderInput) (*domain.OidcProvider, error) - wantErr bool - wantErrIs error - wantErrCode string - wantNilResult bool - }{ - { - name: "success with github_actions", - input: domain.CreateOidcProviderInput{ - ProviderType: "github_actions", - IssuerURL: "https://token.actions.githubusercontent.com", - Audience: "https://envsync.cloud", - }, - mockFn: func(_ context.Context, input domain.CreateOidcProviderInput) (*domain.OidcProvider, error) { - return &domain.OidcProvider{ - ID: "oidc-123", - ProviderType: input.ProviderType, - IssuerURL: input.IssuerURL, - Audience: input.Audience, - Enabled: true, - }, nil - }, - wantErr: false, - }, - { - name: "success with kubernetes", - input: domain.CreateOidcProviderInput{ - ProviderType: "kubernetes", - IssuerURL: "https://kubernetes.default.svc", - Audience: "envsync", - AllowedSubjects: []string{"system:serviceaccount:default:myapp"}, - }, - mockFn: func(_ context.Context, input domain.CreateOidcProviderInput) (*domain.OidcProvider, error) { - return &domain.OidcProvider{ - ID: "oidc-456", - ProviderType: input.ProviderType, - IssuerURL: input.IssuerURL, - Audience: input.Audience, - AllowedSubjects: input.AllowedSubjects, - Enabled: true, - }, nil - }, - wantErr: false, - }, - { - name: "success with generic", - input: domain.CreateOidcProviderInput{ - ProviderType: "generic", - IssuerURL: "https://accounts.google.com", - Audience: "envsync-client", - }, - mockFn: func(_ context.Context, _ domain.CreateOidcProviderInput) (*domain.OidcProvider, error) { - return &domain.OidcProvider{ID: "oidc-789", ProviderType: "generic"}, nil - }, - wantErr: false, - }, - { - name: "success with gitlab_ci", - input: domain.CreateOidcProviderInput{ - ProviderType: "gitlab_ci", - IssuerURL: "https://gitlab.com", - Audience: "https://envsync.cloud", - }, - mockFn: func(_ context.Context, _ domain.CreateOidcProviderInput) (*domain.OidcProvider, error) { - return &domain.OidcProvider{ID: "oidc-gitlab", ProviderType: "gitlab_ci"}, nil - }, - wantErr: false, - }, - { - name: "validation error - empty provider type", - input: domain.CreateOidcProviderInput{ - ProviderType: "", - IssuerURL: "https://token.actions.githubusercontent.com", - Audience: "https://envsync.cloud", - }, - mockFn: nil, - wantErr: true, - wantErrIs: ErrProviderTypeRequired, - wantErrCode: OidcErrorCodeValidation, - wantNilResult: true, - }, - { - name: "validation error - whitespace provider type", - input: domain.CreateOidcProviderInput{ - ProviderType: " ", - IssuerURL: "https://token.actions.githubusercontent.com", - Audience: "https://envsync.cloud", - }, - mockFn: nil, - wantErr: true, - wantErrIs: ErrProviderTypeRequired, - wantErrCode: OidcErrorCodeValidation, - wantNilResult: true, - }, - { - name: "validation error - empty issuer URL", - input: domain.CreateOidcProviderInput{ - ProviderType: "github_actions", - IssuerURL: "", - Audience: "https://envsync.cloud", - }, - mockFn: nil, - wantErr: true, - wantErrIs: ErrIssuerURLRequired, - wantErrCode: OidcErrorCodeValidation, - wantNilResult: true, - }, - { - name: "validation error - whitespace issuer URL", - input: domain.CreateOidcProviderInput{ - ProviderType: "github_actions", - IssuerURL: " \t ", - Audience: "https://envsync.cloud", - }, - mockFn: nil, - wantErr: true, - wantErrIs: ErrIssuerURLRequired, - wantErrCode: OidcErrorCodeValidation, - wantNilResult: true, - }, - { - name: "validation error - empty audience", - input: domain.CreateOidcProviderInput{ - ProviderType: "github_actions", - IssuerURL: "https://token.actions.githubusercontent.com", - Audience: "", - }, - mockFn: nil, - wantErr: true, - wantErrIs: ErrAudienceRequired, - wantErrCode: OidcErrorCodeValidation, - wantNilResult: true, - }, - { - name: "validation error - invalid provider type", - input: domain.CreateOidcProviderInput{ - ProviderType: "aws_sts", - IssuerURL: "https://sts.amazonaws.com", - Audience: "envsync", - }, - mockFn: nil, - wantErr: true, - wantErrIs: ErrInvalidProviderType, - wantErrCode: OidcErrorCodeValidation, - wantNilResult: true, - }, - { - name: "service error", - input: domain.CreateOidcProviderInput{ - ProviderType: "github_actions", - IssuerURL: "https://token.actions.githubusercontent.com", - Audience: "https://envsync.cloud", - }, - mockFn: func(_ context.Context, _ domain.CreateOidcProviderInput) (*domain.OidcProvider, error) { - return nil, errors.New("network error") - }, - wantErr: true, - wantErrCode: OidcErrorCodeServiceError, - wantNilResult: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - uc := &createOidcProviderUseCase{ - service: &mockOidcService{createProviderFn: tt.mockFn}, - } - - result, err := uc.Execute(context.Background(), tt.input) - - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - var oidcErr *OidcError - if !errors.As(err, &oidcErr) { - t.Fatalf("expected *OidcError, got %T", err) - } - if oidcErr.Code != tt.wantErrCode { - t.Errorf("expected error code %q, got %q", tt.wantErrCode, oidcErr.Code) - } - if tt.wantErrIs != nil && !errors.Is(err, tt.wantErrIs) { - t.Errorf("expected error to wrap %v, got %v", tt.wantErrIs, err) - } - if tt.wantNilResult && result != nil { - t.Errorf("expected nil result, got %+v", result) - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result == nil { - t.Fatal("expected non-nil result") - } - }) - } -} - -// --- ListOidcProvidersUseCase --- - -func TestListOidcProvidersUseCase_Execute(t *testing.T) { - tests := []struct { - name string - mockFn func(ctx context.Context) ([]domain.OidcProvider, error) - wantErr bool - wantErrCode string - wantCount int - }{ - { - name: "success - empty list", - mockFn: func(_ context.Context) ([]domain.OidcProvider, error) { - return []domain.OidcProvider{}, nil - }, - wantErr: false, - wantCount: 0, - }, - { - name: "success - multiple providers", - mockFn: func(_ context.Context) ([]domain.OidcProvider, error) { - return []domain.OidcProvider{ - {ID: "oidc-1", ProviderType: "github_actions"}, - {ID: "oidc-2", ProviderType: "gitlab_ci"}, - {ID: "oidc-3", ProviderType: "kubernetes"}, - }, nil - }, - wantErr: false, - wantCount: 3, - }, - { - name: "service error", - mockFn: func(_ context.Context) ([]domain.OidcProvider, error) { - return nil, errors.New("connection refused") - }, - wantErr: true, - wantErrCode: OidcErrorCodeServiceError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - uc := &listOidcProvidersUseCase{ - service: &mockOidcService{listProvidersFn: tt.mockFn}, - } - - result, err := uc.Execute(context.Background()) - - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - var oidcErr *OidcError - if !errors.As(err, &oidcErr) { - t.Fatalf("expected *OidcError, got %T", err) - } - if oidcErr.Code != tt.wantErrCode { - t.Errorf("expected error code %q, got %q", tt.wantErrCode, oidcErr.Code) - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result) != tt.wantCount { - t.Errorf("expected %d providers, got %d", tt.wantCount, len(result)) - } - }) - } -} - -// --- GetOidcProviderUseCase --- - -func TestGetOidcProviderUseCase_Execute(t *testing.T) { - tests := []struct { - name string - id string - mockFn func(ctx context.Context, id string) (*domain.OidcProvider, error) - wantErr bool - wantErrIs error - wantErrCode string - wantNilResult bool - }{ - { - name: "success", - id: "oidc-123", - mockFn: func(_ context.Context, id string) (*domain.OidcProvider, error) { - return &domain.OidcProvider{ - ID: id, - ProviderType: "github_actions", - IssuerURL: "https://token.actions.githubusercontent.com", - Audience: "envsync", - Enabled: true, - }, nil - }, - wantErr: false, - }, - { - name: "validation error - empty ID", - id: "", - mockFn: nil, - wantErr: true, - wantErrIs: ErrProviderIDRequired, - wantErrCode: OidcErrorCodeValidation, - wantNilResult: true, - }, - { - name: "validation error - whitespace ID", - id: " \t ", - mockFn: nil, - wantErr: true, - wantErrIs: ErrProviderIDRequired, - wantErrCode: OidcErrorCodeValidation, - wantNilResult: true, - }, - { - name: "service error", - id: "oidc-nonexistent", - mockFn: func(_ context.Context, _ string) (*domain.OidcProvider, error) { - return nil, errors.New("not found") - }, - wantErr: true, - wantErrCode: OidcErrorCodeServiceError, - wantNilResult: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - uc := &getOidcProviderUseCase{ - service: &mockOidcService{getProviderFn: tt.mockFn}, - } - - result, err := uc.Execute(context.Background(), tt.id) - - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - var oidcErr *OidcError - if !errors.As(err, &oidcErr) { - t.Fatalf("expected *OidcError, got %T", err) - } - if oidcErr.Code != tt.wantErrCode { - t.Errorf("expected error code %q, got %q", tt.wantErrCode, oidcErr.Code) - } - if tt.wantErrIs != nil && !errors.Is(err, tt.wantErrIs) { - t.Errorf("expected error to wrap %v, got %v", tt.wantErrIs, err) - } - if tt.wantNilResult && result != nil { - t.Errorf("expected nil result, got %+v", result) - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result == nil { - t.Fatal("expected non-nil result") - } - if result.ID != tt.id { - t.Errorf("expected ID %q, got %q", tt.id, result.ID) - } - }) - } -} - -// --- UpdateOidcProviderUseCase --- - -func TestUpdateOidcProviderUseCase_Execute(t *testing.T) { - enabled := true - - tests := []struct { - name string - input domain.UpdateOidcProviderInput - mockFn func(ctx context.Context, input domain.UpdateOidcProviderInput) error - wantErr bool - wantErrIs error - wantErrCode string - }{ - { - name: "success - update audience", - input: domain.UpdateOidcProviderInput{ - ID: "oidc-123", - Audience: "new-audience", - }, - mockFn: func(_ context.Context, _ domain.UpdateOidcProviderInput) error { - return nil - }, - wantErr: false, - }, - { - name: "success - update enabled", - input: domain.UpdateOidcProviderInput{ - ID: "oidc-123", - Enabled: &enabled, - }, - mockFn: func(_ context.Context, _ domain.UpdateOidcProviderInput) error { - return nil - }, - wantErr: false, - }, - { - name: "success - update allowed subjects", - input: domain.UpdateOidcProviderInput{ - ID: "oidc-123", - AllowedSubjects: []string{"subject-1", "subject-2"}, - }, - mockFn: func(_ context.Context, _ domain.UpdateOidcProviderInput) error { - return nil - }, - wantErr: false, - }, - { - name: "validation error - empty ID", - input: domain.UpdateOidcProviderInput{ - ID: "", - Audience: "new-audience", - }, - mockFn: nil, - wantErr: true, - wantErrIs: ErrProviderIDRequired, - wantErrCode: OidcErrorCodeValidation, - }, - { - name: "validation error - whitespace ID", - input: domain.UpdateOidcProviderInput{ - ID: " ", - Audience: "new-audience", - }, - mockFn: nil, - wantErr: true, - wantErrIs: ErrProviderIDRequired, - wantErrCode: OidcErrorCodeValidation, - }, - { - name: "service error", - input: domain.UpdateOidcProviderInput{ - ID: "oidc-123", - Audience: "new-audience", - }, - mockFn: func(_ context.Context, _ domain.UpdateOidcProviderInput) error { - return errors.New("timeout") - }, - wantErr: true, - wantErrCode: OidcErrorCodeServiceError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - uc := &updateOidcProviderUseCase{ - service: &mockOidcService{updateProviderFn: tt.mockFn}, - } - - err := uc.Execute(context.Background(), tt.input) - - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - var oidcErr *OidcError - if !errors.As(err, &oidcErr) { - t.Fatalf("expected *OidcError, got %T", err) - } - if oidcErr.Code != tt.wantErrCode { - t.Errorf("expected error code %q, got %q", tt.wantErrCode, oidcErr.Code) - } - if tt.wantErrIs != nil && !errors.Is(err, tt.wantErrIs) { - t.Errorf("expected error to wrap %v, got %v", tt.wantErrIs, err) - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - } -} - -// --- DeleteOidcProviderUseCase --- - -func TestDeleteOidcProviderUseCase_Execute(t *testing.T) { - tests := []struct { - name string - id string - mockFn func(ctx context.Context, id string) error - wantErr bool - wantErrIs error - wantErrCode string - }{ - { - name: "success", - id: "oidc-123", - mockFn: func(_ context.Context, _ string) error { - return nil - }, - wantErr: false, - }, - { - name: "validation error - empty ID", - id: "", - mockFn: nil, - wantErr: true, - wantErrIs: ErrProviderIDRequired, - wantErrCode: OidcErrorCodeValidation, - }, - { - name: "validation error - whitespace ID", - id: " \t ", - mockFn: nil, - wantErr: true, - wantErrIs: ErrProviderIDRequired, - wantErrCode: OidcErrorCodeValidation, - }, - { - name: "service error", - id: "oidc-123", - mockFn: func(_ context.Context, _ string) error { - return errors.New("permission denied") - }, - wantErr: true, - wantErrCode: OidcErrorCodeServiceError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - uc := &deleteOidcProviderUseCase{ - service: &mockOidcService{deleteProviderFn: tt.mockFn}, - } - - err := uc.Execute(context.Background(), tt.id) - - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - var oidcErr *OidcError - if !errors.As(err, &oidcErr) { - t.Fatalf("expected *OidcError, got %T", err) - } - if oidcErr.Code != tt.wantErrCode { - t.Errorf("expected error code %q, got %q", tt.wantErrCode, oidcErr.Code) - } - if tt.wantErrIs != nil && !errors.Is(err, tt.wantErrIs) { - t.Errorf("expected error to wrap %v, got %v", tt.wantErrIs, err) - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - } -} - -// --- OidcError type tests --- - -func TestOidcError_Error(t *testing.T) { - tests := []struct { - name string - err OidcError - expected string - }{ - { - name: "without cause", - err: OidcError{Code: "TEST", Message: "something failed"}, - expected: "something failed", - }, - { - name: "with cause", - err: OidcError{Code: "TEST", Message: "wrap", Cause: errors.New("root")}, - expected: "wrap: root", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.err.Error(); got != tt.expected { - t.Errorf("expected %q, got %q", tt.expected, got) - } - }) - } -} - -func TestOidcError_Unwrap(t *testing.T) { - cause := errors.New("root cause") - err := OidcError{Code: "TEST", Message: "wrap", Cause: cause} - - if err.Unwrap() != cause { - t.Errorf("expected Unwrap() to return root cause") - } - - errNoCause := OidcError{Code: "TEST", Message: "no cause"} - if errNoCause.Unwrap() != nil { - t.Errorf("expected Unwrap() to return nil when no cause") - } -} - -func TestOidcError_ErrorsIs(t *testing.T) { - err := NewValidationError("validation failed", ErrProviderIDRequired) - - if !errors.Is(err, ErrProviderIDRequired) { - t.Error("expected errors.Is to match ErrProviderIDRequired") - } - - wrapped := fmt.Errorf("outer: %w", err) - if !errors.Is(wrapped, ErrProviderIDRequired) { - t.Error("expected nested errors.Is to match ErrProviderIDRequired") - } -} - -func TestNewValidationError(t *testing.T) { - cause := errors.New("cause") - err := NewValidationError("msg", cause) - - if err.Code != OidcErrorCodeValidation { - t.Errorf("expected code %q, got %q", OidcErrorCodeValidation, err.Code) - } - if err.Message != "msg" { - t.Errorf("expected message %q, got %q", "msg", err.Message) - } - if err.Cause != cause { - t.Error("expected cause to match") - } -} - -func TestNewNotFoundError(t *testing.T) { - err := NewNotFoundError("gone", nil) - - if err.Code != OidcErrorCodeNotFound { - t.Errorf("expected code %q, got %q", OidcErrorCodeNotFound, err.Code) - } - if err.Cause != nil { - t.Error("expected nil cause") - } -} - -func TestNewServiceError(t *testing.T) { - cause := errors.New("db down") - err := NewServiceError("svc fail", cause) - - if err.Code != OidcErrorCodeServiceError { - t.Errorf("expected code %q, got %q", OidcErrorCodeServiceError, err.Code) - } - if !errors.Is(err, cause) { - t.Error("expected errors.Is to match cause") - } -} - -// Test validateInput directly for edge cases not reachable through Execute -// (since the mock is nil when validation fails). -func TestCreateOidcProviderUseCase_validateInput(t *testing.T) { - uc := &createOidcProviderUseCase{service: &mockOidcService{}} - - tests := []struct { - name string - input domain.CreateOidcProviderInput - wantErr bool - }{ - { - name: "all valid - github_actions", - input: domain.CreateOidcProviderInput{ - ProviderType: "github_actions", - IssuerURL: "https://token.actions.githubusercontent.com", - Audience: "envsync", - }, - wantErr: false, - }, - { - name: "all valid - gitlab_ci", - input: domain.CreateOidcProviderInput{ - ProviderType: "gitlab_ci", - IssuerURL: "https://gitlab.com", - Audience: "envsync", - }, - wantErr: false, - }, - { - name: "all valid - kubernetes", - input: domain.CreateOidcProviderInput{ - ProviderType: "kubernetes", - IssuerURL: "https://kubernetes.default.svc", - Audience: "envsync", - }, - wantErr: false, - }, - { - name: "all valid - generic", - input: domain.CreateOidcProviderInput{ - ProviderType: "generic", - IssuerURL: "https://accounts.google.com", - Audience: "envsync", - }, - wantErr: false, - }, - { - name: "invalid type - empty", - input: domain.CreateOidcProviderInput{ - ProviderType: "", - IssuerURL: "https://example.com", - Audience: "envsync", - }, - wantErr: true, - }, - { - name: "invalid type - unknown", - input: domain.CreateOidcProviderInput{ - ProviderType: "azure_devops", - IssuerURL: "https://example.com", - Audience: "envsync", - }, - wantErr: true, - }, - { - name: "missing issuer URL", - input: domain.CreateOidcProviderInput{ - ProviderType: "github_actions", - IssuerURL: "", - Audience: "envsync", - }, - wantErr: true, - }, - { - name: "missing audience", - input: domain.CreateOidcProviderInput{ - ProviderType: "github_actions", - IssuerURL: "https://example.com", - Audience: "", - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := uc.validateInput(tt.input) - if tt.wantErr && err == nil { - t.Error("expected error, got nil") - } - if !tt.wantErr && err != nil { - t.Errorf("unexpected error: %v", err) - } - }) - } -} diff --git a/packages/envsync-cli/internal/features/usecases/rotation/create.go b/packages/envsync-cli/internal/features/usecases/rotation/create.go deleted file mode 100644 index 2a57a8b7..00000000 --- a/packages/envsync-cli/internal/features/usecases/rotation/create.go +++ /dev/null @@ -1,55 +0,0 @@ -package rotation - -import ( - "context" - - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" -) - -type createPolicyUseCase struct { - rotationService services.RotationService -} - -func NewCreatePolicyUseCase() CreatePolicyUseCase { - return &createPolicyUseCase{ - rotationService: services.NewRotationService(), - } -} - -func (uc *createPolicyUseCase) Execute(ctx context.Context, req *sdk.CreateRotationPolicyRequest) (*sdk.RotationPolicyResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "rotation.create") - defer span.End() - - if err := uc.validateCreateRequest(req); err != nil { - return nil, err - } - - policy, err := uc.rotationService.CreatePolicy(ctx, req) - if err != nil { - return nil, NewServiceError("failed to create rotation policy", err) - } - - return policy, nil -} - -func (uc *createPolicyUseCase) validateCreateRequest(req *sdk.CreateRotationPolicyRequest) error { - if req.AppId == "" { - return NewValidationError("app_id is required", ErrSecretIDRequired) - } - if req.EnvTypeId == "" { - return NewValidationError("env_type_id is required", ErrSecretIDRequired) - } - if req.VariableKey == "" { - return NewValidationError("variable_key is required", ErrSecretIDRequired) - } - if req.EngineType == "" { - return NewValidationError("engine_type is required", ErrEngineRequired) - } - if req.ScheduleCron == "" { - return NewValidationError("schedule_cron is required", ErrScheduleRequired) - } - return nil -} diff --git a/packages/envsync-cli/internal/features/usecases/rotation/delete.go b/packages/envsync-cli/internal/features/usecases/rotation/delete.go deleted file mode 100644 index 77e8a04a..00000000 --- a/packages/envsync-cli/internal/features/usecases/rotation/delete.go +++ /dev/null @@ -1,34 +0,0 @@ -package rotation - -import ( - "context" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" -) - -type deletePolicyUseCase struct { - rotationService services.RotationService -} - -func NewDeletePolicyUseCase() DeletePolicyUseCase { - return &deletePolicyUseCase{ - rotationService: services.NewRotationService(), - } -} - -func (uc *deletePolicyUseCase) Execute(ctx context.Context, id string) error { - ctx, span := telemetry.Tracer().Start(ctx, "rotation.delete") - defer span.End() - - if id == "" { - return NewValidationError("rotation policy ID is required", ErrIDRequired) - } - - _, err := uc.rotationService.DeletePolicy(ctx, id) - if err != nil { - return NewServiceError("failed to delete rotation policy", err) - } - - return nil -} diff --git a/packages/envsync-cli/internal/features/usecases/rotation/errors.go b/packages/envsync-cli/internal/features/usecases/rotation/errors.go index 1b8838c4..158a6126 100644 --- a/packages/envsync-cli/internal/features/usecases/rotation/errors.go +++ b/packages/envsync-cli/internal/features/usecases/rotation/errors.go @@ -3,21 +3,7 @@ package rotation import "errors" var ( - ErrIDRequired = errors.New("rotation policy ID is required") - ErrNameRequired = errors.New("rotation policy name is required") - ErrEngineRequired = errors.New("engine type is required") - ErrSecretIDRequired = errors.New("secret ID (app_id + env_type_id + variable_key) is required") - ErrScheduleRequired = errors.New("schedule cron expression is required") - ErrInvalidEngineType = errors.New("invalid engine type") - ErrPolicyNotFound = errors.New("rotation policy not found") - ErrFailedToList = errors.New("failed to list rotation policies") - ErrFailedToCreate = errors.New("failed to create rotation policy") - ErrFailedToGet = errors.New("failed to get rotation policy") - ErrFailedToUpdate = errors.New("failed to update rotation policy") - ErrFailedToDelete = errors.New("failed to delete rotation policy") - ErrFailedToTrigger = errors.New("failed to trigger rotation") - ErrFailedToGetStates = errors.New("failed to get rotation states") - ErrFailedToRevoke = errors.New("failed to revoke expired credentials") + ErrIDRequired = errors.New("rotation policy ID is required") ) type RotationError struct { diff --git a/packages/envsync-cli/internal/features/usecases/rotation/get.go b/packages/envsync-cli/internal/features/usecases/rotation/get.go index 6a357098..7890ef4c 100644 --- a/packages/envsync-cli/internal/features/usecases/rotation/get.go +++ b/packages/envsync-cli/internal/features/usecases/rotation/get.go @@ -7,6 +7,8 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type getPolicyUseCase struct { @@ -19,9 +21,10 @@ func NewGetPolicyUseCase() GetPolicyUseCase { } } -func (uc *getPolicyUseCase) Execute(ctx context.Context, id string) (*sdk.RotationPolicyResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "rotation.get") - defer span.End() +func (uc *getPolicyUseCase) Execute(ctx context.Context, id string) (result *sdk.RotationPolicyResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "rotation.get", + trace.WithAttributes(attribute.String("rotation.id", id))) + defer end(&err) if id == "" { return nil, NewValidationError("rotation policy ID is required", ErrIDRequired) diff --git a/packages/envsync-cli/internal/features/usecases/rotation/interfaces.go b/packages/envsync-cli/internal/features/usecases/rotation/interfaces.go index fbac5c66..09626cbf 100644 --- a/packages/envsync-cli/internal/features/usecases/rotation/interfaces.go +++ b/packages/envsync-cli/internal/features/usecases/rotation/interfaces.go @@ -10,22 +10,10 @@ type ListPoliciesUseCase interface { Execute(ctx context.Context, req *sdk.GetRotationPoliciesRequest) (sdk.RotationPoliciesResponse, error) } -type CreatePolicyUseCase interface { - Execute(ctx context.Context, req *sdk.CreateRotationPolicyRequest) (*sdk.RotationPolicyResponse, error) -} - type GetPolicyUseCase interface { Execute(ctx context.Context, id string) (*sdk.RotationPolicyResponse, error) } -type UpdatePolicyUseCase interface { - Execute(ctx context.Context, id string, req *sdk.UpdateRotationPolicyRequest) (*sdk.RotationPolicyResponse, error) -} - -type DeletePolicyUseCase interface { - Execute(ctx context.Context, id string) error -} - type TriggerRotationUseCase interface { Execute(ctx context.Context, id string) (*sdk.TriggerRotationResponse, error) } diff --git a/packages/envsync-cli/internal/features/usecases/rotation/list.go b/packages/envsync-cli/internal/features/usecases/rotation/list.go index 900811e3..1eb3bf3c 100644 --- a/packages/envsync-cli/internal/features/usecases/rotation/list.go +++ b/packages/envsync-cli/internal/features/usecases/rotation/list.go @@ -19,9 +19,9 @@ func NewListPoliciesUseCase() ListPoliciesUseCase { } } -func (uc *listPoliciesUseCase) Execute(ctx context.Context, req *sdk.GetRotationPoliciesRequest) (sdk.RotationPoliciesResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "rotation.list") - defer span.End() +func (uc *listPoliciesUseCase) Execute(ctx context.Context, req *sdk.GetRotationPoliciesRequest) (result sdk.RotationPoliciesResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "rotation.list") + defer end(&err) policies, err := uc.rotationService.ListPolicies(ctx, req) if err != nil { diff --git a/packages/envsync-cli/internal/features/usecases/rotation/revoke_expired.go b/packages/envsync-cli/internal/features/usecases/rotation/revoke_expired.go index 995f7648..3bc53bee 100644 --- a/packages/envsync-cli/internal/features/usecases/rotation/revoke_expired.go +++ b/packages/envsync-cli/internal/features/usecases/rotation/revoke_expired.go @@ -19,9 +19,9 @@ func NewRevokeExpiredCredentialsUseCase() RevokeExpiredCredentialsUseCase { } } -func (uc *revokeExpiredCredentialsUseCase) Execute(ctx context.Context) (*sdk.RevokeOldCredentialResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "rotation.revoke-expired") - defer span.End() +func (uc *revokeExpiredCredentialsUseCase) Execute(ctx context.Context) (resp *sdk.RevokeOldCredentialResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "rotation.revoke-expired") + defer end(&err) result, err := uc.rotationService.RevokeExpiredCredentials(ctx) if err != nil { diff --git a/packages/envsync-cli/internal/features/usecases/rotation/states.go b/packages/envsync-cli/internal/features/usecases/rotation/states.go index 34032bf1..7432ba54 100644 --- a/packages/envsync-cli/internal/features/usecases/rotation/states.go +++ b/packages/envsync-cli/internal/features/usecases/rotation/states.go @@ -7,6 +7,8 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type getRotationStatesUseCase struct { @@ -19,9 +21,10 @@ func NewGetRotationStatesUseCase() GetRotationStatesUseCase { } } -func (uc *getRotationStatesUseCase) Execute(ctx context.Context, id string) (sdk.RotationStatesResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "rotation.states") - defer span.End() +func (uc *getRotationStatesUseCase) Execute(ctx context.Context, id string) (result sdk.RotationStatesResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "rotation.states", + trace.WithAttributes(attribute.String("rotation.id", id))) + defer end(&err) if id == "" { return nil, NewValidationError("rotation policy ID is required", ErrIDRequired) diff --git a/packages/envsync-cli/internal/features/usecases/rotation/trigger.go b/packages/envsync-cli/internal/features/usecases/rotation/trigger.go index 46209052..f8c42283 100644 --- a/packages/envsync-cli/internal/features/usecases/rotation/trigger.go +++ b/packages/envsync-cli/internal/features/usecases/rotation/trigger.go @@ -7,6 +7,8 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type triggerRotationUseCase struct { @@ -19,9 +21,10 @@ func NewTriggerRotationUseCase() TriggerRotationUseCase { } } -func (uc *triggerRotationUseCase) Execute(ctx context.Context, id string) (*sdk.TriggerRotationResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "rotation.trigger") - defer span.End() +func (uc *triggerRotationUseCase) Execute(ctx context.Context, id string) (resp *sdk.TriggerRotationResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "rotation.trigger", + trace.WithAttributes(attribute.String("rotation.id", id))) + defer end(&err) if id == "" { return nil, NewValidationError("rotation policy ID is required", ErrIDRequired) diff --git a/packages/envsync-cli/internal/features/usecases/rotation/update.go b/packages/envsync-cli/internal/features/usecases/rotation/update.go deleted file mode 100644 index 731795e3..00000000 --- a/packages/envsync-cli/internal/features/usecases/rotation/update.go +++ /dev/null @@ -1,36 +0,0 @@ -package rotation - -import ( - "context" - - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" -) - -type updatePolicyUseCase struct { - rotationService services.RotationService -} - -func NewUpdatePolicyUseCase() UpdatePolicyUseCase { - return &updatePolicyUseCase{ - rotationService: services.NewRotationService(), - } -} - -func (uc *updatePolicyUseCase) Execute(ctx context.Context, id string, req *sdk.UpdateRotationPolicyRequest) (*sdk.RotationPolicyResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "rotation.update") - defer span.End() - - if id == "" { - return nil, NewValidationError("rotation policy ID is required", ErrIDRequired) - } - - policy, err := uc.rotationService.UpdatePolicy(ctx, id, req) - if err != nil { - return nil, NewServiceError("failed to update rotation policy", err) - } - - return policy, nil -} diff --git a/packages/envsync-cli/internal/features/usecases/rotation/usecases_test.go b/packages/envsync-cli/internal/features/usecases/rotation/usecases_test.go index bb12990e..b408db1f 100644 --- a/packages/envsync-cli/internal/features/usecases/rotation/usecases_test.go +++ b/packages/envsync-cli/internal/features/usecases/rotation/usecases_test.go @@ -10,36 +10,21 @@ import ( // mockRotationService implements services.RotationService for testing. type mockRotationService struct { - listPoliciesFn func(ctx context.Context, req *sdk.GetRotationPoliciesRequest) (sdk.RotationPoliciesResponse, error) - createPolicyFn func(ctx context.Context, req *sdk.CreateRotationPolicyRequest) (*sdk.RotationPolicyResponse, error) - getPolicyFn func(ctx context.Context, id string) (*sdk.RotationPolicyResponse, error) - deletePolicyFn func(ctx context.Context, id string) (*sdk.ErrorResponse, error) - updatePolicyFn func(ctx context.Context, id string, req *sdk.UpdateRotationPolicyRequest) (*sdk.RotationPolicyResponse, error) - triggerRotationFn func(ctx context.Context, id string) (*sdk.TriggerRotationResponse, error) - getRotationStatesFn func(ctx context.Context, id string) (sdk.RotationStatesResponse, error) - revokeExpiredCredsFn func(ctx context.Context) (*sdk.RevokeOldCredentialResponse, error) + listPoliciesFn func(ctx context.Context, req *sdk.GetRotationPoliciesRequest) (sdk.RotationPoliciesResponse, error) + getPolicyFn func(ctx context.Context, id string) (*sdk.RotationPolicyResponse, error) + triggerRotationFn func(ctx context.Context, id string) (*sdk.TriggerRotationResponse, error) + getRotationStatesFn func(ctx context.Context, id string) (sdk.RotationStatesResponse, error) + revokeExpiredCredsFn func(ctx context.Context) (*sdk.RevokeOldCredentialResponse, error) } func (m *mockRotationService) ListPolicies(ctx context.Context, req *sdk.GetRotationPoliciesRequest) (sdk.RotationPoliciesResponse, error) { return m.listPoliciesFn(ctx, req) } -func (m *mockRotationService) CreatePolicy(ctx context.Context, req *sdk.CreateRotationPolicyRequest) (*sdk.RotationPolicyResponse, error) { - return m.createPolicyFn(ctx, req) -} - func (m *mockRotationService) GetPolicy(ctx context.Context, id string) (*sdk.RotationPolicyResponse, error) { return m.getPolicyFn(ctx, id) } -func (m *mockRotationService) DeletePolicy(ctx context.Context, id string) (*sdk.ErrorResponse, error) { - return m.deletePolicyFn(ctx, id) -} - -func (m *mockRotationService) UpdatePolicy(ctx context.Context, id string, req *sdk.UpdateRotationPolicyRequest) (*sdk.RotationPolicyResponse, error) { - return m.updatePolicyFn(ctx, id, req) -} - func (m *mockRotationService) TriggerRotation(ctx context.Context, id string) (*sdk.TriggerRotationResponse, error) { return m.triggerRotationFn(ctx, id) } @@ -54,164 +39,6 @@ func (m *mockRotationService) RevokeExpiredCredentials(ctx context.Context) (*sd var errService = errors.New("service error") -// --- CreatePolicyUseCase --- - -func TestCreatePolicyUseCase_Execute(t *testing.T) { - validReq := &sdk.CreateRotationPolicyRequest{ - AppId: "app-123", - EnvTypeId: "env-456", - VariableKey: "DB_PASSWORD", - EngineType: sdk.CreateRotationPolicyRequestEngineTypePostgres, - ScheduleCron: "0 */6 * * *", - } - - expectedResp := &sdk.RotationPolicyResponse{ - Id: "policy-789", - AppId: "app-123", - EnvTypeId: "env-456", - VariableKey: "DB_PASSWORD", - EngineType: sdk.RotationPolicyResponseEngineTypePostgres, - ScheduleCron: "0 */6 * * *", - Enabled: true, - } - - tests := []struct { - name string - req *sdk.CreateRotationPolicyRequest - mockFn func(ctx context.Context, req *sdk.CreateRotationPolicyRequest) (*sdk.RotationPolicyResponse, error) - wantErr bool - wantErrCode string - checkResp func(t *testing.T, resp *sdk.RotationPolicyResponse) - }{ - { - name: "success", - req: validReq, - mockFn: func(_ context.Context, _ *sdk.CreateRotationPolicyRequest) (*sdk.RotationPolicyResponse, error) { - return expectedResp, nil - }, - wantErr: false, - checkResp: func(t *testing.T, resp *sdk.RotationPolicyResponse) { - if resp.Id != "policy-789" { - t.Errorf("expected policy ID 'policy-789', got '%s'", resp.Id) - } - if resp.AppId != "app-123" { - t.Errorf("expected app ID 'app-123', got '%s'", resp.AppId) - } - }, - }, - { - name: "empty app_id returns validation error", - req: &sdk.CreateRotationPolicyRequest{ - AppId: "", - EnvTypeId: "env-456", - VariableKey: "DB_PASSWORD", - EngineType: sdk.CreateRotationPolicyRequestEngineTypePostgres, - ScheduleCron: "0 */6 * * *", - }, - mockFn: nil, - wantErr: true, - wantErrCode: RotationErrorCodeValidation, - }, - { - name: "empty env_type_id returns validation error", - req: &sdk.CreateRotationPolicyRequest{ - AppId: "app-123", - EnvTypeId: "", - VariableKey: "DB_PASSWORD", - EngineType: sdk.CreateRotationPolicyRequestEngineTypePostgres, - ScheduleCron: "0 */6 * * *", - }, - mockFn: nil, - wantErr: true, - wantErrCode: RotationErrorCodeValidation, - }, - { - name: "empty variable_key returns validation error", - req: &sdk.CreateRotationPolicyRequest{ - AppId: "app-123", - EnvTypeId: "env-456", - VariableKey: "", - EngineType: sdk.CreateRotationPolicyRequestEngineTypePostgres, - ScheduleCron: "0 */6 * * *", - }, - mockFn: nil, - wantErr: true, - wantErrCode: RotationErrorCodeValidation, - }, - { - name: "empty engine_type returns validation error", - req: &sdk.CreateRotationPolicyRequest{ - AppId: "app-123", - EnvTypeId: "env-456", - VariableKey: "DB_PASSWORD", - EngineType: "", - ScheduleCron: "0 */6 * * *", - }, - mockFn: nil, - wantErr: true, - wantErrCode: RotationErrorCodeValidation, - }, - { - name: "empty schedule_cron returns validation error", - req: &sdk.CreateRotationPolicyRequest{ - AppId: "app-123", - EnvTypeId: "env-456", - VariableKey: "DB_PASSWORD", - EngineType: sdk.CreateRotationPolicyRequestEngineTypePostgres, - ScheduleCron: "", - }, - mockFn: nil, - wantErr: true, - wantErrCode: RotationErrorCodeValidation, - }, - { - name: "service error returns service error", - req: validReq, - mockFn: func(_ context.Context, _ *sdk.CreateRotationPolicyRequest) (*sdk.RotationPolicyResponse, error) { - return nil, errService - }, - wantErr: true, - wantErrCode: RotationErrorCodeServiceError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - uc := &createPolicyUseCase{ - rotationService: &mockRotationService{ - createPolicyFn: tt.mockFn, - }, - } - - resp, err := uc.Execute(context.Background(), tt.req) - - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - var rotErr *RotationError - if !errors.As(err, &rotErr) { - t.Fatalf("expected *RotationError, got %T", err) - } - if rotErr.Code != tt.wantErrCode { - t.Errorf("expected error code '%s', got '%s'", tt.wantErrCode, rotErr.Code) - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp == nil { - t.Fatal("expected non-nil response") - } - if tt.checkResp != nil { - tt.checkResp(t, resp) - } - }) - } -} - // --- ListPoliciesUseCase --- func TestListPoliciesUseCase_Execute(t *testing.T) { @@ -366,163 +193,6 @@ func TestGetPolicyUseCase_Execute(t *testing.T) { } } -// --- UpdatePolicyUseCase --- - -func TestUpdatePolicyUseCase_Execute(t *testing.T) { - newCron := "0 */12 * * *" - updateReq := &sdk.UpdateRotationPolicyRequest{ - ScheduleCron: &newCron, - } - - expectedPolicy := &sdk.RotationPolicyResponse{ - Id: "policy-123", - ScheduleCron: "0 */12 * * *", - } - - tests := []struct { - name string - id string - req *sdk.UpdateRotationPolicyRequest - mockFn func(ctx context.Context, id string, req *sdk.UpdateRotationPolicyRequest) (*sdk.RotationPolicyResponse, error) - wantErr bool - wantErrCode string - }{ - { - name: "success", - id: "policy-123", - req: updateReq, - mockFn: func(_ context.Context, id string, _ *sdk.UpdateRotationPolicyRequest) (*sdk.RotationPolicyResponse, error) { - if id != "policy-123" { - t.Errorf("expected ID 'policy-123', got '%s'", id) - } - return expectedPolicy, nil - }, - wantErr: false, - }, - { - name: "empty ID returns validation error", - id: "", - req: updateReq, - mockFn: nil, - wantErr: true, - wantErrCode: RotationErrorCodeValidation, - }, - { - name: "service error returns service error", - id: "policy-123", - req: updateReq, - mockFn: func(_ context.Context, _ string, _ *sdk.UpdateRotationPolicyRequest) (*sdk.RotationPolicyResponse, error) { - return nil, errService - }, - wantErr: true, - wantErrCode: RotationErrorCodeServiceError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - uc := &updatePolicyUseCase{ - rotationService: &mockRotationService{ - updatePolicyFn: tt.mockFn, - }, - } - - resp, err := uc.Execute(context.Background(), tt.id, tt.req) - - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - var rotErr *RotationError - if !errors.As(err, &rotErr) { - t.Fatalf("expected *RotationError, got %T", err) - } - if rotErr.Code != tt.wantErrCode { - t.Errorf("expected error code '%s', got '%s'", tt.wantErrCode, rotErr.Code) - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp == nil { - t.Fatal("expected non-nil response") - } - }) - } -} - -// --- DeletePolicyUseCase --- - -func TestDeletePolicyUseCase_Execute(t *testing.T) { - tests := []struct { - name string - id string - mockFn func(ctx context.Context, id string) (*sdk.ErrorResponse, error) - wantErr bool - wantErrCode string - }{ - { - name: "success", - id: "policy-123", - mockFn: func(_ context.Context, id string) (*sdk.ErrorResponse, error) { - if id != "policy-123" { - t.Errorf("expected ID 'policy-123', got '%s'", id) - } - return nil, nil - }, - wantErr: false, - }, - { - name: "empty ID returns validation error", - id: "", - mockFn: nil, - wantErr: true, - wantErrCode: RotationErrorCodeValidation, - }, - { - name: "service error returns service error", - id: "policy-123", - mockFn: func(_ context.Context, _ string) (*sdk.ErrorResponse, error) { - return nil, errService - }, - wantErr: true, - wantErrCode: RotationErrorCodeServiceError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - uc := &deletePolicyUseCase{ - rotationService: &mockRotationService{ - deletePolicyFn: tt.mockFn, - }, - } - - err := uc.Execute(context.Background(), tt.id) - - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - var rotErr *RotationError - if !errors.As(err, &rotErr) { - t.Fatalf("expected *RotationError, got %T", err) - } - if rotErr.Code != tt.wantErrCode { - t.Errorf("expected error code '%s', got '%s'", tt.wantErrCode, rotErr.Code) - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - } -} - // --- TriggerRotationUseCase --- func TestTriggerRotationUseCase_Execute(t *testing.T) { @@ -847,39 +517,3 @@ func TestNewServiceError(t *testing.T) { t.Error("expected error to wrap the cause") } } - -// --- Sentinel error tests --- - -func TestSentinelErrors(t *testing.T) { - sentinels := []struct { - name string - err error - }{ - {"ErrIDRequired", ErrIDRequired}, - {"ErrNameRequired", ErrNameRequired}, - {"ErrEngineRequired", ErrEngineRequired}, - {"ErrSecretIDRequired", ErrSecretIDRequired}, - {"ErrScheduleRequired", ErrScheduleRequired}, - {"ErrInvalidEngineType", ErrInvalidEngineType}, - {"ErrPolicyNotFound", ErrPolicyNotFound}, - {"ErrFailedToList", ErrFailedToList}, - {"ErrFailedToCreate", ErrFailedToCreate}, - {"ErrFailedToGet", ErrFailedToGet}, - {"ErrFailedToUpdate", ErrFailedToUpdate}, - {"ErrFailedToDelete", ErrFailedToDelete}, - {"ErrFailedToTrigger", ErrFailedToTrigger}, - {"ErrFailedToGetStates", ErrFailedToGetStates}, - {"ErrFailedToRevoke", ErrFailedToRevoke}, - } - - for _, tt := range sentinels { - t.Run(tt.name, func(t *testing.T) { - if tt.err == nil { - t.Errorf("sentinel error %s is nil", tt.name) - } - if tt.err.Error() == "" { - t.Errorf("sentinel error %s has empty message", tt.name) - } - }) - } -} diff --git a/packages/envsync-cli/internal/features/usecases/run/fetch_app.go b/packages/envsync-cli/internal/features/usecases/run/fetch_app.go index e9dfc52e..3ca8a577 100644 --- a/packages/envsync-cli/internal/features/usecases/run/fetch_app.go +++ b/packages/envsync-cli/internal/features/usecases/run/fetch_app.go @@ -5,6 +5,9 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type fetchAppUseCase struct { @@ -18,7 +21,11 @@ func NewFetchAppUseCase() FetchAppUseCase { } } -func (f *fetchAppUseCase) Execute(ctx context.Context, appID string) (*domain.Application, error) { +func (f *fetchAppUseCase) Execute(ctx context.Context, appID string) (result *domain.Application, err error) { + ctx, end := telemetry.StartSpan(ctx, "run.fetch_app", + trace.WithAttributes(attribute.String("app.id", appID))) + defer end(&err) + app, err := f.appService.GetAppByID(ctx, appID) if err != nil { return nil, err diff --git a/packages/envsync-cli/internal/features/usecases/run/inject_env.go b/packages/envsync-cli/internal/features/usecases/run/inject_env.go index 01682e13..81ca1c1e 100644 --- a/packages/envsync-cli/internal/features/usecases/run/inject_env.go +++ b/packages/envsync-cli/internal/features/usecases/run/inject_env.go @@ -5,6 +5,7 @@ import ( "os" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type injectEnv struct { @@ -18,7 +19,10 @@ func NewInjectEnv() InjectEnvUseCase { } } -func (uc *injectEnv) Execute(ctx context.Context) (map[string]string, error) { +func (uc *injectEnv) Execute(ctx context.Context) (result map[string]string, err error) { + ctx, end := telemetry.StartSpan(ctx, "run.inject_env") + defer end(&err) + env, err := uc.readRemoteEnv(ctx) if err != nil { return nil, err diff --git a/packages/envsync-cli/internal/features/usecases/run/inject_secret.go b/packages/envsync-cli/internal/features/usecases/run/inject_secret.go index 44dcc163..702cfa18 100644 --- a/packages/envsync-cli/internal/features/usecases/run/inject_secret.go +++ b/packages/envsync-cli/internal/features/usecases/run/inject_secret.go @@ -6,6 +6,7 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/utils" ) @@ -20,7 +21,10 @@ func NewInjectSecretUseCase() InjectSecretsUseCase { } } -func (i *injectSecretUseCase) Execute(ctx context.Context) (map[string]string, error) { +func (i *injectSecretUseCase) Execute(ctx context.Context) (result map[string]string, err error) { + ctx, end := telemetry.StartSpan(ctx, "run.inject_secret") + defer end(&err) + managedSecret := ctx.Value("managedSecret").(bool) privateKeyPath := ctx.Value("privateKeyPath").(string) appID := ctx.Value("appID").(string) diff --git a/packages/envsync-cli/internal/features/usecases/run/read_config.go b/packages/envsync-cli/internal/features/usecases/run/read_config.go index c86d6439..14fe3b61 100644 --- a/packages/envsync-cli/internal/features/usecases/run/read_config.go +++ b/packages/envsync-cli/internal/features/usecases/run/read_config.go @@ -5,6 +5,7 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type readConfigUseCase struct { @@ -18,7 +19,10 @@ func NewReadConfigUseCase() ReadConfigUseCase { } } -func (r *readConfigUseCase) Execute(ctx context.Context) (*domain.SyncConfig, error) { +func (r *readConfigUseCase) Execute(ctx context.Context) (result *domain.SyncConfig, err error) { + ctx, end := telemetry.StartSpan(ctx, "run.read_config") + defer end(&err) + config, err := r.syncService.ReadConfigData() if err != nil { return nil, err diff --git a/packages/envsync-cli/internal/features/usecases/run/redactor.go b/packages/envsync-cli/internal/features/usecases/run/redactor.go index e5231ba4..d2cf4c0b 100644 --- a/packages/envsync-cli/internal/features/usecases/run/redactor.go +++ b/packages/envsync-cli/internal/features/usecases/run/redactor.go @@ -12,6 +12,7 @@ import ( "syscall" "time" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" "github.com/aymanbagabas/go-pty" ) @@ -22,6 +23,9 @@ func NewRedactor() RedactUseCase { } func (uc *redactUseCase) Execute(ctx context.Context, args []string, envData map[string]string) int { + ctx, span := telemetry.Tracer().Start(ctx, "run.redactor") + defer span.End() + if len(args) == 0 { fmt.Fprintf(os.Stderr, "No command provided\n") return 1 @@ -108,12 +112,10 @@ func (uc *redactUseCase) Execute(ctx context.Context, args []string, envData map cancel() return exitCode case <-cancelCtx.Done(): - // Wait for command to finish with timeout select { case exitCode := <-cmdDone: return exitCode case <-time.After(2 * time.Second): - // Force kill if still running if cmd.Process != nil { cmd.Process.Kill() } @@ -168,7 +170,6 @@ func (uc *redactUseCase) handleOutput(ctx context.Context, ptyMaster pty.Pty, do done <- 0 }() - // Use a goroutine to handle PTY reading without blocking outputChan := make(chan []byte, 1) errorChan := make(chan error, 1) @@ -176,7 +177,6 @@ func (uc *redactUseCase) handleOutput(ctx context.Context, ptyMaster pty.Pty, do for { n, err := ptyMaster.Read(buffer) if n > 0 { - // Make a copy of the buffer to send through channel data := make([]byte, n) copy(data, buffer[:n]) select { @@ -200,11 +200,9 @@ func (uc *redactUseCase) handleOutput(ctx context.Context, ptyMaster pty.Pty, do case <-ctx.Done(): return case data := <-outputChan: - // Process and redact the output text := string(data) redactedText := uc.processAndRedactText(text, envData) - // Write the redacted content to stdout _, writeErr := os.Stdout.Write([]byte(redactedText)) if writeErr != nil { fmt.Fprintf(os.Stderr, "Error writing output: %v\n", writeErr) @@ -212,7 +210,6 @@ func (uc *redactUseCase) handleOutput(ctx context.Context, ptyMaster pty.Pty, do } case err := <-errorChan: if err == io.EOF { - // Command finished return } fmt.Fprintf(os.Stderr, "Error reading PTY: %v\n", err) @@ -237,16 +234,13 @@ func (uc *redactUseCase) redactWithRegex(text string, envData map[string]string) output := text - // Create redaction patterns for each env var (similar to JavaScript implementation) for _, value := range envData { if value == "" { continue } - // Escape special regex characters in the value escapedValue := regexp.QuoteMeta(value) - // Create regex pattern pattern, err := regexp.Compile(escapedValue) if err != nil { // If regex compilation fails, fall back to simple string replacement @@ -254,7 +248,6 @@ func (uc *redactUseCase) redactWithRegex(text string, envData map[string]string) continue } - // Replace all occurrences with the redacted message replacement := "[REDACTED]" output = pattern.ReplaceAllString(output, replacement) } diff --git a/packages/envsync-cli/internal/features/usecases/saml/create.go b/packages/envsync-cli/internal/features/usecases/saml/create.go deleted file mode 100644 index e6187ebd..00000000 --- a/packages/envsync-cli/internal/features/usecases/saml/create.go +++ /dev/null @@ -1,70 +0,0 @@ -package saml - -import ( - "context" - "strings" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" -) - -type createSamlProviderUseCase struct { - service services.SamlService -} - -func NewCreateSamlProviderUseCase() CreateSamlProviderUseCase { - return &createSamlProviderUseCase{ - service: services.NewSamlService(), - } -} - -func (uc *createSamlProviderUseCase) Execute(ctx context.Context, input domain.CreateSamlProviderInput) (*domain.SamlProvider, error) { - ctx, span := telemetry.Tracer().Start(ctx, "saml.create") - defer span.End() - - if err := uc.validateInput(input); err != nil { - return nil, err - } - - provider, err := uc.service.CreateProvider(ctx, input) - if err != nil { - return nil, NewServiceError("failed to create SAML provider", err) - } - - return provider, nil -} - -func (uc *createSamlProviderUseCase) validateInput(input domain.CreateSamlProviderInput) error { - if strings.TrimSpace(input.ProviderType) == "" { - return NewValidationError("provider type is required", ErrProviderTypeRequired) - } - if strings.TrimSpace(input.Name) == "" { - return NewValidationError("name is required", ErrNameRequired) - } - if strings.TrimSpace(input.EntityID) == "" { - return NewValidationError("entity ID is required", ErrEntityIDRequired) - } - if strings.TrimSpace(input.SsoURL) == "" { - return NewValidationError("SSO URL is required", ErrSsoURLRequired) - } - if strings.TrimSpace(input.Certificate) == "" { - return NewValidationError("certificate is required", ErrCertificateRequired) - } - - validTypes := map[string]bool{ - "okta": true, - "onelogin": true, - "azure-ad": true, - "google-workspace": true, - "duo": true, - "rippling": true, - "oracle": true, - "ping-identity": true, - } - if !validTypes[input.ProviderType] { - return NewValidationError("invalid provider type", ErrInvalidProviderType) - } - - return nil -} diff --git a/packages/envsync-cli/internal/features/usecases/saml/delete.go b/packages/envsync-cli/internal/features/usecases/saml/delete.go deleted file mode 100644 index 5d0fe98a..00000000 --- a/packages/envsync-cli/internal/features/usecases/saml/delete.go +++ /dev/null @@ -1,34 +0,0 @@ -package saml - -import ( - "context" - "strings" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" -) - -type deleteSamlProviderUseCase struct { - service services.SamlService -} - -func NewDeleteSamlProviderUseCase() DeleteSamlProviderUseCase { - return &deleteSamlProviderUseCase{ - service: services.NewSamlService(), - } -} - -func (uc *deleteSamlProviderUseCase) Execute(ctx context.Context, id string) error { - ctx, span := telemetry.Tracer().Start(ctx, "saml.delete") - defer span.End() - - if strings.TrimSpace(id) == "" { - return NewValidationError("provider ID is required", ErrProviderIDRequired) - } - - if err := uc.service.DeleteProvider(ctx, id); err != nil { - return NewServiceError("failed to delete SAML provider", err) - } - - return nil -} diff --git a/packages/envsync-cli/internal/features/usecases/saml/errors.go b/packages/envsync-cli/internal/features/usecases/saml/errors.go deleted file mode 100644 index 967ed9c2..00000000 --- a/packages/envsync-cli/internal/features/usecases/saml/errors.go +++ /dev/null @@ -1,57 +0,0 @@ -package saml - -import "errors" - -var ( - ErrProviderIDRequired = errors.New("provider ID is required") - ErrOrgSlugRequired = errors.New("organization slug is required") - ErrProviderTypeRequired = errors.New("provider type is required") - ErrNameRequired = errors.New("provider name is required") - ErrEntityIDRequired = errors.New("entity ID is required") - ErrSsoURLRequired = errors.New("SSO URL is required") - ErrCertificateRequired = errors.New("certificate is required") - ErrProviderNotFound = errors.New("SAML provider not found") - ErrInvalidProviderType = errors.New("invalid provider type") - ErrProviderCreateFailed = errors.New("failed to create SAML provider") - ErrProviderUpdateFailed = errors.New("failed to update SAML provider") - ErrProviderDeleteFailed = errors.New("failed to delete SAML provider") - ErrProviderListFailed = errors.New("failed to list SAML providers") - ErrProviderGetFailed = errors.New("failed to get SAML provider") - ErrMetadataFailed = errors.New("failed to get SAML metadata") - ErrSsoInitiationFailed = errors.New("failed to initiate SAML SSO") -) - -type SamlError struct { - Code string - Message string - Cause error -} - -func (e SamlError) Error() string { - if e.Cause != nil { - return e.Message + ": " + e.Cause.Error() - } - return e.Message -} - -func (e SamlError) Unwrap() error { - return e.Cause -} - -const ( - SamlErrorCodeValidation = "VALIDATION_ERROR" - SamlErrorCodeNotFound = "PROVIDER_NOT_FOUND" - SamlErrorCodeServiceError = "SERVICE_ERROR" -) - -func NewValidationError(message string, cause error) *SamlError { - return &SamlError{Code: SamlErrorCodeValidation, Message: message, Cause: cause} -} - -func NewNotFoundError(message string, cause error) *SamlError { - return &SamlError{Code: SamlErrorCodeNotFound, Message: message, Cause: cause} -} - -func NewServiceError(message string, cause error) *SamlError { - return &SamlError{Code: SamlErrorCodeServiceError, Message: message, Cause: cause} -} diff --git a/packages/envsync-cli/internal/features/usecases/saml/get.go b/packages/envsync-cli/internal/features/usecases/saml/get.go deleted file mode 100644 index 1abec98f..00000000 --- a/packages/envsync-cli/internal/features/usecases/saml/get.go +++ /dev/null @@ -1,36 +0,0 @@ -package saml - -import ( - "context" - "strings" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" -) - -type getSamlProviderUseCase struct { - service services.SamlService -} - -func NewGetSamlProviderUseCase() GetSamlProviderUseCase { - return &getSamlProviderUseCase{ - service: services.NewSamlService(), - } -} - -func (uc *getSamlProviderUseCase) Execute(ctx context.Context, id string) (*domain.SamlProvider, error) { - ctx, span := telemetry.Tracer().Start(ctx, "saml.get") - defer span.End() - - if strings.TrimSpace(id) == "" { - return nil, NewValidationError("provider ID is required", ErrProviderIDRequired) - } - - provider, err := uc.service.GetProvider(ctx, id) - if err != nil { - return nil, NewServiceError("failed to get SAML provider", err) - } - - return provider, nil -} diff --git a/packages/envsync-cli/internal/features/usecases/saml/interfaces.go b/packages/envsync-cli/internal/features/usecases/saml/interfaces.go deleted file mode 100644 index 5c570e06..00000000 --- a/packages/envsync-cli/internal/features/usecases/saml/interfaces.go +++ /dev/null @@ -1,35 +0,0 @@ -package saml - -import ( - "context" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" -) - -type CreateSamlProviderUseCase interface { - Execute(ctx context.Context, input domain.CreateSamlProviderInput) (*domain.SamlProvider, error) -} - -type ListSamlProvidersUseCase interface { - Execute(ctx context.Context) ([]domain.SamlProvider, error) -} - -type GetSamlProviderUseCase interface { - Execute(ctx context.Context, id string) (*domain.SamlProvider, error) -} - -type UpdateSamlProviderUseCase interface { - Execute(ctx context.Context, input domain.UpdateSamlProviderInput) error -} - -type DeleteSamlProviderUseCase interface { - Execute(ctx context.Context, id string) error -} - -type GetSamlMetadataUseCase interface { - Execute(ctx context.Context, orgSlug string) (string, error) -} - -type InitiateSamlSsoUseCase interface { - Execute(ctx context.Context, orgSlug string, providerID string) (*domain.SamlSsoResult, error) -} diff --git a/packages/envsync-cli/internal/features/usecases/saml/list.go b/packages/envsync-cli/internal/features/usecases/saml/list.go deleted file mode 100644 index 281851d5..00000000 --- a/packages/envsync-cli/internal/features/usecases/saml/list.go +++ /dev/null @@ -1,31 +0,0 @@ -package saml - -import ( - "context" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" -) - -type listSamlProvidersUseCase struct { - service services.SamlService -} - -func NewListSamlProvidersUseCase() ListSamlProvidersUseCase { - return &listSamlProvidersUseCase{ - service: services.NewSamlService(), - } -} - -func (uc *listSamlProvidersUseCase) Execute(ctx context.Context) ([]domain.SamlProvider, error) { - ctx, span := telemetry.Tracer().Start(ctx, "saml.list") - defer span.End() - - providers, err := uc.service.ListProviders(ctx) - if err != nil { - return nil, NewServiceError("failed to list SAML providers", err) - } - - return providers, nil -} diff --git a/packages/envsync-cli/internal/features/usecases/saml/metadata.go b/packages/envsync-cli/internal/features/usecases/saml/metadata.go deleted file mode 100644 index 2a33549f..00000000 --- a/packages/envsync-cli/internal/features/usecases/saml/metadata.go +++ /dev/null @@ -1,35 +0,0 @@ -package saml - -import ( - "context" - "strings" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" -) - -type getSamlMetadataUseCase struct { - service services.SamlService -} - -func NewGetSamlMetadataUseCase() GetSamlMetadataUseCase { - return &getSamlMetadataUseCase{ - service: services.NewSamlService(), - } -} - -func (uc *getSamlMetadataUseCase) Execute(ctx context.Context, orgSlug string) (string, error) { - ctx, span := telemetry.Tracer().Start(ctx, "saml.metadata") - defer span.End() - - if strings.TrimSpace(orgSlug) == "" { - return "", NewValidationError("organization slug is required", ErrOrgSlugRequired) - } - - xml, err := uc.service.GetMetadata(ctx, orgSlug) - if err != nil { - return "", NewServiceError("failed to get SAML metadata", err) - } - - return xml, nil -} diff --git a/packages/envsync-cli/internal/features/usecases/saml/sso.go b/packages/envsync-cli/internal/features/usecases/saml/sso.go deleted file mode 100644 index a988afc1..00000000 --- a/packages/envsync-cli/internal/features/usecases/saml/sso.go +++ /dev/null @@ -1,36 +0,0 @@ -package saml - -import ( - "context" - "strings" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" -) - -type initiateSamlSsoUseCase struct { - service services.SamlService -} - -func NewInitiateSamlSsoUseCase() InitiateSamlSsoUseCase { - return &initiateSamlSsoUseCase{ - service: services.NewSamlService(), - } -} - -func (uc *initiateSamlSsoUseCase) Execute(ctx context.Context, orgSlug string, providerID string) (*domain.SamlSsoResult, error) { - ctx, span := telemetry.Tracer().Start(ctx, "saml.sso") - defer span.End() - - if strings.TrimSpace(orgSlug) == "" { - return nil, NewValidationError("organization slug is required", ErrOrgSlugRequired) - } - - result, err := uc.service.InitiateSso(ctx, orgSlug, providerID) - if err != nil { - return nil, NewServiceError("failed to initiate SAML SSO", err) - } - - return result, nil -} diff --git a/packages/envsync-cli/internal/features/usecases/saml/update.go b/packages/envsync-cli/internal/features/usecases/saml/update.go deleted file mode 100644 index 88bda55d..00000000 --- a/packages/envsync-cli/internal/features/usecases/saml/update.go +++ /dev/null @@ -1,35 +0,0 @@ -package saml - -import ( - "context" - "strings" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" -) - -type updateSamlProviderUseCase struct { - service services.SamlService -} - -func NewUpdateSamlProviderUseCase() UpdateSamlProviderUseCase { - return &updateSamlProviderUseCase{ - service: services.NewSamlService(), - } -} - -func (uc *updateSamlProviderUseCase) Execute(ctx context.Context, input domain.UpdateSamlProviderInput) error { - ctx, span := telemetry.Tracer().Start(ctx, "saml.update") - defer span.End() - - if strings.TrimSpace(input.ID) == "" { - return NewValidationError("provider ID is required", ErrProviderIDRequired) - } - - if err := uc.service.UpdateProvider(ctx, input); err != nil { - return NewServiceError("failed to update SAML provider", err) - } - - return nil -} diff --git a/packages/envsync-cli/internal/features/usecases/saml/usecases_test.go b/packages/envsync-cli/internal/features/usecases/saml/usecases_test.go deleted file mode 100644 index d15205a4..00000000 --- a/packages/envsync-cli/internal/features/usecases/saml/usecases_test.go +++ /dev/null @@ -1,1107 +0,0 @@ -package saml - -import ( - "context" - "errors" - "fmt" - "testing" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" -) - -// Ensure mockSamlService satisfies the interface at compile time. -var _ services.SamlService = (*mockSamlService)(nil) - -type mockSamlService struct { - createProviderFn func(ctx context.Context, input domain.CreateSamlProviderInput) (*domain.SamlProvider, error) - listProvidersFn func(ctx context.Context) ([]domain.SamlProvider, error) - getProviderFn func(ctx context.Context, id string) (*domain.SamlProvider, error) - updateProviderFn func(ctx context.Context, input domain.UpdateSamlProviderInput) error - deleteProviderFn func(ctx context.Context, id string) error - getMetadataFn func(ctx context.Context, orgSlug string) (string, error) - initiateSsoFn func(ctx context.Context, orgSlug string, providerID string) (*domain.SamlSsoResult, error) -} - -func (m *mockSamlService) CreateProvider(ctx context.Context, input domain.CreateSamlProviderInput) (*domain.SamlProvider, error) { - return m.createProviderFn(ctx, input) -} - -func (m *mockSamlService) ListProviders(ctx context.Context) ([]domain.SamlProvider, error) { - return m.listProvidersFn(ctx) -} - -func (m *mockSamlService) GetProvider(ctx context.Context, id string) (*domain.SamlProvider, error) { - return m.getProviderFn(ctx, id) -} - -func (m *mockSamlService) UpdateProvider(ctx context.Context, input domain.UpdateSamlProviderInput) error { - return m.updateProviderFn(ctx, input) -} - -func (m *mockSamlService) DeleteProvider(ctx context.Context, id string) error { - return m.deleteProviderFn(ctx, id) -} - -func (m *mockSamlService) GetMetadata(ctx context.Context, orgSlug string) (string, error) { - return m.getMetadataFn(ctx, orgSlug) -} - -func (m *mockSamlService) InitiateSso(ctx context.Context, orgSlug string, providerID string) (*domain.SamlSsoResult, error) { - return m.initiateSsoFn(ctx, orgSlug, providerID) -} - -// --- CreateSamlProviderUseCase --- - -func TestCreateSamlProviderUseCase_Execute(t *testing.T) { - tests := []struct { - name string - input domain.CreateSamlProviderInput - mockFn func(ctx context.Context, input domain.CreateSamlProviderInput) (*domain.SamlProvider, error) - wantErr bool - wantErrIs error - wantErrCode string - wantNilResult bool - }{ - { - name: "success with okta", - input: domain.CreateSamlProviderInput{ - ProviderType: "okta", - Name: "Okta SSO", - EntityID: "http://www.okta.com/exk123", - SsoURL: "https://dev-123.okta.com/app/exk123/sso/saml", - Certificate: "-----BEGIN CERTIFICATE-----\nMIID...\n-----END CERTIFICATE-----", - }, - mockFn: func(_ context.Context, input domain.CreateSamlProviderInput) (*domain.SamlProvider, error) { - return &domain.SamlProvider{ - ID: "saml-123", - ProviderType: input.ProviderType, - Name: input.Name, - EntityID: input.EntityID, - SsoURL: input.SsoURL, - Enabled: true, - }, nil - }, - wantErr: false, - }, - { - name: "success with azure-ad", - input: domain.CreateSamlProviderInput{ - ProviderType: "azure-ad", - Name: "Azure AD SSO", - EntityID: "https://sts.windows.net/tenant-id/", - SsoURL: "https://login.microsoftonline.com/tenant-id/saml2", - Certificate: "-----BEGIN CERTIFICATE-----\nMIID...\n-----END CERTIFICATE-----", - }, - mockFn: func(_ context.Context, _ domain.CreateSamlProviderInput) (*domain.SamlProvider, error) { - return &domain.SamlProvider{ID: "saml-azure", ProviderType: "azure-ad"}, nil - }, - wantErr: false, - }, - { - name: "success with onelogin", - input: domain.CreateSamlProviderInput{ - ProviderType: "onelogin", - Name: "OneLogin SSO", - EntityID: "https://app.onelogin.com/saml/metadata/123", - SsoURL: "https://app.onelogin.com/trust/saml2/http-post/sso/123", - Certificate: "-----BEGIN CERTIFICATE-----\nMIID...\n-----END CERTIFICATE-----", - }, - mockFn: func(_ context.Context, _ domain.CreateSamlProviderInput) (*domain.SamlProvider, error) { - return &domain.SamlProvider{ID: "saml-ol"}, nil - }, - wantErr: false, - }, - { - name: "success with google-workspace", - input: domain.CreateSamlProviderInput{ - ProviderType: "google-workspace", - Name: "Google Workspace", - EntityID: "google.com", - SsoURL: "https://accounts.google.com/o/saml2/idp", - Certificate: "-----BEGIN CERTIFICATE-----\nMIID...\n-----END CERTIFICATE-----", - }, - mockFn: func(_ context.Context, _ domain.CreateSamlProviderInput) (*domain.SamlProvider, error) { - return &domain.SamlProvider{ID: "saml-google"}, nil - }, - wantErr: false, - }, - { - name: "success with duo", - input: domain.CreateSamlProviderInput{ - ProviderType: "duo", - Name: "Duo SSO", - EntityID: "https://sso.duo.com", - SsoURL: "https://sso.duo.com/saml", - Certificate: "cert", - }, - mockFn: func(_ context.Context, _ domain.CreateSamlProviderInput) (*domain.SamlProvider, error) { - return &domain.SamlProvider{ID: "saml-duo"}, nil - }, - wantErr: false, - }, - { - name: "success with rippling", - input: domain.CreateSamlProviderInput{ - ProviderType: "rippling", - Name: "Rippling SSO", - EntityID: "https://rippling.com", - SsoURL: "https://rippling.com/saml/sso", - Certificate: "cert", - }, - mockFn: func(_ context.Context, _ domain.CreateSamlProviderInput) (*domain.SamlProvider, error) { - return &domain.SamlProvider{ID: "saml-rip"}, nil - }, - wantErr: false, - }, - { - name: "validation error - empty provider type", - input: domain.CreateSamlProviderInput{ - ProviderType: "", - Name: "Test", - EntityID: "entity", - SsoURL: "https://sso.example.com", - Certificate: "cert", - }, - mockFn: nil, - wantErr: true, - wantErrIs: ErrProviderTypeRequired, - wantErrCode: SamlErrorCodeValidation, - wantNilResult: true, - }, - { - name: "validation error - whitespace provider type", - input: domain.CreateSamlProviderInput{ - ProviderType: " ", - Name: "Test", - EntityID: "entity", - SsoURL: "https://sso.example.com", - Certificate: "cert", - }, - mockFn: nil, - wantErr: true, - wantErrIs: ErrProviderTypeRequired, - wantErrCode: SamlErrorCodeValidation, - wantNilResult: true, - }, - { - name: "validation error - empty name", - input: domain.CreateSamlProviderInput{ - ProviderType: "okta", - Name: "", - EntityID: "entity", - SsoURL: "https://sso.example.com", - Certificate: "cert", - }, - mockFn: nil, - wantErr: true, - wantErrIs: ErrNameRequired, - wantErrCode: SamlErrorCodeValidation, - wantNilResult: true, - }, - { - name: "validation error - empty entity ID", - input: domain.CreateSamlProviderInput{ - ProviderType: "okta", - Name: "Test", - EntityID: "", - SsoURL: "https://sso.example.com", - Certificate: "cert", - }, - mockFn: nil, - wantErr: true, - wantErrIs: ErrEntityIDRequired, - wantErrCode: SamlErrorCodeValidation, - wantNilResult: true, - }, - { - name: "validation error - empty SSO URL", - input: domain.CreateSamlProviderInput{ - ProviderType: "okta", - Name: "Test", - EntityID: "entity", - SsoURL: "", - Certificate: "cert", - }, - mockFn: nil, - wantErr: true, - wantErrIs: ErrSsoURLRequired, - wantErrCode: SamlErrorCodeValidation, - wantNilResult: true, - }, - { - name: "validation error - empty certificate", - input: domain.CreateSamlProviderInput{ - ProviderType: "okta", - Name: "Test", - EntityID: "entity", - SsoURL: "https://sso.example.com", - Certificate: "", - }, - mockFn: nil, - wantErr: true, - wantErrIs: ErrCertificateRequired, - wantErrCode: SamlErrorCodeValidation, - wantNilResult: true, - }, - { - name: "validation error - invalid provider type", - input: domain.CreateSamlProviderInput{ - ProviderType: "auth0", - Name: "Test", - EntityID: "entity", - SsoURL: "https://sso.example.com", - Certificate: "cert", - }, - mockFn: nil, - wantErr: true, - wantErrIs: ErrInvalidProviderType, - wantErrCode: SamlErrorCodeValidation, - wantNilResult: true, - }, - { - name: "service error", - input: domain.CreateSamlProviderInput{ - ProviderType: "okta", - Name: "Test", - EntityID: "entity", - SsoURL: "https://sso.example.com", - Certificate: "cert", - }, - mockFn: func(_ context.Context, _ domain.CreateSamlProviderInput) (*domain.SamlProvider, error) { - return nil, errors.New("network error") - }, - wantErr: true, - wantErrCode: SamlErrorCodeServiceError, - wantNilResult: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - uc := &createSamlProviderUseCase{ - service: &mockSamlService{createProviderFn: tt.mockFn}, - } - - result, err := uc.Execute(context.Background(), tt.input) - - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - var samlErr *SamlError - if !errors.As(err, &samlErr) { - t.Fatalf("expected *SamlError, got %T", err) - } - if samlErr.Code != tt.wantErrCode { - t.Errorf("expected error code %q, got %q", tt.wantErrCode, samlErr.Code) - } - if tt.wantErrIs != nil && !errors.Is(err, tt.wantErrIs) { - t.Errorf("expected error to wrap %v, got %v", tt.wantErrIs, err) - } - if tt.wantNilResult && result != nil { - t.Errorf("expected nil result, got %+v", result) - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result == nil { - t.Fatal("expected non-nil result") - } - }) - } -} - -// --- ListSamlProvidersUseCase --- - -func TestListSamlProvidersUseCase_Execute(t *testing.T) { - tests := []struct { - name string - mockFn func(ctx context.Context) ([]domain.SamlProvider, error) - wantErr bool - wantErrCode string - wantCount int - }{ - { - name: "success - empty list", - mockFn: func(_ context.Context) ([]domain.SamlProvider, error) { - return []domain.SamlProvider{}, nil - }, - wantCount: 0, - }, - { - name: "success - multiple providers", - mockFn: func(_ context.Context) ([]domain.SamlProvider, error) { - return []domain.SamlProvider{ - {ID: "saml-1", ProviderType: "okta", Name: "Okta"}, - {ID: "saml-2", ProviderType: "azure-ad", Name: "Azure"}, - {ID: "saml-3", ProviderType: "onelogin", Name: "OneLogin"}, - }, nil - }, - wantCount: 3, - }, - { - name: "service error", - mockFn: func(_ context.Context) ([]domain.SamlProvider, error) { - return nil, errors.New("connection refused") - }, - wantErr: true, - wantErrCode: SamlErrorCodeServiceError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - uc := &listSamlProvidersUseCase{ - service: &mockSamlService{listProvidersFn: tt.mockFn}, - } - - result, err := uc.Execute(context.Background()) - - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - var samlErr *SamlError - if !errors.As(err, &samlErr) { - t.Fatalf("expected *SamlError, got %T", err) - } - if samlErr.Code != tt.wantErrCode { - t.Errorf("expected error code %q, got %q", tt.wantErrCode, samlErr.Code) - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result) != tt.wantCount { - t.Errorf("expected %d providers, got %d", tt.wantCount, len(result)) - } - }) - } -} - -// --- GetSamlProviderUseCase --- - -func TestGetSamlProviderUseCase_Execute(t *testing.T) { - tests := []struct { - name string - id string - mockFn func(ctx context.Context, id string) (*domain.SamlProvider, error) - wantErr bool - wantErrIs error - wantErrCode string - wantNilResult bool - }{ - { - name: "success", - id: "saml-123", - mockFn: func(_ context.Context, id string) (*domain.SamlProvider, error) { - return &domain.SamlProvider{ - ID: id, - ProviderType: "okta", - Name: "Okta SSO", - EntityID: "http://www.okta.com/exk123", - Enabled: true, - }, nil - }, - }, - { - name: "validation error - empty ID", - id: "", - mockFn: nil, - wantErr: true, - wantErrIs: ErrProviderIDRequired, - wantErrCode: SamlErrorCodeValidation, - wantNilResult: true, - }, - { - name: "validation error - whitespace ID", - id: " \t ", - mockFn: nil, - wantErr: true, - wantErrIs: ErrProviderIDRequired, - wantErrCode: SamlErrorCodeValidation, - wantNilResult: true, - }, - { - name: "service error", - id: "saml-nonexistent", - mockFn: func(_ context.Context, _ string) (*domain.SamlProvider, error) { - return nil, errors.New("not found") - }, - wantErr: true, - wantErrCode: SamlErrorCodeServiceError, - wantNilResult: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - uc := &getSamlProviderUseCase{ - service: &mockSamlService{getProviderFn: tt.mockFn}, - } - - result, err := uc.Execute(context.Background(), tt.id) - - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - var samlErr *SamlError - if !errors.As(err, &samlErr) { - t.Fatalf("expected *SamlError, got %T", err) - } - if samlErr.Code != tt.wantErrCode { - t.Errorf("expected error code %q, got %q", tt.wantErrCode, samlErr.Code) - } - if tt.wantErrIs != nil && !errors.Is(err, tt.wantErrIs) { - t.Errorf("expected error to wrap %v, got %v", tt.wantErrIs, err) - } - if tt.wantNilResult && result != nil { - t.Errorf("expected nil result, got %+v", result) - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result == nil { - t.Fatal("expected non-nil result") - } - if result.ID != tt.id { - t.Errorf("expected ID %q, got %q", tt.id, result.ID) - } - }) - } -} - -// --- UpdateSamlProviderUseCase --- - -func TestUpdateSamlProviderUseCase_Execute(t *testing.T) { - enabled := true - - tests := []struct { - name string - input domain.UpdateSamlProviderInput - mockFn func(ctx context.Context, input domain.UpdateSamlProviderInput) error - wantErr bool - wantErrIs error - wantErrCode string - }{ - { - name: "success - update name", - input: domain.UpdateSamlProviderInput{ - ID: "saml-123", - Name: "Updated Name", - }, - mockFn: func(_ context.Context, _ domain.UpdateSamlProviderInput) error { - return nil - }, - }, - { - name: "success - update multiple fields", - input: domain.UpdateSamlProviderInput{ - ID: "saml-123", - Name: "New Name", - EntityID: "new-entity", - SsoURL: "https://new-sso.example.com", - Certificate: "new-cert", - }, - mockFn: func(_ context.Context, _ domain.UpdateSamlProviderInput) error { - return nil - }, - }, - { - name: "success - disable provider", - input: domain.UpdateSamlProviderInput{ - ID: "saml-123", - Enabled: &enabled, - }, - mockFn: func(_ context.Context, _ domain.UpdateSamlProviderInput) error { - return nil - }, - }, - { - name: "validation error - empty ID", - input: domain.UpdateSamlProviderInput{ - ID: "", - Name: "Updated", - }, - mockFn: nil, - wantErr: true, - wantErrIs: ErrProviderIDRequired, - wantErrCode: SamlErrorCodeValidation, - }, - { - name: "validation error - whitespace ID", - input: domain.UpdateSamlProviderInput{ - ID: " ", - Name: "Updated", - }, - mockFn: nil, - wantErr: true, - wantErrIs: ErrProviderIDRequired, - wantErrCode: SamlErrorCodeValidation, - }, - { - name: "service error", - input: domain.UpdateSamlProviderInput{ - ID: "saml-123", - Name: "Updated", - }, - mockFn: func(_ context.Context, _ domain.UpdateSamlProviderInput) error { - return errors.New("timeout") - }, - wantErr: true, - wantErrCode: SamlErrorCodeServiceError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - uc := &updateSamlProviderUseCase{ - service: &mockSamlService{updateProviderFn: tt.mockFn}, - } - - err := uc.Execute(context.Background(), tt.input) - - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - var samlErr *SamlError - if !errors.As(err, &samlErr) { - t.Fatalf("expected *SamlError, got %T", err) - } - if samlErr.Code != tt.wantErrCode { - t.Errorf("expected error code %q, got %q", tt.wantErrCode, samlErr.Code) - } - if tt.wantErrIs != nil && !errors.Is(err, tt.wantErrIs) { - t.Errorf("expected error to wrap %v, got %v", tt.wantErrIs, err) - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - } -} - -// --- DeleteSamlProviderUseCase --- - -func TestDeleteSamlProviderUseCase_Execute(t *testing.T) { - tests := []struct { - name string - id string - mockFn func(ctx context.Context, id string) error - wantErr bool - wantErrIs error - wantErrCode string - }{ - { - name: "success", - id: "saml-123", - mockFn: func(_ context.Context, _ string) error { - return nil - }, - }, - { - name: "validation error - empty ID", - id: "", - mockFn: nil, - wantErr: true, - wantErrIs: ErrProviderIDRequired, - wantErrCode: SamlErrorCodeValidation, - }, - { - name: "validation error - whitespace ID", - id: " \t ", - mockFn: nil, - wantErr: true, - wantErrIs: ErrProviderIDRequired, - wantErrCode: SamlErrorCodeValidation, - }, - { - name: "service error", - id: "saml-123", - mockFn: func(_ context.Context, _ string) error { - return errors.New("permission denied") - }, - wantErr: true, - wantErrCode: SamlErrorCodeServiceError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - uc := &deleteSamlProviderUseCase{ - service: &mockSamlService{deleteProviderFn: tt.mockFn}, - } - - err := uc.Execute(context.Background(), tt.id) - - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - var samlErr *SamlError - if !errors.As(err, &samlErr) { - t.Fatalf("expected *SamlError, got %T", err) - } - if samlErr.Code != tt.wantErrCode { - t.Errorf("expected error code %q, got %q", tt.wantErrCode, samlErr.Code) - } - if tt.wantErrIs != nil && !errors.Is(err, tt.wantErrIs) { - t.Errorf("expected error to wrap %v, got %v", tt.wantErrIs, err) - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - } -} - -// --- GetSamlMetadataUseCase --- - -func TestGetSamlMetadataUseCase_Execute(t *testing.T) { - tests := []struct { - name string - orgSlug string - mockFn func(ctx context.Context, orgSlug string) (string, error) - wantErr bool - wantErrIs error - wantErrCode string - }{ - { - name: "success", - orgSlug: "acme", - mockFn: func(_ context.Context, _ string) (string, error) { - return "", nil - }, - }, - { - name: "validation error - empty slug", - orgSlug: "", - mockFn: nil, - wantErr: true, - wantErrIs: ErrOrgSlugRequired, - wantErrCode: SamlErrorCodeValidation, - }, - { - name: "validation error - whitespace slug", - orgSlug: " \t ", - mockFn: nil, - wantErr: true, - wantErrIs: ErrOrgSlugRequired, - wantErrCode: SamlErrorCodeValidation, - }, - { - name: "service error", - orgSlug: "acme", - mockFn: func(_ context.Context, _ string) (string, error) { - return "", errors.New("metadata fetch failed") - }, - wantErr: true, - wantErrCode: SamlErrorCodeServiceError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - uc := &getSamlMetadataUseCase{ - service: &mockSamlService{getMetadataFn: tt.mockFn}, - } - - _, err := uc.Execute(context.Background(), tt.orgSlug) - - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - var samlErr *SamlError - if !errors.As(err, &samlErr) { - t.Fatalf("expected *SamlError, got %T", err) - } - if samlErr.Code != tt.wantErrCode { - t.Errorf("expected error code %q, got %q", tt.wantErrCode, samlErr.Code) - } - if tt.wantErrIs != nil && !errors.Is(err, tt.wantErrIs) { - t.Errorf("expected error to wrap %v, got %v", tt.wantErrIs, err) - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - } -} - -// --- InitiateSamlSsoUseCase --- - -func TestInitiateSamlSsoUseCase_Execute(t *testing.T) { - tests := []struct { - name string - orgSlug string - mockFn func(ctx context.Context, orgSlug string, providerID string) (*domain.SamlSsoResult, error) - wantErr bool - wantErrIs error - wantErrCode string - wantNilResult bool - }{ - { - name: "success", - orgSlug: "acme", - mockFn: func(_ context.Context, orgSlug string, _ string) (*domain.SamlSsoResult, error) { - return &domain.SamlSsoResult{ - RedirectURL: "https://idp.example.com/sso?SAMLRequest=...", - RequestID: "req-abc123", - }, nil - }, - }, - { - name: "validation error - empty org slug", - orgSlug: "", - mockFn: nil, - wantErr: true, - wantErrIs: ErrOrgSlugRequired, - wantErrCode: SamlErrorCodeValidation, - wantNilResult: true, - }, - { - name: "validation error - whitespace org slug", - orgSlug: " \t ", - mockFn: nil, - wantErr: true, - wantErrIs: ErrOrgSlugRequired, - wantErrCode: SamlErrorCodeValidation, - wantNilResult: true, - }, - { - name: "service error", - orgSlug: "acme", - mockFn: func(_ context.Context, _ string, _ string) (*domain.SamlSsoResult, error) { - return nil, errors.New("SSO initiation failed") - }, - wantErr: true, - wantErrCode: SamlErrorCodeServiceError, - wantNilResult: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - uc := &initiateSamlSsoUseCase{ - service: &mockSamlService{initiateSsoFn: tt.mockFn}, - } - - result, err := uc.Execute(context.Background(), tt.orgSlug, "") - - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - var samlErr *SamlError - if !errors.As(err, &samlErr) { - t.Fatalf("expected *SamlError, got %T", err) - } - if samlErr.Code != tt.wantErrCode { - t.Errorf("expected error code %q, got %q", tt.wantErrCode, samlErr.Code) - } - if tt.wantErrIs != nil && !errors.Is(err, tt.wantErrIs) { - t.Errorf("expected error to wrap %v, got %v", tt.wantErrIs, err) - } - if tt.wantNilResult && result != nil { - t.Errorf("expected nil result, got %+v", result) - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result == nil { - t.Fatal("expected non-nil result") - } - if result.RedirectURL == "" { - t.Error("expected non-empty RedirectURL") - } - if result.RequestID == "" { - t.Error("expected non-empty RequestID") - } - }) - } -} - -// --- SamlError type tests --- - -func TestSamlError_Error(t *testing.T) { - tests := []struct { - name string - err SamlError - expected string - }{ - { - name: "without cause", - err: SamlError{Code: "TEST", Message: "something failed"}, - expected: "something failed", - }, - { - name: "with cause", - err: SamlError{Code: "TEST", Message: "wrap", Cause: errors.New("root")}, - expected: "wrap: root", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.err.Error(); got != tt.expected { - t.Errorf("expected %q, got %q", tt.expected, got) - } - }) - } -} - -func TestSamlError_Unwrap(t *testing.T) { - cause := errors.New("root cause") - err := SamlError{Code: "TEST", Message: "wrap", Cause: cause} - - if err.Unwrap() != cause { - t.Errorf("expected Unwrap() to return root cause") - } - - errNoCause := SamlError{Code: "TEST", Message: "no cause"} - if errNoCause.Unwrap() != nil { - t.Errorf("expected Unwrap() to return nil when no cause") - } -} - -func TestSamlError_ErrorsIs(t *testing.T) { - err := NewValidationError("validation failed", ErrProviderIDRequired) - - if !errors.Is(err, ErrProviderIDRequired) { - t.Error("expected errors.Is to match ErrProviderIDRequired") - } - - wrapped := fmt.Errorf("outer: %w", err) - if !errors.Is(wrapped, ErrProviderIDRequired) { - t.Error("expected nested errors.Is to match ErrProviderIDRequired") - } -} - -func TestSamlNewValidationError(t *testing.T) { - cause := errors.New("cause") - err := NewValidationError("msg", cause) - - if err.Code != SamlErrorCodeValidation { - t.Errorf("expected code %q, got %q", SamlErrorCodeValidation, err.Code) - } - if err.Message != "msg" { - t.Errorf("expected message %q, got %q", "msg", err.Message) - } - if err.Cause != cause { - t.Error("expected cause to match") - } -} - -func TestSamlNewNotFoundError(t *testing.T) { - err := NewNotFoundError("gone", nil) - - if err.Code != SamlErrorCodeNotFound { - t.Errorf("expected code %q, got %q", SamlErrorCodeNotFound, err.Code) - } - if err.Cause != nil { - t.Error("expected nil cause") - } -} - -func TestSamlNewServiceError(t *testing.T) { - cause := errors.New("db down") - err := NewServiceError("svc fail", cause) - - if err.Code != SamlErrorCodeServiceError { - t.Errorf("expected code %q, got %q", SamlErrorCodeServiceError, err.Code) - } - if !errors.Is(err, cause) { - t.Error("expected errors.Is to match cause") - } -} - -// Test validateInput directly for edge cases not reachable through Execute. -func TestCreateSamlProviderUseCase_validateInput(t *testing.T) { - uc := &createSamlProviderUseCase{service: &mockSamlService{}} - - tests := []struct { - name string - input domain.CreateSamlProviderInput - wantErr bool - }{ - { - name: "all valid - okta", - input: domain.CreateSamlProviderInput{ - ProviderType: "okta", - Name: "Okta SSO", - EntityID: "entity", - SsoURL: "https://sso.example.com", - Certificate: "cert", - }, - wantErr: false, - }, - { - name: "all valid - azure-ad", - input: domain.CreateSamlProviderInput{ - ProviderType: "azure-ad", - Name: "Azure AD", - EntityID: "entity", - SsoURL: "https://sso.example.com", - Certificate: "cert", - }, - wantErr: false, - }, - { - name: "all valid - onelogin", - input: domain.CreateSamlProviderInput{ - ProviderType: "onelogin", - Name: "OneLogin", - EntityID: "entity", - SsoURL: "https://sso.example.com", - Certificate: "cert", - }, - wantErr: false, - }, - { - name: "all valid - google-workspace", - input: domain.CreateSamlProviderInput{ - ProviderType: "google-workspace", - Name: "Google", - EntityID: "entity", - SsoURL: "https://sso.example.com", - Certificate: "cert", - }, - wantErr: false, - }, - { - name: "all valid - duo", - input: domain.CreateSamlProviderInput{ - ProviderType: "duo", - Name: "Duo", - EntityID: "entity", - SsoURL: "https://sso.example.com", - Certificate: "cert", - }, - wantErr: false, - }, - { - name: "all valid - rippling", - input: domain.CreateSamlProviderInput{ - ProviderType: "rippling", - Name: "Rippling", - EntityID: "entity", - SsoURL: "https://sso.example.com", - Certificate: "cert", - }, - wantErr: false, - }, - { - name: "all valid - oracle", - input: domain.CreateSamlProviderInput{ - ProviderType: "oracle", - Name: "Oracle", - EntityID: "entity", - SsoURL: "https://sso.example.com", - Certificate: "cert", - }, - wantErr: false, - }, - { - name: "all valid - ping-identity", - input: domain.CreateSamlProviderInput{ - ProviderType: "ping-identity", - Name: "Ping", - EntityID: "entity", - SsoURL: "https://sso.example.com", - Certificate: "cert", - }, - wantErr: false, - }, - { - name: "invalid type - unknown", - input: domain.CreateSamlProviderInput{ - ProviderType: "auth0", - Name: "Auth0", - EntityID: "entity", - SsoURL: "https://sso.example.com", - Certificate: "cert", - }, - wantErr: true, - }, - { - name: "missing name", - input: domain.CreateSamlProviderInput{ - ProviderType: "okta", - Name: "", - EntityID: "entity", - SsoURL: "https://sso.example.com", - Certificate: "cert", - }, - wantErr: true, - }, - { - name: "missing entity ID", - input: domain.CreateSamlProviderInput{ - ProviderType: "okta", - Name: "Test", - EntityID: "", - SsoURL: "https://sso.example.com", - Certificate: "cert", - }, - wantErr: true, - }, - { - name: "missing SSO URL", - input: domain.CreateSamlProviderInput{ - ProviderType: "okta", - Name: "Test", - EntityID: "entity", - SsoURL: "", - Certificate: "cert", - }, - wantErr: true, - }, - { - name: "missing certificate", - input: domain.CreateSamlProviderInput{ - ProviderType: "okta", - Name: "Test", - EntityID: "entity", - SsoURL: "https://sso.example.com", - Certificate: "", - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := uc.validateInput(tt.input) - if tt.wantErr && err == nil { - t.Error("expected error, got nil") - } - if !tt.wantErr && err != nil { - t.Errorf("unexpected error: %v", err) - } - }) - } -} diff --git a/packages/envsync-cli/internal/features/usecases/service_token/create_token.go b/packages/envsync-cli/internal/features/usecases/service_token/create_token.go index a11f29bd..46f303b7 100644 --- a/packages/envsync-cli/internal/features/usecases/service_token/create_token.go +++ b/packages/envsync-cli/internal/features/usecases/service_token/create_token.go @@ -4,8 +4,10 @@ import ( "context" "strings" - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) @@ -20,18 +22,22 @@ func NewCreateServiceTokenUseCase() CreateServiceTokenUseCase { } } -func (uc *createServiceTokenUseCase) Execute(ctx context.Context, req *sdk.CreateServiceTokenRequest) (*sdk.CreateServiceTokenResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "service_token.create") - defer span.End() +func (uc *createServiceTokenUseCase) Execute(ctx context.Context, st *domain.ServiceToken) (result *domain.ServiceToken, err error) { + ctx, end := telemetry.StartSpan(ctx, "service_token.create", trace.WithAttributes( + attribute.String("token.name", st.Name), + attribute.String("app.id", st.AppID), + attribute.String("env.id", st.EnvTypeID), + )) + defer end(&err) - if strings.TrimSpace(req.Name) == "" { + if strings.TrimSpace(st.Name) == "" { return nil, NewServiceTokenValidationError("service token name cannot be empty", ErrTokenNameRequired) } - resp, err := uc.service.CreateServiceToken(ctx, req) + resp, err := uc.service.CreateServiceToken(ctx, st) if err != nil { return nil, NewServiceTokenServiceError("failed to create service token", err) } - return resp, nil + return &resp, nil } diff --git a/packages/envsync-cli/internal/features/usecases/service_token/delete_token.go b/packages/envsync-cli/internal/features/usecases/service_token/delete_token.go index 5de5c8df..f0b9a916 100644 --- a/packages/envsync-cli/internal/features/usecases/service_token/delete_token.go +++ b/packages/envsync-cli/internal/features/usecases/service_token/delete_token.go @@ -4,6 +4,9 @@ import ( "context" "strings" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) @@ -18,9 +21,9 @@ func NewDeleteServiceTokenUseCase() DeleteServiceTokenUseCase { } } -func (uc *deleteServiceTokenUseCase) Execute(ctx context.Context, id string) error { - ctx, span := telemetry.Tracer().Start(ctx, "service_token.delete") - defer span.End() +func (uc *deleteServiceTokenUseCase) Execute(ctx context.Context, id string) (err error) { + ctx, end := telemetry.StartSpan(ctx, "service_token.delete", trace.WithAttributes(attribute.String("token.id", id))) + defer end(&err) if strings.TrimSpace(id) == "" { return NewServiceTokenValidationError("service token ID is required", ErrTokenIDRequired) diff --git a/packages/envsync-cli/internal/features/usecases/service_token/errors.go b/packages/envsync-cli/internal/features/usecases/service_token/errors.go index fc2230d5..0ac21b7b 100644 --- a/packages/envsync-cli/internal/features/usecases/service_token/errors.go +++ b/packages/envsync-cli/internal/features/usecases/service_token/errors.go @@ -6,6 +6,16 @@ var ( ErrTokenNameRequired = errors.New("service token name is required") ErrTokenIDRequired = errors.New("service token ID is required") ErrTokenNotFound = errors.New("service token not found") + + // ErrTokenCreateCancelled means the user backed out of the create wizard. + ErrTokenCreateCancelled = errors.New("service token creation cancelled by user") + // ErrTokenSelectionCancelled means the user backed out of the get picker. + ErrTokenSelectionCancelled = errors.New("service token selection cancelled by user") + // ErrNoApplications / ErrNoEnvironments / ErrNoTokens mean a picker has + // nothing to show. + ErrNoApplications = errors.New("no applications available") + ErrNoEnvironments = errors.New("no environments available for this application") + ErrNoTokens = errors.New("no service tokens available") ) type ServiceTokenError struct { diff --git a/packages/envsync-cli/internal/features/usecases/service_token/get_token.go b/packages/envsync-cli/internal/features/usecases/service_token/get_token.go index e91efbe4..0d12bc56 100644 --- a/packages/envsync-cli/internal/features/usecases/service_token/get_token.go +++ b/packages/envsync-cli/internal/features/usecases/service_token/get_token.go @@ -4,8 +4,10 @@ import ( "context" "strings" - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) @@ -20,9 +22,9 @@ func NewGetServiceTokenUseCase() GetServiceTokenUseCase { } } -func (uc *getServiceTokenUseCase) Execute(ctx context.Context, id string) (*sdk.ServiceTokenResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "service_token.get") - defer span.End() +func (uc *getServiceTokenUseCase) Execute(ctx context.Context, id string) (result *domain.ServiceToken, err error) { + ctx, end := telemetry.StartSpan(ctx, "service_token.get", trace.WithAttributes(attribute.String("token.id", id))) + defer end(&err) if strings.TrimSpace(id) == "" { return nil, NewServiceTokenValidationError("service token ID is required", ErrTokenIDRequired) @@ -33,5 +35,5 @@ func (uc *getServiceTokenUseCase) Execute(ctx context.Context, id string) (*sdk. return nil, NewServiceTokenServiceError("failed to get service token", err) } - return token, nil + return &token, nil } diff --git a/packages/envsync-cli/internal/features/usecases/service_token/interfaces.go b/packages/envsync-cli/internal/features/usecases/service_token/interfaces.go index 656360f1..90ddb7bb 100644 --- a/packages/envsync-cli/internal/features/usecases/service_token/interfaces.go +++ b/packages/envsync-cli/internal/features/usecases/service_token/interfaces.go @@ -3,19 +3,19 @@ package service_token import ( "context" - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" ) type CreateServiceTokenUseCase interface { - Execute(ctx context.Context, req *sdk.CreateServiceTokenRequest) (*sdk.CreateServiceTokenResponse, error) + Execute(ctx context.Context, req *domain.ServiceToken) (*domain.ServiceToken, error) } type ListServiceTokensUseCase interface { - Execute(ctx context.Context) (sdk.ServiceTokensResponse, error) + Execute(ctx context.Context) ([]domain.ServiceToken, error) } type GetServiceTokenUseCase interface { - Execute(ctx context.Context, id string) (*sdk.ServiceTokenResponse, error) + Execute(ctx context.Context, id string) (*domain.ServiceToken, error) } type DeleteServiceTokenUseCase interface { diff --git a/packages/envsync-cli/internal/features/usecases/service_token/list_tokens.go b/packages/envsync-cli/internal/features/usecases/service_token/list_tokens.go index 60307c1b..406410e0 100644 --- a/packages/envsync-cli/internal/features/usecases/service_token/list_tokens.go +++ b/packages/envsync-cli/internal/features/usecases/service_token/list_tokens.go @@ -3,8 +3,7 @@ package service_token import ( "context" - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" - + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) @@ -19,9 +18,9 @@ func NewListServiceTokensUseCase() ListServiceTokensUseCase { } } -func (uc *listServiceTokensUseCase) Execute(ctx context.Context) (sdk.ServiceTokensResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "service_token.list") - defer span.End() +func (uc *listServiceTokensUseCase) Execute(ctx context.Context) (result []domain.ServiceToken, err error) { + ctx, end := telemetry.StartSpan(ctx, "service_token.list") + defer end(&err) tokens, err := uc.service.GetAllServiceTokens(ctx) if err != nil { diff --git a/packages/envsync-cli/internal/features/usecases/service_token/usecases_test.go b/packages/envsync-cli/internal/features/usecases/service_token/usecases_test.go index dc671c6b..84d79f6f 100644 --- a/packages/envsync-cli/internal/features/usecases/service_token/usecases_test.go +++ b/packages/envsync-cli/internal/features/usecases/service_token/usecases_test.go @@ -6,41 +6,40 @@ import ( "strings" "testing" - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" - + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" ) // mockServiceTokenService is a test double implementing services.ServiceTokenService. // Each method delegates to an assignable function field so tests control behavior. type mockServiceTokenService struct { - createFn func(ctx context.Context, req *sdk.CreateServiceTokenRequest) (*sdk.CreateServiceTokenResponse, error) - getAllFn func(ctx context.Context) (sdk.ServiceTokensResponse, error) - getByIDFn func(ctx context.Context, id string) (*sdk.ServiceTokenResponse, error) - deleteFn func(ctx context.Context, id string) error + createFn func(ctx context.Context, st *domain.ServiceToken) (domain.ServiceToken, error) + getAllFn func(ctx context.Context) ([]domain.ServiceToken, error) + getByIDFn func(ctx context.Context, id string) (domain.ServiceToken, error) + deleteFn func(ctx context.Context, id string) error } var _ services.ServiceTokenService = (*mockServiceTokenService)(nil) -func (m *mockServiceTokenService) CreateServiceToken(ctx context.Context, req *sdk.CreateServiceTokenRequest) (*sdk.CreateServiceTokenResponse, error) { +func (m *mockServiceTokenService) CreateServiceToken(ctx context.Context, st *domain.ServiceToken) (domain.ServiceToken, error) { if m.createFn != nil { - return m.createFn(ctx, req) + return m.createFn(ctx, st) } - return nil, nil + return domain.ServiceToken{}, nil } -func (m *mockServiceTokenService) GetAllServiceTokens(ctx context.Context) (sdk.ServiceTokensResponse, error) { +func (m *mockServiceTokenService) GetAllServiceTokens(ctx context.Context) ([]domain.ServiceToken, error) { if m.getAllFn != nil { return m.getAllFn(ctx) } return nil, nil } -func (m *mockServiceTokenService) GetServiceToken(ctx context.Context, id string) (*sdk.ServiceTokenResponse, error) { +func (m *mockServiceTokenService) GetServiceToken(ctx context.Context, id string) (domain.ServiceToken, error) { if m.getByIDFn != nil { return m.getByIDFn(ctx, id) } - return nil, nil + return domain.ServiceToken{}, nil } func (m *mockServiceTokenService) DeleteServiceToken(ctx context.Context, id string) error { @@ -170,50 +169,46 @@ func TestSentinelErrors(t *testing.T) { func TestCreateServiceTokenUseCase_Execute(t *testing.T) { tests := []struct { name string - req *sdk.CreateServiceTokenRequest - mockFn func(ctx context.Context, req *sdk.CreateServiceTokenRequest) (*sdk.CreateServiceTokenResponse, error) + req *domain.ServiceToken + mockFn func(ctx context.Context, st *domain.ServiceToken) (domain.ServiceToken, error) wantErr bool wantCode string errContains string }{ { name: "success", - req: &sdk.CreateServiceTokenRequest{Name: "my-token"}, - mockFn: func(_ context.Context, req *sdk.CreateServiceTokenRequest) (*sdk.CreateServiceTokenResponse, error) { - return &sdk.CreateServiceTokenResponse{ - Id: "tok-123", - Token: "secret-value", - Name: req.Name, - }, nil + req: &domain.ServiceToken{Name: "my-token"}, + mockFn: func(_ context.Context, st *domain.ServiceToken) (domain.ServiceToken, error) { + return domain.ServiceToken{ID: "tok-123", Token: "secret-value", Name: st.Name}, nil }, wantErr: false, }, { name: "empty name", - req: &sdk.CreateServiceTokenRequest{Name: ""}, + req: &domain.ServiceToken{Name: ""}, wantErr: true, wantCode: ServiceTokenErrorCodeValidation, errContains: "name cannot be empty", }, { name: "whitespace only name", - req: &sdk.CreateServiceTokenRequest{Name: " "}, + req: &domain.ServiceToken{Name: " "}, wantErr: true, wantCode: ServiceTokenErrorCodeValidation, errContains: "name cannot be empty", }, { name: "tab-only name", - req: &sdk.CreateServiceTokenRequest{Name: "\t\n"}, + req: &domain.ServiceToken{Name: "\t\n"}, wantErr: true, wantCode: ServiceTokenErrorCodeValidation, errContains: "name cannot be empty", }, { name: "service returns error", - req: &sdk.CreateServiceTokenRequest{Name: "valid-name"}, - mockFn: func(_ context.Context, _ *sdk.CreateServiceTokenRequest) (*sdk.CreateServiceTokenResponse, error) { - return nil, errors.New("network error") + req: &domain.ServiceToken{Name: "valid-name"}, + mockFn: func(_ context.Context, _ *domain.ServiceToken) (domain.ServiceToken, error) { + return domain.ServiceToken{}, errors.New("network error") }, wantErr: true, wantCode: ServiceTokenErrorCodeServiceError, @@ -221,12 +216,9 @@ func TestCreateServiceTokenUseCase_Execute(t *testing.T) { }, { name: "name with leading/trailing spaces is valid", - req: &sdk.CreateServiceTokenRequest{Name: " real-token "}, - mockFn: func(_ context.Context, req *sdk.CreateServiceTokenRequest) (*sdk.CreateServiceTokenResponse, error) { - return &sdk.CreateServiceTokenResponse{ - Id: "tok-456", - Name: strings.TrimSpace(req.Name), - }, nil + req: &domain.ServiceToken{Name: " real-token "}, + mockFn: func(_ context.Context, st *domain.ServiceToken) (domain.ServiceToken, error) { + return domain.ServiceToken{ID: "tok-456", Name: strings.TrimSpace(st.Name)}, nil }, wantErr: false, }, @@ -274,17 +266,17 @@ func TestCreateServiceTokenUseCase_Execute(t *testing.T) { func TestListServiceTokensUseCase_Execute(t *testing.T) { tests := []struct { name string - mockFn func(ctx context.Context) (sdk.ServiceTokensResponse, error) + mockFn func(ctx context.Context) ([]domain.ServiceToken, error) wantErr bool wantCode string wantLen int }{ { name: "success with tokens", - mockFn: func(_ context.Context) (sdk.ServiceTokensResponse, error) { - return sdk.ServiceTokensResponse{ - &sdk.ServiceTokenResponse{Id: "tok-1", Name: "alpha"}, - &sdk.ServiceTokenResponse{Id: "tok-2", Name: "beta"}, + mockFn: func(_ context.Context) ([]domain.ServiceToken, error) { + return []domain.ServiceToken{ + {ID: "tok-1", Name: "alpha"}, + {ID: "tok-2", Name: "beta"}, }, nil }, wantErr: false, @@ -292,15 +284,15 @@ func TestListServiceTokensUseCase_Execute(t *testing.T) { }, { name: "success empty list", - mockFn: func(_ context.Context) (sdk.ServiceTokensResponse, error) { - return sdk.ServiceTokensResponse{}, nil + mockFn: func(_ context.Context) ([]domain.ServiceToken, error) { + return []domain.ServiceToken{}, nil }, wantErr: false, wantLen: 0, }, { name: "success nil list", - mockFn: func(_ context.Context) (sdk.ServiceTokensResponse, error) { + mockFn: func(_ context.Context) ([]domain.ServiceToken, error) { return nil, nil }, wantErr: false, @@ -308,7 +300,7 @@ func TestListServiceTokensUseCase_Execute(t *testing.T) { }, { name: "service error", - mockFn: func(_ context.Context) (sdk.ServiceTokensResponse, error) { + mockFn: func(_ context.Context) ([]domain.ServiceToken, error) { return nil, errors.New("unauthorized") }, wantErr: true, @@ -328,9 +320,6 @@ func TestListServiceTokensUseCase_Execute(t *testing.T) { if err == nil { t.Fatal("Execute() error = nil, want error") } - if !strings.Contains(err.Error(), "failed to list service tokens") { - t.Errorf("error %q does not contain expected prefix", err.Error()) - } var svcErr *ServiceTokenError if !errors.As(err, &svcErr) { t.Errorf("error is not *ServiceTokenError: %T", err) @@ -344,7 +333,7 @@ func TestListServiceTokensUseCase_Execute(t *testing.T) { t.Fatalf("Execute() error = %v, want nil", err) } if len(resp) != tt.wantLen { - t.Errorf("len(response) = %d, want %d", len(resp), tt.wantLen) + t.Errorf("len(resp) = %d, want %d", len(resp), tt.wantLen) } }) } @@ -356,19 +345,16 @@ func TestGetServiceTokenUseCase_Execute(t *testing.T) { tests := []struct { name string id string - mockFn func(ctx context.Context, id string) (*sdk.ServiceTokenResponse, error) + mockFn func(ctx context.Context, id string) (domain.ServiceToken, error) wantErr bool wantCode string errContains string }{ { name: "success", - id: "tok-abc", - mockFn: func(_ context.Context, id string) (*sdk.ServiceTokenResponse, error) { - return &sdk.ServiceTokenResponse{ - Id: id, - Name: "my-token", - }, nil + id: "tok-123", + mockFn: func(_ context.Context, id string) (domain.ServiceToken, error) { + return domain.ServiceToken{ID: id, Name: "my-token"}, nil }, wantErr: false, }, @@ -380,34 +366,17 @@ func TestGetServiceTokenUseCase_Execute(t *testing.T) { errContains: "ID is required", }, { - name: "whitespace only id", + name: "whitespace id", id: " ", wantErr: true, wantCode: ServiceTokenErrorCodeValidation, errContains: "ID is required", }, - { - name: "tab-only id", - id: "\t", - wantErr: true, - wantCode: ServiceTokenErrorCodeValidation, - errContains: "ID is required", - }, { name: "service error", - id: "tok-valid", - mockFn: func(_ context.Context, _ string) (*sdk.ServiceTokenResponse, error) { - return nil, errors.New("connection reset") - }, - wantErr: true, - wantCode: ServiceTokenErrorCodeServiceError, - errContains: "failed to get service token", - }, - { - name: "not found wraps as service error", - id: "tok-missing", - mockFn: func(_ context.Context, _ string) (*sdk.ServiceTokenResponse, error) { - return nil, NewServiceTokenNotFoundError("token not found", ErrTokenNotFound) + id: "tok-123", + mockFn: func(_ context.Context, _ string) (domain.ServiceToken, error) { + return domain.ServiceToken{}, errors.New("boom") }, wantErr: true, wantCode: ServiceTokenErrorCodeServiceError, @@ -465,7 +434,7 @@ func TestDeleteServiceTokenUseCase_Execute(t *testing.T) { }{ { name: "success", - id: "tok-del", + id: "tok-123", mockFn: func(_ context.Context, _ string) error { return nil }, @@ -478,25 +447,11 @@ func TestDeleteServiceTokenUseCase_Execute(t *testing.T) { wantCode: ServiceTokenErrorCodeValidation, errContains: "ID is required", }, - { - name: "whitespace only id", - id: " ", - wantErr: true, - wantCode: ServiceTokenErrorCodeValidation, - errContains: "ID is required", - }, - { - name: "newline-only id", - id: "\n", - wantErr: true, - wantCode: ServiceTokenErrorCodeValidation, - errContains: "ID is required", - }, { name: "service error", - id: "tok-valid", + id: "tok-123", mockFn: func(_ context.Context, _ string) error { - return errors.New("permission denied") + return errors.New("boom") }, wantErr: true, wantCode: ServiceTokenErrorCodeServiceError, diff --git a/packages/envsync-cli/internal/features/usecases/sync/pull.go b/packages/envsync-cli/internal/features/usecases/sync/pull.go index 93c71f73..654f0c74 100644 --- a/packages/envsync-cli/internal/features/usecases/sync/pull.go +++ b/packages/envsync-cli/internal/features/usecases/sync/pull.go @@ -9,6 +9,8 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" "github.com/joho/godotenv" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type pullUseCase struct { @@ -22,9 +24,10 @@ func NewPullUseCase() PullUseCase { } } -func (uc *pullUseCase) Execute(ctx context.Context, configPath string) (SyncResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "sync.pull") - defer span.End() +func (uc *pullUseCase) Execute(ctx context.Context, configPath string) (result SyncResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "sync.pull", + trace.WithAttributes(attribute.String("file.path", configPath))) + defer end(&err) // Check if the configuration file exists if err := uc.checkConfigFileExists(configPath); err != nil { diff --git a/packages/envsync-cli/internal/features/usecases/sync/push.go b/packages/envsync-cli/internal/features/usecases/sync/push.go index 0ba514ac..c2d3e043 100644 --- a/packages/envsync-cli/internal/features/usecases/sync/push.go +++ b/packages/envsync-cli/internal/features/usecases/sync/push.go @@ -8,6 +8,8 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/services" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type pushUseCase struct { @@ -21,9 +23,10 @@ func NewPushUseCase() PushUseCase { } } -func (uc *pushUseCase) Execute(ctx context.Context, configPath string) (SyncResponse, error) { - ctx, span := telemetry.Tracer().Start(ctx, "sync.push") - defer span.End() +func (uc *pushUseCase) Execute(ctx context.Context, configPath string) (result SyncResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "sync.push", + trace.WithAttributes(attribute.String("file.path", configPath))) + defer end(&err) // Check if the configuration file exists if err := uc.checkConfigFileExists(configPath); err != nil { diff --git a/packages/envsync-cli/internal/helper/helper.go b/packages/envsync-cli/internal/helper/helper.go new file mode 100644 index 00000000..82f90130 --- /dev/null +++ b/packages/envsync-cli/internal/helper/helper.go @@ -0,0 +1,9 @@ +package helper + +func Map[T, U any](in []T, f func(T) U) []U { + out := make([]U, len(in)) + for i, v := range in { + out[i] = f(v) + } + return out +} diff --git a/packages/envsync-cli/internal/helper/time.go b/packages/envsync-cli/internal/helper/time.go new file mode 100644 index 00000000..95c11edb --- /dev/null +++ b/packages/envsync-cli/internal/helper/time.go @@ -0,0 +1,19 @@ +package helper + +import "time" + +// ParseStrToTime parses time with layout-RFC3339 +func ParseStrToTime(str string) time.Time { + t, _ := time.Parse(time.RFC3339, str) + return t +} + +// ParseStringPtrToTime parses an optional time string with layout-RFC3339. +// Returns nil when str is nil. +func ParseStringPtrToTime(str *string) *time.Time { + if str == nil { + return nil + } + t := ParseStrToTime(*str) + return &t +} diff --git a/packages/envsync-cli/internal/logger/logger.go b/packages/envsync-cli/internal/logger/logger.go index 4a1a1d8f..3d541dee 100644 --- a/packages/envsync-cli/internal/logger/logger.go +++ b/packages/envsync-cli/internal/logger/logger.go @@ -1,23 +1,27 @@ package logger import ( + "context" "os" "path/filepath" "runtime" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/constants" "go.opentelemetry.io/contrib/bridges/otelzap" sdklog "go.opentelemetry.io/otel/sdk/log" + "go.opentelemetry.io/otel/trace" "go.uber.org/zap" "go.uber.org/zap/zapcore" "gopkg.in/natefinch/lumberjack.v2" ) -// NewLogger creates a zap.Logger that writes to stdout and a rotating log file. -// When lp is non-nil an otelzap core is added so log records are also exported -// as OTEL log signals. +// NewLogger creates a zap.Logger that writes to a rotating log file and, when +// lp is non-nil, exports records as OTEL log signals through an otelzap core. +// +// There is deliberately no console core: command results are written to stdout +// (including --json output) and the spinner/TUI own the terminal, so stray log +// writes would corrupt both. func NewLogger(lp *sdklog.LoggerProvider) *zap.Logger { - stdout := zapcore.AddSync(os.Stdout) - file := zapcore.AddSync(&lumberjack.Logger{ Filename: getLogPath(), MaxSize: 10, // megabytes @@ -31,14 +35,9 @@ func NewLogger(lp *sdklog.LoggerProvider) *zap.Logger { productionCfg.TimeKey = "timestamp" productionCfg.EncodeTime = zapcore.ISO8601TimeEncoder - developmentCfg := zap.NewDevelopmentEncoderConfig() - developmentCfg.EncodeLevel = zapcore.CapitalColorLevelEncoder - - consoleEncoder := zapcore.NewConsoleEncoder(developmentCfg) fileEncoder := zapcore.NewJSONEncoder(productionCfg) cores := []zapcore.Core{ - zapcore.NewCore(consoleEncoder, stdout, level), zapcore.NewCore(fileEncoder, file, level), } @@ -51,6 +50,24 @@ func NewLogger(lp *sdklog.LoggerProvider) *zap.Logger { return zap.New(core) } +// FromContext returns the logger attached to ctx by the CLI before-hook. The +// returned logger carries trace_id/span_id of the active span, so records in +// the local log file can be matched against the exported OTEL logs and traces. +// Outside the CLI flow it returns a no-op logger. +func FromContext(ctx context.Context) *zap.Logger { + l, ok := ctx.Value(constants.LoggerKey).(*zap.Logger) + if !ok || l == nil { + return zap.NewNop() + } + if sc := trace.SpanFromContext(ctx).SpanContext(); sc.IsValid() { + return l.With( + zap.String("trace_id", sc.TraceID().String()), + zap.String("span_id", sc.SpanID().String()), + ) + } + return l +} + func getLogPath() string { var logDir string diff --git a/packages/envsync-cli/internal/logger/logger_test.go b/packages/envsync-cli/internal/logger/logger_test.go new file mode 100644 index 00000000..fe2d3777 --- /dev/null +++ b/packages/envsync-cli/internal/logger/logger_test.go @@ -0,0 +1,53 @@ +package logger + +import ( + "context" + "testing" + + "go.opentelemetry.io/otel" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/constants" +) + +func TestFromContextWithoutLoggerIsNoop(t *testing.T) { + l := FromContext(context.Background()) + if l == nil { + t.Fatal("FromContext returned nil") + } + // Must be safe to use and emit nothing anywhere. + l.Info("must not panic or write") +} + +func TestFromContextBindsTraceIDs(t *testing.T) { + tp := sdktrace.NewTracerProvider() + prev := otel.GetTracerProvider() + otel.SetTracerProvider(tp) + t.Cleanup(func() { otel.SetTracerProvider(prev) }) + + ctx, span := tp.Tracer("test").Start(context.Background(), "op") + defer span.End() + + core, recorded := observer.New(zapcore.InfoLevel) + ctx = context.WithValue(ctx, constants.LoggerKey, zap.New(core)) + + FromContext(ctx).Info("hello") + + entries := recorded.All() + if len(entries) != 1 { + t.Fatalf("got %d entries, want 1", len(entries)) + } + fields := entries[0].ContextMap() + want := map[string]string{ + "trace_id": span.SpanContext().TraceID().String(), + "span_id": span.SpanContext().SpanID().String(), + } + for k, v := range want { + if got, ok := fields[k].(string); !ok || got != v { + t.Errorf("field %s = %v, want %s", k, fields[k], v) + } + } +} diff --git a/packages/envsync-cli/internal/mappers/service_token.go b/packages/envsync-cli/internal/mappers/service_token.go new file mode 100644 index 00000000..2bb69888 --- /dev/null +++ b/packages/envsync-cli/internal/mappers/service_token.go @@ -0,0 +1,84 @@ +package mappers + +import ( + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/helper" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository/requests" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository/responses" +) + +func ServiceTokenResponseToDomain(res responses.ServiceTokenResponse) domain.ServiceToken { + permissions := domain.ServiceTokenPermissions{ + Read: res.Permissions.Read, + Write: res.Permissions.Write, + } + + // An empty rotated_from_id means "not rotated": keep the pointer nil so + // consumers can tell that apart from a rotation that named "". + var rotatedFromID *string + if res.RotatedFromId != "" { + rotatedFromID = &res.RotatedFromId + } + + return domain.ServiceToken{ + ID: res.Id, + Name: res.Name, + AppID: res.AppId, + EnvTypeID: res.EnvTypeId, + Permissions: &permissions, + Scopes: mapResScopeToDomain(res.Scopes), + Token: res.Token, + ExpiresAt: res.ExpiresAt, + LastUsedAt: res.LastUsedAt, + CreatedAt: res.CreatedAt, + RotatedFromID: rotatedFromID, + GraceUntil: res.GraceUntil, + } +} + +// DomainToServiceTokenRequest keeps unset fields unset: the API distinguishes +// "absent" (server default applies) from an explicit empty value, so only the +// fields the user actually stated are sent. +func DomainToServiceTokenRequest(d *domain.ServiceToken) requests.CreateServiceTokenRequest { + req := requests.CreateServiceTokenRequest{ + Name: d.Name, + Scopes: mapDomainToServiceTokenScopeRequest(d.Scopes), + } + + if d.AppID != "" { + req.AppId = &d.AppID + } + if d.EnvTypeID != "" { + req.EnvTypeId = &d.EnvTypeID + } + if d.Permissions != nil { + req.Permissions = &requests.ServiceTokenPermissionRequest{ + Read: d.Permissions.Read, + Write: d.Permissions.Write, + } + } + if d.ExpiresInDays > 0 { + req.ExpiresInDays = &d.ExpiresInDays + } + + return req +} + +func mapDomainToServiceTokenScopeRequest(d []domain.ServiceTokenScopes) []*requests.ServiceTokenScopeRequest { + return helper.Map(d, func(s domain.ServiceTokenScopes) *requests.ServiceTokenScopeRequest { + scope := &requests.ServiceTokenScopeRequest{Path: s.Path} + if s.EnvTypeID != "" { + scope.EnvTypeId = &s.EnvTypeID + } + return scope + }) +} + +func mapResScopeToDomain(res []responses.ServiceTokenScope) []domain.ServiceTokenScopes { + return helper.Map(res, func(s responses.ServiceTokenScope) domain.ServiceTokenScopes { + return domain.ServiceTokenScopes{ + EnvTypeID: s.EnvTypeId, + Path: s.Path, + } + }) +} diff --git a/packages/envsync-cli/internal/mappers/service_token_test.go b/packages/envsync-cli/internal/mappers/service_token_test.go new file mode 100644 index 00000000..6ea53957 --- /dev/null +++ b/packages/envsync-cli/internal/mappers/service_token_test.go @@ -0,0 +1,131 @@ +package mappers + +import ( + "testing" + "time" + + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository/responses" +) + +// Unset request fields must stay absent: the API rejects empty app_id and +// expires_in_days below 1, so "not stated" has to marshal as omitted. +func TestDomainToServiceTokenRequest_OmitsUnsetFields(t *testing.T) { + req := DomainToServiceTokenRequest(&domain.ServiceToken{Name: "bare"}) + + if req.Name != "bare" { + t.Errorf("Name = %q, want %q", req.Name, "bare") + } + if req.AppId != nil { + t.Errorf("AppId = %v, want nil", *req.AppId) + } + if req.EnvTypeId != nil { + t.Errorf("EnvTypeId = %v, want nil", *req.EnvTypeId) + } + if req.Permissions != nil { + t.Errorf("Permissions = %v, want nil (server default applies)", *req.Permissions) + } + if req.ExpiresInDays != nil { + t.Errorf("ExpiresInDays = %v, want nil (server default applies)", *req.ExpiresInDays) + } + if len(req.Scopes) != 0 { + t.Errorf("len(Scopes) = %d, want 0", len(req.Scopes)) + } +} + +func TestDomainToServiceTokenRequest_MapsStatedFields(t *testing.T) { + permissions := &domain.ServiceTokenPermissions{Read: true, Write: false} + d := &domain.ServiceToken{ + Name: "full", + AppID: "app-1", + EnvTypeID: "env-1", + Permissions: permissions, + ExpiresInDays: 30, + Scopes: []domain.ServiceTokenScopes{ + {EnvTypeID: "env-1", Path: "/db"}, + {Path: "/cache"}, + }, + } + + req := DomainToServiceTokenRequest(d) + + if req.AppId == nil || *req.AppId != "app-1" { + t.Errorf("AppId = %v, want app-1", req.AppId) + } + if req.EnvTypeId == nil || *req.EnvTypeId != "env-1" { + t.Errorf("EnvTypeId = %v, want env-1", req.EnvTypeId) + } + if req.Permissions == nil || !req.Permissions.Read || req.Permissions.Write { + t.Errorf("Permissions = %+v, want read=true write=false", req.Permissions) + } + if req.ExpiresInDays == nil || *req.ExpiresInDays != 30 { + t.Errorf("ExpiresInDays = %v, want 30", req.ExpiresInDays) + } + // Two scopes must both survive: a make(len 0) regression crashed here. + if len(req.Scopes) != 2 { + t.Fatalf("len(Scopes) = %d, want 2", len(req.Scopes)) + } + if req.Scopes[0].EnvTypeId == nil || *req.Scopes[0].EnvTypeId != "env-1" || req.Scopes[0].Path != "/db" { + t.Errorf("Scopes[0] = %+v, want env-1//db", req.Scopes[0]) + } + if req.Scopes[1].EnvTypeId != nil || req.Scopes[1].Path != "/cache" { + t.Errorf("Scopes[1] = %+v, want unset env//cache", req.Scopes[1]) + } +} + +func TestServiceTokenResponseToDomain(t *testing.T) { + expires := time.Date(2026, 11, 7, 0, 0, 0, 0, time.UTC) + created := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + lastUsed := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC) + + res := responses.ServiceTokenResponse{ + Id: "tok-1", + Name: "deployer", + AppId: "app-1", + EnvTypeId: "env-1", + Token: "st_live_secret", + Permissions: responses.ServiceTokenPermission{Read: true, Write: true}, + Scopes: []responses.ServiceTokenScope{ + {EnvTypeId: "env-1", Path: "/db"}, + {Path: "/cache"}, + }, + RotatedFromId: "", + ExpiresAt: expires, + LastUsedAt: &lastUsed, + CreatedAt: created, + } + + got := ServiceTokenResponseToDomain(res) + + if got.Token != "st_live_secret" { + t.Errorf("Token = %q, want the create secret", got.Token) + } + if got.Permissions == nil || !got.Permissions.Read || !got.Permissions.Write { + t.Errorf("Permissions = %+v, want read/write true", got.Permissions) + } + // Empty rotated_from_id means "not rotated", not "rotated to ''". + if got.RotatedFromID != nil { + t.Errorf("RotatedFromID = %v, want nil", *got.RotatedFromID) + } + if len(got.Scopes) != 2 { + t.Fatalf("len(Scopes) = %d, want 2", len(got.Scopes)) + } + if got.Scopes[0].EnvTypeID != "env-1" || got.Scopes[0].Path != "/db" { + t.Errorf("Scopes[0] = %+v, want env-1//db", got.Scopes[0]) + } + if got.Scopes[1].EnvTypeID != "" || got.Scopes[1].Path != "/cache" { + t.Errorf("Scopes[1] = %+v, want unset env//cache", got.Scopes[1]) + } + if !got.ExpiresAt.Equal(expires) || !got.CreatedAt.Equal(created) { + t.Errorf("times not mapped: expires=%v created=%v", got.ExpiresAt, got.CreatedAt) + } + if got.LastUsedAt == nil || !got.LastUsedAt.Equal(lastUsed) { + t.Errorf("LastUsedAt = %v, want %v", got.LastUsedAt, lastUsed) + } + + res.RotatedFromId = "tok-0" + got = ServiceTokenResponseToDomain(res) + if got.RotatedFromID == nil || *got.RotatedFromID != "tok-0" { + t.Errorf("RotatedFromID = %v, want tok-0", got.RotatedFromID) + } +} diff --git a/packages/envsync-cli/internal/presentation/formatters/app_formatter.go b/packages/envsync-cli/internal/presentation/formatters/app_formatter.go index c54716cb..8ae59962 100644 --- a/packages/envsync-cli/internal/presentation/formatters/app_formatter.go +++ b/packages/envsync-cli/internal/presentation/formatters/app_formatter.go @@ -21,20 +21,42 @@ func NewAppFormatter() *AppFormatter { } func (f *AppFormatter) FormatCreateSuccessMessage(writer io.Writer, app domain.Application) error { - successMsg := fmt.Sprintf("✅ Application '%s' created successfully!\n\n", app.Name) - successMsg += fmt.Sprintf("📛 Name: %s\n", app.Name) - successMsg += fmt.Sprintf("🆔 ID: %s\n", app.ID) + var sb strings.Builder + + sb.WriteString(style.SuccessStyle.Render("✅ Application created successfully!") + "\n\n") + sb.WriteString(fmt.Sprintf("📛 Name: %s\n", app.Name)) + sb.WriteString(fmt.Sprintf("🆔 ID: %s\n", app.ID)) if app.Description != "" { - successMsg += fmt.Sprintf("📝 Description: %s\n", app.Description) + sb.WriteString(fmt.Sprintf("📝 Description: %s\n", app.Description)) } + sb.WriteString(fmt.Sprintf("🔐 Secrets: %s\n", secretsStatus(app))) - successMsg = style.BoxStyle.Render(successMsg) + if len(app.EnvTypes) > 0 { + names := make([]string, 0, len(app.EnvTypes)) + for _, t := range app.EnvTypes { + names = append(names, t.Name) + } + sb.WriteString(fmt.Sprintf("🌍 Environment types: %s\n", strings.Join(names, ", "))) + } - _, err := writer.Write([]byte(successMsg)) + sb.WriteString("\nNext: envsync env list --app-id " + app.ID + "\n") + _, err := writer.Write([]byte(style.Render(style.BoxStyle, sb.String()))) return err } +// secretsStatus describes how the application's secret encryption is managed. +func secretsStatus(app domain.Application) string { + switch { + case !app.EnableSecrets: + return "disabled" + case app.IsManagedSecret: + return "enabled — key generated and managed by EnvSync" + default: + return "enabled — using your public key" + } +} + func (f *AppFormatter) FormatListTable(writer io.Writer, apps []domain.Application) error { if len(apps) == 0 { _, err := writer.Write([]byte("📭 No applications found.\n")) diff --git a/packages/envsync-cli/internal/presentation/formatters/auth_formatter.go b/packages/envsync-cli/internal/presentation/formatters/auth_formatter.go index dc057b67..5c092da7 100644 --- a/packages/envsync-cli/internal/presentation/formatters/auth_formatter.go +++ b/packages/envsync-cli/internal/presentation/formatters/auth_formatter.go @@ -57,11 +57,14 @@ func (f *AuthFormatter) FormatSuccess(writer io.Writer, message string) error { return err } -// FormatError formats error messages +// FormatError formats error messages. The returned RenderedError marks the +// command as failed without re-printing the message. func (f *AuthFormatter) FormatError(writer io.Writer, message string) error { output := fmt.Sprintf("❌ %s\n", message) - _, err := writer.Write([]byte(output)) - return err + if _, err := writer.Write([]byte(output)); err != nil { + return err + } + return NewRenderedError(message) } // FormatWarning formats warning messages diff --git a/packages/envsync-cli/internal/presentation/formatters/base_formatter.go b/packages/envsync-cli/internal/presentation/formatters/base_formatter.go index bd0e21b8..20b7c168 100644 --- a/packages/envsync-cli/internal/presentation/formatters/base_formatter.go +++ b/packages/envsync-cli/internal/presentation/formatters/base_formatter.go @@ -34,7 +34,10 @@ func (f *BaseFormatter) FormatJSONError(writer io.Writer, err error) error { jsonError := map[string]string{ "error": err.Error(), } - return f.FormatJSON(writer, jsonError) + if werr := f.FormatJSON(writer, jsonError); werr != nil { + return werr + } + return NewRenderedError(err.Error()) } func (f *BaseFormatter) FormatWarningJSON(writer io.Writer, message string) error { @@ -45,21 +48,25 @@ func (f *BaseFormatter) FormatWarningJSON(writer io.Writer, message string) erro } func (f *BaseFormatter) FormatSuccess(writer io.Writer, message string) error { - output := style.BoxStyle.Render(style.SuccessStyle.Render(fmt.Sprintf("✅ %s\n", message))) + output := style.Render(style.BoxStyle, style.SuccessStyle.Render("✓ "+message)) _, err := writer.Write([]byte(output)) return err } -// FormatError formats error messages +// FormatError formats error messages. Callers pass the error stream (stderr) +// so error text never pollutes machine-readable stdout. The returned +// RenderedError marks the command as failed without re-printing the message. func (f *BaseFormatter) FormatError(writer io.Writer, message string) error { - output := style.ErrorStyle.Render(fmt.Sprintf("❎ %s\n", message)) - _, err := writer.Write([]byte(output)) - return err + output := style.Render(style.ErrorStyle, "✗ "+message) + if _, err := writer.Write([]byte(output)); err != nil { + return err + } + return NewRenderedError(message) } // FormatWarning formats warning messages func (f *BaseFormatter) FormatWarning(writer io.Writer, message string) error { - output := style.WarningStyle.Render(fmt.Sprintf("⚠️ %s\n", message)) + output := style.Render(style.WarningStyle, "⚠️ "+message) _, err := writer.Write([]byte(output)) return err } diff --git a/packages/envsync-cli/internal/presentation/formatters/base_formatter_test.go b/packages/envsync-cli/internal/presentation/formatters/base_formatter_test.go new file mode 100644 index 00000000..a5a3b406 --- /dev/null +++ b/packages/envsync-cli/internal/presentation/formatters/base_formatter_test.go @@ -0,0 +1,47 @@ +package formatters + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +// Regression: FormatError/FormatJSONError returned only the write error, so +// handlers that returned their result swallowed the failure and every failed +// command exited 0 with a successful root span. +func TestFormatErrorReturnsRenderedError(t *testing.T) { + f := NewBaseFormatter() + var out bytes.Buffer + + err := f.FormatError(&out, "boom") + + var rendered RenderedError + if !errors.As(err, &rendered) { + t.Fatalf("FormatError returned %v, want a RenderedError", err) + } + if rendered.Error() != "boom" { + t.Errorf("message = %q, want %q", rendered.Error(), "boom") + } + if !strings.Contains(out.String(), "boom") { + t.Errorf("output %q does not contain the message", out.String()) + } +} + +func TestFormatJSONErrorReturnsRenderedError(t *testing.T) { + f := NewBaseFormatter() + var out bytes.Buffer + + err := f.FormatJSONError(&out, errors.New("boom")) + + var rendered RenderedError + if !errors.As(err, &rendered) { + t.Fatalf("FormatJSONError returned %v, want a RenderedError", err) + } + if rendered.Error() != "boom" { + t.Errorf("message = %q, want %q", rendered.Error(), "boom") + } + if !strings.Contains(out.String(), `"error": "boom"`) { + t.Errorf("JSON output %q does not contain the error field", out.String()) + } +} diff --git a/packages/envsync-cli/internal/presentation/formatters/config_formatter.go b/packages/envsync-cli/internal/presentation/formatters/config_formatter.go index 7f846e0a..9495595c 100644 --- a/packages/envsync-cli/internal/presentation/formatters/config_formatter.go +++ b/packages/envsync-cli/internal/presentation/formatters/config_formatter.go @@ -23,11 +23,26 @@ func (f *ConfigFormatter) FormatSingleValue(writer io.Writer, key, value string) var output string switch strings.ToLower(key) { - case "backend_url", "backendurl": + case "backend_url": if value == "" { value = "" } output = fmt.Sprintf("🌐 backend_url: %s\n", value) + case "otel_config.endpoint": + if value == "" { + value = "" + } + output = fmt.Sprintf("📡 otel_config.endpoint: %s\n", value) + case "otel_config.service_name": + if value == "" { + value = "" + } + output = fmt.Sprintf("📝 otel_config.service_name: %s\n", value) + case "otel_config.disabled": + if value == "" { + value = "" + } + output = fmt.Sprintf("🔌 otel_config.disabled: %s\n", value) default: output = fmt.Sprintf("❓ %s: %s\n", key, value) } diff --git a/packages/envsync-cli/internal/presentation/formatters/dynamic_secret_formatter.go b/packages/envsync-cli/internal/presentation/formatters/dynamic_secret_formatter.go index 096adc6a..1f3d5f8b 100644 --- a/packages/envsync-cli/internal/presentation/formatters/dynamic_secret_formatter.go +++ b/packages/envsync-cli/internal/presentation/formatters/dynamic_secret_formatter.go @@ -48,7 +48,7 @@ func (f *DynamicSecretFormatter) FormatEngineDetail(writer io.Writer, engine *sd enabled = "Yes" } - output := style.BoxStyle.Render(fmt.Sprintf( + output := style.Render(style.BoxStyle, fmt.Sprintf( "🔐 Dynamic Secret Engine\n\n"+ "🆔 ID: %s\n"+ "📛 Name: %s\n"+ @@ -69,7 +69,7 @@ func (f *DynamicSecretFormatter) FormatEngineDetail(writer io.Writer, engine *sd } func (f *DynamicSecretFormatter) FormatCreateEngineSuccess(writer io.Writer, engine *sdk.DynamicSecretEngineResponse) error { - output := style.BoxStyle.Render(fmt.Sprintf( + output := style.Render(style.BoxStyle, fmt.Sprintf( "✅ Dynamic secret engine created successfully!\n\n"+ "🆔 ID: %s\n"+ "📛 Name: %s\n"+ @@ -84,7 +84,7 @@ func (f *DynamicSecretFormatter) FormatCreateEngineSuccess(writer io.Writer, eng } func (f *DynamicSecretFormatter) FormatUpdateEngineSuccess(writer io.Writer, engine *sdk.DynamicSecretEngineResponse) error { - output := style.BoxStyle.Render(fmt.Sprintf( + output := style.Render(style.BoxStyle, fmt.Sprintf( "✅ Dynamic secret engine updated successfully!\n\n"+ "🆔 ID: %s\n"+ "📛 Name: %s\n"+ @@ -130,7 +130,7 @@ func (f *DynamicSecretFormatter) FormatLeaseDetail(writer io.Writer, lease *sdk. status = "revoked" } - output := style.BoxStyle.Render(fmt.Sprintf( + output := style.Render(style.BoxStyle, fmt.Sprintf( "🔑 Dynamic Secret Lease\n\n"+ "🆔 ID: %s\n"+ "🔧 Engine ID: %s\n"+ @@ -153,7 +153,7 @@ func (f *DynamicSecretFormatter) FormatLeaseDetail(writer io.Writer, lease *sdk. } func (f *DynamicSecretFormatter) FormatCreateLeaseSuccess(writer io.Writer, lease *sdk.DynamicSecretLeaseResponse) error { - output := style.BoxStyle.Render(fmt.Sprintf( + output := style.Render(style.BoxStyle, fmt.Sprintf( "✅ Dynamic secret lease created successfully!\n\n"+ "🆔 ID: %s\n"+ "🔧 Engine ID: %s\n"+ @@ -170,7 +170,7 @@ func (f *DynamicSecretFormatter) FormatCreateLeaseSuccess(writer io.Writer, leas } func (f *DynamicSecretFormatter) FormatRevokeLeaseSuccess(writer io.Writer, result *sdk.RevokeLeaseResponse) error { - output := style.BoxStyle.Render(fmt.Sprintf( + output := style.Render(style.BoxStyle, fmt.Sprintf( "✅ Dynamic secret lease revoked successfully!\n\n"+ "💬 Message: %s\n"+ "🆔 ID: %s\n", @@ -183,7 +183,7 @@ func (f *DynamicSecretFormatter) FormatRevokeLeaseSuccess(writer io.Writer, resu } func (f *DynamicSecretFormatter) FormatCleanupSuccess(writer io.Writer, result *sdk.CleanupResponse) error { - output := style.BoxStyle.Render(fmt.Sprintf( + output := style.Render(style.BoxStyle, fmt.Sprintf( "✅ Expired leases cleanup completed!\n\n"+ "🧹 Cleaned: %d lease(s)\n", result.Cleaned, diff --git a/packages/envsync-cli/internal/presentation/formatters/env_formatter.go b/packages/envsync-cli/internal/presentation/formatters/env_formatter.go index 7f224cb4..daef343f 100644 --- a/packages/envsync-cli/internal/presentation/formatters/env_formatter.go +++ b/packages/envsync-cli/internal/presentation/formatters/env_formatter.go @@ -19,7 +19,7 @@ func NewEnvFormatter() *EnvFormatter { } } -func (f *EnvFormatter) FormatEnvList(writer io.Writer, envs []domain.EnvType) error { +func (f *EnvFormatter) FormatEnvTable(writer io.Writer, envs []domain.EnvType) error { if len(envs) == 0 { _, err := writer.Write([]byte("📭 No environments found.\n")) return err diff --git a/packages/envsync-cli/internal/presentation/formatters/formatters_test.go b/packages/envsync-cli/internal/presentation/formatters/formatters_test.go deleted file mode 100644 index c25defc5..00000000 --- a/packages/envsync-cli/internal/presentation/formatters/formatters_test.go +++ /dev/null @@ -1,1704 +0,0 @@ -package formatters - -import ( - "bytes" - "encoding/json" - "errors" - "strings" - "testing" - - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" - managementsdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" -) - -// --------------------------------------------------------------------------- -// BaseFormatter tests (inherited by all formatters) -// --------------------------------------------------------------------------- - -func TestBaseFormatter_FormatJSON(t *testing.T) { - f := NewBaseFormatter() - - tests := []struct { - name string - data any - wantKey string - wantErr bool - }{ - { - name: "map data", - data: map[string]string{"key": "value"}, - wantKey: `"key"`, - }, - { - name: "struct data", - data: struct{ Name string }{Name: "test"}, - wantKey: `"Name"`, - }, - { - name: "slice data", - data: []string{"a", "b"}, - wantKey: `"a"`, - }, - { - name: "nil data", - data: nil, - wantKey: "null", - }, - { - name: "empty map", - data: map[string]string{}, - wantKey: "{}", - }, - { - name: "nested data", - data: map[string]any{"nested": map[string]int{"count": 42}}, - wantKey: `"count"`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatJSON(&buf, tt.data) - if (err != nil) != tt.wantErr { - t.Errorf("FormatJSON() error = %v, wantErr %v", err, tt.wantErr) - return - } - output := buf.String() - if !strings.Contains(output, tt.wantKey) { - t.Errorf("FormatJSON() output = %q, want it to contain %q", output, tt.wantKey) - } - // Verify valid JSON - if err := json.Unmarshal([]byte(output), new(any)); err != nil { - t.Errorf("FormatJSON() produced invalid JSON: %v", err) - } - }) - } -} - -func TestBaseFormatter_FormatJSONError(t *testing.T) { - f := NewBaseFormatter() - - tests := []struct { - name string - err error - wantMsg string - }{ - { - name: "simple error", - err: errors.New("something failed"), - wantMsg: "something failed", - }, - { - name: "error with special characters", - err: errors.New("failed to connect: \"host\" not found"), - wantMsg: "failed to connect", - }, - { - name: "empty error message", - err: errors.New(""), - wantMsg: `"error"`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatJSONError(&buf, tt.err) - if err != nil { - t.Fatalf("FormatJSONError() error = %v", err) - } - output := buf.String() - if !strings.Contains(output, tt.wantMsg) { - t.Errorf("FormatJSONError() output = %q, want it to contain %q", output, tt.wantMsg) - } - // Verify it's valid JSON with an "error" key - var parsed map[string]string - if err := json.Unmarshal([]byte(output), &parsed); err != nil { - t.Fatalf("FormatJSONError() produced invalid JSON: %v", err) - } - if _, ok := parsed["error"]; !ok { - t.Error("FormatJSONError() JSON should have 'error' key") - } - }) - } -} - -func TestBaseFormatter_FormatWarningJSON(t *testing.T) { - f := NewBaseFormatter() - - tests := []struct { - name string - message string - }{ - { - name: "simple warning", - message: "secrets are enabled but no public key was provided", - }, - { - name: "empty message", - message: "", - }, - { - name: "message with special characters", - message: "⚠️ warning: \"quotes\" and ", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatWarningJSON(&buf, tt.message) - if err != nil { - t.Fatalf("FormatWarningJSON() error = %v", err) - } - output := buf.String() - var parsed map[string]string - if err := json.Unmarshal([]byte(output), &parsed); err != nil { - t.Fatalf("FormatWarningJSON() produced invalid JSON: %v", err) - } - if parsed["warning"] != tt.message { - t.Errorf("FormatWarningJSON() warning = %q, want %q", parsed["warning"], tt.message) - } - }) - } -} - -func TestBaseFormatter_FormatSuccess(t *testing.T) { - f := NewBaseFormatter() - - tests := []struct { - name string - message string - }{ - {name: "simple", message: "Operation completed"}, - {name: "empty", message: ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatSuccess(&buf, tt.message) - if err != nil { - t.Fatalf("FormatSuccess() error = %v", err) - } - output := buf.String() - if len(output) == 0 { - t.Error("FormatSuccess() produced empty output") - } - if !strings.Contains(output, tt.message) { - t.Errorf("FormatSuccess() output = %q, want it to contain %q", output, tt.message) - } - }) - } -} - -func TestBaseFormatter_FormatError(t *testing.T) { - f := NewBaseFormatter() - - tests := []struct { - name string - message string - }{ - {name: "simple", message: "Something went wrong"}, - {name: "empty", message: ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatError(&buf, tt.message) - if err != nil { - t.Fatalf("FormatError() error = %v", err) - } - output := buf.String() - if len(output) == 0 { - t.Error("FormatError() produced empty output") - } - if !strings.Contains(output, tt.message) { - t.Errorf("FormatError() output = %q, want it to contain %q", output, tt.message) - } - }) - } -} - -func TestBaseFormatter_FormatWarning(t *testing.T) { - f := NewBaseFormatter() - - tests := []struct { - name string - message string - }{ - {name: "simple", message: "This is a warning"}, - {name: "empty", message: ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatWarning(&buf, tt.message) - if err != nil { - t.Fatalf("FormatWarning() error = %v", err) - } - output := buf.String() - if len(output) == 0 { - t.Error("FormatWarning() produced empty output") - } - if !strings.Contains(output, tt.message) { - t.Errorf("FormatWarning() output = %q, want it to contain %q", output, tt.message) - } - }) - } -} - -// --------------------------------------------------------------------------- -// ServiceTokenFormatter tests -// --------------------------------------------------------------------------- - -func TestServiceTokenFormatter_FormatCreateSuccessMessage(t *testing.T) { - f := NewServiceTokenFormatter() - appID := "app-123" - envTypeID := "env-456" - - tests := []struct { - name string - token *sdk.CreateServiceTokenResponse - wantText []string - }{ - { - name: "full token with all fields", - token: &sdk.CreateServiceTokenResponse{ - Id: "tok-001", - Token: "secret-token-value", - Name: "my-token", - AppId: &appID, - EnvTypeId: &envTypeID, - Permissions: &sdk.ServiceTokenPermissions{ - Read: true, - Write: false, - }, - ExpiresAt: "2025-12-31T23:59:59Z", - CreatedAt: "2025-01-01T00:00:00Z", - }, - wantText: []string{"my-token", "tok-001", "secret-token-value", "app-123", "env-456", "true", "false"}, - }, - { - name: "minimal token without optional fields", - token: &sdk.CreateServiceTokenResponse{ - Id: "tok-002", - Token: "another-token", - Name: "minimal-token", - ExpiresAt: "2025-06-30T00:00:00Z", - CreatedAt: "2025-01-15T00:00:00Z", - }, - wantText: []string{"minimal-token", "tok-002", "another-token"}, - }, - { - name: "token with nil permissions", - token: &sdk.CreateServiceTokenResponse{ - Id: "tok-003", - Token: "no-perms-token", - Name: "no-perms", - ExpiresAt: "2025-03-01T00:00:00Z", - CreatedAt: "2025-01-01T00:00:00Z", - }, - wantText: []string{"no-perms", "tok-003"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatCreateSuccessMessage(&buf, tt.token) - if err != nil { - t.Fatalf("FormatCreateSuccessMessage() error = %v", err) - } - output := buf.String() - for _, text := range tt.wantText { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } - }) - } -} - -func TestServiceTokenFormatter_FormatGetSuccessMessage(t *testing.T) { - f := NewServiceTokenFormatter() - appID := "app-789" - lastUsed := "2025-06-01T12:00:00Z" - - tests := []struct { - name string - token *sdk.ServiceTokenResponse - wantText []string - }{ - { - name: "full token details", - token: &sdk.ServiceTokenResponse{ - Id: "tok-100", - Name: "detail-token", - AppId: &appID, - Permissions: &sdk.ServiceTokenPermissions{Read: true, Write: true}, - ExpiresAt: "2025-12-31T00:00:00Z", - LastUsedAt: &lastUsed, - CreatedAt: "2025-01-01T00:00:00Z", - }, - wantText: []string{"detail-token", "tok-100", "app-789", "true", "2025-06-01T12:00:00Z"}, - }, - { - name: "token without optional fields", - token: &sdk.ServiceTokenResponse{ - Id: "tok-200", - Name: "simple-token", - ExpiresAt: "2025-06-01T00:00:00Z", - CreatedAt: "2025-01-01T00:00:00Z", - }, - wantText: []string{"simple-token", "tok-200"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatGetSuccessMessage(&buf, tt.token) - if err != nil { - t.Fatalf("FormatGetSuccessMessage() error = %v", err) - } - output := buf.String() - for _, text := range tt.wantText { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } - }) - } -} - -func TestServiceTokenFormatter_FormatListTable(t *testing.T) { - f := NewServiceTokenFormatter() - - tests := []struct { - name string - tokens sdk.ServiceTokensResponse - wantText []string - }{ - { - name: "empty list", - tokens: sdk.ServiceTokensResponse{}, - wantText: []string{"ID", "NAME", "EXPIRES AT"}, - }, - { - name: "single token", - tokens: sdk.ServiceTokensResponse{ - &sdk.ServiceTokenResponse{ - Id: "tok-1", - Name: "token-one", - ExpiresAt: "2025-12-31", - CreatedAt: "2025-01-01", - Permissions: &sdk.ServiceTokenPermissions{Read: true, Write: false}, - }, - }, - wantText: []string{"tok-1", "token-one", "2025-12-31", "true", "false"}, - }, - { - name: "multiple tokens with nil optional fields", - tokens: sdk.ServiceTokensResponse{ - &sdk.ServiceTokenResponse{ - Id: "t1", Name: "first", ExpiresAt: "2025-06-01", CreatedAt: "2025-01-01", - }, - &sdk.ServiceTokenResponse{ - Id: "t2", Name: "second", ExpiresAt: "2025-07-01", CreatedAt: "2025-02-01", - }, - }, - wantText: []string{"t1", "first", "t2", "second"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatListTable(&buf, tt.tokens) - if err != nil { - t.Fatalf("FormatListTable() error = %v", err) - } - output := buf.String() - for _, text := range tt.wantText { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } - }) - } -} - -func TestServiceTokenFormatter_FormatDeleteSuccessMessage(t *testing.T) { - f := NewServiceTokenFormatter() - - var buf bytes.Buffer - err := f.FormatDeleteSuccessMessage(&buf, "tok-to-delete") - if err != nil { - t.Fatalf("FormatDeleteSuccessMessage() error = %v", err) - } - output := buf.String() - if !strings.Contains(output, "tok-to-delete") { - t.Errorf("output = %q, want it to contain the token ID", output) - } -} - -// --------------------------------------------------------------------------- -// OidcFormatter tests -// --------------------------------------------------------------------------- - -func TestOidcFormatter_FormatCreateSuccess(t *testing.T) { - f := NewOidcFormatter() - - tests := []struct { - name string - provider domain.OidcProvider - wantText []string - }{ - { - name: "full provider with allowed subjects", - provider: domain.OidcProvider{ - ID: "oidc-001", - ProviderType: "github_actions", - IssuerURL: "https://token.actions.githubusercontent.com", - Audience: "https://envsync.cloud", - Enabled: true, - AllowedSubjects: []string{"repo:org/repo:ref:refs/heads/main"}, - }, - wantText: []string{"oidc-001", "github_actions", "token.actions.githubusercontent.com", "true", "repo:org/repo"}, - }, - { - name: "provider without allowed subjects", - provider: domain.OidcProvider{ - ID: "oidc-002", - ProviderType: "kubernetes", - IssuerURL: "https://kubernetes.default.svc", - Audience: "envsync", - Enabled: false, - }, - wantText: []string{"oidc-002", "kubernetes", "false"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatCreateSuccess(&buf, tt.provider) - if err != nil { - t.Fatalf("FormatCreateSuccess() error = %v", err) - } - output := buf.String() - for _, text := range tt.wantText { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } - }) - } -} - -func TestOidcFormatter_FormatList(t *testing.T) { - f := NewOidcFormatter() - - tests := []struct { - name string - providers []domain.OidcProvider - wantText []string - }{ - { - name: "empty list", - providers: []domain.OidcProvider{}, - wantText: []string{"No OIDC providers found"}, - }, - { - name: "single provider", - providers: []domain.OidcProvider{ - { - ID: "oidc-1", ProviderType: "github_actions", - IssuerURL: "https://token.actions.githubusercontent.com", - Audience: "envsync", Enabled: true, - }, - }, - wantText: []string{"oidc-1", "github_actions", "ID", "TYPE", "ISSUER URL"}, - }, - { - name: "multiple providers", - providers: []domain.OidcProvider{ - {ID: "a", ProviderType: "github_actions", IssuerURL: "https://a.com", Audience: "a1", Enabled: true}, - {ID: "b", ProviderType: "kubernetes", IssuerURL: "https://b.com", Audience: "b1", Enabled: false}, - }, - wantText: []string{"a", "b", "github_actions", "kubernetes"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatList(&buf, tt.providers) - if err != nil { - t.Fatalf("FormatList() error = %v", err) - } - output := buf.String() - for _, text := range tt.wantText { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } - }) - } -} - -func TestOidcFormatter_FormatDetail(t *testing.T) { - f := NewOidcFormatter() - - tests := []struct { - name string - provider domain.OidcProvider - wantText []string - }{ - { - name: "full detail with subjects and machine user", - provider: domain.OidcProvider{ - ID: "oidc-d1", OrgID: "org-1", ProviderType: "github_actions", - IssuerURL: "https://token.actions.githubusercontent.com", - Audience: "envsync", Enabled: true, - AllowedSubjects: []string{"repo:a/b", "repo:c/d"}, - MachineUserID: "mu-001", - CreatedAt: "2025-01-01", UpdatedAt: "2025-06-01", - }, - wantText: []string{"oidc-d1", "org-1", "mu-001", "repo:a/b, repo:c/d", "2025-01-01"}, - }, - { - name: "detail without subjects shows (all)", - provider: domain.OidcProvider{ - ID: "oidc-d2", OrgID: "org-2", ProviderType: "gitlab_ci", - IssuerURL: "https://gitlab.com", Audience: "envsync", Enabled: false, - CreatedAt: "2025-02-01", UpdatedAt: "2025-02-01", - }, - wantText: []string{"oidc-d2", "(all)"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatDetail(&buf, tt.provider) - if err != nil { - t.Fatalf("FormatDetail() error = %v", err) - } - output := buf.String() - for _, text := range tt.wantText { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } - }) - } -} - -func TestOidcFormatter_FormatDeleteSuccess(t *testing.T) { - f := NewOidcFormatter() - - var buf bytes.Buffer - err := f.FormatDeleteSuccess(&buf, "oidc-to-delete") - if err != nil { - t.Fatalf("FormatDeleteSuccess() error = %v", err) - } - output := buf.String() - if !strings.Contains(output, "oidc-to-delete") { - t.Errorf("output = %q, want it to contain the provider ID", output) - } -} - -// --------------------------------------------------------------------------- -// SamlFormatter tests -// --------------------------------------------------------------------------- - -func TestSamlFormatter_FormatCreateSuccess(t *testing.T) { - f := NewSamlFormatter() - - tests := []struct { - name string - provider domain.SamlProvider - wantText []string - }{ - { - name: "full provider", - provider: domain.SamlProvider{ - ID: "saml-001", ProviderType: "okta", Name: "My Okta", - EntityID: "https://okta.com/app/123", SsoURL: "https://okta.com/sso", - Enabled: true, - }, - wantText: []string{"saml-001", "okta", "My Okta", "https://okta.com/app/123", "https://okta.com/sso", "true"}, - }, - { - name: "disabled provider", - provider: domain.SamlProvider{ - ID: "saml-002", ProviderType: "azure-ad", Name: "Azure AD", - EntityID: "https://azure.com/app", SsoURL: "https://azure.com/sso", - Enabled: false, - }, - wantText: []string{"saml-002", "azure-ad", "false"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatCreateSuccess(&buf, tt.provider) - if err != nil { - t.Fatalf("FormatCreateSuccess() error = %v", err) - } - output := buf.String() - for _, text := range tt.wantText { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } - }) - } -} - -func TestSamlFormatter_FormatList(t *testing.T) { - f := NewSamlFormatter() - - tests := []struct { - name string - providers []domain.SamlProvider - wantText []string - }{ - { - name: "empty list", - providers: []domain.SamlProvider{}, - wantText: []string{"No SAML providers found"}, - }, - { - name: "single provider", - providers: []domain.SamlProvider{ - {ID: "s1", ProviderType: "okta", Name: "Okta", EntityID: "https://okta.com", Enabled: true}, - }, - wantText: []string{"s1", "okta", "ID", "TYPE", "NAME"}, - }, - { - name: "multiple providers", - providers: []domain.SamlProvider{ - {ID: "s1", ProviderType: "okta", Name: "Okta", EntityID: "https://okta.com", Enabled: true}, - {ID: "s2", ProviderType: "duo", Name: "Duo", EntityID: "https://duo.com", Enabled: false}, - }, - wantText: []string{"s1", "s2", "okta", "duo"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatList(&buf, tt.providers) - if err != nil { - t.Fatalf("FormatList() error = %v", err) - } - output := buf.String() - for _, text := range tt.wantText { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } - }) - } -} - -func TestSamlFormatter_FormatDetail(t *testing.T) { - f := NewSamlFormatter() - - tests := []struct { - name string - provider domain.SamlProvider - wantText []string - }{ - { - name: "full detail", - provider: domain.SamlProvider{ - ID: "saml-d1", OrgID: "org-1", ProviderType: "okta", - Name: "Okta SSO", EntityID: "https://okta.com/app", - SsoURL: "https://okta.com/sso", Certificate: "MIIDxTCCAq2gAw...", - Enabled: true, CreatedAt: "2025-01-01", UpdatedAt: "2025-06-01", - }, - wantText: []string{"saml-d1", "org-1", "okta", "Okta SSO", "MIIDxTCCAq2gAw", "2025-01-01"}, - }, - { - name: "long certificate gets truncated", - provider: domain.SamlProvider{ - ID: "saml-d2", OrgID: "org-2", ProviderType: "onelogin", - Name: "OneLogin", EntityID: "https://onelogin.com", - SsoURL: "https://onelogin.com/sso", - Certificate: strings.Repeat("A", 100), - Enabled: false, CreatedAt: "2025-02-01", UpdatedAt: "2025-02-01", - }, - wantText: []string{"saml-d2", "..."}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatDetail(&buf, tt.provider) - if err != nil { - t.Fatalf("FormatDetail() error = %v", err) - } - output := buf.String() - for _, text := range tt.wantText { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } - }) - } -} - -func TestSamlFormatter_FormatDeleteSuccess(t *testing.T) { - f := NewSamlFormatter() - - var buf bytes.Buffer - err := f.FormatDeleteSuccess(&buf, "saml-del-1") - if err != nil { - t.Fatalf("FormatDeleteSuccess() error = %v", err) - } - output := buf.String() - if !strings.Contains(output, "saml-del-1") { - t.Errorf("output = %q, want it to contain the provider ID", output) - } -} - -func TestSamlFormatter_FormatMetadataSuccess(t *testing.T) { - f := NewSamlFormatter() - - var buf bytes.Buffer - err := f.FormatMetadataSuccess(&buf, "saml-meta-1") - if err != nil { - t.Fatalf("FormatMetadataSuccess() error = %v", err) - } - output := buf.String() - if !strings.Contains(output, "saml-meta-1") { - t.Errorf("output = %q, want it to contain the provider ID", output) - } -} - -func TestSamlFormatter_FormatSsoResult(t *testing.T) { - f := NewSamlFormatter() - - tests := []struct { - name string - result domain.SamlSsoResult - wantText []string - }{ - { - name: "standard SSO result", - result: domain.SamlSsoResult{RedirectURL: "https://idp.example.com/sso?SAMLRequest=abc", RequestID: "req-123"}, - wantText: []string{"https://idp.example.com/sso", "req-123", "Redirect URL"}, - }, - { - name: "result with empty fields", - result: domain.SamlSsoResult{}, - wantText: []string{"Redirect URL", "Request ID"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatSsoResult(&buf, tt.result) - if err != nil { - t.Fatalf("FormatSsoResult() error = %v", err) - } - output := buf.String() - for _, text := range tt.wantText { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } - }) - } -} - -// --------------------------------------------------------------------------- -// RotationFormatter tests -// --------------------------------------------------------------------------- - -func TestRotationFormatter_FormatPolicyList(t *testing.T) { - f := NewRotationFormatter() - - tests := []struct { - name string - policies managementsdk.RotationPoliciesResponse - wantText []string - }{ - { - name: "empty list", - policies: managementsdk.RotationPoliciesResponse{}, - wantText: []string{"No rotation policies found"}, - }, - { - name: "single enabled policy", - policies: managementsdk.RotationPoliciesResponse{ - &managementsdk.RotationPolicyResponse{ - Id: "rp-1", EngineType: managementsdk.RotationPolicyResponseEngineTypePostgres, - VariableKey: "DB_PASSWORD", ScheduleCron: "0 */6 * * *", Enabled: true, - }, - }, - wantText: []string{"rp-1", "postgres", "DB_PASSWORD", "0 */6 * * *", "enabled"}, - }, - { - name: "multiple policies mixed enabled", - policies: managementsdk.RotationPoliciesResponse{ - &managementsdk.RotationPolicyResponse{ - Id: "rp-1", EngineType: managementsdk.RotationPolicyResponseEngineTypeAwsIam, - VariableKey: "AWS_KEY", ScheduleCron: "0 0 * * *", Enabled: true, - }, - &managementsdk.RotationPolicyResponse{ - Id: "rp-2", EngineType: managementsdk.RotationPolicyResponseEngineTypeMysql, - VariableKey: "MYSQL_PASS", ScheduleCron: "0 */12 * * *", Enabled: false, - }, - }, - wantText: []string{"rp-1", "rp-2", "aws-iam", "mysql", "disabled"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatPolicyList(&buf, tt.policies) - if err != nil { - t.Fatalf("FormatPolicyList() error = %v", err) - } - output := buf.String() - for _, text := range tt.wantText { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } - }) - } -} - -func TestRotationFormatter_FormatPolicyDetail(t *testing.T) { - f := NewRotationFormatter() - - policy := &managementsdk.RotationPolicyResponse{ - Id: "rp-detail", OrgId: "org-1", AppId: "app-1", EnvTypeId: "env-1", - VariableKey: "DB_SECRET", EngineType: managementsdk.RotationPolicyResponseEngineTypePostgres, - ScheduleCron: "0 */6 * * *", DualWindowMinutes: 30, Enabled: true, - CreatedAt: "2025-01-01", UpdatedAt: "2025-06-01", - } - - var buf bytes.Buffer - err := f.FormatPolicyDetail(&buf, policy) - if err != nil { - t.Fatalf("FormatPolicyDetail() error = %v", err) - } - output := buf.String() - for _, text := range []string{"rp-detail", "postgres", "DB_SECRET", "0 */6 * * *", "true", "30"} { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } -} - -func TestRotationFormatter_FormatCreateSuccess(t *testing.T) { - f := NewRotationFormatter() - - policy := &managementsdk.RotationPolicyResponse{ - Id: "rp-new", EngineType: managementsdk.RotationPolicyResponseEngineTypeAwsIam, - VariableKey: "AWS_SECRET", ScheduleCron: "0 0 * * 1", - } - - var buf bytes.Buffer - err := f.FormatCreateSuccess(&buf, policy) - if err != nil { - t.Fatalf("FormatCreateSuccess() error = %v", err) - } - output := buf.String() - for _, text := range []string{"rp-new", "aws-iam", "AWS_SECRET", "created successfully"} { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } -} - -func TestRotationFormatter_FormatUpdateSuccess(t *testing.T) { - f := NewRotationFormatter() - - policy := &managementsdk.RotationPolicyResponse{ - Id: "rp-updated", EngineType: managementsdk.RotationPolicyResponseEngineTypePostgres, - ScheduleCron: "0 */12 * * *", Enabled: false, - } - - var buf bytes.Buffer - err := f.FormatUpdateSuccess(&buf, policy) - if err != nil { - t.Fatalf("FormatUpdateSuccess() error = %v", err) - } - output := buf.String() - for _, text := range []string{"rp-updated", "postgres", "updated successfully"} { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } -} - -func TestRotationFormatter_FormatTriggerSuccess(t *testing.T) { - f := NewRotationFormatter() - - result := &managementsdk.TriggerRotationResponse{ - Message: "Rotation initiated", - RotationStateId: "state-001", - NewCredentialStored: true, - OldCredentialExpiresAt: "2025-07-01T00:00:00Z", - } - - var buf bytes.Buffer - err := f.FormatTriggerSuccess(&buf, result) - if err != nil { - t.Fatalf("FormatTriggerSuccess() error = %v", err) - } - output := buf.String() - for _, text := range []string{"Rotation initiated", "state-001", "true", "2025-07-01"} { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } -} - -func TestRotationFormatter_FormatStatesList(t *testing.T) { - f := NewRotationFormatter() - - tests := []struct { - name string - states managementsdk.RotationStatesResponse - wantText []string - }{ - { - name: "empty states", - states: managementsdk.RotationStatesResponse{}, - wantText: []string{"No rotation states found"}, - }, - { - name: "single state", - states: managementsdk.RotationStatesResponse{ - &managementsdk.RotationStateResponse{ - Id: "st-1", RotatedAt: "2025-06-01T00:00:00Z", - OldCredentialExpiresAt: "2025-07-01T00:00:00Z", - OldCredentialRevoked: true, - }, - }, - wantText: []string{"st-1", "2025-06-01", "yes"}, - }, - { - name: "state with revoked=no", - states: managementsdk.RotationStatesResponse{ - &managementsdk.RotationStateResponse{ - Id: "st-2", RotatedAt: "2025-06-15T00:00:00Z", - OldCredentialExpiresAt: "2025-07-15T00:00:00Z", - OldCredentialRevoked: false, - }, - }, - wantText: []string{"st-2", "no"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatStatesList(&buf, tt.states) - if err != nil { - t.Fatalf("FormatStatesList() error = %v", err) - } - output := buf.String() - for _, text := range tt.wantText { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } - }) - } -} - -func TestRotationFormatter_FormatRevokeSuccess(t *testing.T) { - f := NewRotationFormatter() - - result := &managementsdk.RevokeOldCredentialResponse{ - Message: "Credentials revoked", - RevokedAt: "2025-06-01T12:00:00Z", - } - - var buf bytes.Buffer - err := f.FormatRevokeSuccess(&buf, result) - if err != nil { - t.Fatalf("FormatRevokeSuccess() error = %v", err) - } - output := buf.String() - for _, text := range []string{"Credentials revoked", "2025-06-01T12:00:00Z"} { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } -} - -// --------------------------------------------------------------------------- -// DynamicSecretFormatter tests -// --------------------------------------------------------------------------- - -func TestDynamicSecretFormatter_FormatEngineList(t *testing.T) { - f := NewDynamicSecretFormatter() - - tests := []struct { - name string - engines managementsdk.DynamicSecretEnginesResponse - wantText []string - }{ - { - name: "empty list", - engines: managementsdk.DynamicSecretEnginesResponse{}, - wantText: []string{"No dynamic secret engines found"}, - }, - { - name: "single engine", - engines: managementsdk.DynamicSecretEnginesResponse{ - &managementsdk.DynamicSecretEngineResponse{ - Id: "eng-1", Name: "Postgres Engine", - EngineType: managementsdk.DynamicSecretEngineResponseEngineTypePostgres, Enabled: true, - }, - }, - wantText: []string{"eng-1", "Postgres Engine", "postgres", "enabled"}, - }, - { - name: "multiple engines", - engines: managementsdk.DynamicSecretEnginesResponse{ - &managementsdk.DynamicSecretEngineResponse{ - Id: "e1", Name: "PG", EngineType: managementsdk.DynamicSecretEngineResponseEngineTypePostgres, Enabled: true, - }, - &managementsdk.DynamicSecretEngineResponse{ - Id: "e2", Name: "MySQL", EngineType: managementsdk.DynamicSecretEngineResponseEngineTypeMysql, Enabled: false, - }, - }, - wantText: []string{"e1", "e2", "disabled"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatEngineList(&buf, tt.engines) - if err != nil { - t.Fatalf("FormatEngineList() error = %v", err) - } - output := buf.String() - for _, text := range tt.wantText { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } - }) - } -} - -func TestDynamicSecretFormatter_FormatEngineDetail(t *testing.T) { - f := NewDynamicSecretFormatter() - - engine := &managementsdk.DynamicSecretEngineResponse{ - Id: "eng-detail", Name: "My Engine", OrgId: "org-1", - EngineType: managementsdk.DynamicSecretEngineResponseEngineTypeMysql, - Enabled: true, CreatedAt: "2025-01-01", UpdatedAt: "2025-06-01", - } - - var buf bytes.Buffer - err := f.FormatEngineDetail(&buf, engine) - if err != nil { - t.Fatalf("FormatEngineDetail() error = %v", err) - } - output := buf.String() - for _, text := range []string{"eng-detail", "My Engine", "mysql", "Yes", "2025-01-01"} { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } -} - -func TestDynamicSecretFormatter_FormatEngineDetail_Disabled(t *testing.T) { - f := NewDynamicSecretFormatter() - - engine := &managementsdk.DynamicSecretEngineResponse{ - Id: "eng-off", Name: "Off Engine", - EngineType: managementsdk.DynamicSecretEngineResponseEngineTypeMysql, - Enabled: false, CreatedAt: "2025-01-01", UpdatedAt: "2025-01-01", - } - - var buf bytes.Buffer - err := f.FormatEngineDetail(&buf, engine) - if err != nil { - t.Fatalf("FormatEngineDetail() error = %v", err) - } - output := buf.String() - if !strings.Contains(output, "No") { - t.Errorf("disabled engine detail should show 'No' for enabled, got %q", output) - } -} - -func TestDynamicSecretFormatter_FormatCreateEngineSuccess(t *testing.T) { - f := NewDynamicSecretFormatter() - - engine := &managementsdk.DynamicSecretEngineResponse{ - Id: "eng-new", Name: "New Engine", - EngineType: managementsdk.DynamicSecretEngineResponseEngineTypePostgres, - } - - var buf bytes.Buffer - err := f.FormatCreateEngineSuccess(&buf, engine) - if err != nil { - t.Fatalf("FormatCreateEngineSuccess() error = %v", err) - } - output := buf.String() - for _, text := range []string{"eng-new", "New Engine", "postgres", "created successfully"} { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } -} - -func TestDynamicSecretFormatter_FormatUpdateEngineSuccess(t *testing.T) { - f := NewDynamicSecretFormatter() - - engine := &managementsdk.DynamicSecretEngineResponse{ - Id: "eng-upd", Name: "Updated Engine", - EngineType: managementsdk.DynamicSecretEngineResponseEngineTypePostgres, - Enabled: true, - } - - var buf bytes.Buffer - err := f.FormatUpdateEngineSuccess(&buf, engine) - if err != nil { - t.Fatalf("FormatUpdateEngineSuccess() error = %v", err) - } - output := buf.String() - for _, text := range []string{"eng-upd", "Updated Engine", "postgres", "updated successfully"} { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } -} - -func TestDynamicSecretFormatter_FormatLeaseList(t *testing.T) { - f := NewDynamicSecretFormatter() - - tests := []struct { - name string - leases managementsdk.DynamicSecretLeasesResponse - wantText []string - }{ - { - name: "empty list", - leases: managementsdk.DynamicSecretLeasesResponse{}, - wantText: []string{"No dynamic secret leases found"}, - }, - { - name: "single active lease", - leases: managementsdk.DynamicSecretLeasesResponse{ - &managementsdk.DynamicSecretLeaseResponse{ - Id: "ls-1", EngineId: "eng-1", VariableKey: "DB_CREDS", - ExpiresAt: "2025-07-01T00:00:00Z", - }, - }, - wantText: []string{"ls-1", "eng-1", "DB_CREDS", "active"}, - }, - { - name: "revoked lease", - leases: managementsdk.DynamicSecretLeasesResponse{ - &managementsdk.DynamicSecretLeaseResponse{ - Id: "ls-2", EngineId: "eng-1", VariableKey: "OLD_CREDS", - ExpiresAt: "2025-06-01T00:00:00Z", - RevokedAt: strPtr("2025-05-15T00:00:00Z"), - }, - }, - wantText: []string{"ls-2", "revoked"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatLeaseList(&buf, tt.leases) - if err != nil { - t.Fatalf("FormatLeaseList() error = %v", err) - } - output := buf.String() - for _, text := range tt.wantText { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } - }) - } -} - -func TestDynamicSecretFormatter_FormatLeaseDetail(t *testing.T) { - f := NewDynamicSecretFormatter() - - tests := []struct { - name string - lease *managementsdk.DynamicSecretLeaseResponse - wantText []string - }{ - { - name: "active lease", - lease: &managementsdk.DynamicSecretLeaseResponse{ - Id: "ls-d1", EngineId: "eng-1", VariableKey: "CREDS", - ExpiresAt: "2025-12-31T00:00:00Z", CreatedAt: "2025-01-01", UpdatedAt: "2025-06-01", - }, - wantText: []string{"ls-d1", "eng-1", "CREDS", "active", "2025-12-31"}, - }, - { - name: "revoked lease", - lease: &managementsdk.DynamicSecretLeaseResponse{ - Id: "ls-d2", EngineId: "eng-2", VariableKey: "OLD", - ExpiresAt: "2025-06-01", RevokedAt: strPtr("2025-05-01"), - CreatedAt: "2025-01-01", UpdatedAt: "2025-05-01", - }, - wantText: []string{"ls-d2", "revoked"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatLeaseDetail(&buf, tt.lease) - if err != nil { - t.Fatalf("FormatLeaseDetail() error = %v", err) - } - output := buf.String() - for _, text := range tt.wantText { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } - }) - } -} - -func TestDynamicSecretFormatter_FormatCreateLeaseSuccess(t *testing.T) { - f := NewDynamicSecretFormatter() - - lease := &managementsdk.DynamicSecretLeaseResponse{ - Id: "ls-new", EngineId: "eng-1", VariableKey: "NEW_CREDS", - ExpiresAt: "2025-12-31T00:00:00Z", - } - - var buf bytes.Buffer - err := f.FormatCreateLeaseSuccess(&buf, lease) - if err != nil { - t.Fatalf("FormatCreateLeaseSuccess() error = %v", err) - } - output := buf.String() - for _, text := range []string{"ls-new", "eng-1", "NEW_CREDS", "created successfully"} { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } -} - -func TestDynamicSecretFormatter_FormatRevokeLeaseSuccess(t *testing.T) { - f := NewDynamicSecretFormatter() - - result := &managementsdk.RevokeLeaseResponse{ - Message: "Lease revoked", - Id: "ls-revoked", - } - - var buf bytes.Buffer - err := f.FormatRevokeLeaseSuccess(&buf, result) - if err != nil { - t.Fatalf("FormatRevokeLeaseSuccess() error = %v", err) - } - output := buf.String() - for _, text := range []string{"Lease revoked", "ls-revoked"} { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } -} - -func TestDynamicSecretFormatter_FormatCleanupSuccess(t *testing.T) { - f := NewDynamicSecretFormatter() - - result := &managementsdk.CleanupResponse{Cleaned: 5} - - var buf bytes.Buffer - err := f.FormatCleanupSuccess(&buf, result) - if err != nil { - t.Fatalf("FormatCleanupSuccess() error = %v", err) - } - output := buf.String() - for _, text := range []string{"5", "cleanup completed"} { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } -} - -// --------------------------------------------------------------------------- -// LogForwardingFormatter tests -// --------------------------------------------------------------------------- - -func TestLogForwardingFormatter_FormatCreateSuccess(t *testing.T) { - f := NewLogForwardingFormatter() - - config := &managementsdk.LogForwardingResponse{ - Id: "lf-1", Name: "Datadog Logs", OrgId: "org-1", - ProviderType: managementsdk.LogForwardingResponseProviderTypeDatadog, - Enabled: true, CreatedAt: "2025-01-01", UpdatedAt: "2025-06-01", - } - - var buf bytes.Buffer - err := f.FormatCreateSuccess(&buf, config) - if err != nil { - t.Fatalf("FormatCreateSuccess() error = %v", err) - } - output := buf.String() - for _, text := range []string{"Datadog Logs", "lf-1", "datadog", "true", "created successfully"} { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } -} - -func TestLogForwardingFormatter_FormatListTable(t *testing.T) { - f := NewLogForwardingFormatter() - - tests := []struct { - name string - configs managementsdk.LogForwardingsResponse - wantText []string - }{ - { - name: "empty list", - configs: managementsdk.LogForwardingsResponse{}, - wantText: []string{"ID", "NAME", "PROVIDER", "ENABLED"}, - }, - { - name: "single config", - configs: managementsdk.LogForwardingsResponse{ - &managementsdk.LogForwardingResponse{ - Id: "lf-1", Name: "DD", ProviderType: managementsdk.LogForwardingResponseProviderTypeDatadog, - Enabled: true, CreatedAt: "2025-01-01", - }, - }, - wantText: []string{"lf-1", "DD", "datadog", "true"}, - }, - { - name: "multiple configs", - configs: managementsdk.LogForwardingsResponse{ - &managementsdk.LogForwardingResponse{ - Id: "lf-1", Name: "DD", ProviderType: managementsdk.LogForwardingResponseProviderTypeDatadog, - Enabled: true, CreatedAt: "2025-01-01", - }, - &managementsdk.LogForwardingResponse{ - Id: "lf-2", Name: "Splunk", ProviderType: managementsdk.LogForwardingResponseProviderTypeSplunk, - Enabled: false, CreatedAt: "2025-02-01", - }, - }, - wantText: []string{"lf-1", "lf-2", "datadog", "splunk"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatListTable(&buf, tt.configs) - if err != nil { - t.Fatalf("FormatListTable() error = %v", err) - } - output := buf.String() - for _, text := range tt.wantText { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } - }) - } -} - -func TestLogForwardingFormatter_FormatGetDetail(t *testing.T) { - f := NewLogForwardingFormatter() - - tests := []struct { - name string - config *managementsdk.LogForwardingResponse - wantText []string - }{ - { - name: "config without api_key masking", - config: &managementsdk.LogForwardingResponse{ - Id: "lf-d1", OrgId: "org-1", Name: "Sumo Logic", - ProviderType: managementsdk.LogForwardingResponseProviderTypeSumoLogic, - Enabled: true, CreatedAt: "2025-01-01", UpdatedAt: "2025-06-01", - Config: map[string]interface{}{"endpoint": "https://sumologic.com/collect"}, - }, - wantText: []string{"lf-d1", "org-1", "Sumo Logic", "sumo-logic", "endpoint"}, - }, - { - name: "config with api_key masked", - config: &managementsdk.LogForwardingResponse{ - Id: "lf-d2", OrgId: "org-1", Name: "Datadog", - ProviderType: managementsdk.LogForwardingResponseProviderTypeDatadog, - Enabled: true, CreatedAt: "2025-01-01", UpdatedAt: "2025-06-01", - Config: map[string]interface{}{"api_key": "secret-key-123", "site": "datadoghq.com"}, - }, - wantText: []string{"lf-d2", "Datadog", "api_key", "****", "site"}, - }, - { - name: "config with nil config map", - config: &managementsdk.LogForwardingResponse{ - Id: "lf-d3", OrgId: "org-1", Name: "No Config", - ProviderType: managementsdk.LogForwardingResponseProviderTypeSplunk, - Enabled: false, CreatedAt: "2025-01-01", UpdatedAt: "2025-01-01", - }, - wantText: []string{"lf-d3", "No Config"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := f.FormatGetDetail(&buf, tt.config) - if err != nil { - t.Fatalf("FormatGetDetail() error = %v", err) - } - output := buf.String() - for _, text := range tt.wantText { - if !strings.Contains(output, text) { - t.Errorf("output = %q, want it to contain %q", output, text) - } - } - }) - } -} - -func TestLogForwardingFormatter_FormatGetDetail_ApiKeyMasking(t *testing.T) { - f := NewLogForwardingFormatter() - - config := &managementsdk.LogForwardingResponse{ - Id: "lf-mask", OrgId: "org-1", Name: "DD", - ProviderType: managementsdk.LogForwardingResponseProviderTypeDatadog, - Enabled: true, CreatedAt: "2025-01-01", UpdatedAt: "2025-06-01", - Config: map[string]interface{}{"api_key": "super-secret-key-do-not-leak"}, - } - - var buf bytes.Buffer - err := f.FormatGetDetail(&buf, config) - if err != nil { - t.Fatalf("FormatGetDetail() error = %v", err) - } - output := buf.String() - // The actual secret must NOT appear in output - if strings.Contains(output, "super-secret-key-do-not-leak") { - t.Error("FormatGetDetail() should mask api_key values, but the secret was found in output") - } - // The masked version should appear - if !strings.Contains(output, "****") { - t.Error("FormatGetDetail() should show **** for api_key") - } -} - -func TestLogForwardingFormatter_FormatDeleteSuccess(t *testing.T) { - f := NewLogForwardingFormatter() - - var buf bytes.Buffer - err := f.FormatDeleteSuccess(&buf, "lf-to-delete") - if err != nil { - t.Fatalf("FormatDeleteSuccess() error = %v", err) - } - output := buf.String() - if !strings.Contains(output, "lf-to-delete") { - t.Errorf("output = %q, want it to contain the config ID", output) - } - if !strings.Contains(output, "deleted successfully") { - t.Errorf("output = %q, want it to contain 'deleted successfully'", output) - } -} - -// --------------------------------------------------------------------------- -// Constructor tests -// --------------------------------------------------------------------------- - -func TestNewServiceTokenFormatter(t *testing.T) { - f := NewServiceTokenFormatter() - if f == nil { - t.Fatal("NewServiceTokenFormatter() returned nil") - } - if f.BaseFormatter == nil { - t.Fatal("ServiceTokenFormatter.BaseFormatter is nil") - } -} - -func TestNewOidcFormatter(t *testing.T) { - f := NewOidcFormatter() - if f == nil { - t.Fatal("NewOidcFormatter() returned nil") - } - if f.BaseFormatter == nil { - t.Fatal("OidcFormatter.BaseFormatter is nil") - } -} - -func TestNewSamlFormatter(t *testing.T) { - f := NewSamlFormatter() - if f == nil { - t.Fatal("NewSamlFormatter() returned nil") - } - if f.BaseFormatter == nil { - t.Fatal("SamlFormatter.BaseFormatter is nil") - } -} - -func TestNewRotationFormatter(t *testing.T) { - f := NewRotationFormatter() - if f == nil { - t.Fatal("NewRotationFormatter() returned nil") - } - if f.BaseFormatter == nil { - t.Fatal("RotationFormatter.BaseFormatter is nil") - } -} - -func TestNewDynamicSecretFormatter(t *testing.T) { - f := NewDynamicSecretFormatter() - if f == nil { - t.Fatal("NewDynamicSecretFormatter() returned nil") - } - if f.BaseFormatter == nil { - t.Fatal("DynamicSecretFormatter.BaseFormatter is nil") - } -} - -func TestNewLogForwardingFormatter(t *testing.T) { - f := NewLogForwardingFormatter() - if f == nil { - t.Fatal("NewLogForwardingFormatter() returned nil") - } - if f.BaseFormatter == nil { - t.Fatal("LogForwardingFormatter.BaseFormatter is nil") - } -} - -// --------------------------------------------------------------------------- -// Writer error propagation tests -// --------------------------------------------------------------------------- - -type errWriter struct{} - -func (e errWriter) Write(p []byte) (int, error) { - return 0, errors.New("write failed") -} - -func TestFormatters_PropagateWriterErrors(t *testing.T) { - t.Run("BaseFormatter_FormatJSON", func(t *testing.T) { - f := NewBaseFormatter() - err := f.FormatJSON(errWriter{}, map[string]string{"a": "b"}) - if err == nil { - t.Error("expected error from writer, got nil") - } - }) - - t.Run("BaseFormatter_FormatError", func(t *testing.T) { - f := NewBaseFormatter() - err := f.FormatError(errWriter{}, "test") - if err == nil { - t.Error("expected error from writer, got nil") - } - }) - - t.Run("BaseFormatter_FormatSuccess", func(t *testing.T) { - f := NewBaseFormatter() - err := f.FormatSuccess(errWriter{}, "test") - if err == nil { - t.Error("expected error from writer, got nil") - } - }) - - t.Run("ServiceTokenFormatter_FormatListTable", func(t *testing.T) { - f := NewServiceTokenFormatter() - err := f.FormatListTable(errWriter{}, sdk.ServiceTokensResponse{}) - if err == nil { - t.Error("expected error from writer, got nil") - } - }) - - t.Run("LogForwardingFormatter_FormatListTable", func(t *testing.T) { - f := NewLogForwardingFormatter() - err := f.FormatListTable(errWriter{}, managementsdk.LogForwardingsResponse{}) - if err == nil { - t.Error("expected error from writer, got nil") - } - }) -} - -// --------------------------------------------------------------------------- -// Edge case: truncate helpers -// --------------------------------------------------------------------------- - -func TestTruncateOidc(t *testing.T) { - tests := []struct { - name string - input string - maxLen int - want string - }{ - {name: "short string", input: "hello", maxLen: 10, want: "hello"}, - {name: "exact length", input: "hello", maxLen: 5, want: "hello"}, - {name: "needs truncation", input: "hello world", maxLen: 8, want: "hello..."}, - {name: "maxLen 3", input: "abcdef", maxLen: 3, want: "..."}, - {name: "empty string", input: "", maxLen: 5, want: ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := truncateOidc(tt.input, tt.maxLen) - if got != tt.want { - t.Errorf("truncateOidc(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want) - } - }) - } -} - -func TestTruncateSaml(t *testing.T) { - tests := []struct { - name string - input string - maxLen int - want string - }{ - {name: "short string", input: "hello", maxLen: 10, want: "hello"}, - {name: "exact length", input: "hello", maxLen: 5, want: "hello"}, - {name: "needs truncation", input: "hello world", maxLen: 8, want: "hello..."}, - {name: "empty string", input: "", maxLen: 5, want: ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := truncateSaml(tt.input, tt.maxLen) - if got != tt.want { - t.Errorf("truncateSaml(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want) - } - }) - } -} - -// --------------------------------------------------------------------------- -// Helper -// --------------------------------------------------------------------------- - -func strPtr(s string) *string { - return &s -} diff --git a/packages/envsync-cli/internal/presentation/formatters/gpg_key_formatter.go b/packages/envsync-cli/internal/presentation/formatters/gpg_key_formatter.go index 320226af..e766595b 100644 --- a/packages/envsync-cli/internal/presentation/formatters/gpg_key_formatter.go +++ b/packages/envsync-cli/internal/presentation/formatters/gpg_key_formatter.go @@ -16,40 +16,54 @@ func NewGpgKeyFormatter() *GpgKeyFormatter { return &GpgKeyFormatter{BaseFormatter: NewBaseFormatter()} } -func (f *GpgKeyFormatter) FormatKeyList(writer io.Writer, keys []domain.GpgKey) error { +func (f *GpgKeyFormatter) FormatListTable(writer io.Writer, keys []domain.GpgKey) error { if len(keys) == 0 { - return f.FormatWarning(writer, "No GPG keys found.") + _, err := writer.Write([]byte("📭 No GPG keys found.\n")) + return err } var sb strings.Builder - sb.WriteString(fmt.Sprintf("%-36s %-20s %-30s %-16s %-10s %-8s\n", - "ID", "Name", "Email", "Fingerprint", "Algorithm", "Status")) + + sb.WriteString(fmt.Sprintf("\n📋 GPG Keys (%d)\n", len(keys))) + sb.WriteString(strings.Repeat("─", 130) + "\n") + sb.WriteString(fmt.Sprintf("%-36s %-20s %-30s %-16s %-10s %-8s\n", + "ID", "NAME", "EMAIL", "FINGERPRINT", "ALGORITHM", "STATUS")) sb.WriteString(strings.Repeat("─", 130) + "\n") for _, key := range keys { - fp := key.Fingerprint - if len(fp) > 16 { - fp = fp[:4] + "..." + fp[len(fp)-8:] - } - - status := key.Status - if status == "" { - status = "active" - if key.RevokedAt != nil { - status = "revoked" - } else if key.ExpiresAt != nil && key.ExpiresAt.Before(key.CreatedAt) { - status = "expired" - } - } - - sb.WriteString(fmt.Sprintf("%-36s %-20s %-30s %-16s %-10s %-8s\n", - key.ID, truncate(key.Name, 20), truncate(key.Email, 30), fp, key.Algorithm, status)) + sb.WriteString(fmt.Sprintf("%-36s %-20s %-30s %-16s %-10s %-8s\n", + key.ID, truncate(key.Name, 18), truncate(key.Email, 28), ShortFingerprint(key.Fingerprint), key.Algorithm, GpgKeyStatus(key))) } + sb.WriteString(strings.Repeat("─", 130) + "\n") + _, err := writer.Write([]byte(sb.String())) return err } +// ShortFingerprint abbreviates a fingerprint to its first four and last eight +// hex characters. +func ShortFingerprint(fp string) string { + if len(fp) > 16 { + return fp[:4] + "..." + fp[len(fp)-8:] + } + return fp +} + +// GpgKeyStatus derives a display status when the backend did not send one. +func GpgKeyStatus(key domain.GpgKey) string { + if key.Status != "" { + return key.Status + } + if key.RevokedAt != nil { + return "revoked" + } + if key.ExpiresAt != nil && key.ExpiresAt.Before(key.CreatedAt) { + return "expired" + } + return "active" +} + func (f *GpgKeyFormatter) FormatKeyGenerated(writer io.Writer, key domain.GpgKey) error { msg := fmt.Sprintf("GPG key generated successfully!\n\n"+ " Name: %s\n"+ diff --git a/packages/envsync-cli/internal/presentation/formatters/log_forwarding_formatter.go b/packages/envsync-cli/internal/presentation/formatters/log_forwarding_formatter.go deleted file mode 100644 index 951771df..00000000 --- a/packages/envsync-cli/internal/presentation/formatters/log_forwarding_formatter.go +++ /dev/null @@ -1,80 +0,0 @@ -package formatters - -import ( - "fmt" - "io" - "text/tabwriter" - - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" -) - -type LogForwardingFormatter struct { - *BaseFormatter -} - -func NewLogForwardingFormatter() *LogForwardingFormatter { - return &LogForwardingFormatter{ - BaseFormatter: NewBaseFormatter(), - } -} - -func (f *LogForwardingFormatter) FormatCreateSuccess(writer io.Writer, config *sdk.LogForwardingResponse) error { - msg := fmt.Sprintf("✅ Log forwarding config '%s' created successfully!\n\n", config.GetName()) - msg += fmt.Sprintf("🆔 ID: %s\n", config.GetId()) - msg += fmt.Sprintf("📛 Name: %s\n", config.GetName()) - msg += fmt.Sprintf("📤 Provider: %s\n", config.GetProviderType()) - msg += fmt.Sprintf("🔛 Enabled: %v\n", config.GetEnabled()) - msg += fmt.Sprintf("📅 Created: %s\n", config.GetCreatedAt()) - - _, err := writer.Write([]byte(msg)) - return err -} - -func (f *LogForwardingFormatter) FormatListTable(writer io.Writer, configs sdk.LogForwardingsResponse) error { - w := tabwriter.NewWriter(writer, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "ID\tNAME\tPROVIDER\tENABLED\tCREATED AT") - fmt.Fprintln(w, "--\t----\t--------\t-------\t----------") - - for _, config := range configs { - fmt.Fprintf(w, "%s\t%s\t%s\t%v\t%s\n", - config.GetId(), - config.GetName(), - config.GetProviderType(), - config.GetEnabled(), - config.GetCreatedAt(), - ) - } - - return w.Flush() -} - -func (f *LogForwardingFormatter) FormatGetDetail(writer io.Writer, config *sdk.LogForwardingResponse) error { - msg := fmt.Sprintf("📛 Name: %s\n", config.GetName()) - msg += fmt.Sprintf("🆔 ID: %s\n", config.GetId()) - msg += fmt.Sprintf("🏢 Org ID: %s\n", config.GetOrgId()) - msg += fmt.Sprintf("📤 Provider: %s\n", config.GetProviderType()) - msg += fmt.Sprintf("🔛 Enabled: %v\n", config.GetEnabled()) - msg += fmt.Sprintf("📅 Created: %s\n", config.GetCreatedAt()) - msg += fmt.Sprintf("🔄 Updated: %s\n", config.GetUpdatedAt()) - - if config.GetConfig() != nil { - msg += "⚙️ Config:\n" - for k, v := range config.GetConfig() { - // Mask API key for security - if k == "api_key" { - msg += fmt.Sprintf(" %s: ****\n", k) - } else { - msg += fmt.Sprintf(" %s: %v\n", k, v) - } - } - } - - _, err := writer.Write([]byte(msg)) - return err -} - -func (f *LogForwardingFormatter) FormatDeleteSuccess(writer io.Writer, id string) error { - msg := fmt.Sprintf("✅ Log forwarding config '%s' deleted successfully!\n", id) - _, err := writer.Write([]byte(msg)) - return err -} diff --git a/packages/envsync-cli/internal/presentation/formatters/oidc_formatter.go b/packages/envsync-cli/internal/presentation/formatters/oidc_formatter.go deleted file mode 100644 index 606cdbb3..00000000 --- a/packages/envsync-cli/internal/presentation/formatters/oidc_formatter.go +++ /dev/null @@ -1,86 +0,0 @@ -package formatters - -import ( - "fmt" - "io" - "strings" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/style" -) - -type OidcFormatter struct { - *BaseFormatter -} - -func NewOidcFormatter() *OidcFormatter { - return &OidcFormatter{BaseFormatter: NewBaseFormatter()} -} - -func (f *OidcFormatter) FormatCreateSuccess(writer io.Writer, p domain.OidcProvider) error { - msg := fmt.Sprintf("✅ OIDC provider '%s' created successfully!\n\n", p.ProviderType) - msg += fmt.Sprintf("🆔 ID: %s\n", p.ID) - msg += fmt.Sprintf("🏷️ Type: %s\n", p.ProviderType) - msg += fmt.Sprintf("🔗 Issuer URL: %s\n", p.IssuerURL) - msg += fmt.Sprintf("🎯 Audience: %s\n", p.Audience) - msg += fmt.Sprintf("✅ Enabled: %t\n", p.Enabled) - if len(p.AllowedSubjects) > 0 { - msg += fmt.Sprintf("👤 Allowed Subjects: %s\n", strings.Join(p.AllowedSubjects, ", ")) - } - msg = style.BoxStyle.Render(msg) - _, err := writer.Write([]byte(msg)) - return err -} - -func (f *OidcFormatter) FormatList(writer io.Writer, providers []domain.OidcProvider) error { - if len(providers) == 0 { - _, err := writer.Write([]byte("No OIDC providers found.\n")) - return err - } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("%-36s %-18s %-40s %-20s %-8s\n", "ID", "TYPE", "ISSUER URL", "AUDIENCE", "ENABLED")) - sb.WriteString(strings.Repeat("-", 126) + "\n") - - for _, p := range providers { - sb.WriteString(fmt.Sprintf("%-36s %-18s %-40s %-20s %-8t\n", - p.ID, p.ProviderType, truncateOidc(p.IssuerURL, 38), truncateOidc(p.Audience, 18), p.Enabled)) - } - - _, err := writer.Write([]byte(sb.String())) - return err -} - -func (f *OidcFormatter) FormatDetail(writer io.Writer, p domain.OidcProvider) error { - msg := fmt.Sprintf("🆔 ID: %s\n", p.ID) - msg += fmt.Sprintf("🏢 Org ID: %s\n", p.OrgID) - msg += fmt.Sprintf("🏷️ Type: %s\n", p.ProviderType) - msg += fmt.Sprintf("🔗 Issuer URL: %s\n", p.IssuerURL) - msg += fmt.Sprintf("🎯 Audience: %s\n", p.Audience) - msg += fmt.Sprintf("✅ Enabled: %t\n", p.Enabled) - if len(p.AllowedSubjects) > 0 { - msg += fmt.Sprintf("👤 Allowed Subjects: %s\n", strings.Join(p.AllowedSubjects, ", ")) - } else { - msg += "👤 Allowed Subjects: (all)\n" - } - if p.MachineUserID != "" { - msg += fmt.Sprintf("🤖 Machine User ID: %s\n", p.MachineUserID) - } - msg += fmt.Sprintf("🕐 Created: %s\n", p.CreatedAt) - msg += fmt.Sprintf("🕐 Updated: %s\n", p.UpdatedAt) - - msg = style.BoxStyle.Render(msg) - _, err := writer.Write([]byte(msg)) - return err -} - -func (f *OidcFormatter) FormatDeleteSuccess(writer io.Writer, id string) error { - return f.FormatSuccess(writer, fmt.Sprintf("OIDC provider '%s' deleted successfully", id)) -} - -func truncateOidc(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - return s[:maxLen-3] + "..." -} diff --git a/packages/envsync-cli/internal/presentation/formatters/rendered_error.go b/packages/envsync-cli/internal/presentation/formatters/rendered_error.go new file mode 100644 index 00000000..52da0afb --- /dev/null +++ b/packages/envsync-cli/internal/presentation/formatters/rendered_error.go @@ -0,0 +1,17 @@ +package formatters + +// RenderedError reports a failure whose message a formatter has already +// displayed to the user. Handlers return it instead of nil so the command +// exits non-zero and the failure reaches the CLI observability layer (root +// span status, failure logs). main treats it as already displayed and must +// not print the message again. +type RenderedError struct { + msg string +} + +// NewRenderedError wraps an already-displayed failure message. +func NewRenderedError(message string) RenderedError { + return RenderedError{msg: message} +} + +func (e RenderedError) Error() string { return e.msg } diff --git a/packages/envsync-cli/internal/presentation/formatters/rotation_formatter.go b/packages/envsync-cli/internal/presentation/formatters/rotation_formatter.go index 8d08def2..762461ed 100644 --- a/packages/envsync-cli/internal/presentation/formatters/rotation_formatter.go +++ b/packages/envsync-cli/internal/presentation/formatters/rotation_formatter.go @@ -42,7 +42,7 @@ func (f *RotationFormatter) FormatPolicyList(writer io.Writer, policies sdk.Rota } func (f *RotationFormatter) FormatPolicyDetail(writer io.Writer, policy *sdk.RotationPolicyResponse) error { - output := style.BoxStyle.Render(fmt.Sprintf( + output := style.Render(style.BoxStyle, fmt.Sprintf( "🔄 Rotation Policy\n\n"+ "🆔 ID: %s\n"+ "🔧 Engine: %s\n"+ @@ -66,42 +66,8 @@ func (f *RotationFormatter) FormatPolicyDetail(writer io.Writer, policy *sdk.Rot return err } -func (f *RotationFormatter) FormatCreateSuccess(writer io.Writer, policy *sdk.RotationPolicyResponse) error { - output := style.BoxStyle.Render(fmt.Sprintf( - "✅ Rotation policy created successfully!\n\n"+ - "🆔 ID: %s\n"+ - "🔧 Engine: %s\n"+ - "🔑 Variable: %s\n"+ - "📅 Schedule: %s\n", - policy.Id, - policy.EngineType, - policy.VariableKey, - policy.ScheduleCron, - )) - - _, err := writer.Write([]byte(output + "\n")) - return err -} - -func (f *RotationFormatter) FormatUpdateSuccess(writer io.Writer, policy *sdk.RotationPolicyResponse) error { - output := style.BoxStyle.Render(fmt.Sprintf( - "✅ Rotation policy updated successfully!\n\n"+ - "🆔 ID: %s\n"+ - "🔧 Engine: %s\n"+ - "📅 Schedule: %s\n"+ - "✅ Enabled: %t\n", - policy.Id, - policy.EngineType, - policy.ScheduleCron, - policy.Enabled, - )) - - _, err := writer.Write([]byte(output + "\n")) - return err -} - func (f *RotationFormatter) FormatTriggerSuccess(writer io.Writer, result *sdk.TriggerRotationResponse) error { - output := style.BoxStyle.Render(fmt.Sprintf( + output := style.Render(style.BoxStyle, fmt.Sprintf( "✅ Rotation triggered successfully!\n\n"+ "💬 Message: %s\n"+ "🆔 State ID: %s\n"+ @@ -140,7 +106,7 @@ func (f *RotationFormatter) FormatStatesList(writer io.Writer, states sdk.Rotati } func (f *RotationFormatter) FormatRevokeSuccess(writer io.Writer, result *sdk.RevokeOldCredentialResponse) error { - output := style.BoxStyle.Render(fmt.Sprintf( + output := style.Render(style.BoxStyle, fmt.Sprintf( "✅ Expired credentials revoked successfully!\n\n"+ "💬 Message: %s\n"+ "🕐 Revoked At: %s\n", diff --git a/packages/envsync-cli/internal/presentation/formatters/run_formatter.go b/packages/envsync-cli/internal/presentation/formatters/run_formatter.go new file mode 100644 index 00000000..11716119 --- /dev/null +++ b/packages/envsync-cli/internal/presentation/formatters/run_formatter.go @@ -0,0 +1,29 @@ +package formatters + +import ( + "io" + + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/style" +) + +type RunFormatter struct { + *BaseFormatter +} + +func NewRunFormatter() *RunFormatter { + return &RunFormatter{ + BaseFormatter: NewBaseFormatter(), + } +} + +func (f *RunFormatter) FormatLog(writer io.Writer, str string) error { + out := style.SuccessStyle.Render("✓ "+str) + "\n" + _, err := writer.Write([]byte(out)) + return err +} + +func (f *RunFormatter) FormatRunningStatus(writer io.Writer) error { + out := style.SuccessStyle.Render("→ Application running...") + "\n" + _, err := writer.Write([]byte(out)) + return err +} diff --git a/packages/envsync-cli/internal/presentation/formatters/run_formatter_test.go b/packages/envsync-cli/internal/presentation/formatters/run_formatter_test.go new file mode 100644 index 00000000..ed613043 --- /dev/null +++ b/packages/envsync-cli/internal/presentation/formatters/run_formatter_test.go @@ -0,0 +1,43 @@ +package formatters + +import ( + "bytes" + "testing" +) + +// Regression: RunFormatter used to pass the newline into lipgloss Render, which +// pads the trailing empty line to the block width. The invisible padding +// shifted every subsequent line right (e.g. "→ Application running..." printed +// mid-line), so the written bytes must be exactly the message plus "\n". +func TestRunFormatterOutputHasNoTrailingPadding(t *testing.T) { + f := NewRunFormatter() + + tests := []struct { + name string + run func(*bytes.Buffer) error + want string + }{ + { + name: "log line", + run: func(b *bytes.Buffer) error { return f.FormatLog(b, "Fetched project configuration.") }, + want: "✓ Fetched project configuration.\n", + }, + { + name: "running status", + run: func(b *bytes.Buffer) error { return f.FormatRunningStatus(b) }, + want: "→ Application running...\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + if err := tt.run(&buf); err != nil { + t.Fatalf("write error = %v", err) + } + if got := buf.String(); got != tt.want { + t.Errorf("output = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/packages/envsync-cli/internal/presentation/formatters/saml_formatter.go b/packages/envsync-cli/internal/presentation/formatters/saml_formatter.go deleted file mode 100644 index cfd4a7eb..00000000 --- a/packages/envsync-cli/internal/presentation/formatters/saml_formatter.go +++ /dev/null @@ -1,93 +0,0 @@ -package formatters - -import ( - "fmt" - "io" - "strings" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/style" -) - -type SamlFormatter struct { - *BaseFormatter -} - -func NewSamlFormatter() *SamlFormatter { - return &SamlFormatter{BaseFormatter: NewBaseFormatter()} -} - -func (f *SamlFormatter) FormatCreateSuccess(writer io.Writer, p domain.SamlProvider) error { - msg := fmt.Sprintf("✅ SAML provider '%s' created successfully!\n\n", p.Name) - msg += fmt.Sprintf("🆔 ID: %s\n", p.ID) - msg += fmt.Sprintf("🏷️ Type: %s\n", p.ProviderType) - msg += fmt.Sprintf("📛 Name: %s\n", p.Name) - msg += fmt.Sprintf("🔗 Entity ID: %s\n", p.EntityID) - msg += fmt.Sprintf("🌐 SSO URL: %s\n", p.SsoURL) - msg += fmt.Sprintf("✅ Enabled: %t\n", p.Enabled) - msg = style.BoxStyle.Render(msg) - _, err := writer.Write([]byte(msg)) - return err -} - -func (f *SamlFormatter) FormatList(writer io.Writer, providers []domain.SamlProvider) error { - if len(providers) == 0 { - _, err := writer.Write([]byte("No SAML providers found.\n")) - return err - } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("%-36s %-18s %-25s %-40s %-8s\n", "ID", "TYPE", "NAME", "ENTITY ID", "ENABLED")) - sb.WriteString(strings.Repeat("-", 131) + "\n") - - for _, p := range providers { - sb.WriteString(fmt.Sprintf("%-36s %-18s %-25s %-40s %-8t\n", - p.ID, p.ProviderType, truncateSaml(p.Name, 23), truncateSaml(p.EntityID, 38), p.Enabled)) - } - - _, err := writer.Write([]byte(sb.String())) - return err -} - -func (f *SamlFormatter) FormatDetail(writer io.Writer, p domain.SamlProvider) error { - msg := fmt.Sprintf("🆔 ID: %s\n", p.ID) - msg += fmt.Sprintf("🏢 Org ID: %s\n", p.OrgID) - msg += fmt.Sprintf("🏷️ Type: %s\n", p.ProviderType) - msg += fmt.Sprintf("📛 Name: %s\n", p.Name) - msg += fmt.Sprintf("🔗 Entity ID: %s\n", p.EntityID) - msg += fmt.Sprintf("🌐 SSO URL: %s\n", p.SsoURL) - msg += fmt.Sprintf("📜 Certificate: %s\n", truncateSaml(p.Certificate, 60)) - msg += fmt.Sprintf("✅ Enabled: %t\n", p.Enabled) - msg += fmt.Sprintf("🕐 Created: %s\n", p.CreatedAt) - msg += fmt.Sprintf("🕐 Updated: %s\n", p.UpdatedAt) - - msg = style.BoxStyle.Render(msg) - _, err := writer.Write([]byte(msg)) - return err -} - -func (f *SamlFormatter) FormatDeleteSuccess(writer io.Writer, id string) error { - return f.FormatSuccess(writer, fmt.Sprintf("SAML provider '%s' deleted successfully", id)) -} - -func (f *SamlFormatter) FormatMetadataSuccess(writer io.Writer, id string) error { - return f.FormatSuccess(writer, fmt.Sprintf("SAML metadata retrieved for provider '%s'", id)) -} - -func (f *SamlFormatter) FormatSsoResult(writer io.Writer, result domain.SamlSsoResult) error { - msg := "🔐 SAML SSO initiated successfully!\n\n" - msg += fmt.Sprintf("🌐 Redirect URL: %s\n", result.RedirectURL) - msg += fmt.Sprintf("🆔 Request ID: %s\n", result.RequestID) - msg += "\nOpen the redirect URL in your browser to complete authentication.\n" - - msg = style.BoxStyle.Render(msg) - _, err := writer.Write([]byte(msg)) - return err -} - -func truncateSaml(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - return s[:maxLen-3] + "..." -} diff --git a/packages/envsync-cli/internal/presentation/formatters/service_token_formatter.go b/packages/envsync-cli/internal/presentation/formatters/service_token_formatter.go index 3e4f3627..4c1ad349 100644 --- a/packages/envsync-cli/internal/presentation/formatters/service_token_formatter.go +++ b/packages/envsync-cli/internal/presentation/formatters/service_token_formatter.go @@ -4,9 +4,9 @@ import ( "fmt" "io" "text/tabwriter" + "time" - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" - + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/style" ) @@ -21,57 +21,57 @@ func NewServiceTokenFormatter() *ServiceTokenFormatter { } } -func (f *ServiceTokenFormatter) FormatCreateSuccessMessage(writer io.Writer, token *sdk.CreateServiceTokenResponse) error { +func (f *ServiceTokenFormatter) FormatCreateSuccessMessage(writer io.Writer, token *domain.ServiceToken) error { successMsg := fmt.Sprintf("✅ Service token '%s' created successfully!\n\n", token.Name) successMsg += fmt.Sprintf("📛 Name: %s\n", token.Name) - successMsg += fmt.Sprintf("🆔 ID: %s\n", token.Id) + successMsg += fmt.Sprintf("🆔 ID: %s\n", token.ID) successMsg += fmt.Sprintf("🔑 Token: %s\n", token.Token) - if token.AppId != nil { - successMsg += fmt.Sprintf("📱 App ID: %s\n", *token.AppId) + if token.AppID != "" { + successMsg += fmt.Sprintf("📱 App ID: %s\n", token.AppID) } - if token.EnvTypeId != nil { - successMsg += fmt.Sprintf("🌍 Env Type ID: %s\n", *token.EnvTypeId) + if token.EnvTypeID != "" { + successMsg += fmt.Sprintf("🌍 Env Type ID: %s\n", token.EnvTypeID) } if token.Permissions != nil { successMsg += fmt.Sprintf("📖 Read: %v\n", token.Permissions.Read) successMsg += fmt.Sprintf("✏️ Write: %v\n", token.Permissions.Write) } - successMsg += fmt.Sprintf("📅 Expires At: %s\n", token.ExpiresAt) - successMsg += fmt.Sprintf("🕐 Created At: %s\n", token.CreatedAt) + successMsg += fmt.Sprintf("📅 Expires At: %s\n", formatTime(token.ExpiresAt)) + successMsg += fmt.Sprintf("🕐 Created At: %s\n", formatTime(token.CreatedAt)) - successMsg = style.BoxStyle.Render(successMsg) + successMsg = style.Render(style.BoxStyle, successMsg) _, err := writer.Write([]byte(successMsg)) return err } -func (f *ServiceTokenFormatter) FormatGetSuccessMessage(writer io.Writer, token *sdk.ServiceTokenResponse) error { +func (f *ServiceTokenFormatter) FormatGetSuccessMessage(writer io.Writer, token *domain.ServiceToken) error { msg := "🔑 Service Token Details\n\n" msg += fmt.Sprintf("📛 Name: %s\n", token.Name) - msg += fmt.Sprintf("🆔 ID: %s\n", token.Id) - if token.AppId != nil { - msg += fmt.Sprintf("📱 App ID: %s\n", *token.AppId) + msg += fmt.Sprintf("🆔 ID: %s\n", token.ID) + if token.AppID != "" { + msg += fmt.Sprintf("📱 App ID: %s\n", token.AppID) } - if token.EnvTypeId != nil { - msg += fmt.Sprintf("🌍 Env Type ID: %s\n", *token.EnvTypeId) + if token.EnvTypeID != "" { + msg += fmt.Sprintf("🌍 Env Type ID: %s\n", token.EnvTypeID) } if token.Permissions != nil { msg += fmt.Sprintf("📖 Read: %v\n", token.Permissions.Read) msg += fmt.Sprintf("✏️ Write: %v\n", token.Permissions.Write) } - msg += fmt.Sprintf("📅 Expires At: %s\n", token.ExpiresAt) + msg += fmt.Sprintf("📅 Expires At: %s\n", formatTime(token.ExpiresAt)) if token.LastUsedAt != nil { - msg += fmt.Sprintf("🕐 Last Used At: %s\n", *token.LastUsedAt) + msg += fmt.Sprintf("🕐 Last Used At: %s\n", formatTime(*token.LastUsedAt)) } - msg += fmt.Sprintf("🕐 Created At: %s\n", token.CreatedAt) + msg += fmt.Sprintf("🕐 Created At: %s\n", formatTime(token.CreatedAt)) - msg = style.BoxStyle.Render(msg) + msg = style.Render(style.BoxStyle, msg) _, err := writer.Write([]byte(msg)) return err } -func (f *ServiceTokenFormatter) FormatListTable(writer io.Writer, tokens sdk.ServiceTokensResponse) error { +func (f *ServiceTokenFormatter) FormatListTable(writer io.Writer, tokens []domain.ServiceToken) error { w := tabwriter.NewWriter(writer, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "ID\tNAME\tAPP ID\tENV TYPE ID\tREAD\tWRITE\tEXPIRES AT\tCREATED AT") @@ -79,39 +79,48 @@ func (f *ServiceTokenFormatter) FormatListTable(writer io.Writer, tokens sdk.Ser for _, token := range tokens { appID := "-" - if token.AppId != nil { - appID = *token.AppId + if token.AppID != "" { + appID = token.AppID } envTypeID := "-" - if token.EnvTypeId != nil { - envTypeID = *token.EnvTypeId + if token.EnvTypeID != "" { + envTypeID = token.EnvTypeID } - readPerm := "false" - writePerm := "false" + readPerm := "-" + writePerm := "-" if token.Permissions != nil { readPerm = fmt.Sprintf("%v", token.Permissions.Read) writePerm = fmt.Sprintf("%v", token.Permissions.Write) } fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - token.Id, + token.ID, token.Name, appID, envTypeID, readPerm, writePerm, - token.ExpiresAt, - token.CreatedAt, + formatTime(token.ExpiresAt), + formatTime(token.CreatedAt), ) } return w.Flush() } +// formatTime renders timestamps in RFC3339, the shape the API returns and the +// CLI printed before the domain switch. Zero times mean "unknown". +func formatTime(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Format(time.RFC3339) +} + func (f *ServiceTokenFormatter) FormatDeleteSuccessMessage(writer io.Writer, id string) error { successMsg := fmt.Sprintf("✅ Service token deleted successfully! (ID: %s)\n", id) - successMsg = style.BoxStyle.Render(successMsg) + successMsg = style.Render(style.BoxStyle, successMsg) _, err := writer.Write([]byte(successMsg)) return err diff --git a/packages/envsync-cli/internal/presentation/formatters/sync_formatter.go b/packages/envsync-cli/internal/presentation/formatters/sync_formatter.go index d6afb4e6..9ccee374 100644 --- a/packages/envsync-cli/internal/presentation/formatters/sync_formatter.go +++ b/packages/envsync-cli/internal/presentation/formatters/sync_formatter.go @@ -1,5 +1,13 @@ package formatters +import ( + "fmt" + "strconv" + "strings" + + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/style" +) + type SyncFormatter struct { *BaseFormatter } @@ -10,3 +18,29 @@ func NewSyncFormatter() *SyncFormatter { BaseFormatter: base, } } + +func (f *SyncFormatter) FormatVariableDetails(added, updated, deleted int) { + if added == 0 && updated == 0 && deleted == 0 { + fmt.Println(style.SuccessStyle.Render("✓ No changes detected during sync.")) + return + } + + s := strings.Builder{} + if added > 0 { + s.WriteString("Added: ") + s.WriteString(strconv.Itoa(added)) + s.WriteString(" ") + } + if updated > 0 { + s.WriteString("Updated: ") + s.WriteString(strconv.Itoa(updated)) + s.WriteString(" ") + } + if deleted > 0 { + s.WriteString("Deleted: ") + s.WriteString(strconv.Itoa(deleted)) + s.WriteString(" ") + } + + fmt.Println(style.SuccessStyle.Render("✓ " + s.String() + "variables · ")) +} diff --git a/packages/envsync-cli/internal/presentation/spinner/spinner.go b/packages/envsync-cli/internal/presentation/spinner/spinner.go new file mode 100644 index 00000000..7408a27c --- /dev/null +++ b/packages/envsync-cli/internal/presentation/spinner/spinner.go @@ -0,0 +1,126 @@ +// Package spinner renders a single-line activity indicator for long waits. +package spinner + +import ( + "fmt" + "io" + "os" + "sync" + "time" + + charmspinner "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/x/term" + + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/style" +) + +// clearLine returns the cursor to column 0 and erases the line, so the +// animation can be replaced by the caller's own output. +const clearLine = "\r\033[2K" + +// Spinner animates a status line while blocking work is in flight. +// +// It draws to stderr so machine-readable stdout stays clean, and it draws +// nothing unless the caller decided the destination is an interactive +// terminal. Unlike style.Render it deliberately leaves the line unterminated: +// the animation owns the line and rewrites it in place. +type Spinner struct { + out io.Writer + enabled bool + message string + frames []string + interval time.Duration + + mu sync.Mutex + running bool + stop chan struct{} + done chan struct{} +} + +// New returns a spinner that writes to out and animates only while enabled. +// +// Terminal policy belongs at the call site: the spinner cannot know whether the +// surrounding command asked for machine-readable output. +func New(message string, enabled bool, out io.Writer) *Spinner { + preset := charmspinner.Dot + return &Spinner{ + out: out, + enabled: enabled, + message: message, + frames: preset.Frames, + interval: preset.FPS, + } +} + +// IsTerminal reports whether w is attached to a terminal. Character devices +// like /dev/null are not terminals, and treating them as one would push +// interactive TUIs into places where they cannot run. +func IsTerminal(w io.Writer) bool { + f, ok := w.(*os.File) + if !ok { + return false + } + return term.IsTerminal(f.Fd()) +} + +// Start begins animating. It is a no-op when disabled or already running. +func (s *Spinner) Start() { + s.mu.Lock() + if !s.enabled || s.running { + s.mu.Unlock() + return + } + s.running = true + s.stop = make(chan struct{}) + s.done = make(chan struct{}) + stop, done := s.stop, s.done + s.mu.Unlock() + + // Draw immediately so the status is visible without waiting a full tick; + // the goroutine only advances the frame from here on. + s.draw(0) + + go func() { + defer close(done) + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + for i := 1; ; i++ { + select { + case <-stop: + return + case <-ticker.C: + s.draw(i) + } + } + }() +} + +// Stop ends the animation and erases the spinner line so the caller can print +// its own output in its place. +func (s *Spinner) Stop() { + s.mu.Lock() + if !s.running { + s.mu.Unlock() + return + } + s.running = false + close(s.stop) + done := s.done + s.mu.Unlock() + + <-done + + s.mu.Lock() + defer s.mu.Unlock() + fmt.Fprint(s.out, clearLine) +} + +func (s *Spinner) draw(i int) { + frame := s.frames[i%len(s.frames)] + + s.mu.Lock() + defer s.mu.Unlock() + // Render inline: style.Render terminates the line, which would make the + // terminal scroll instead of rewriting in place. + fmt.Fprintf(s.out, "%s%s %s", clearLine, style.SpinnerStyle.Render(frame), s.message) +} diff --git a/packages/envsync-cli/internal/presentation/spinner/spinner_test.go b/packages/envsync-cli/internal/presentation/spinner/spinner_test.go new file mode 100644 index 00000000..2ccfa09b --- /dev/null +++ b/packages/envsync-cli/internal/presentation/spinner/spinner_test.go @@ -0,0 +1,72 @@ +package spinner + +import ( + "bytes" + "strings" + "testing" +) + +// The spinner must be silent unless the caller enabled it, so piped and +// machine-readable output never picks up ANSI escape sequences. +func TestSpinnerDisabledDrawsNothing(t *testing.T) { + var buf bytes.Buffer + s := New("working…", false, &buf) + + s.Start() + s.Stop() + + if buf.Len() != 0 { + t.Errorf("disabled spinner wrote %q, want no output", buf.String()) + } +} + +// Stop has to erase the line, otherwise the animation is left on screen and the +// caller's success message cannot take its place. +func TestSpinnerStopClearsTheLine(t *testing.T) { + var buf bytes.Buffer + s := New("Waiting for sign-in…", true, &buf) + + s.Start() + s.Stop() + + out := buf.String() + if !strings.Contains(out, "Waiting for sign-in…") { + t.Errorf("spinner never showed its message: %q", out) + } + if !strings.HasPrefix(out, clearLine) { + t.Errorf("spinner did not start with a line clear: %q", out) + } + if !strings.HasSuffix(out, clearLine) { + t.Errorf("Stop() did not erase the spinner line: %q", out) + } +} + +func TestSpinnerStopWithoutStartIsSafe(t *testing.T) { + var buf bytes.Buffer + s := New("working…", true, &buf) + + s.Stop() // must not panic + + if buf.Len() != 0 { + t.Errorf("Stop() without Start() wrote %q, want no output", buf.String()) + } +} + +func TestSpinnerStartIsIdempotent(t *testing.T) { + var buf bytes.Buffer + s := New("working…", true, &buf) + + s.Start() + s.Start() // second call must not start a second animation + s.Stop() + + if !strings.HasSuffix(buf.String(), clearLine) { + t.Errorf("output not terminated by a line clear: %q", buf.String()) + } +} + +func TestIsTerminalRejectsNonFileWriters(t *testing.T) { + if IsTerminal(&bytes.Buffer{}) { + t.Error("bytes.Buffer reported as a terminal") + } +} diff --git a/packages/envsync-cli/internal/presentation/style/style.go b/packages/envsync-cli/internal/presentation/style/style.go index 935d43db..9884b28a 100644 --- a/packages/envsync-cli/internal/presentation/style/style.go +++ b/packages/envsync-cli/internal/presentation/style/style.go @@ -1,6 +1,10 @@ package style -import "github.com/charmbracelet/lipgloss" +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) // Color palette var ( @@ -64,7 +68,7 @@ var ( SubHeaderStyle = lipgloss.NewStyle(). Foreground(SecondaryColor). - Bold(true). + Bold(false). Margin(0, 0, 1, 0) // Title styles @@ -74,7 +78,7 @@ var ( SubtitleStyle = lipgloss.NewStyle(). Foreground(MutedColor). - Italic(true) + Italic(false) // Content styles ContentStyle = lipgloss.NewStyle(). @@ -86,23 +90,24 @@ var ( // Status styles SuccessStyle = lipgloss.NewStyle(). Foreground(SuccessColor). - Bold(true) + Bold(false) ErrorStyle = lipgloss.NewStyle(). + Margin(1). Foreground(ErrorColor) WarningStyle = lipgloss.NewStyle(). Foreground(WarningColor). - Bold(true) + Bold(false) InfoStyle = lipgloss.NewStyle(). Foreground(InfoColor). - Bold(true) + Bold(false) // Interactive styles FocusedStyle = lipgloss.NewStyle(). Foreground(PrimaryColor). - Bold(true) + Bold(false) BlurredStyle = lipgloss.NewStyle(). Foreground(MutedColor) @@ -115,7 +120,7 @@ var ( // Key bindings KeyStyle = lipgloss.NewStyle(). Foreground(PrimaryColor). - Bold(true) + Bold(false) KeyDescStyle = lipgloss.NewStyle(). Foreground(MutedColor) @@ -214,6 +219,22 @@ var ( ) // Utility functions + +// Render styles text and guarantees the result ends with a newline. +// +// lipgloss pads every line of a multi-line block to the block's display width. +// A trailing "\n" in the input therefore becomes a padded empty line rather +// than a terminator, so the output ends with spaces and no newline — the shell +// prompt then appears glued to the last line. Keep the newline outside the +// styled block instead. +func Render(s lipgloss.Style, text string) string { + out := s.Render(strings.TrimRight(text, "\n")) + if !strings.HasSuffix(out, "\n") { + out += "\n" + } + return out +} + func WithEmoji(emoji, text string) string { return emoji + " " + text } diff --git a/packages/envsync-cli/internal/presentation/tui/component/form_component.go b/packages/envsync-cli/internal/presentation/tui/component/form_component.go index b3e3a723..652f34aa 100644 --- a/packages/envsync-cli/internal/presentation/tui/component/form_component.go +++ b/packages/envsync-cli/internal/presentation/tui/component/form_component.go @@ -152,7 +152,7 @@ func (fc *FormComponent) createFormField(config FormFieldConfig, key string) (hu Key(key). Title(config.Title). Value(config.StringPtr). - Password(true) + EchoMode(huh.EchoModePassword) if config.Description != "" { passwordField = passwordField.Description(config.Description) diff --git a/packages/envsync-cli/internal/presentation/tui/component/list_component.go b/packages/envsync-cli/internal/presentation/tui/component/list_component.go deleted file mode 100644 index 3acb992f..00000000 --- a/packages/envsync-cli/internal/presentation/tui/component/list_component.go +++ /dev/null @@ -1,312 +0,0 @@ -package component - -import ( - "io" - - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -// ListDisplayItem wraps any type T to implement the list.Item interface -type ListDisplayItem[T any] struct { - Item T - TitleFn func(T) string - DescFn func(T) string - FilterFn func(T) string -} - -// FilterValue returns the string that should be matched when filtering -func (i ListDisplayItem[T]) FilterValue() string { - if i.FilterFn != nil { - return i.FilterFn(i.Item) - } - return i.TitleFn(i.Item) -} - -// Title returns the title for the list item -func (i ListDisplayItem[T]) Title() string { - return i.TitleFn(i.Item) -} - -// Description returns the description for the list item (required by list.Item) -func (i ListDisplayItem[T]) Description() string { - return i.DescFn(i.Item) -} - -// GetValue returns the underlying item -func (i ListDisplayItem[T]) GetValue() T { - return i.Item -} - -// GenericListModel represents a generic list component using bubbles/list -type GenericListModel[T any] struct { - list list.Model - items []T - title string - emptyMsg string - width int - height int - showHelp bool - showStatus bool - showPaging bool - filtering bool -} - -// Define custom styles for the generic list (same as original) -var ( - genericDocStyle = lipgloss.NewStyle().Margin(1, 2) -) - -// GenericCustomDelegate extends the default list delegate with custom styling -type GenericCustomDelegate struct { - list.DefaultDelegate -} - -// NewGenericCustomDelegate creates a new custom delegate (same styling as original) -func NewGenericCustomDelegate() *GenericCustomDelegate { - d := &GenericCustomDelegate{ - DefaultDelegate: list.NewDefaultDelegate(), - } - - // Customize the delegate styles (same as original) - d.ShowDescription = true - d.SetHeight(4) // Increase height to accommodate multi-line descriptions - d.SetSpacing(1) - - // Define custom styles (same as original) - d.Styles.SelectedTitle = d.Styles.SelectedTitle. - Border(lipgloss.NormalBorder(), false, false, false, true). - BorderForeground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"}). - Foreground(lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}). - Bold(true) - - d.Styles.SelectedDesc = d.Styles.SelectedDesc. - Border(lipgloss.NormalBorder(), false, false, false, true). - BorderForeground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"}). - Foreground(lipgloss.AdaptiveColor{Light: "#AD58B4", Dark: "#AD58B4"}) - - return d -} - -// Render implements the list.ItemDelegate interface -func (d GenericCustomDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - d.DefaultDelegate.Render(w, m, index, listItem) -} - -// GenericListConfig holds configuration for creating a generic list -type GenericListConfig[T any] struct { - Items []T - Title string - EmptyMsg string - Width int - Height int - ShowHelp bool - ShowStatus bool - ShowPaging bool - Filtering bool - TitleFn func(T) string - DescFn func(T) string - FilterFn func(T) string // Optional, defaults to TitleFn -} - -// NewGenericListModel creates a new generic list model with the provided items and configuration -func NewGenericListModel[T any](config GenericListConfig[T]) *GenericListModel[T] { - // Convert generic items to list.Item slice - items := make([]list.Item, len(config.Items)) - for i, item := range config.Items { - filterFn := config.FilterFn - if filterFn == nil { - filterFn = config.TitleFn - } - - items[i] = ListDisplayItem[T]{ - Item: item, - TitleFn: config.TitleFn, - DescFn: config.DescFn, - FilterFn: filterFn, - } - } - - // Set defaults - width := config.Width - if width == 0 { - width = 80 - } - height := config.Height - if height == 0 { - height = 24 - } - - title := config.Title - if title == "" { - title = "📋 Items List" - } - - emptyMsg := config.EmptyMsg - if emptyMsg == "" { - emptyMsg = `📭 No items found. - -💡 Add some items to get started! - -Press Q to quit.` - } - - // Create the list with custom delegate - delegate := NewGenericCustomDelegate() - l := list.New(items, delegate, width, height) - - // Configure the list - l.Title = title - l.SetShowStatusBar(config.ShowStatus) - l.SetShowPagination(config.ShowPaging) - l.SetFilteringEnabled(config.Filtering) - l.SetShowHelp(config.ShowHelp) - - // Ensure proper sizing - l.SetSize(width, height) - - model := &GenericListModel[T]{ - list: l, - items: config.Items, - title: title, - emptyMsg: emptyMsg, - width: width, - height: height, - showHelp: config.ShowHelp, - showStatus: config.ShowStatus, - showPaging: config.ShowPaging, - filtering: config.Filtering, - } - - return model -} - -// Init initializes the model -func (m *GenericListModel[T]) Init() tea.Cmd { - // Ensure the list is properly sized on initialization - m.list.SetSize(m.width, m.height) - return nil -} - -// Update handles messages and updates the model state -func (m *GenericListModel[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - h, v := genericDocStyle.GetFrameSize() - m.width = msg.Width - h - m.height = msg.Height - v - m.list.SetSize(m.width, m.height) - - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit - } - } - - // Update the list and get any commands - var cmd tea.Cmd - m.list, cmd = m.list.Update(msg) - return m, cmd -} - -// View renders the current view -func (m *GenericListModel[T]) View() string { - if len(m.items) == 0 { - return genericDocStyle.Render(m.emptyMsg) - } - - // Ensure the list has proper dimensions - if m.list.Width() == 0 || m.list.Height() == 0 { - m.list.SetSize(m.width, m.height) - } - - return genericDocStyle.Render(m.list.View()) -} - -// GetItems returns all items -func (m *GenericListModel[T]) GetItems() []T { - return m.items -} - -// GetSelectedItem returns the currently selected item -func (m *GenericListModel[T]) GetSelectedItem() *T { - if item, ok := m.list.SelectedItem().(ListDisplayItem[T]); ok { - value := item.GetValue() - return &value - } - return nil -} - -// GetSelectedIndex returns the index of the currently selected item -func (m *GenericListModel[T]) GetSelectedIndex() int { - return m.list.Index() -} - -// SetItems updates the items in the list -func (m *GenericListModel[T]) SetItems(items []T, titleFn func(T) string, descFn func(T) string, filterFn func(T) string) { - m.items = items - - // Convert to list items - listItems := make([]list.Item, len(items)) - for i, item := range items { - if filterFn == nil { - filterFn = titleFn - } - - listItems[i] = ListDisplayItem[T]{ - Item: item, - TitleFn: titleFn, - DescFn: descFn, - FilterFn: filterFn, - } - } - - m.list.SetItems(listItems) -} - -// SetSize updates the size of the list -func (m *GenericListModel[T]) SetSize(width, height int) { - m.width = width - m.height = height - m.list.SetSize(width, height) -} - -// SetTitle updates the title of the list -func (m *GenericListModel[T]) SetTitle(title string) { - m.title = title - m.list.Title = title -} - -// IsEmpty returns true if the list has no items -func (m *GenericListModel[T]) IsEmpty() bool { - return len(m.items) == 0 -} - -// Len returns the number of items in the list -func (m *GenericListModel[T]) Len() int { - return len(m.items) -} - -// Helper function to create a default configuration -func DefaultGenericListConfig[ - T any]( - items []T, - title string, - titleFn func(T) string, - descFn func(T) string, -) GenericListConfig[T] { - return GenericListConfig[T]{ - Items: items, - Title: title, - Width: 80, - Height: 24, - ShowHelp: true, - ShowStatus: true, - ShowPaging: true, - Filtering: true, - TitleFn: titleFn, - DescFn: descFn, - } -} \ No newline at end of file diff --git a/packages/envsync-cli/internal/presentation/tui/component/table_component.go b/packages/envsync-cli/internal/presentation/tui/component/table_component.go new file mode 100644 index 00000000..265a8fcb --- /dev/null +++ b/packages/envsync-cli/internal/presentation/tui/component/table_component.go @@ -0,0 +1,233 @@ +package component + +import ( + "strings" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/styles" +) + +// TableConfig describes a tabular view. +type TableConfig struct { + Title string + Columns []table.Column + Rows []table.Row + Width int + Height int + // Selectable makes Enter submit the highlighted row and quit. + Selectable bool + // Confirm, when set with Selectable, turns that submit into an overlay + // asking the question it returns for the highlighted row. + Confirm func(table.Row) string + // Help replaces the default footer hint. + Help string +} + +// TableModel renders tabular data with bubbles/table. +type TableModel struct { + table table.Model + title string + help string + confirm func(table.Row) string + width int + rowCount int + selectable bool + submitted bool + confirming bool +} + +// minBodyRows is the floor for the visible data area, so a short list still +// reads as a table rather than a couple of lines. +const minBodyRows = 5 + +// bodyHeight sizes the table to the data: at least five rows, tall enough to +// show every row, and never taller than the terminal allows. +func (m *TableModel) bodyHeight(termHeight int) int { + rows := m.rowCount + if rows < minBodyRows { + rows = minBodyRows + } + h := rows + 1 // data rows + header + + if max := termHeight - 4; h > max { // header + blank above, blank + footer below + h = max + } + if h < 3 { + h = 3 + } + return h +} + +// NewTableModel builds a table styled with the shared brand palette. +func NewTableModel(cfg TableConfig) *TableModel { + if cfg.Width == 0 { + cfg.Width = 80 + } + if cfg.Height == 0 { + cfg.Height = 24 + } + + m := &TableModel{ + title: cfg.Title, + help: cfg.Help, + confirm: cfg.Confirm, + width: cfg.Width, + rowCount: len(cfg.Rows), + selectable: cfg.Selectable, + } + + // A rendered row is the columns plus Cell's horizontal padding, so derive + // the table width from the columns. Setting it wider leaves a gap between + // the data and the header rule / surrounding box. + rowWidth := 0 + for _, c := range cfg.Columns { + rowWidth += c.Width + 2 // cell padding(0, 1) + } + + m.table = table.New( + table.WithColumns(cfg.Columns), + table.WithRows(cfg.Rows), + table.WithWidth(rowWidth), + table.WithHeight(m.bodyHeight(cfg.Height)), + table.WithFocused(true), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(styles.DarkGrayColor). + BorderBottom(true). + Foreground(styles.PrimaryColor). + Bold(true). + Padding(0, 1) + s.Cell = s.Cell.Padding(0, 1) + s.Selected = s.Selected. + Foreground(styles.LightGrayColor). + Background(styles.SecondaryColor). + Bold(false) + m.table.SetStyles(s) + + return m +} + +func (m *TableModel) Init() tea.Cmd { return nil } + +func (m *TableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.table.SetHeight(m.bodyHeight(msg.Height)) + case tea.KeyMsg: + if m.confirming { + switch msg.String() { + case "y", "enter": + m.confirming = false + m.submitted = true + return m, tea.Quit + case "n", "esc": + // Back to the table so a different application can be picked. + m.confirming = false + return m, nil + case "ctrl+c", "q": + return m, tea.Quit + } + return m, nil + } + + switch msg.String() { + case "ctrl+c", "q", "esc": + return m, tea.Quit + case "enter": + if m.selectable { + if m.confirm != nil { + m.confirming = true + return m, nil + } + m.submitted = true + return m, tea.Quit + } + } + } + + var cmd tea.Cmd + m.table, cmd = m.table.Update(msg) + return m, cmd +} + +// tableBox frames the table, matching the bubbletea table example. +var tableBox = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(styles.DarkGrayColor) + +func (m *TableModel) View() string { + var b strings.Builder + b.WriteString(styles.Boundary(m.width, m.title, styles.FormHeaderStyle, styles.FormBoundaryFillStyle)) + b.WriteString("\n\n") + + body := strings.Split(tableBox.Render(m.table.View()), "\n") + if m.confirming { + body = overlay(body, strings.Split(m.confirmModal(), "\n")) + } + b.WriteString(strings.Join(body, "\n")) + + b.WriteString("\n") + help := m.help + switch { + case m.confirming: + help = "y/enter confirm • n/esc back" + case help == "": + help = "↑/k up • ↓/j down • q quit" + } + b.WriteString(styles.Boundary(m.width, help, styles.FormFooterStyle, styles.FormBoundaryFillStyle)) + return b.String() +} + +// confirmModal is the overlay asking the user to confirm the highlighted row. +func (m *TableModel) confirmModal() string { + question := "Are you sure?" + if m.confirm != nil { + if row := m.table.SelectedRow(); row != nil { + question = m.confirm(row) + } + } + + box := styles.ModalStyle.Render( + lipgloss.NewStyle().Bold(true).Foreground(styles.LightGrayColor).Render(question) + + "\n\n" + + lipgloss.NewStyle().Foreground(styles.MutedColor).Render("y/enter confirm n/esc back"), + ) + + // Centre the box in a block as wide as the table so it lines up with it. + return lipgloss.Place(m.width, lipgloss.Height(box), lipgloss.Center, lipgloss.Center, box) +} + +// overlay centres box over lines, replacing the rows it covers while leaving +// the table visible above and below it. +func overlay(lines, box []string) []string { + out := append([]string(nil), lines...) + start := (len(out) - len(box)) / 2 + if start < 0 { + start = 0 + } + for len(out) < start+len(box) { + out = append(out, "") + } + for i := range box { + out[start+i] = box[i] + } + return out +} + +// SelectedRow returns the row under the cursor, or nil when there is none. +func (m *TableModel) SelectedRow() table.Row { + return m.table.SelectedRow() +} + +// Submitted reports whether the user confirmed a row with Enter rather than +// quitting out of the table. +func (m *TableModel) Submitted() bool { + return m.submitted +} diff --git a/packages/envsync-cli/internal/presentation/tui/component/table_component_test.go b/packages/envsync-cli/internal/presentation/tui/component/table_component_test.go new file mode 100644 index 00000000..2ea2bb08 --- /dev/null +++ b/packages/envsync-cli/internal/presentation/tui/component/table_component_test.go @@ -0,0 +1,150 @@ +package component + +import ( + "testing" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" +) + +func newTable(selectable bool) *TableModel { + return NewTableModel(TableConfig{ + Title: "Applications", + Columns: []table.Column{ + {Title: "NAME", Width: 10}, + {Title: "ID", Width: 8}, + }, + Rows: []table.Row{ + {"alpha", "a1"}, + {"beta", "b2"}, + }, + Width: 30, + Height: 10, + Selectable: selectable, + }) +} + +// Regression: the app delete picker needs Enter to confirm the highlighted row +// and to tell the caller that the user chose rather than quit. +func TestTableEnterSubmitsWhenSelectable(t *testing.T) { + m := newTable(true) + + if m.SelectedRow()[1] != "a1" { + t.Fatalf("cursor starts on %v, want the first row", m.SelectedRow()) + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + model := updated.(*TableModel) + if model.SelectedRow()[1] != "b2" { + t.Fatalf("cursor after down = %v, want the second row", model.SelectedRow()) + } + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(*TableModel) + + if !model.Submitted() { + t.Error("Enter did not mark the table as submitted") + } + if model.SelectedRow()[1] != "b2" { + t.Errorf("submitted row = %v, want the highlighted row", model.SelectedRow()) + } +} + +// A browse-only table must not treat Enter as a selection. +func TestTableEnterDoesNotSubmitWhenNotSelectable(t *testing.T) { + m := newTable(false) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if updated.(*TableModel).Submitted() { + t.Error("Enter submitted a non-selectable table") + } +} + +// Quitting must never look like a submission, or the caller would delete +// whatever happened to be under the cursor. +func TestTableQuitDoesNotSubmit(t *testing.T) { + for _, key := range []tea.KeyMsg{ + {Type: tea.KeyEsc}, + {Type: tea.KeyCtrlC}, + {Type: tea.KeyRunes, Runes: []rune{'q'}}, + } { + m := newTable(true) + updated, _ := m.Update(key) + if updated.(*TableModel).Submitted() { + t.Errorf("%q reported as a submission", key.String()) + } + } +} + +// When a confirm is configured, Enter opens the overlay and only an accepted +// prompt may reach the caller as a submission. +func TestTableConfirmGatesSubmission(t *testing.T) { + start := func() *TableModel { + m := newTable(true) + m.confirm = func(table.Row) string { return "Delete it?" } + return m + } + + updated, _ := start().Update(tea.KeyMsg{Type: tea.KeyEnter}) + model := updated.(*TableModel) + if model.Submitted() { + t.Fatal("Enter submitted without confirming") + } + if !model.confirming { + t.Fatal("Enter did not open the confirmation overlay") + } + + // Declining goes back to the table and must not submit. + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + model = updated.(*TableModel) + if model.Submitted() { + t.Error("declining the prompt still submitted") + } + if model.confirming { + t.Error("declining left the confirmation overlay open") + } + + // Accepting submits. + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(*TableModel) + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}) + if !updated.(*TableModel).Submitted() { + t.Error("confirming did not submit") + } +} + +// A short list must still read as a table, and a longer one must grow to show +// every row until the terminal runs out of room. +func TestTableBodyHeight(t *testing.T) { + tests := []struct { + name string + rows int + term int + wantRows int // visible data rows, excluding the header + }{ + {name: "short list is padded to five rows", rows: 2, term: 40, wantRows: 5}, + {name: "five rows fit exactly", rows: 5, term: 40, wantRows: 5}, + {name: "longer list grows to fit", rows: 9, term: 40, wantRows: 9}, + {name: "terminal caps the height", rows: 30, term: 12, wantRows: 7}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rows := make([]table.Row, 0, tc.rows) + for range tc.rows { + rows = append(rows, table.Row{"x", "y"}) + } + + m := NewTableModel(TableConfig{ + Columns: []table.Column{{Title: "A", Width: 4}, {Title: "B", Width: 4}}, + Rows: rows, + Width: 20, + Height: tc.term, + }) + + if got := m.bodyHeight(tc.term) - 1; got != tc.wantRows { + t.Errorf("visible data rows = %d, want %d", got, tc.wantRows) + } + }) + } +} diff --git a/packages/envsync-cli/internal/presentation/tui/factory/access_factory.go b/packages/envsync-cli/internal/presentation/tui/factory/access_factory.go new file mode 100644 index 00000000..8d5d0af4 --- /dev/null +++ b/packages/envsync-cli/internal/presentation/tui/factory/access_factory.go @@ -0,0 +1,142 @@ +package factory + +import ( + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/component" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/styles" +) + +type AccessFactory struct{} + +func NewAccessFactory() *AccessFactory { + return &AccessFactory{} +} + +// AccessChoice is one selectable row of an access picker: the row's identity +// plus the summary cells shown for it. +type AccessChoice struct { + ID string + Cells []string +} + +// PickApplication shows the applications in a table and returns the ID of the +// one the user submits. ok is false when they quit without choosing. +func (f *AccessFactory) PickApplication(choices []AccessChoice, title string) (string, bool, error) { + return pickAccessChoice(title, []table.Column{ + {Title: "NAME", Width: 24}, + {Title: "DESCRIPTION", Width: 32}, + }, choices) +} + +// PickUser shows the users in a table and returns the ID of the one the user +// submits. ok is false when they quit without choosing. +func (f *AccessFactory) PickUser(choices []AccessChoice, title string) (string, bool, error) { + return pickAccessChoice(title, []table.Column{ + {Title: "EMAIL", Width: 36}, + }, choices) +} + +// PickTeam shows the teams in a table and returns the ID of the one the user +// submits. ok is false when they quit without choosing. +func (f *AccessFactory) PickTeam(choices []AccessChoice, title string) (string, bool, error) { + return pickAccessChoice(title, []table.Column{ + {Title: "NAME", Width: 24}, + {Title: "DESCRIPTION", Width: 32}, + }, choices) +} + +// pickAccessChoice renders the summary columns plus a zero-width ID column: +// invisible in the table (the renderer skips zero-width columns) but still the +// row's identity on submit, so a lookalike row can never resolve to the wrong +// subject. +func pickAccessChoice(title string, cols []table.Column, choices []AccessChoice) (string, bool, error) { + rows := make([]table.Row, 0, len(choices)) + for _, c := range choices { + // Cells and columns are written together; pad defensively so a + // mismatch renders blanks instead of crashing the table. + row := make(table.Row, 0, len(cols)+1) + for i := range cols { + if i < len(c.Cells) { + row = append(row, c.Cells[i]) + } else { + row = append(row, "") + } + } + rows = append(rows, append(row, c.ID)) + } + + width := 0 + for _, c := range cols { + width += c.Width + 2 // cell padding(0, 1) + } + + row, ok, err := selectRow(component.TableConfig{ + Title: title, + Columns: append(cols, table.Column{Title: "ID", Width: 0}), + Rows: rows, + Width: width, + Height: 24, + Help: "↑/k up • ↓/j down • enter select • q cancel", + }) + if !ok || err != nil { + return "", ok, err + } + + return row[len(row)-1], true, nil +} + +// SelectSubjectType asks whether the grant targets a user or a team. ok is +// false when the user backs out. +func (f *AccessFactory) SelectSubjectType() (string, bool, error) { + return selectAccessValue( + "Select the subject type", + "Whether the grant targets a user or a team.", + domain.AccessSubjectTypes(), + ) +} + +// SelectRelation asks for the access level to grant. ok is false when the +// user backs out. +func (f *AccessFactory) SelectRelation() (string, bool, error) { + return selectAccessValue( + "Select the relation", + "The access level to grant.", + domain.AccessRelations(), + ) +} + +// selectAccessValue asks for one value from the accepted list, offering no +// other choice. ok is false when the user backs out. +func selectAccessValue(title, description string, options []string) (string, bool, error) { + value := options[0] + opts := make([]huh.Option[string], 0, len(options)) + for _, o := range options { + opts = append(opts, huh.NewOption(o, o)) + } + + form := huh.NewForm(huh.NewGroup( + huh.NewSelect[string](). + Title(title). + Description(description). + Options(opts...). + Value(&value), + )).WithTheme(styles.FormTheme()).WithShowHelp(false) + + if _, err := tea.NewProgram(formModel{ + form: form, + title: title, + width: 80, + }).Run(); err != nil { + return "", false, err + } + + if form.State == huh.StateAborted { + return "", false, nil + } + + return value, true, nil +} diff --git a/packages/envsync-cli/internal/presentation/tui/factory/app_factory.go b/packages/envsync-cli/internal/presentation/tui/factory/app_factory.go index 48acd276..56cb1d73 100644 --- a/packages/envsync-cli/internal/presentation/tui/factory/app_factory.go +++ b/packages/envsync-cli/internal/presentation/tui/factory/app_factory.go @@ -4,138 +4,309 @@ import ( "context" "errors" "fmt" + "strconv" "strings" + "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/component" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/styles" ) type AppFactory struct{} +// formModel wraps a huh form with the slash boundary header and footer that +// frame the interactive flows. +type formModel struct { + form *huh.Form + title string + width int +} + +func (m formModel) Init() tea.Cmd { + return m.form.Init() +} + +func (m formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if size, ok := msg.(tea.WindowSizeMsg); ok { + m.width = size.Width + // The form is wrapped in a header and a help footer it does not know + // about. Hand it the height that is left, or tall fields (like the file + // picker) grow past the help text and off the screen. + avail := size.Height - m.chromeHeight() + if avail < 1 { + avail = 1 + } + msg = tea.WindowSizeMsg{Width: size.Width, Height: avail} + } + + form, cmd := m.form.Update(msg) + if f, ok := form.(*huh.Form); ok { + m.form = f + } + + if m.form.State == huh.StateCompleted || m.form.State == huh.StateAborted { + return m, tea.Quit + } + + return m, cmd +} + +// chromeHeight reports how many lines the header and footer add around the +// form view. +func (m formModel) chromeHeight() int { + return lipgloss.Height(m.header()) + 1 + lipgloss.Height(m.footer()) +} + +// header renders the slash boundary line above the form. +func (m formModel) header() string { + return styles.Boundary(m.formWidth(), m.title, styles.FormHeaderStyle, styles.FormBoundaryFillStyle) +} + +// footer renders the slash boundary line with the form's key help below it. +func (m formModel) footer() string { + return styles.Boundary(m.formWidth(), m.form.Help().ShortHelpView(m.form.KeyBinds()), styles.FormFooterStyle, styles.FormBoundaryFillStyle) +} + +func (m formModel) formWidth() int { + if m.width <= 0 { + return 80 + } + return m.width +} + +func (m formModel) View() string { + // The chrome belongs to the form only. Once the form is submitted or + // aborted it is dropped, so the header is not left on screen above the + // loader and the success message. + if m.form.State != huh.StateNormal { + return m.form.View() + } + + return m.header() + "\n\n" + m.form.View() + "\n" + m.footer() +} + func NewAppFactory() *AppFactory { return &AppFactory{} } -// CreateAppTUI runs the interactive app creation flow +// CreateAppTUI runs the interactive app creation flow. +// +// The fields mirror the `app create` flags. The public key is only offered when +// secret encryption is enabled, and it is optional there. func (f *AppFactory) CreateAppTUI(ctx context.Context, app *domain.Application) (*domain.Application, error) { - var confirm bool - - form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Application Name"). - Description("Enter a unique name for your application"). - Placeholder("my-awesome-app"). - Value(&app.Name). - Validate(func(str string) error { - if strings.TrimSpace(str) == "" { - return fmt.Errorf("application name is required") - } - if len(str) > 100 { - return fmt.Errorf("application name must be 100 characters or less") - } - return nil - }), - - huh.NewText(). - Title("Description"). - Description("Provide a description for your application"). - Placeholder("A brief description of what this application does..."). - Value(&app.Description). - Lines(3). - Validate(func(str string) error { - if strings.TrimSpace(str) == "" { - return fmt.Errorf("application description is required") - } - if len(str) > 500 { - return fmt.Errorf("description must be 500 characters or less") - } - return nil - }), - huh.NewConfirm(). - Title("Are you sure?"). - Affirmative("Yes!"). - Negative("No."). - Value(&confirm), - ), - ).WithTheme(huh.ThemeCharm()) - - err := form.Run() - if err != nil { + var ( + metadataText string + defaultTypes bool + enableSecrets bool + publicKey string + ) + + details := huh.NewGroup( + huh.NewInput(). + Title("Application Name"). + Description("Used across the CLI and the dashboard."). + Placeholder("my-awesome-app"). + Value(&app.Name). + Validate(func(s string) error { + name := strings.TrimSpace(s) + if name == "" { + return fmt.Errorf("application name is required") + } + if len(name) > 100 { + return fmt.Errorf("application name must be 100 characters or less") + } + return nil + }), + + huh.NewText(). + Title("Description"). + Description("A brief description of what this application does."). + Placeholder("Customer-facing REST API."). + Value(&app.Description). + Lines(3). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("application description is required") + } + if len(s) > 500 { + return fmt.Errorf("description must be 500 characters or less") + } + return nil + }), + + huh.NewText(). + Title("Metadata (optional)"). + Description("One key=value pair per line."). + Placeholder("team=core\nowner=platform"). + Value(&metadataText). + Lines(3), + ).Title("Application details") + + options := huh.NewGroup( + huh.NewConfirm(). + Title("Enable secret encryption?"). + Description("Encrypts the secrets stored for this application."). + Affirmative("Yes"). + Negative("No"). + WithButtonAlignment(lipgloss.Left). + Value(&enableSecrets), + + huh.NewConfirm(). + Title("Create default environment types?"). + Description("Creates the default environment types: Production, Development, and Staging."). + Affirmative("Yes"). + Negative("No"). + WithButtonAlignment(lipgloss.Left). + Value(&defaultTypes), + ).Title("Options") + + // huh can only hide whole groups, so the public key gets its own step and + // is skipped entirely when secret encryption is off. + encryption := huh.NewGroup( + huh.NewText(). + Title("Public Key (optional)"). + Description("PEM public key used to encrypt secrets. Leave blank and EnvSync will generate and manage one for you."). + Placeholder("-----BEGIN PUBLIC KEY-----"). + Value(&publicKey). + Lines(4), + ).Title("Encryption"). + WithHideFunc(func() bool { return !enableSecrets }) + + form := huh.NewForm(details, options, encryption). + WithTheme(styles.FormTheme()). + WithShowHelp(false) + + if _, err := tea.NewProgram(formModel{ + form: form, + title: "Create application", + width: 80, + }).Run(); err != nil { return nil, err } - if !confirm { + if form.State == huh.StateAborted { return nil, errors.New("application creation cancelled by user") } + // The textarea is free-form, so blank lines are simply ignored. + var entries []string + for _, line := range strings.Split(metadataText, "\n") { + if strings.TrimSpace(line) != "" { + entries = append(entries, line) + } + } + metadata, err := domain.ParseMetadata(entries) + if err != nil { + return nil, fmt.Errorf("invalid metadata: %w", err) + } + + app.Metadata = metadata + app.EnableSecrets = enableSecrets + app.PublicKey = strings.TrimSpace(publicKey) + app.IsManagedSecret = enableSecrets && app.PublicKey == "" + if defaultTypes { + app.EnvTypes = append(app.EnvTypes, domain.DefaultEnvTypes...) + } + return app, nil } -// DeleteAppTUI runs the interactive app deletion flow using Bubble Tea -func (f *AppFactory) DeleteAppsTUI(apps []domain.Application) ([]domain.Application, error) { - adapter := func(item domain.Application, selected bool, multiSelect bool) component.GenericListItem[domain.Application] { - return component.GenericListItem[domain.Application]{ - Item: item, - TitleStr: item.Name, - DescStr: item.ID, - FilterStr: item.Name, - Selected: selected, - MultiSelect: multiSelect, +// appTableColumns are the columns shared by every application table. +func appTableColumns() []table.Column { + return []table.Column{ + {Title: "NAME", Width: 24}, + {Title: "ID", Width: 22}, + {Title: "DESCRIPTION", Width: 48}, + {Title: "ENVS", Width: 6}, + } +} + +// appTableRows projects applications onto those columns. +func appTableRows(apps []domain.Application) []table.Row { + rows := make([]table.Row, 0, len(apps)) + for _, a := range apps { + envCount := strconv.Itoa(len(a.EnvTypes)) + if a.EnvCount != "" { + envCount = a.EnvCount } + rows = append(rows, table.Row{a.Name, a.ID, a.Description, envCount}) } - keyFn := func(e domain.Application) string { return e.ID } + return rows +} - model := component.NewSelectableListModel( - apps, - adapter, - "🗑️ Select Environment", - 80, 20, - true, - keyFn, - ) +// ListAppsInteractive runs the interactive app listing flow +func (f *AppFactory) ListAppsInteractive(apps []domain.Application) error { + model := component.NewTableModel(component.TableConfig{ + Title: "Applications", + Columns: appTableColumns(), + Rows: appTableRows(apps), + Width: 108, + Height: 24, + }) - program := tea.NewProgram(model, tea.WithAltScreen()) + // No alt screen: the table renders inline rather than taking over the + // terminal. + if _, err := tea.NewProgram(model).Run(); err != nil { + return fmt.Errorf("error running app list TUI: %w", err) + } - finalModel, err := program.Run() + return nil +} + +// selectRow runs a selectable table and returns the row the user submits. ok is +// false when they quit without choosing. Program options let a multi-step flow +// run the table full screen so it cannot stack with the next step. +func selectRow(cfg component.TableConfig, opts ...tea.ProgramOption) (table.Row, bool, error) { + cfg.Selectable = true + model := component.NewTableModel(cfg) + + final, err := tea.NewProgram(model, opts...).Run() if err != nil { - return nil, err + return nil, false, fmt.Errorf("error running selection table: %w", err) } - deleteModel := finalModel.(*component.SelectableListModel[domain.Application]).GetSelectedItems() + picked, ok := final.(*component.TableModel) + if !ok || !picked.Submitted() { + return nil, false, nil + } - return deleteModel, nil + row := picked.SelectedRow() + if row == nil { + return nil, false, nil + } + return row, true, nil } -// ListAppsInteractive runs the interactive app listing flow -func (f *AppFactory) ListAppsInteractive(apps []domain.Application) error { - // // Create the list model with loading capability - config := component.DefaultGenericListConfig( - apps, - "🚀 Applications List", - func(app domain.Application) string { - return fmt.Sprintf("📛 %s", app.Name) +// PickApp shows the applications in a table and returns the one the user +// submits. ok is false when they quit without choosing. +func (f *AppFactory) PickApp(apps []domain.Application) (domain.Application, bool, error) { + row, ok, err := selectRow(component.TableConfig{ + Title: "Select the application to delete", + Columns: appTableColumns(), + Rows: appTableRows(apps), + Width: 108, + Height: 24, + Confirm: func(row table.Row) string { + return fmt.Sprintf("Delete '%s'?", row[0]) }, - func(app domain.Application) string { - return fmt.Sprintf("🆔 %s\n📝 %s", app.ID, app.Description) - }, - ) - - model := component.NewGenericListModel(config) - - // Run the program - program := tea.NewProgram( - model, - tea.WithAltScreen(), - ) + Help: "↑/k up • ↓/j down • enter select • q cancel", + }) + if !ok || err != nil { + return domain.Application{}, ok, err + } - _, err := program.Run() - if err != nil { - return fmt.Errorf("error running app list TUI: %w", err) + // Match on the ID cell rather than the cursor position so a reordered or + // filtered table can never select the wrong application. + for _, a := range apps { + if a.ID == row[1] { + return a, true, nil + } } - return nil + return domain.Application{}, false, fmt.Errorf("selected application %q is no longer available", row[1]) } diff --git a/packages/envsync-cli/internal/presentation/tui/factory/environment_factory.go b/packages/envsync-cli/internal/presentation/tui/factory/environment_factory.go index b2cb5b21..a9bd369f 100644 --- a/packages/envsync-cli/internal/presentation/tui/factory/environment_factory.go +++ b/packages/envsync-cli/internal/presentation/tui/factory/environment_factory.go @@ -2,10 +2,13 @@ package factory import ( "errors" + "fmt" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/component" - tea "github.com/charmbracelet/bubbletea" ) type EnvFactory struct{} @@ -47,3 +50,46 @@ func (f *EnvFactory) SelectEnvironmentTUI(envs []domain.EnvType) (domain.EnvType } return selected[0], nil } + +// envTableColumns are the columns shared by every environment table. +func envTableColumns() []table.Column { + return []table.Column{ + {Title: "NAME", Width: 24}, + {Title: "ID", Width: 36}, + {Title: "DEFAULT", Width: 8}, + {Title: "PROTECTED", Width: 10}, + } +} + +// envTableRows projects environments onto those columns. +func envTableRows(envs []domain.EnvType) []table.Row { + rows := make([]table.Row, 0, len(envs)) + for _, e := range envs { + rows = append(rows, table.Row{e.Name, e.ID, boolWord(e.IsDefault), boolWord(e.IsProtected)}) + } + return rows +} + +func boolWord(v bool) string { + if v { + return "yes" + } + return "no" +} + +// ListEnvsInteractive runs the interactive environment listing flow +func (f *EnvFactory) ListEnvsInteractive(envs []domain.EnvType) error { + model := component.NewTableModel(component.TableConfig{ + Title: "Environments", + Columns: envTableColumns(), + Rows: envTableRows(envs), + Width: 108, + Height: 24, + }) + + if _, err := tea.NewProgram(model).Run(); err != nil { + return fmt.Errorf("error running env list TUI: %w", err) + } + + return nil +} diff --git a/packages/envsync-cli/internal/presentation/tui/factory/form_model_test.go b/packages/envsync-cli/internal/presentation/tui/factory/form_model_test.go new file mode 100644 index 00000000..fba993bb --- /dev/null +++ b/packages/envsync-cli/internal/presentation/tui/factory/form_model_test.go @@ -0,0 +1,84 @@ +package factory + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/styles" +) + +// Regression: the form model wraps the form in a header and help footer, but +// the form was sized to the full terminal height. Tall fields (the file picker +// zooms to the form height) then pushed the frame past the help text and off +// the screen. +func TestFormModelFrameFitsTerminal(t *testing.T) { + for _, height := range []int{24, 40} { + filePath := "" + mode := "binary" + picker := huh.NewFilePicker(). + Title("File"). + Description("Select the file to sign."). + FileAllowed(true). + DirAllowed(false). + Value(&filePath) + form := huh.NewForm( + huh.NewGroup( + &boundedFilePicker{FilePicker: picker, max: 12}, + huh.NewSelect[string](). + Title("Mode"). + Options(huh.NewOption("binary", "binary"), huh.NewOption("text", "text")). + Value(&mode), + ), + ).WithTheme(styles.FormTheme()).WithShowHelp(false) + + var model tea.Model = formModel{form: form, title: "Sign with GPG key", width: 100} + model, _ = model.Update(tea.WindowSizeMsg{Width: 100, Height: height}) + + // Opening the picker zooms the field to the form height. + _, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil { + if msg := cmd(); msg != nil { + model, _ = model.Update(msg) + } + } + + // Repeated size messages (terminal resizes, tmux redraws) ratcheted the + // zoomed picker taller every time until the frame ran past the help + // text. The frame must stay within the terminal through all of them. + for range 15 { + model, _ = model.Update(tea.WindowSizeMsg{Width: 100, Height: height}) + } + + view := model.(formModel).View() + if got := lipgloss.Height(view); got > height { + t.Errorf("terminal %d rows: frame is %d lines, want <= %d", height, got, height) + } + + // The picker box must render complete (its closing border) and the help + // footer must be the last thing on screen — the reported bug was the + // picker's viewport continuing below the help line. + lines := strings.Split(view, "\n") + closed := false + for _, line := range lines { + if strings.Contains(line, "╰") { + closed = true + } + } + if !closed { + t.Errorf("terminal %d rows: picker box never closed with a bottom border", height) + } + last := "" + for _, line := range lines { + if strings.TrimSpace(line) != "" { + last = line + } + } + if !strings.Contains(last, "esc close") { + t.Errorf("terminal %d rows: help footer is not the last line, got %q", height, last) + } + } +} diff --git a/packages/envsync-cli/internal/presentation/tui/factory/gpg_key_factory.go b/packages/envsync-cli/internal/presentation/tui/factory/gpg_key_factory.go new file mode 100644 index 00000000..474945b8 --- /dev/null +++ b/packages/envsync-cli/internal/presentation/tui/factory/gpg_key_factory.go @@ -0,0 +1,339 @@ +package factory + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/formatters" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/component" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/styles" +) + +type GpgKeyFactory struct{} + +func NewGpgKeyFactory() *GpgKeyFactory { + return &GpgKeyFactory{} +} + +// gpgKeyTableColumns are the columns shared by every GPG key table. +func gpgKeyTableColumns() []table.Column { + return []table.Column{ + {Title: "ID", Width: 36}, + {Title: "NAME", Width: 20}, + {Title: "EMAIL", Width: 30}, + {Title: "FINGERPRINT", Width: 16}, + {Title: "ALGORITHM", Width: 10}, + {Title: "STATUS", Width: 8}, + } +} + +// gpgKeyTableRows projects GPG keys onto those columns +func gpgKeyTableRows(keys []domain.GpgKey) []table.Row { + rows := make([]table.Row, 0, len(keys)) + for _, k := range keys { + rows = append(rows, table.Row{ + k.ID, k.Name, k.Email, + formatters.ShortFingerprint(k.Fingerprint), + k.Algorithm, + formatters.GpgKeyStatus(k), + }) + } + return rows +} + +// ListKeysInteractive runs the interactive GPG key listing flow +func (f *GpgKeyFactory) ListKeysInteractive(keys []domain.GpgKey) error { + model := component.NewTableModel(component.TableConfig{ + Title: "GPG Keys", + Columns: gpgKeyTableColumns(), + Rows: gpgKeyTableRows(keys), + Width: 140, + Height: 24, + }) + + if _, err := tea.NewProgram(model).Run(); err != nil { + return fmt.Errorf("error running gpg key list TUI: %w", err) + } + + return nil +} + +// GenerateKeyValues are the values the generation form collects. +type GenerateKeyValues struct { + Name string + Email string + Algorithm string + ExpiresInDays *int +} + +// GenerateKeyTUI runs the interactive key generation flow. +func (f *GpgKeyFactory) GenerateKeyTUI(preset GenerateKeyValues) (GenerateKeyValues, error) { + name := preset.Name + email := preset.Email + + // Flag values arrive as API slugs ("ecc-p256"); the select works in labels. + algorithm := domain.GpgAlgorithmOptions()[0].Label + if opt, ok := domain.ResolveGpgAlgorithm(preset.Algorithm); ok { + algorithm = opt.Label + } + expiresText := "" + if preset.ExpiresInDays != nil { + expiresText = strconv.Itoa(*preset.ExpiresInDays) + } + + labels := make([]huh.Option[string], 0, 4) + for _, o := range domain.GpgAlgorithmOptions() { + labels = append(labels, huh.NewOption(o.Label, o.Label)) + } + + details := huh.NewGroup( + huh.NewInput(). + Title("Name"). + Description("Key owner name."). + Placeholder("release-bot"). + Value(&name). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("name is required") + } + if len(s) > 100 { + return fmt.Errorf("name must be 100 characters or less") + } + return nil + }), + + huh.NewInput(). + Title("Email"). + Description("Key owner email."). + Placeholder("bot@example.com"). + Value(&email). + Validate(func(s string) error { + s = strings.TrimSpace(s) + if s == "" { + return fmt.Errorf("email is required") + } + if !strings.Contains(s, "@") || strings.ContainsAny(s, " \t") { + return fmt.Errorf("must be a valid email address") + } + return nil + }), + ).Title("Key owner") + + options := huh.NewGroup( + huh.NewSelect[string](). + Title("Algorithm"). + Description("RSA is fixed at 4096 bits; the ECC curves take no key size."). + Options(labels...). + Value(&algorithm), + + huh.NewInput(). + Title("Expires in (days)"). + Description("Leave empty for a key that never expires."). + Placeholder("365"). + Value(&expiresText). + Validate(func(s string) error { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + days, err := strconv.Atoi(s) + if err != nil || days <= 0 { + return fmt.Errorf("must be a positive number of days") + } + return nil + }), + ).Title("Key options") + + form := huh.NewForm(details, options). + WithTheme(styles.FormTheme()). + WithShowHelp(false) + + if _, err := tea.NewProgram(formModel{ + form: form, + title: "Generate GPG key", + width: 80, + }).Run(); err != nil { + return GenerateKeyValues{}, err + } + + if form.State == huh.StateAborted { + return GenerateKeyValues{}, errors.New("key generation cancelled by user") + } + + var expiresInDays *int + if s := strings.TrimSpace(expiresText); s != "" { + days, _ := strconv.Atoi(s) + expiresInDays = &days + } + + return GenerateKeyValues{Name: name, Email: email, Algorithm: algorithm, ExpiresInDays: expiresInDays}, nil +} + +// SignKeyValues are the signing details the form collects. +type SignKeyValues struct { + FilePath string + Mode string + Detached bool + Output string +} + +// PickGpgKey shows the available keys in a table and returns the one the user +// submits. ok is false when they quit without choosing. +func (f *GpgKeyFactory) PickGpgKey(keys []domain.GpgKey, title string) (domain.GpgKey, bool, error) { + rows := make([]table.Row, 0, len(keys)) + for _, k := range keys { + rows = append(rows, table.Row{ + k.Name, k.Email, formatters.ShortFingerprint(k.Fingerprint), k.Algorithm, k.ID, + }) + } + + // Full screen so the picker cannot stack with the form that follows and + // drift below its help text on short terminals. + row, ok, err := selectRow(component.TableConfig{ + Title: title, + Columns: []table.Column{ + {Title: "NAME", Width: 24}, + {Title: "EMAIL", Width: 28}, + {Title: "FINGERPRINT", Width: 18}, + {Title: "ALGORITHM", Width: 14}, + {Title: "ID", Width: 36}, + }, + Rows: rows, + Width: 130, + Height: 24, + Help: "↑/k up • ↓/j down • enter select • q cancel", + }) + if !ok || err != nil { + return domain.GpgKey{}, ok, err + } + + // Match on the ID cell rather than the cursor position so a reordered or + // filtered table can never select the wrong key. + for _, k := range keys { + if k.ID == row[4] { + return k, true, nil + } + } + + return domain.GpgKey{}, false, fmt.Errorf("selected GPG key %q is no longer available", row[4]) +} + +// boundedFilePicker keeps the file picker inside the form's viewport. The +// group's zoom hands the field the full group height, but the field's box +// renders taller than that (two border lines plus the list's trailing pad), +// so the box is clipped at the bottom and its height drifts past the help +// text. This caps the list and pays for the box chrome up front. +type boundedFilePicker struct { + *huh.FilePicker + max int +} + +func (b *boundedFilePicker) WithHeight(height int) huh.Field { + height -= 3 // top and bottom border + the list's trailing pad + if height > b.max { + height = b.max + } + if height < 4 { + height = 4 + } + b.FilePicker.WithHeight(height) + return b +} + +// Update keeps the wrapper in the group's field list: huh stores whatever +// Update returns, so the bound must survive every round trip. +func (b *boundedFilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m, cmd := b.FilePicker.Update(msg) + if fp, ok := m.(*huh.FilePicker); ok { + b.FilePicker = fp + } + return b, cmd +} + +// SignKeyTUI runs the interactive signing flow for the remaining details. +func (f *GpgKeyFactory) SignKeyTUI(preset SignKeyValues) (SignKeyValues, error) { + filePath := preset.FilePath + mode := preset.Mode + if mode == "" { + mode = domain.SignModes()[0] + } + detached := preset.Detached + output := preset.Output + + labels := make([]huh.Option[string], 0, len(domain.SignModes())) + for _, m := range domain.SignModes() { + labels = append(labels, huh.NewOption(m, m)) + } + + homeDir := "." + if d, err := os.UserHomeDir(); err == nil { + homeDir = d + } + + picker := huh.NewFilePicker(). + Title("File"). + Description("Select the file to sign."). + ShowSize(true). + FileAllowed(true). + DirAllowed(false). + Value(&filePath). + ShowHidden(true). + CurrentDirectory(homeDir). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("a file is required") + } + return nil + }) + + details := huh.NewGroup( + &boundedFilePicker{FilePicker: picker, max: 12}, + + huh.NewSelect[string](). + Title("Mode"). + Description("How the data should be signed."). + Options(labels...). + Value(&mode), + ).Title("Signing details") + + options := huh.NewGroup( + huh.NewConfirm(). + Title("Detached signature?"). + Description("Write the signature separately instead of embedding it in the data."). + Affirmative("Yes"). + Negative("No"). + Value(&detached), + + huh.NewInput(). + Title("Output path"). + Description("Leave empty to print the signature to stdout."). + Placeholder("signature.sig"). + Value(&output), + ).Title("Output") + + form := huh.NewForm(details, options). + WithTheme(styles.FormTheme()). + WithShowHelp(false) + + if _, err := tea.NewProgram(formModel{ + form: form.WithHeight(20), + title: "Sign with GPG key", + width: 80, + }).Run(); err != nil { + return SignKeyValues{}, err + } + + if form.State == huh.StateAborted { + return SignKeyValues{}, errors.New("signing cancelled by user") + } + + return SignKeyValues{FilePath: filePath, Mode: mode, Detached: detached, Output: output}, nil +} diff --git a/packages/envsync-cli/internal/presentation/tui/factory/init_factory.go b/packages/envsync-cli/internal/presentation/tui/factory/init_factory.go index 81ec78f8..091944b3 100644 --- a/packages/envsync-cli/internal/presentation/tui/factory/init_factory.go +++ b/packages/envsync-cli/internal/presentation/tui/factory/init_factory.go @@ -3,8 +3,10 @@ package factory import ( "fmt" + "github.com/charmbracelet/bubbles/table" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" - "github.com/charmbracelet/huh" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/component" ) type InitFactory struct{} @@ -13,61 +15,59 @@ func NewInitFactory() *InitFactory { return &InitFactory{} } -func (f *InitFactory) OpenInitForm(apps []domain.Application) (string, string, error) { - var appID string - var envID string - - form := huh.NewForm(huh.NewGroup( - huh.NewSelect[string](). - Title("Application Name"). - Description("Choose your application"). - Height(len(apps)+2). - OptionsFunc(func() []huh.Option[string] { - if len(apps) == 0 { - return []huh.Option[string]{ - huh.NewOption("No applications available", ""), - } - } - var options []huh.Option[string] - for _, a := range apps { - options = append(options, huh.NewOption(a.Name, a.ID)) - } - - return options - }, &apps). - Value(&appID), +// PickApp shows the applications in a table and returns the one the user +// submits. ok is false when they quit without choosing. +func (f *InitFactory) PickApp(apps []domain.Application) (domain.Application, bool, error) { + row, ok, err := selectRow(component.TableConfig{ + Title: "Select an application", + Columns: appTableColumns(), + Rows: appTableRows(apps), + Width: 108, + Height: 24, + Help: "↑/k up • ↓/j down • enter select • q cancel", + }) + if !ok || err != nil { + return domain.Application{}, ok, err + } - huh.NewSelect[string](). - Title("Environment"). - Description("Select the environment type"). - OptionsFunc(func() []huh.Option[string] { - envTypes := []domain.EnvType{} + // Match on the ID cell rather than the cursor position. + for _, a := range apps { + if a.ID == row[1] { + return a, true, nil + } + } - for _, a := range apps { - if a.ID == appID { - envTypes = a.EnvTypes - break - } - } + return domain.Application{}, false, fmt.Errorf("selected application %q is no longer available", row[1]) +} - if len(envTypes) == 0 { - return []huh.Option[string]{ - huh.NewOption("No environments available", ""), - } - } - var options []huh.Option[string] - for _, envType := range envTypes { - options = append(options, huh.NewOption(envType.Name, envType.ID)) - } +// PickEnvType shows environment types in a table and returns the one the user +// submits. ok is false when they quit without choosing. +func PickEnvType(envTypes []domain.EnvType, title string) (domain.EnvType, bool, error) { + rows := make([]table.Row, 0, len(envTypes)) + for _, e := range envTypes { + rows = append(rows, table.Row{e.Name, e.ID}) + } - return options - }, &appID). - Value(&envID), - )) + row, ok, err := selectRow(component.TableConfig{ + Title: title, + Columns: []table.Column{ + {Title: "NAME", Width: 28}, + {Title: "ID", Width: 24}, + }, + Rows: rows, + Width: 64, + Height: 24, + Help: "↑/k up • ↓/j down • enter select • q cancel", + }) + if !ok || err != nil { + return domain.EnvType{}, ok, err + } - if err := form.Run(); err != nil { - return "", "", fmt.Errorf("failed to run form: %w", err) + for _, e := range envTypes { + if e.ID == row[1] { + return e, true, nil + } } - return appID, envID, nil + return domain.EnvType{}, false, fmt.Errorf("selected environment %q is no longer available", row[1]) } diff --git a/packages/envsync-cli/internal/presentation/tui/factory/probe_layout_test.go b/packages/envsync-cli/internal/presentation/tui/factory/probe_layout_test.go new file mode 100644 index 00000000..bbc0d79e --- /dev/null +++ b/packages/envsync-cli/internal/presentation/tui/factory/probe_layout_test.go @@ -0,0 +1,84 @@ +package factory + +import ( + "fmt" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/styles" +) + +// Replicates the SignKeyTUI form exactly and prints the zoomed frame's layout. +func TestProbeSignFormLayout(t *testing.T) { + filePath := "" + mode := "binary" + detached := true + output := "" + + labels := make([]huh.Option[string], 0, len(domain.SignModes())) + for _, m := range domain.SignModes() { + labels = append(labels, huh.NewOption(m, m)) + } + + details := huh.NewGroup( + huh.NewFilePicker(). + Title("File"). + Description("Select the file to sign."). + ShowSize(true). + FileAllowed(true). + DirAllowed(false). + Value(&filePath), + huh.NewSelect[string](). + Title("Mode"). + Description("How the data should be signed."). + Options(labels...). + Value(&mode), + ).Title("Signing details") + + options := huh.NewGroup( + huh.NewConfirm(). + Title("Detached signature?"). + Value(&detached), + huh.NewInput(). + Title("Output path"). + Value(&output), + ).Title("Output") + + form := huh.NewForm(details, options). + WithTheme(styles.FormTheme()). + WithShowHelp(false) + + var model tea.Model = formModel{form: form, title: "Sign with GPG key", width: 80} + model, _ = model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + _, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) // open the picker + if cmd != nil { + if msg := cmd(); msg != nil { + model, _ = model.Update(msg) + } + } + + view := model.(formModel).View() + for i, line := range splitLines(view) { + fmt.Printf("%02d|%s\n", i+1, line) + } + fmt.Println("TOTAL LINES:", lipgloss.Height(view), "TERMINAL: 24") +} + +func splitLines(s string) []string { + out := []string{} + cur := "" + for _, r := range s { + if r == '\n' { + out = append(out, cur) + cur = "" + continue + } + cur += string(r) + } + out = append(out, cur) + return out +} diff --git a/packages/envsync-cli/internal/presentation/tui/factory/service_token_factory.go b/packages/envsync-cli/internal/presentation/tui/factory/service_token_factory.go new file mode 100644 index 00000000..f5c80e48 --- /dev/null +++ b/packages/envsync-cli/internal/presentation/tui/factory/service_token_factory.go @@ -0,0 +1,326 @@ +package factory + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/component" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/presentation/tui/styles" +) + +type ServiceTokenFactory struct{} + +func NewServiceTokenFactory() *ServiceTokenFactory { + return &ServiceTokenFactory{} +} + +// ServiceTokenCreateValues are the details the creation form collects. +type ServiceTokenCreateValues struct { + Name string + Read bool + Write bool + ExpiresInDays int +} + +// PickApplication shows the applications in a table and returns the one the +// user submits. ok is false when they quit without choosing. +func (f *ServiceTokenFactory) PickApplication(apps []domain.Application, title string) (domain.Application, bool, error) { + rows := make([]table.Row, 0, len(apps)) + for _, a := range apps { + rows = append(rows, table.Row{a.Name, a.Description, a.ID}) + } + + // Full screen: the wizard runs pickers and the form back to back, and + // stacked inline programs drift below each other's help text. + row, ok, err := selectRow(component.TableConfig{ + Title: title, + Columns: []table.Column{ + {Title: "NAME", Width: 24}, + {Title: "DESCRIPTION", Width: 32}, + {Title: "ID", Width: 36}, + }, + Rows: rows, + Width: 130, + Height: 24, + Help: "↑/k up • ↓/j down • enter select • q cancel", + }, tea.WithAltScreen()) + if !ok || err != nil { + return domain.Application{}, ok, err + } + + // Match on the ID cell rather than the cursor position so a reordered or + // filtered table can never select the wrong application. + for _, a := range apps { + if a.ID == row[2] { + return a, true, nil + } + } + + return domain.Application{}, false, fmt.Errorf("selected application %q is no longer available", row[2]) +} + +// PickEnvironment shows the application's environments in a table and returns +// the one the user submits. ok is false when they quit without choosing. +func (f *ServiceTokenFactory) PickEnvironment(envs []domain.EnvType, title string) (domain.EnvType, bool, error) { + rows := make([]table.Row, 0, len(envs)) + for _, e := range envs { + rows = append(rows, table.Row{e.Name, e.ID}) + } + + row, ok, err := selectRow(component.TableConfig{ + Title: title, + Columns: []table.Column{ + {Title: "NAME", Width: 28}, + {Title: "ID", Width: 36}, + }, + Rows: rows, + Width: 100, + Height: 24, + Help: "↑/k up • ↓/j down • enter select • q cancel", + }, tea.WithAltScreen()) + if !ok || err != nil { + return domain.EnvType{}, ok, err + } + + for _, e := range envs { + if e.ID == row[1] { + return e, true, nil + } + } + + return domain.EnvType{}, false, fmt.Errorf("selected environment %q is no longer available", row[1]) +} + +// serviceTokenTableColumns are the columns every service token table shows. +func serviceTokenTableColumns() []table.Column { + return []table.Column{ + {Title: "NAME", Width: 22}, + {Title: "APP ID", Width: 24}, + {Title: "ENV TYPE ID", Width: 24}, + {Title: "READ", Width: 5}, + {Title: "WRITE", Width: 6}, + {Title: "EXPIRES AT", Width: 20}, + } +} + +// serviceTokenTableRows projects tokens onto those columns. +func serviceTokenTableRows(tokens []domain.ServiceToken) []table.Row { + rows := make([]table.Row, 0, len(tokens)) + for _, t := range tokens { + rows = append(rows, serviceTokenTableRow(t)) + } + return rows +} + +// serviceTokenTableRow renders one token's summary cells. +func serviceTokenTableRow(t domain.ServiceToken) table.Row { + appID, envTypeID := "-", "-" + if t.AppID != "" { + appID = t.AppID + } + if t.EnvTypeID != "" { + envTypeID = t.EnvTypeID + } + read, write := "-", "-" + if t.Permissions != nil { + read = strconv.FormatBool(t.Permissions.Read) + write = strconv.FormatBool(t.Permissions.Write) + } + return table.Row{ + t.Name, + appID, + envTypeID, + read, + write, + formatTokenTime(t.ExpiresAt), + } +} + +// PickToken shows the tokens in a table and returns the one the user submits. +// ok is false when they quit without choosing. +func (f *ServiceTokenFactory) PickToken(tokens []domain.ServiceToken, title string) (domain.ServiceToken, bool, error) { + return f.pickToken(tokens, title, nil) +} + +// PickTokenToDelete shows the tokens and asks the user to confirm the +// highlighted one before returning it. +func (f *ServiceTokenFactory) PickTokenToDelete(tokens []domain.ServiceToken, title string) (domain.ServiceToken, bool, error) { + return f.pickToken(tokens, title, func(row table.Row) string { + return fmt.Sprintf("Delete '%s'?", row[0]) + }) +} + +// pickToken shows the tokens in a table and returns the one the user submits. +// ok is false when they quit without choosing. The ID is carried in a +// zero-width column: invisible in the table (the renderer skips zero-width +// columns) but still the row's identity on submit, so a lookalike row can +// never resolve to the wrong token. +func (f *ServiceTokenFactory) pickToken(tokens []domain.ServiceToken, title string, confirm func(table.Row) string) (domain.ServiceToken, bool, error) { + cols := append(serviceTokenTableColumns(), table.Column{Title: "ID", Width: 0}) + rows := make([]table.Row, 0, len(tokens)) + for _, t := range tokens { + rows = append(rows, append(serviceTokenTableRow(t), t.ID)) + } + + // Width covers the visible columns only; the hidden one contributes no + // rendered cells. + width := 0 + for _, c := range serviceTokenTableColumns() { + width += c.Width + 2 // cell padding(0, 1) + } + + row, ok, err := selectRow(component.TableConfig{ + Title: title, + Columns: cols, + Rows: rows, + Width: width, + Height: 24, + Confirm: confirm, + Help: "↑/k up • ↓/j down • enter select • q cancel", + }) + if !ok || err != nil { + return domain.ServiceToken{}, ok, err + } + + id := row[len(row)-1] + for _, t := range tokens { + if t.ID == id { + return t, true, nil + } + } + + return domain.ServiceToken{}, false, fmt.Errorf("selected service token %q is no longer available", id) +} + +// formatTokenTime renders timestamps in RFC3339; zero means "unknown". +func formatTokenTime(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Format(time.RFC3339) +} + +// ListTokensInteractive renders the token list as a table TUI. +func (f *ServiceTokenFactory) ListTokensInteractive(tokens []domain.ServiceToken) error { + cols := serviceTokenTableColumns() + width := 0 + for _, c := range cols { + width += c.Width + 2 // cell padding(0, 1) + } + + model := component.NewTableModel(component.TableConfig{ + Title: "Service tokens", + Columns: cols, + Rows: serviceTokenTableRows(tokens), + Width: width, + Height: 24, + }) + + // No alt screen: the table renders inline rather than taking over the + // terminal. + if _, err := tea.NewProgram(model).Run(); err != nil { + return fmt.Errorf("error running service token list TUI: %w", err) + } + + return nil +} + +// CreateTokenTUI runs the interactive form for the remaining details. +// +// The fields mirror the `service-token create` flags; values already set (from +// flags) are offered as starting points and can be edited in the form. +func (f *ServiceTokenFactory) CreateTokenTUI(preset ServiceTokenCreateValues) (ServiceTokenCreateValues, error) { + name := preset.Name + read := preset.Read + write := preset.Write + expiresText := "0" + if preset.ExpiresInDays > 0 { + expiresText = strconv.Itoa(preset.ExpiresInDays) + } + + details := huh.NewGroup( + huh.NewInput(). + Title("Name"). + Description("Service token name."). + Placeholder("ci-deployer"). + Value(&name). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("name is required") + } + if len(s) > 100 { + return fmt.Errorf("name must be 100 characters or less") + } + return nil + }), + + huh.NewConfirm(). + Title("Read access?"). + Description("Allow the token to read secrets."). + Affirmative("Yes"). + Negative("No"). + Value(&read), + + huh.NewConfirm(). + Title("Write access?"). + Description("Allow the token to change secrets."). + Affirmative("Yes"). + Negative("No"). + Value(&write), + + huh.NewInput(). + Title("Expires in days"). + Description("Leave 0 to use the server default."). + Placeholder("0"). + Value(&expiresText). + Validate(func(s string) error { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + days, err := strconv.Atoi(s) + if err != nil || days < 0 { + return fmt.Errorf("must be zero or a positive number of days") + } + return nil + }), + ).Title("Service token") + + form := huh.NewForm(details). + WithTheme(styles.FormTheme()). + WithShowHelp(false) + + // Full screen like the pickers that precede it: an inline form would stack + // below their frames and drift past the help text on short terminals. + if _, err := tea.NewProgram(formModel{ + form: form, + title: "Create service token", + width: 80, + }, tea.WithAltScreen()).Run(); err != nil { + return ServiceTokenCreateValues{}, err + } + + if form.State == huh.StateAborted { + return ServiceTokenCreateValues{}, errors.New("service token creation cancelled by user") + } + + expiresInDays := 0 + if s := strings.TrimSpace(expiresText); s != "" { + expiresInDays, _ = strconv.Atoi(s) + } + + return ServiceTokenCreateValues{ + Name: name, + Read: read, + Write: write, + ExpiresInDays: expiresInDays, + }, nil +} diff --git a/packages/envsync-cli/internal/presentation/tui/shared/styles/theme.go b/packages/envsync-cli/internal/presentation/tui/shared/styles/theme.go deleted file mode 100644 index 9c21c6c9..00000000 --- a/packages/envsync-cli/internal/presentation/tui/shared/styles/theme.go +++ /dev/null @@ -1,14 +0,0 @@ -package styles - -import "github.com/charmbracelet/lipgloss" - -// This file exists to resolve import references. -// The main theme definitions are in internal/presentation/tui/styles/theme.go - -// Re-export commonly used styles from the main theme file -var ( - // Basic color palette - PrimaryColor = lipgloss.Color("#89F336") - SecondaryColor = lipgloss.Color("#7C3AED") - AccentColor = lipgloss.Color("#F59E0B") -) diff --git a/packages/envsync-cli/internal/presentation/tui/styles/theme.go b/packages/envsync-cli/internal/presentation/tui/styles/theme.go index c19b219b..bc1e357b 100644 --- a/packages/envsync-cli/internal/presentation/tui/styles/theme.go +++ b/packages/envsync-cli/internal/presentation/tui/styles/theme.go @@ -1,6 +1,11 @@ package styles -import "github.com/charmbracelet/lipgloss" +import ( + "strings" + + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) // Color palette var ( @@ -204,8 +209,7 @@ var ( BorderStyle(lipgloss.ThickBorder()). BorderForeground(PrimaryColor). Background(lipgloss.Color("#1F2937")). - Padding(2, 4). - Margin(2, 4) + Padding(1, 3) OverlayStyle = lipgloss.NewStyle(). Background(lipgloss.Color("#00000080")) @@ -235,3 +239,96 @@ func Bold(text string) string { func Italic(text string) string { return lipgloss.NewStyle().Italic(true).Render(text) } + +// Form chrome: the slash boundary header and footer that frame the interactive +// forms. +var ( + FormHeaderStyle = lipgloss.NewStyle(). + Foreground(PrimaryColor). + Bold(true). + Padding(0, 1) + + FormFooterStyle = lipgloss.NewStyle(). + Foreground(MutedColor). + Padding(0, 1) + + FormBoundaryFillStyle = lipgloss.NewStyle(). + Foreground(SecondaryColor) +) + +// Boundary renders text left-aligned followed by a slash fill that pads the +// line out to exactly width cells: "Create application ////…". +func Boundary(width int, text string, textStyle, fillStyle lipgloss.Style) string { + head := textStyle.Render(text) + fill := width - lipgloss.Width(head) + if fill < 0 { + fill = 0 + } + return head + fillStyle.Render(strings.Repeat("/", fill)) +} + +// FormTheme returns the huh theme used by the interactive forms. +// +// Every field is boxed in a rounded border. The focused field is accented in +// the brand purple and the rest fall back to a muted border, so the active +// field is always obvious without relying on colour alone. +func FormTheme() *huh.Theme { + t := huh.ThemeBase() + + box := lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + Padding(0, 1) + + t.Focused.Base = box.BorderForeground(SecondaryColor) + t.Blurred.Base = box.BorderForeground(DarkGrayColor) + + t.Focused.Title = lipgloss.NewStyle().Foreground(PrimaryColor).Bold(true) + t.Blurred.Title = lipgloss.NewStyle().Foreground(MutedColor).Bold(true) + + t.Focused.Description = lipgloss.NewStyle().Foreground(MutedColor) + t.Blurred.Description = lipgloss.NewStyle().Foreground(DarkGrayColor) + + t.Focused.TextInput.Text = lipgloss.NewStyle().Foreground(LightGrayColor) + t.Blurred.TextInput.Text = lipgloss.NewStyle().Foreground(MutedColor) + t.Focused.TextInput.Placeholder = lipgloss.NewStyle().Foreground(DarkGrayColor).Italic(true) + t.Blurred.TextInput.Placeholder = lipgloss.NewStyle().Foreground(DarkGrayColor).Italic(true) + t.Focused.TextInput.Prompt = lipgloss.NewStyle().Foreground(SecondaryColor) + t.Blurred.TextInput.Prompt = lipgloss.NewStyle().Foreground(DarkGrayColor) + t.Focused.TextInput.Cursor = lipgloss.NewStyle().Background(PrimaryColor).Foreground(lipgloss.Color("#000000")) + t.Blurred.TextInput.Cursor = lipgloss.NewStyle() + + t.Focused.SelectSelector = lipgloss.NewStyle().Foreground(SecondaryColor).Bold(true) + t.Blurred.SelectSelector = lipgloss.NewStyle().Foreground(DarkGrayColor) + t.Focused.SelectedOption = lipgloss.NewStyle().Foreground(PrimaryColor) + t.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(LightGrayColor) + t.Blurred.SelectedOption = lipgloss.NewStyle().Foreground(MutedColor) + t.Blurred.UnselectedOption = lipgloss.NewStyle().Foreground(DarkGrayColor) + + t.Focused.MultiSelectSelector = lipgloss.NewStyle().Foreground(SecondaryColor).Bold(true) + t.Blurred.MultiSelectSelector = lipgloss.NewStyle().Foreground(DarkGrayColor) + t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(PrimaryColor) + t.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(DarkGrayColor) + t.Blurred.SelectedPrefix = lipgloss.NewStyle().Foreground(MutedColor) + t.Blurred.UnselectedPrefix = lipgloss.NewStyle().Foreground(DarkGrayColor) + + t.Focused.ErrorIndicator = lipgloss.NewStyle().Foreground(ErrorColor).Bold(true) + t.Focused.ErrorMessage = lipgloss.NewStyle().Foreground(ErrorColor) + t.Blurred.ErrorIndicator = lipgloss.NewStyle().Foreground(ErrorColor) + + t.Focused.FocusedButton = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(PrimaryColor). + Bold(true). + Padding(0, 2). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(PrimaryColor) + t.Focused.BlurredButton = lipgloss.NewStyle(). + Foreground(MutedColor). + Padding(0, 2). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(DarkGrayColor) + t.Blurred.FocusedButton = t.Focused.BlurredButton + t.Blurred.BlurredButton = t.Focused.BlurredButton + + return t +} diff --git a/packages/envsync-cli/internal/repository/access_custom.go b/packages/envsync-cli/internal/repository/access_custom.go index 65967088..bc0215f8 100644 --- a/packages/envsync-cli/internal/repository/access_custom.go +++ b/packages/envsync-cli/internal/repository/access_custom.go @@ -45,7 +45,7 @@ func (r *accessRepo) GrantAppAccess(ctx context.Context, appID, subjectID, subje if err != nil { return err } - if resp.IsError() { + if resp.IsStatusFailure() { return fmt.Errorf("grant app access failed: %s", resp.String()) } return nil @@ -64,7 +64,7 @@ func (r *accessRepo) RevokeAppAccess(ctx context.Context, appID, subjectID, subj if err != nil { return err } - if resp.IsError() { + if resp.IsStatusFailure() { return fmt.Errorf("revoke app access failed: %s", resp.String()) } return nil @@ -77,7 +77,7 @@ func (r *accessRepo) ListAppGrants(ctx context.Context, appID string) ([]AppGran if err != nil { return nil, err } - if resp.IsError() { + if resp.IsStatusFailure() { return nil, fmt.Errorf("list app grants failed: %s", resp.String()) } return result, nil @@ -90,7 +90,7 @@ func (r *accessRepo) EffectiveAppAccess(ctx context.Context, appID string) ([]Ef if err != nil { return nil, err } - if resp.IsError() { + if resp.IsStatusFailure() { return nil, fmt.Errorf("list effective app access failed: %s", resp.String()) } return result, nil diff --git a/packages/envsync-cli/internal/repository/application.go b/packages/envsync-cli/internal/repository/application.go index 8a71ecbd..3d679934 100644 --- a/packages/envsync-cli/internal/repository/application.go +++ b/packages/envsync-cli/internal/repository/application.go @@ -50,8 +50,11 @@ func (a *appRepo) Create(ctx context.Context, app requests.ApplicationRequest) ( } return responses.AppResponse{ - ID: resp.Id, - Name: app.Name, + ID: resp.Id, + Name: app.Name, + Description: app.Description, + EnableSecrets: app.EnableSecrets, + IsManagedSecret: app.IsManagedSecret, }, nil } diff --git a/packages/envsync-cli/internal/repository/certificate.go b/packages/envsync-cli/internal/repository/certificate.go index e50b1b87..a2e654ce 100644 --- a/packages/envsync-cli/internal/repository/certificate.go +++ b/packages/envsync-cli/internal/repository/certificate.go @@ -291,7 +291,7 @@ func (r *certRepo) Renew(ctx context.Context, id string, description string, rev if err != nil { return responses.MemberCertResponse{}, err } - if resp.IsError() { + if resp.IsStatusFailure() { return responses.MemberCertResponse{}, fmt.Errorf("renew certificate failed: %s", resp.String()) } return result, nil @@ -315,7 +315,7 @@ func (r *certRepo) Rotate(ctx context.Context, id string, description string, re if err != nil { return responses.MemberCertResponse{}, err } - if resp.IsError() { + if resp.IsStatusFailure() { return responses.MemberCertResponse{}, fmt.Errorf("rotate certificate failed: %s", resp.String()) } return result, nil diff --git a/packages/envsync-cli/internal/repository/change_request_custom.go b/packages/envsync-cli/internal/repository/change_request_custom.go index 52e09c03..e1446d38 100644 --- a/packages/envsync-cli/internal/repository/change_request_custom.go +++ b/packages/envsync-cli/internal/repository/change_request_custom.go @@ -70,7 +70,7 @@ func (r *changeRequestRepo) List(ctx context.Context) ([]ChangeRequestResponse, if err != nil { return nil, err } - if resp.IsError() { + if resp.IsStatusFailure() { return nil, fmt.Errorf("list change requests failed: %s", resp.String()) } return result, nil @@ -83,7 +83,7 @@ func (r *changeRequestRepo) Get(ctx context.Context, id string) (ChangeRequestRe if err != nil { return ChangeRequestResponse{}, err } - if resp.IsError() { + if resp.IsStatusFailure() { return ChangeRequestResponse{}, fmt.Errorf("get change request failed: %s", resp.String()) } return result, nil @@ -112,7 +112,7 @@ func (r *changeRequestRepo) post(ctx context.Context, path string, payload any) if err != nil { return ChangeRequestResponse{}, err } - if resp.IsError() { + if resp.IsStatusFailure() { return ChangeRequestResponse{}, fmt.Errorf("change request call failed: %s", resp.String()) } return result, nil diff --git a/packages/envsync-cli/internal/repository/gpg_key.go b/packages/envsync-cli/internal/repository/gpg_key.go index 673817d3..53323dd1 100644 --- a/packages/envsync-cli/internal/repository/gpg_key.go +++ b/packages/envsync-cli/internal/repository/gpg_key.go @@ -177,7 +177,7 @@ func (r *gpgKeyRepo) Rotate(ctx context.Context, id string, payload map[string]a if err != nil { return responses.GpgKeyResponse{}, err } - if resp.IsError() { + if resp.IsStatusFailure() { return responses.GpgKeyResponse{}, fmt.Errorf("rotate gpg key failed: %s", resp.String()) } return result, nil @@ -194,7 +194,7 @@ func (r *gpgKeyRepo) ExtendExpiry(ctx context.Context, id string, expiresInDays if err != nil { return responses.GpgKeyResponse{}, err } - if resp.IsError() { + if resp.IsStatusFailure() { return responses.GpgKeyResponse{}, fmt.Errorf("extend gpg key expiry failed: %s", resp.String()) } return result, nil @@ -217,7 +217,7 @@ func sdkGpgKeyToResponse(k *sdk.GpgKeyResponse) responses.GpgKeyResponse { KeySize: keySize, UsageFlags: k.UsageFlags, TrustLevel: k.TrustLevel, - Status: "", + Status: k.Status, ExpiresAt: k.ExpiresAt, RevokedAt: k.RevokedAt, IsDefault: k.IsDefault, @@ -243,7 +243,7 @@ func sdkGpgKeyDetailToResponse(k *sdk.GpgKeyDetailResponse) responses.GpgKeyResp KeySize: keySize, UsageFlags: k.UsageFlags, TrustLevel: k.TrustLevel, - Status: "", + Status: k.Status, ExpiresAt: k.ExpiresAt, RevokedAt: k.RevokedAt, IsDefault: k.IsDefault, diff --git a/packages/envsync-cli/internal/repository/gpg_key_test.go b/packages/envsync-cli/internal/repository/gpg_key_test.go new file mode 100644 index 00000000..fd60676e --- /dev/null +++ b/packages/envsync-cli/internal/repository/gpg_key_test.go @@ -0,0 +1,22 @@ +package repository + +import ( + "testing" + + sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" +) + +// Regression: both mappers used to hardcode Status to "", which silently +// dropped the wire value. Everything filtering on key.Status (e.g. the active +// key picker for `gpg sign`) then saw an empty status and mis-filtered. +func TestGpgKeyMappersKeepStatus(t *testing.T) { + list := sdkGpgKeyToResponse(&sdk.GpgKeyResponse{Id: "k1", Status: "active"}) + if list.Status != "active" { + t.Errorf("sdkGpgKeyToResponse Status = %q, want %q", list.Status, "active") + } + + detail := sdkGpgKeyDetailToResponse(&sdk.GpgKeyDetailResponse{Id: "k1", Status: "revoked"}) + if detail.Status != "revoked" { + t.Errorf("sdkGpgKeyDetailToResponse Status = %q, want %q", detail.Status, "revoked") + } +} diff --git a/packages/envsync-cli/internal/repository/repository.go b/packages/envsync-cli/internal/repository/repository.go index c1d40e56..082d74fe 100644 --- a/packages/envsync-cli/internal/repository/repository.go +++ b/packages/envsync-cli/internal/repository/repository.go @@ -1,17 +1,31 @@ package repository import ( + "errors" + "fmt" "net/http" "os" + "sync" + "time" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "resty.dev/v3" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/config" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository/responses" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" sdkclient "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk/client" "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk/option" ) +// tokenRefreshSkew refreshes slightly before the recorded expiry so a token +// cannot lapse between the check and the request that uses it. +const tokenRefreshSkew = 30 * time.Second + +var ( + resolvedOnce sync.Once + resolvedToken string +) + // createSDKClient initializes and returns a new SDK client with proper authentication // and configuration for API requests. func createSDKClient() *sdkclient.Client { @@ -31,14 +45,14 @@ func createSDKClient() *sdkclient.Client { option.WithBaseURL(cfg.BackendURL), option.WithHTTPHeader(headers), option.WithHTTPClient(&http.Client{ - Transport: otelhttp.NewTransport(http.DefaultTransport), + Transport: telemetry.NewInstrumentedTransport(http.DefaultTransport), }), } if hasAPIKey && apiKey != "" { opts = append(opts, option.WithApiKey(apiKey)) - } else if cfg.AccessToken != "" { - opts = append(opts, option.WithToken(cfg.AccessToken)) + } else if token := resolveAccessToken(); token != "" { + opts = append(opts, option.WithToken(token)) } return sdkclient.NewClient(opts...) @@ -59,17 +73,109 @@ func createHTTPClient() *resty.Client { } client := resty.New(). - SetDisableWarn(true). + SetLoggerWarnLevel(false). SetBaseURL(cfg.BackendURL). SetHeader("Content-Type", "application/json"). SetHeader("X-CLI-CMD", cliCmd). - SetTransport(otelhttp.NewTransport(http.DefaultTransport)) + SetTransport(telemetry.NewInstrumentedTransport(http.DefaultTransport)) if hasAPIKey && apiKey != "" { client.SetHeader("X-API-Key", apiKey) - } else if cfg.AccessToken != "" { - client.SetHeader("Authorization", "Bearer "+cfg.AccessToken) + } else if token := resolveAccessToken(); token != "" { + client.SetAuthToken("Bearer " + token) } return client } + +// resolveAccessToken returns the token to authenticate API requests with. +func resolveAccessToken() string { + resolvedOnce.Do(func() { + resolvedToken = resolveToken(config.New(), time.Now()) + }) + return resolvedToken +} + +// resolveToken returns the stored access token while it is still valid +// otherwise exchanges the refresh token for a fresh pair. Refresh problems are +// reported on stderr and fall back to the stored token: the request then fails +// with the server's own auth error instead of blocking the command outright. +func resolveToken(cfg config.AppConfig, now time.Time) string { + auth := cfg.AuthConfig + + // An empty access token means logged out — even a leftover refresh token + // must not silently log the user back in. + if auth.AccessToken == "" { + return "" + } + if !tokenExpired(auth, now) { + return auth.AccessToken + } + if auth.RefreshToken == "" { + // Nothing to refresh with; keep the stored token and let the request + // surface the expiry. + // TODO: Suggest better logging mechanism here + return auth.AccessToken + } + + fresh, err := refreshAuth(auth, now) + if err != nil { + // TODO: Suggest better logging mechanism here + return auth.AccessToken + } + + cfg.AuthConfig = fresh + if err := cfg.WriteConfigFile(); err != nil { + // Ignore errors persisting the refreshed token; the request will fail + // with the server's own auth error instead of blocking the command. + // TODO: Suggest better logging mechanism here + } + + return fresh.AccessToken +} + +// tokenExpired reports whether the stored access token expires within the +// refresh skew of now. A zero expiry is unknown and counts as expired so a +// refresh token can re-establish it. +func tokenExpired(auth config.AuthConfig, now time.Time) bool { + if auth.ExpiresAt == 0 { + return true + } + return !now.Add(tokenRefreshSkew).Before(time.Unix(int64(auth.ExpiresAt), 0)) +} + +// refreshAuth exchanges the stored refresh token for a fresh token pair using +// the OAuth refresh grant at the recorded token endpoint. +func refreshAuth(auth config.AuthConfig, now time.Time) (config.AuthConfig, error) { + if auth.TokenURL == "" || auth.ClientID == "" { + return config.AuthConfig{}, errors.New("token endpoint is unknown (this login predates refresh support); run 'envsync auth login' again") + } + + var resBody responses.LoginTokenResponse + res, err := resty.New(). + SetLoggerWarnLevel(false). + SetTransport(telemetry.NewInstrumentedTransport(http.DefaultTransport)). + R(). + SetResult(&resBody). + SetFormData(map[string]string{ + "grant_type": "refresh_token", + "refresh_token": auth.RefreshToken, + "client_id": auth.ClientID, + }). + Post(auth.TokenURL) + if err != nil { + return config.AuthConfig{}, fmt.Errorf("failed to reach token endpoint: %w", err) + } + if res.StatusCode() != http.StatusOK { + return config.AuthConfig{}, fmt.Errorf("unexpected status code while refreshing token: %d", res.StatusCode()) + } + + auth.AccessToken = resBody.AccessToken + // Providers may rotate the refresh token; keep the old one when they don't. + if resBody.RefreshToken != "" { + auth.RefreshToken = resBody.RefreshToken + } + auth.ExpiresAt = int(now.Add(time.Duration(resBody.ExpiresIn) * time.Second).Unix()) + + return auth, nil +} diff --git a/packages/envsync-cli/internal/repository/requests/service_token.go b/packages/envsync-cli/internal/repository/requests/service_token.go new file mode 100644 index 00000000..b49942ef --- /dev/null +++ b/packages/envsync-cli/internal/repository/requests/service_token.go @@ -0,0 +1,24 @@ +package requests + +// CreateServiceTokenRequest mirrors the API contract: the optional fields are +// pointers so "absent" (server default applies) stays distinguishable from an +// explicit empty value. +type CreateServiceTokenRequest struct { + Name string `json:"name"` + AppId *string `json:"app_id,omitempty"` + EnvTypeId *string `json:"env_type_id,omitempty"` + Permissions *ServiceTokenPermissionRequest `json:"permissions,omitempty"` + Scopes []*ServiceTokenScopeRequest `json:"scopes,omitempty"` + ExpiresInDays *int `json:"expires_in_days,omitempty"` +} + +type ServiceTokenPermissionRequest struct { + Read bool `json:"read"` + Write bool `json:"write"` +} + +type ServiceTokenScopeRequest struct { + // EnvTypeId is optional: a scope without it applies to every environment. + EnvTypeId *string `json:"env_type_id,omitempty"` + Path string `json:"path"` +} diff --git a/packages/envsync-cli/internal/repository/responses/application.go b/packages/envsync-cli/internal/repository/responses/application.go index c8036bd2..065d7913 100644 --- a/packages/envsync-cli/internal/repository/responses/application.go +++ b/packages/envsync-cli/internal/repository/responses/application.go @@ -15,4 +15,4 @@ type AppResponse struct { EnableSecrets bool `json:"enable_secrets"` PublicKey string `json:"public_key,omitempty"` IsManagedSecret bool `json:"is_managed_secret"` -} \ No newline at end of file +} diff --git a/packages/envsync-cli/internal/repository/responses/env_type.go b/packages/envsync-cli/internal/repository/responses/env_type.go index 5a4ff37f..e0a9dee5 100644 --- a/packages/envsync-cli/internal/repository/responses/env_type.go +++ b/packages/envsync-cli/internal/repository/responses/env_type.go @@ -13,4 +13,4 @@ type EnvTypeResponse struct { Color string `json:"color"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` -} \ No newline at end of file +} diff --git a/packages/envsync-cli/internal/repository/responses/service_token.go b/packages/envsync-cli/internal/repository/responses/service_token.go new file mode 100644 index 00000000..189b27f8 --- /dev/null +++ b/packages/envsync-cli/internal/repository/responses/service_token.go @@ -0,0 +1,28 @@ +package responses + +import "time" + +type ServiceTokenResponse struct { + Id string `json:"id"` + Name string `json:"name"` + AppId string `json:"app_id"` + EnvTypeId string `json:"env_type_id"` + Permissions ServiceTokenPermission `json:"permissions"` + Scopes []ServiceTokenScope `json:"scopes"` + Token string `json:"token,omitempty"` + RotatedFromId string `json:"rotated_from_id"` + GraceUntil *time.Time `json:"grace_until"` + ExpiresAt time.Time `json:"expires_at"` + LastUsedAt *time.Time `json:"last_used_at"` + CreatedAt time.Time `json:"created_at"` +} + +type ServiceTokenPermission struct { + Read bool `json:"read"` + Write bool `json:"write"` +} + +type ServiceTokenScope struct { + EnvTypeId string `json:"env_type_id"` + Path string `json:"path"` +} diff --git a/packages/envsync-cli/internal/repository/saml_public.go b/packages/envsync-cli/internal/repository/saml_public.go deleted file mode 100644 index b73507b9..00000000 --- a/packages/envsync-cli/internal/repository/saml_public.go +++ /dev/null @@ -1,48 +0,0 @@ -package repository - -import ( - "context" - "fmt" - "net/url" -) - -type PublicSamlSsoResponse struct { - RedirectURL string `json:"redirect_url"` - RequestID string `json:"request_id"` -} - -func GetPublicSamlMetadata(ctx context.Context, orgID string) (string, error) { - client := createHTTPClient() - resp, err := client.R(). - SetContext(ctx). - Get("/api/saml/metadata/" + url.PathEscape(orgID)) - if err != nil { - return "", err - } - if resp.IsError() { - return "", fmt.Errorf("public SAML metadata failed: %s", resp.String()) - } - return resp.String(), nil -} - -func StartPublicSamlSso(ctx context.Context, orgSlug string, providerID string) (*PublicSamlSsoResponse, error) { - client := createHTTPClient() - body := map[string]string{} - if providerID != "" { - body["provider_id"] = providerID - } - - var out PublicSamlSsoResponse - resp, err := client.R(). - SetContext(ctx). - SetBody(body). - SetResult(&out). - Post("/api/saml/sso/" + url.PathEscape(orgSlug)) - if err != nil { - return nil, err - } - if resp.IsError() { - return nil, fmt.Errorf("public SAML SSO start failed: %s", resp.String()) - } - return &out, nil -} diff --git a/packages/envsync-cli/internal/repository/service_token.go b/packages/envsync-cli/internal/repository/service_token.go index f478c5c9..8e1ab023 100644 --- a/packages/envsync-cli/internal/repository/service_token.go +++ b/packages/envsync-cli/internal/repository/service_token.go @@ -5,12 +5,16 @@ import ( sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" sdkclient "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk/client" + + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/helper" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository/requests" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository/responses" ) type ServiceTokenRepository interface { - Create(ctx context.Context, req *sdk.CreateServiceTokenRequest) (*sdk.CreateServiceTokenResponse, error) - GetAll(ctx context.Context) (sdk.ServiceTokensResponse, error) - GetByID(ctx context.Context, id string) (*sdk.ServiceTokenResponse, error) + Create(ctx context.Context, req requests.CreateServiceTokenRequest) (responses.ServiceTokenResponse, error) + GetAll(ctx context.Context) ([]responses.ServiceTokenResponse, error) + GetByID(ctx context.Context, id string) (responses.ServiceTokenResponse, error) Delete(ctx context.Context, id string) error } @@ -25,19 +29,112 @@ func NewServiceTokenRepository() ServiceTokenRepository { } } -func (r *serviceTokenRepo) Create(ctx context.Context, req *sdk.CreateServiceTokenRequest) (*sdk.CreateServiceTokenResponse, error) { - return r.client.ServiceTokens.CreateServiceToken(ctx, req) +func (r *serviceTokenRepo) Create(ctx context.Context, req requests.CreateServiceTokenRequest) (responses.ServiceTokenResponse, error) { + sdkReq := &sdk.CreateServiceTokenRequest{ + Name: req.Name, + AppId: req.AppId, + EnvTypeId: req.EnvTypeId, + ExpiresInDays: req.ExpiresInDays, + } + if req.Permissions != nil { + sdkReq.Permissions = &sdk.ServiceTokenPermissions{ + Read: req.Permissions.Read, + Write: req.Permissions.Write, + } + } + if len(req.Scopes) > 0 { + sdkReq.Scopes = make([]*sdk.ServiceTokenScope, len(req.Scopes)) + for i, s := range req.Scopes { + sdkReq.Scopes[i] = &sdk.ServiceTokenScope{ + EnvTypeId: s.EnvTypeId, + Path: s.Path, + } + } + } + + res, err := r.client.ServiceTokens.CreateServiceToken(ctx, sdkReq) + if err != nil { + return responses.ServiceTokenResponse{}, err + } + + return sdkCreateTokenToResponse(res), nil } -func (r *serviceTokenRepo) GetAll(ctx context.Context) (sdk.ServiceTokensResponse, error) { - return r.client.ServiceTokens.GetAllServiceTokens(ctx, &sdk.GetAllServiceTokensRequest{}) +func (r *serviceTokenRepo) GetAll(ctx context.Context) ([]responses.ServiceTokenResponse, error) { + res, err := r.client.ServiceTokens.GetAllServiceTokens(ctx, &sdk.GetAllServiceTokensRequest{}) + if err != nil { + return nil, err + } + + result := make([]responses.ServiceTokenResponse, len(res)) + for i, tok := range res { + result[i] = sdkServiceTokenToResponse(tok) + } + + return result, nil } -func (r *serviceTokenRepo) GetByID(ctx context.Context, id string) (*sdk.ServiceTokenResponse, error) { - return r.client.ServiceTokens.GetServiceToken(ctx, id) +func (r *serviceTokenRepo) GetByID(ctx context.Context, id string) (responses.ServiceTokenResponse, error) { + res, err := r.client.ServiceTokens.GetServiceToken(ctx, id) + if err != nil { + return responses.ServiceTokenResponse{}, err + } + + return sdkServiceTokenToResponse(res), nil } func (r *serviceTokenRepo) Delete(ctx context.Context, id string) error { _, err := r.client.ServiceTokens.DeleteServiceToken(ctx, id) return err } + +// sdkCreateTokenToResponse maps the create response. Its nullable fields +// (app_id, env_type_id, rotated_from_id, scope env_type_id) are dereferenced +// defensively: the API allows them to be null on unscoped tokens. +func sdkCreateTokenToResponse(res *sdk.CreateServiceTokenResponse) responses.ServiceTokenResponse { + return responses.ServiceTokenResponse{ + Id: res.Id, + Name: res.Name, + AppId: derefString(res.AppId), + EnvTypeId: derefString(res.EnvTypeId), + Permissions: sdkPermissionsToResponse(res.Permissions), + Scopes: sdkScopesToResponse(res.Scopes), + Token: res.Token, + RotatedFromId: derefString(res.RotatedFromId), + ExpiresAt: helper.ParseStrToTime(res.ExpiresAt), + CreatedAt: helper.ParseStrToTime(res.CreatedAt), + } +} + +// sdkServiceTokenToResponse maps the list/get response. +func sdkServiceTokenToResponse(res *sdk.ServiceTokenResponse) responses.ServiceTokenResponse { + return responses.ServiceTokenResponse{ + Id: res.Id, + Name: res.Name, + AppId: derefString(res.AppId), + EnvTypeId: derefString(res.EnvTypeId), + Permissions: sdkPermissionsToResponse(res.Permissions), + Scopes: sdkScopesToResponse(res.Scopes), + RotatedFromId: derefString(res.RotatedFromId), + GraceUntil: helper.ParseStringPtrToTime(res.GraceUntil), + ExpiresAt: helper.ParseStrToTime(res.ExpiresAt), + LastUsedAt: helper.ParseStringPtrToTime(res.LastUsedAt), + CreatedAt: helper.ParseStrToTime(res.CreatedAt), + } +} + +func sdkPermissionsToResponse(p *sdk.ServiceTokenPermissions) responses.ServiceTokenPermission { + if p == nil { + return responses.ServiceTokenPermission{} + } + return responses.ServiceTokenPermission{Read: p.Read, Write: p.Write} +} + +func sdkScopesToResponse(scopes []*sdk.ServiceTokenScope) []responses.ServiceTokenScope { + return helper.Map(scopes, func(s *sdk.ServiceTokenScope) responses.ServiceTokenScope { + return responses.ServiceTokenScope{ + EnvTypeId: derefString(s.EnvTypeId), + Path: s.Path, + } + }) +} diff --git a/packages/envsync-cli/internal/repository/team_custom.go b/packages/envsync-cli/internal/repository/team_custom.go index 5449f0d4..65a349b9 100644 --- a/packages/envsync-cli/internal/repository/team_custom.go +++ b/packages/envsync-cli/internal/repository/team_custom.go @@ -55,7 +55,7 @@ func (r *teamRepo) List(ctx context.Context) ([]TeamResponse, error) { if err != nil { return nil, err } - if resp.IsError() { + if resp.IsStatusFailure() { return nil, fmt.Errorf("list teams failed: %s", resp.String()) } return result, nil @@ -68,7 +68,7 @@ func (r *teamRepo) Get(ctx context.Context, id string) (TeamDetailResponse, erro if err != nil { return TeamDetailResponse{}, err } - if resp.IsError() { + if resp.IsStatusFailure() { return TeamDetailResponse{}, fmt.Errorf("get team failed: %s", resp.String()) } return result, nil @@ -81,7 +81,7 @@ func (r *teamRepo) Create(ctx context.Context, payload map[string]any) (TeamResp if err != nil { return TeamResponse{}, err } - if resp.IsError() { + if resp.IsStatusFailure() { return TeamResponse{}, fmt.Errorf("create team failed: %s", resp.String()) } return result, nil @@ -93,7 +93,7 @@ func (r *teamRepo) Update(ctx context.Context, id string, payload map[string]any if err != nil { return err } - if resp.IsError() { + if resp.IsStatusFailure() { return fmt.Errorf("update team failed: %s", resp.String()) } return nil @@ -105,7 +105,7 @@ func (r *teamRepo) Delete(ctx context.Context, id string) error { if err != nil { return err } - if resp.IsError() { + if resp.IsStatusFailure() { return fmt.Errorf("delete team failed: %s", resp.String()) } return nil @@ -120,7 +120,7 @@ func (r *teamRepo) AddMember(ctx context.Context, teamID, userID string) error { if err != nil { return err } - if resp.IsError() { + if resp.IsStatusFailure() { return fmt.Errorf("add team member failed: %s", resp.String()) } return nil @@ -132,7 +132,7 @@ func (r *teamRepo) RemoveMember(ctx context.Context, teamID, userID string) erro if err != nil { return err } - if resp.IsError() { + if resp.IsStatusFailure() { return fmt.Errorf("remove team member failed: %s", resp.String()) } return nil @@ -147,7 +147,7 @@ func (r *teamRepo) AssignRole(ctx context.Context, teamID, roleID string) error if err != nil { return err } - if resp.IsError() { + if resp.IsStatusFailure() { return fmt.Errorf("assign team role failed: %s", resp.String()) } return nil @@ -159,7 +159,7 @@ func (r *teamRepo) UnassignRole(ctx context.Context, teamID string) error { if err != nil { return err } - if resp.IsError() { + if resp.IsStatusFailure() { return fmt.Errorf("unassign team role failed: %s", resp.String()) } return nil diff --git a/packages/envsync-cli/internal/services/application.go b/packages/envsync-cli/internal/services/application.go index 77fa8f91..d49dbea0 100644 --- a/packages/envsync-cli/internal/services/application.go +++ b/packages/envsync-cli/internal/services/application.go @@ -6,6 +6,7 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/mappers" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type ApplicationService interface { @@ -30,7 +31,10 @@ func NewAppService() ApplicationService { } } -func (a *app) CreateApp(ctx context.Context, app *domain.Application) (domain.Application, error) { +func (a *app) CreateApp(ctx context.Context, app *domain.Application) (result domain.Application, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.app.create_app") + defer end(&err) + req := mappers.DomainToAppRequest(app) var appRes domain.Application @@ -43,7 +47,10 @@ func (a *app) CreateApp(ctx context.Context, app *domain.Application) (domain.Ap return appRes, nil } -func (a *app) GetAllApps(ctx context.Context) ([]domain.Application, error) { +func (a *app) GetAllApps(ctx context.Context) (result []domain.Application, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.app.get_all_apps") + defer end(&err) + res, err := a.appRepo.GetAll(ctx) if err != nil { return nil, err @@ -57,7 +64,10 @@ func (a *app) GetAllApps(ctx context.Context) ([]domain.Application, error) { return apps, nil } -func (a *app) DeleteApp(ctx context.Context, app domain.Application) error { +func (a *app) DeleteApp(ctx context.Context, app domain.Application) (err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.app.delete_app") + defer end(&err) + if err := a.appRepo.Delete(ctx, app.ID); err != nil { return err } @@ -65,7 +75,10 @@ func (a *app) DeleteApp(ctx context.Context, app domain.Application) error { return nil } -func (a *app) GetAppByID(ctx context.Context, id string) (domain.Application, error) { +func (a *app) GetAppByID(ctx context.Context, id string) (result domain.Application, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.app.get_app_by_id") + defer end(&err) + res, err := a.appRepo.GetByID(ctx, id) if err != nil { return domain.Application{}, err diff --git a/packages/envsync-cli/internal/services/auth.go b/packages/envsync-cli/internal/services/auth.go index 958ac7ba..882c1d3a 100644 --- a/packages/envsync-cli/internal/services/auth.go +++ b/packages/envsync-cli/internal/services/auth.go @@ -9,13 +9,14 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/mappers" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type AuthService interface { InitiateLogin(ctx context.Context) (*domain.LoginCredentials, error) CompleteLogin(ctx context.Context, credentials *domain.LoginCredentials) (*domain.AccessToken, error) PollForToken(ctx context.Context, credentials *domain.LoginCredentials) (*domain.AccessToken, error) - SaveToken(token *domain.AccessToken) error + SaveToken(token *domain.AccessToken, credentials *domain.LoginCredentials) error Whoami(ctx context.Context) (*domain.UserInfo, error) Logout() error } @@ -36,7 +37,10 @@ func NewAuthService() AuthService { } // InitiateLogin starts the OAuth device flow and returns login credentials -func (s *auth) InitiateLogin(ctx context.Context) (*domain.LoginCredentials, error) { +func (s *auth) InitiateLogin(ctx context.Context) (result *domain.LoginCredentials, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.auth.initiate_login") + defer end(&err) + deviceCodeResp, err := s.repo.LoginDeviceCode(ctx) if err != nil { return nil, fmt.Errorf("failed to initiate login: %w", err) @@ -48,7 +52,10 @@ func (s *auth) InitiateLogin(ctx context.Context) (*domain.LoginCredentials, err } // CompleteLogin attempts to exchange device code for access token -func (s *auth) CompleteLogin(ctx context.Context, credentials *domain.LoginCredentials) (*domain.AccessToken, error) { +func (s *auth) CompleteLogin(ctx context.Context, credentials *domain.LoginCredentials) (result *domain.AccessToken, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.auth.complete_login") + defer end(&err) + tokenResp, err := s.repo.LoginToken(ctx, credentials.DeviceCode, credentials.ClientId, credentials.TokenUrl) if err != nil { return nil, err @@ -59,7 +66,10 @@ func (s *auth) CompleteLogin(ctx context.Context, credentials *domain.LoginCrede } // PollForToken polls the authorization server until user completes authentication -func (s *auth) PollForToken(ctx context.Context, credentials *domain.LoginCredentials) (*domain.AccessToken, error) { +func (s *auth) PollForToken(ctx context.Context, credentials *domain.LoginCredentials) (result *domain.AccessToken, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.auth.poll_for_token") + defer end(&err) + timeout := time.Now().Add(time.Duration(credentials.ExpiresIn) * time.Second) interval := time.Duration(credentials.Interval) * time.Second @@ -77,20 +87,28 @@ func (s *auth) PollForToken(ctx context.Context, credentials *domain.LoginCreden return nil, fmt.Errorf("authentication timeout: user did not complete login within %d seconds", credentials.ExpiresIn) } -// SaveToken persists the access token to configuration -func (s *auth) SaveToken(token *domain.AccessToken) error { +// SaveToken persists the access token and the token endpoint details needed +// for later refresh grants to configuration +func (s *auth) SaveToken(token *domain.AccessToken, credentials *domain.LoginCredentials) error { cfg := config.New() - cfg.AccessToken = token.Token + cfg.AuthConfig.AccessToken = token.Token + cfg.AuthConfig.RefreshToken = token.RefreshToken + cfg.AuthConfig.ExpiresAt = int(token.ExpiresAt.Unix()) + cfg.AuthConfig.ClientID = credentials.ClientId + cfg.AuthConfig.TokenURL = credentials.TokenUrl if err := cfg.WriteConfigFile(); err != nil { - return fmt.Errorf("failed to save access token: %w", err) + return fmt.Errorf("failed to save refresh/access token: %w", err) } return nil } // Whoami retrieves the current user's information -func (s *auth) Whoami(ctx context.Context) (*domain.UserInfo, error) { +func (s *auth) Whoami(ctx context.Context) (result *domain.UserInfo, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.auth.whoami") + defer end(&err) + userInfoResp, err := s.repo.Whoami(ctx) if err != nil { return nil, fmt.Errorf("failed to retrieve user info: %w", err) @@ -100,16 +118,17 @@ func (s *auth) Whoami(ctx context.Context) (*domain.UserInfo, error) { return userInfo, nil } -// Logout clears the access token from configuration +// Logout clears the stored credentials from configuration. The refresh token +// goes too: leaving it behind would let a later refresh resurrect the session. func (s *auth) Logout() error { // TODO: Refactor this implementation to use a dedicated logout repository // which hits logout endpoint cfg := config.New() - cfg.AccessToken = "" + cfg.AuthConfig = config.AuthConfig{} if err := cfg.WriteConfigFile(); err != nil { - return fmt.Errorf("failed to clear access token: %w", err) + return fmt.Errorf("failed to clear auth config: %w", err) } return nil diff --git a/packages/envsync-cli/internal/services/certificate.go b/packages/envsync-cli/internal/services/certificate.go index cab58874..9cb4e04c 100644 --- a/packages/envsync-cli/internal/services/certificate.go +++ b/packages/envsync-cli/internal/services/certificate.go @@ -2,12 +2,17 @@ package services import ( "context" + "errors" + "net/http" + + core "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk/core" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/mappers" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository/requests" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository/responses" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type CertificateService interface { @@ -34,15 +39,31 @@ func NewCertificateService() CertificateService { return &certService{repo: repo} } -func (s *certService) InitCA(ctx context.Context, req requests.InitOrgCARequest) (domain.Certificate, error) { +func (s *certService) InitCA(ctx context.Context, req requests.InitOrgCARequest) (result domain.Certificate, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.certificate.init_ca") + defer end(&err) + res, err := s.repo.InitCA(ctx, req) if err != nil { + // Org creation already initializes the CA. The SDK returns a plain 409, + // not the generated ConflictError type. + if caAlreadyExists(err) { + return s.GetCA(ctx) + } return domain.Certificate{}, err } return mappers.OrgCAResponseToDomain(res), nil } -func (s *certService) GetCA(ctx context.Context) (domain.Certificate, error) { +func caAlreadyExists(err error) bool { + var apiErr *core.APIError + return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict +} + +func (s *certService) GetCA(ctx context.Context) (result domain.Certificate, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.certificate.get_ca") + defer end(&err) + res, err := s.repo.GetCA(ctx) if err != nil { return domain.Certificate{}, err @@ -50,7 +71,10 @@ func (s *certService) GetCA(ctx context.Context) (domain.Certificate, error) { return mappers.OrgCAResponseToDomain(res), nil } -func (s *certService) GetRootCA(ctx context.Context) (string, error) { +func (s *certService) GetRootCA(ctx context.Context) (result string, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.certificate.get_root_ca") + defer end(&err) + res, err := s.repo.GetRootCA(ctx) if err != nil { return "", err @@ -58,7 +82,10 @@ func (s *certService) GetRootCA(ctx context.Context) (string, error) { return res.CertPEM, nil } -func (s *certService) IssueMemberCert(ctx context.Context, req requests.IssueMemberCertRequest) (domain.Certificate, error) { +func (s *certService) IssueMemberCert(ctx context.Context, req requests.IssueMemberCertRequest) (result domain.Certificate, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.certificate.issue_member_cert") + defer end(&err) + res, err := s.repo.IssueMemberCert(ctx, req) if err != nil { return domain.Certificate{}, err @@ -66,7 +93,10 @@ func (s *certService) IssueMemberCert(ctx context.Context, req requests.IssueMem return mappers.MemberCertResponseToDomain(res), nil } -func (s *certService) IssueLeafCert(ctx context.Context, req requests.IssueLeafCertRequest) (domain.Certificate, error) { +func (s *certService) IssueLeafCert(ctx context.Context, req requests.IssueLeafCertRequest) (result domain.Certificate, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.certificate.issue_leaf_cert") + defer end(&err) + res, err := s.repo.IssueLeafCert(ctx, req) if err != nil { return domain.Certificate{}, err @@ -74,7 +104,10 @@ func (s *certService) IssueLeafCert(ctx context.Context, req requests.IssueLeafC return mappers.MemberCertResponseToDomain(res), nil } -func (s *certService) SignCsr(ctx context.Context, req requests.SignCsrRequest) (domain.Certificate, error) { +func (s *certService) SignCsr(ctx context.Context, req requests.SignCsrRequest) (result domain.Certificate, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.certificate.sign_csr") + defer end(&err) + res, err := s.repo.SignCsr(ctx, req) if err != nil { return domain.Certificate{}, err @@ -82,7 +115,10 @@ func (s *certService) SignCsr(ctx context.Context, req requests.SignCsrRequest) return mappers.MemberCertResponseToDomain(res), nil } -func (s *certService) ListCerts(ctx context.Context) ([]domain.Certificate, error) { +func (s *certService) ListCerts(ctx context.Context) (result []domain.Certificate, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.certificate.list_certs") + defer end(&err) + res, err := s.repo.List(ctx) if err != nil { return nil, err @@ -95,11 +131,17 @@ func (s *certService) ListCerts(ctx context.Context) ([]domain.Certificate, erro return certs, nil } -func (s *certService) RevokeCert(ctx context.Context, serialHex string, reason int) (responses.RevokeCertResponse, error) { +func (s *certService) RevokeCert(ctx context.Context, serialHex string, reason int) (result responses.RevokeCertResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.certificate.revoke_cert") + defer end(&err) + return s.repo.Revoke(ctx, serialHex, requests.RevokeCertRequest{Reason: reason}) } -func (s *certService) GetCRL(ctx context.Context) (domain.CRLResult, error) { +func (s *certService) GetCRL(ctx context.Context) (result domain.CRLResult, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.certificate.get_crl") + defer end(&err) + res, err := s.repo.GetCRL(ctx) if err != nil { return domain.CRLResult{}, err @@ -107,7 +149,10 @@ func (s *certService) GetCRL(ctx context.Context) (domain.CRLResult, error) { return mappers.CRLResponseToDomain(res), nil } -func (s *certService) CheckOCSP(ctx context.Context, serialHex string) (domain.OCSPResult, error) { +func (s *certService) CheckOCSP(ctx context.Context, serialHex string) (result domain.OCSPResult, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.certificate.check_ocsp") + defer end(&err) + res, err := s.repo.CheckOCSP(ctx, serialHex) if err != nil { return domain.OCSPResult{}, err @@ -115,7 +160,10 @@ func (s *certService) CheckOCSP(ctx context.Context, serialHex string) (domain.O return mappers.OCSPResponseToDomain(res), nil } -func (s *certService) RenewCert(ctx context.Context, id string, description string, revokePrevious bool) (domain.Certificate, error) { +func (s *certService) RenewCert(ctx context.Context, id string, description string, revokePrevious bool) (result domain.Certificate, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.certificate.renew_cert") + defer end(&err) + res, err := s.repo.Renew(ctx, id, description, revokePrevious) if err != nil { return domain.Certificate{}, err @@ -123,7 +171,10 @@ func (s *certService) RenewCert(ctx context.Context, id string, description stri return mappers.MemberCertResponseToDomain(res), nil } -func (s *certService) RotateCert(ctx context.Context, id string, description string, revokePrevious bool, reason int) (domain.Certificate, error) { +func (s *certService) RotateCert(ctx context.Context, id string, description string, revokePrevious bool, reason int) (result domain.Certificate, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.certificate.rotate_cert") + defer end(&err) + res, err := s.repo.Rotate(ctx, id, description, revokePrevious, reason) if err != nil { return domain.Certificate{}, err diff --git a/packages/envsync-cli/internal/services/certificate_test.go b/packages/envsync-cli/internal/services/certificate_test.go new file mode 100644 index 00000000..0d359daf --- /dev/null +++ b/packages/envsync-cli/internal/services/certificate_test.go @@ -0,0 +1,21 @@ +package services + +import ( + "errors" + "net/http" + "testing" + + core "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk/core" +) + +func TestCAAlreadyExists(t *testing.T) { + if !caAlreadyExists(core.NewAPIError(http.StatusConflict, errors.New("Organization CA already initialized"))) { + t.Fatal("plain 409 should count as an existing CA") + } + if caAlreadyExists(core.NewAPIError(http.StatusBadRequest, errors.New("bad"))) { + t.Fatal("400 should not count as an existing CA") + } + if caAlreadyExists(errors.New("409: Organization CA already initialized")) { + t.Fatal("a bare string should not count as an existing CA") + } +} diff --git a/packages/envsync-cli/internal/services/dynamic_secret.go b/packages/envsync-cli/internal/services/dynamic_secret.go index 48b303a4..97901610 100644 --- a/packages/envsync-cli/internal/services/dynamic_secret.go +++ b/packages/envsync-cli/internal/services/dynamic_secret.go @@ -7,6 +7,7 @@ import ( "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk/dynamicsecrets" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type DynamicSecretService interface { @@ -33,42 +34,72 @@ func NewDynamicSecretService() DynamicSecretService { } } -func (s *dynamicSecretService) ListEngines(ctx context.Context) (sdk.DynamicSecretEnginesResponse, error) { +func (s *dynamicSecretService) ListEngines(ctx context.Context) (result sdk.DynamicSecretEnginesResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.dynamic_secret.list_engines") + defer end(&err) + return s.client.GetAllDynamicSecretEngines(ctx) } -func (s *dynamicSecretService) CreateEngine(ctx context.Context, req *sdk.CreateDynamicSecretEngineRequest) (*sdk.DynamicSecretEngineResponse, error) { +func (s *dynamicSecretService) CreateEngine(ctx context.Context, req *sdk.CreateDynamicSecretEngineRequest) (result *sdk.DynamicSecretEngineResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.dynamic_secret.create_engine") + defer end(&err) + return s.client.CreateDynamicSecretEngine(ctx, req) } -func (s *dynamicSecretService) GetEngine(ctx context.Context, id string) (*sdk.DynamicSecretEngineResponse, error) { +func (s *dynamicSecretService) GetEngine(ctx context.Context, id string) (result *sdk.DynamicSecretEngineResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.dynamic_secret.get_engine") + defer end(&err) + return s.client.GetDynamicSecretEngine(ctx, id) } -func (s *dynamicSecretService) DeleteEngine(ctx context.Context, id string) (*sdk.ErrorResponse, error) { +func (s *dynamicSecretService) DeleteEngine(ctx context.Context, id string) (result *sdk.ErrorResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.dynamic_secret.delete_engine") + defer end(&err) + return s.client.DeleteDynamicSecretEngine(ctx, id) } -func (s *dynamicSecretService) UpdateEngine(ctx context.Context, id string, req *sdk.UpdateDynamicSecretEngineRequest) (*sdk.DynamicSecretEngineResponse, error) { +func (s *dynamicSecretService) UpdateEngine(ctx context.Context, id string, req *sdk.UpdateDynamicSecretEngineRequest) (result *sdk.DynamicSecretEngineResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.dynamic_secret.update_engine") + defer end(&err) + return s.client.UpdateDynamicSecretEngine(ctx, id, req) } -func (s *dynamicSecretService) ListLeases(ctx context.Context, engineID string) (sdk.DynamicSecretLeasesResponse, error) { +func (s *dynamicSecretService) ListLeases(ctx context.Context, engineID string) (result sdk.DynamicSecretLeasesResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.dynamic_secret.list_leases") + defer end(&err) + return s.client.GetDynamicSecretLeases(ctx, engineID) } -func (s *dynamicSecretService) CreateLease(ctx context.Context, engineID string, req *sdk.CreateDynamicSecretLeaseRequest) (*sdk.DynamicSecretLeaseResponse, error) { +func (s *dynamicSecretService) CreateLease(ctx context.Context, engineID string, req *sdk.CreateDynamicSecretLeaseRequest) (result *sdk.DynamicSecretLeaseResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.dynamic_secret.create_lease") + defer end(&err) + return s.client.CreateDynamicSecretLease(ctx, engineID, req) } -func (s *dynamicSecretService) GetLease(ctx context.Context, leaseID string) (*sdk.DynamicSecretLeaseResponse, error) { +func (s *dynamicSecretService) GetLease(ctx context.Context, leaseID string) (result *sdk.DynamicSecretLeaseResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.dynamic_secret.get_lease") + defer end(&err) + return s.client.GetDynamicSecretLease(ctx, leaseID) } -func (s *dynamicSecretService) RevokeLease(ctx context.Context, leaseID string) (*sdk.RevokeLeaseResponse, error) { +func (s *dynamicSecretService) RevokeLease(ctx context.Context, leaseID string) (result *sdk.RevokeLeaseResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.dynamic_secret.revoke_lease") + defer end(&err) + return s.client.RevokeDynamicSecretLease(ctx, leaseID) } -func (s *dynamicSecretService) CleanupExpiredLeases(ctx context.Context) (*sdk.CleanupResponse, error) { +func (s *dynamicSecretService) CleanupExpiredLeases(ctx context.Context) (result *sdk.CleanupResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.dynamic_secret.cleanup_expired_leases") + defer end(&err) + return s.client.CleanupExpiredLeases(ctx) } diff --git a/packages/envsync-cli/internal/services/env_type.go b/packages/envsync-cli/internal/services/env_type.go index 82b591ba..6c4e8170 100644 --- a/packages/envsync-cli/internal/services/env_type.go +++ b/packages/envsync-cli/internal/services/env_type.go @@ -6,6 +6,7 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/mappers" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type EnvTypeService interface { @@ -27,7 +28,10 @@ func NewEnvTypeService() EnvTypeService { } } -func (e *envTypeService) CreateEnvType(ctx context.Context, envType *domain.EnvType) (domain.EnvType, error) { +func (e *envTypeService) CreateEnvType(ctx context.Context, envType *domain.EnvType) (result domain.EnvType, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.env_type.create_env_type") + defer end(&err) + req := mappers.EnvTypeDomainToRequest(envType) res, err := e.repo.Create(ctx, &req) @@ -38,7 +42,10 @@ func (e *envTypeService) CreateEnvType(ctx context.Context, envType *domain.EnvT return mappers.EnvTypeResponseToDomain(res), nil } -func (e *envTypeService) GetEnvTypeByID(ctx context.Context, id string) (domain.EnvType, error) { +func (e *envTypeService) GetEnvTypeByID(ctx context.Context, id string) (result domain.EnvType, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.env_type.get_env_type_by_id") + defer end(&err) + res, err := e.repo.GetByID(ctx, id) if err != nil { return domain.EnvType{}, err @@ -47,7 +54,10 @@ func (e *envTypeService) GetEnvTypeByID(ctx context.Context, id string) (domain. return mappers.EnvTypeResponseToDomain(res), nil } -func (e *envTypeService) GetEnvTypesByAppID(ctx context.Context, appID string) ([]domain.EnvType, error) { +func (e *envTypeService) GetEnvTypesByAppID(ctx context.Context, appID string) (result []domain.EnvType, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.env_type.get_env_types_by_app_id") + defer end(&err) + res, err := e.repo.GetByAppID(ctx, appID) if err != nil { return nil, err @@ -61,7 +71,10 @@ func (e *envTypeService) GetEnvTypesByAppID(ctx context.Context, appID string) ( return envTypes, nil } -func (e *envTypeService) DeleteEnvType(ctx context.Context, id string) error { +func (e *envTypeService) DeleteEnvType(ctx context.Context, id string) (err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.env_type.delete_env_type") + defer end(&err) + if err := e.repo.Delete(ctx, id); err != nil { return err } diff --git a/packages/envsync-cli/internal/services/gpg_key.go b/packages/envsync-cli/internal/services/gpg_key.go index a5b15ff6..bc20dd6d 100644 --- a/packages/envsync-cli/internal/services/gpg_key.go +++ b/packages/envsync-cli/internal/services/gpg_key.go @@ -7,6 +7,7 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/mappers" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository/requests" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type GpgKeyService interface { @@ -31,7 +32,10 @@ func NewGpgKeyService() GpgKeyService { return &gpgKeyService{repo: repo} } -func (s *gpgKeyService) ListKeys(ctx context.Context) ([]domain.GpgKey, error) { +func (s *gpgKeyService) ListKeys(ctx context.Context) (result []domain.GpgKey, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.gpg_key.list_keys") + defer end(&err) + res, err := s.repo.List(ctx) if err != nil { return nil, err @@ -44,7 +48,10 @@ func (s *gpgKeyService) ListKeys(ctx context.Context) ([]domain.GpgKey, error) { return keys, nil } -func (s *gpgKeyService) GetKey(ctx context.Context, id string) (domain.GpgKey, error) { +func (s *gpgKeyService) GetKey(ctx context.Context, id string) (result domain.GpgKey, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.gpg_key.get_key") + defer end(&err) + res, err := s.repo.Get(ctx, id) if err != nil { return domain.GpgKey{}, err @@ -52,7 +59,10 @@ func (s *gpgKeyService) GetKey(ctx context.Context, id string) (domain.GpgKey, e return mappers.GpgKeyResponseToDomain(res), nil } -func (s *gpgKeyService) GenerateKey(ctx context.Context, req requests.GenerateGpgKeyRequest) (domain.GpgKey, error) { +func (s *gpgKeyService) GenerateKey(ctx context.Context, req requests.GenerateGpgKeyRequest) (result domain.GpgKey, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.gpg_key.generate_key") + defer end(&err) + res, err := s.repo.Generate(ctx, req) if err != nil { return domain.GpgKey{}, err @@ -60,11 +70,17 @@ func (s *gpgKeyService) GenerateKey(ctx context.Context, req requests.GenerateGp return mappers.GpgKeyResponseToDomain(res), nil } -func (s *gpgKeyService) DeleteKey(ctx context.Context, id string) error { +func (s *gpgKeyService) DeleteKey(ctx context.Context, id string) (err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.gpg_key.delete_key") + defer end(&err) + return s.repo.Delete(ctx, id) } -func (s *gpgKeyService) RevokeKey(ctx context.Context, id string, reason string) (domain.GpgKey, error) { +func (s *gpgKeyService) RevokeKey(ctx context.Context, id string, reason string) (result domain.GpgKey, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.gpg_key.revoke_key") + defer end(&err) + res, err := s.repo.Revoke(ctx, id, reason) if err != nil { return domain.GpgKey{}, err @@ -72,7 +88,10 @@ func (s *gpgKeyService) RevokeKey(ctx context.Context, id string, reason string) return mappers.GpgKeyResponseToDomain(res), nil } -func (s *gpgKeyService) ExportKey(ctx context.Context, id string) (string, string, error) { +func (s *gpgKeyService) ExportKey(ctx context.Context, id string) (publicKey, fingerprint string, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.gpg_key.export_key") + defer end(&err) + res, err := s.repo.Export(ctx, id) if err != nil { return "", "", err @@ -80,7 +99,10 @@ func (s *gpgKeyService) ExportKey(ctx context.Context, id string) (string, strin return res.PublicKey, res.Fingerprint, nil } -func (s *gpgKeyService) Sign(ctx context.Context, req requests.SignDataRequest) (domain.GpgSignatureResult, error) { +func (s *gpgKeyService) Sign(ctx context.Context, req requests.SignDataRequest) (result domain.GpgSignatureResult, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.gpg_key.sign") + defer end(&err) + res, err := s.repo.Sign(ctx, req) if err != nil { return domain.GpgSignatureResult{}, err @@ -88,7 +110,10 @@ func (s *gpgKeyService) Sign(ctx context.Context, req requests.SignDataRequest) return mappers.GpgSignatureResponseToDomain(res), nil } -func (s *gpgKeyService) Verify(ctx context.Context, req requests.VerifySignatureRequest) (domain.GpgVerifyResult, error) { +func (s *gpgKeyService) Verify(ctx context.Context, req requests.VerifySignatureRequest) (result domain.GpgVerifyResult, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.gpg_key.verify") + defer end(&err) + res, err := s.repo.Verify(ctx, req) if err != nil { return domain.GpgVerifyResult{}, err @@ -96,7 +121,10 @@ func (s *gpgKeyService) Verify(ctx context.Context, req requests.VerifySignature return mappers.GpgVerifyResponseToDomain(res), nil } -func (s *gpgKeyService) RotateKey(ctx context.Context, id string, payload map[string]any) (domain.GpgKey, error) { +func (s *gpgKeyService) RotateKey(ctx context.Context, id string, payload map[string]any) (result domain.GpgKey, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.gpg_key.rotate_key") + defer end(&err) + res, err := s.repo.Rotate(ctx, id, payload) if err != nil { return domain.GpgKey{}, err @@ -104,7 +132,10 @@ func (s *gpgKeyService) RotateKey(ctx context.Context, id string, payload map[st return mappers.GpgKeyResponseToDomain(res), nil } -func (s *gpgKeyService) ExtendExpiry(ctx context.Context, id string, expiresInDays int) (domain.GpgKey, error) { +func (s *gpgKeyService) ExtendExpiry(ctx context.Context, id string, expiresInDays int) (result domain.GpgKey, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.gpg_key.extend_expiry") + defer end(&err) + res, err := s.repo.ExtendExpiry(ctx, id, expiresInDays) if err != nil { return domain.GpgKey{}, err diff --git a/packages/envsync-cli/internal/services/log_forwarding.go b/packages/envsync-cli/internal/services/log_forwarding.go deleted file mode 100644 index 10599732..00000000 --- a/packages/envsync-cli/internal/services/log_forwarding.go +++ /dev/null @@ -1,42 +0,0 @@ -package services - -import ( - "context" - - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository" -) - -// LogForwardingService defines the interface for log forwarding operations -type LogForwardingService interface { - CreateLogForwardingConfig(ctx context.Context, req *sdk.CreateLogForwardingRequest) (*sdk.LogForwardingResponse, error) - GetLogForwardingConfigs(ctx context.Context) (sdk.LogForwardingsResponse, error) - GetLogForwardingConfig(ctx context.Context, id string) (*sdk.LogForwardingResponse, error) - DeleteLogForwardingConfig(ctx context.Context, id string) (*sdk.ErrorResponse, error) -} - -type logForwardingService struct{} - -func NewLogForwardingService() LogForwardingService { - return &logForwardingService{} -} - -func (s *logForwardingService) CreateLogForwardingConfig(ctx context.Context, req *sdk.CreateLogForwardingRequest) (*sdk.LogForwardingResponse, error) { - client := repository.CreateManagementSDKClient() - return client.LogForwarding.CreateLogForwardingConfig(ctx, req) -} - -func (s *logForwardingService) GetLogForwardingConfigs(ctx context.Context) (sdk.LogForwardingsResponse, error) { - client := repository.CreateManagementSDKClient() - return client.LogForwarding.GetLogForwardingConfigs(ctx) -} - -func (s *logForwardingService) GetLogForwardingConfig(ctx context.Context, id string) (*sdk.LogForwardingResponse, error) { - client := repository.CreateManagementSDKClient() - return client.LogForwarding.GetLogForwardingConfig(ctx, id) -} - -func (s *logForwardingService) DeleteLogForwardingConfig(ctx context.Context, id string) (*sdk.ErrorResponse, error) { - client := repository.CreateManagementSDKClient() - return client.LogForwarding.DeleteLogForwardingConfig(ctx, id) -} diff --git a/packages/envsync-cli/internal/services/oidc.go b/packages/envsync-cli/internal/services/oidc.go deleted file mode 100644 index 29d7bb56..00000000 --- a/packages/envsync-cli/internal/services/oidc.go +++ /dev/null @@ -1,127 +0,0 @@ -package services - -import ( - "context" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository" - - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" -) - -type OidcService interface { - CreateProvider(ctx context.Context, input domain.CreateOidcProviderInput) (*domain.OidcProvider, error) - ListProviders(ctx context.Context) ([]domain.OidcProvider, error) - GetProvider(ctx context.Context, id string) (*domain.OidcProvider, error) - UpdateProvider(ctx context.Context, input domain.UpdateOidcProviderInput) error - DeleteProvider(ctx context.Context, id string) error -} - -type oidcService struct{} - -func NewOidcService() OidcService { - return &oidcService{} -} - -func (s *oidcService) CreateProvider(ctx context.Context, input domain.CreateOidcProviderInput) (*domain.OidcProvider, error) { - client := repository.GetManagementClient() - - providerType, err := sdk.NewCreateOidcProviderRequestProviderTypeFromString(input.ProviderType) - if err != nil { - return nil, err - } - - req := &sdk.CreateOidcProviderRequest{ - ProviderType: providerType, - IssuerUrl: input.IssuerURL, - Audience: input.Audience, - AllowedSubjects: input.AllowedSubjects, - } - - resp, err := client.OidcProviders.CreateOidcProvider(ctx, req) - if err != nil { - return nil, err - } - - return mapOidcResponseToDomain(resp), nil -} - -func (s *oidcService) ListProviders(ctx context.Context) ([]domain.OidcProvider, error) { - client := repository.GetManagementClient() - - resp, err := client.OidcProviders.GetAllOidcProviders(ctx) - if err != nil { - return nil, err - } - - providers := make([]domain.OidcProvider, 0, len(resp)) - for _, r := range resp { - providers = append(providers, *mapOidcResponseToDomain(r)) - } - - return providers, nil -} - -func (s *oidcService) GetProvider(ctx context.Context, id string) (*domain.OidcProvider, error) { - client := repository.GetManagementClient() - - resp, err := client.OidcProviders.GetOidcProvider(ctx, id) - if err != nil { - return nil, err - } - - return mapOidcResponseToDomain(resp), nil -} - -func (s *oidcService) UpdateProvider(ctx context.Context, input domain.UpdateOidcProviderInput) error { - client := repository.GetManagementClient() - - req := &sdk.UpdateOidcProviderRequest{ - Audience: strPtrIfNonEmpty(input.Audience), - Enabled: input.Enabled, - AllowedSubjects: input.AllowedSubjects, - } - - _, err := client.OidcProviders.UpdateOidcProvider(ctx, input.ID, req) - return err -} - -func (s *oidcService) DeleteProvider(ctx context.Context, id string) error { - client := repository.GetManagementClient() - - _, err := client.OidcProviders.DeleteOidcProvider(ctx, id) - return err -} - -func mapOidcResponseToDomain(r *sdk.OidcProviderResponse) *domain.OidcProvider { - var machineUserID string - if r.MachineUserId != nil { - machineUserID = *r.MachineUserId - } - return &domain.OidcProvider{ - ID: r.Id, - OrgID: r.OrgId, - ProviderType: string(r.ProviderType), - IssuerURL: r.IssuerUrl, - Audience: r.Audience, - Enabled: r.Enabled, - AllowedSubjects: r.AllowedSubjects, - MachineUserID: machineUserID, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, - } -} - -func strPtrIfNonEmpty(s string) *string { - if s == "" { - return nil - } - return &s -} - -func derefString(s *string) string { - if s == nil { - return "" - } - return *s -} diff --git a/packages/envsync-cli/internal/services/role.go b/packages/envsync-cli/internal/services/role.go index 9f3ad173..b332264f 100644 --- a/packages/envsync-cli/internal/services/role.go +++ b/packages/envsync-cli/internal/services/role.go @@ -6,6 +6,7 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/mappers" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type RoleService interface { @@ -24,7 +25,10 @@ func NewRoleService() RoleService { } } -func (r *role) GetAllRoles(ctx context.Context) ([]domain.Role, error) { +func (r *role) GetAllRoles(ctx context.Context) (result []domain.Role, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.role.get_all_roles") + defer end(&err) + roleRes, err := r.roleRepo.GetAll(ctx) if err != nil { return nil, err diff --git a/packages/envsync-cli/internal/services/rotation.go b/packages/envsync-cli/internal/services/rotation.go index 79c35a46..265c9e35 100644 --- a/packages/envsync-cli/internal/services/rotation.go +++ b/packages/envsync-cli/internal/services/rotation.go @@ -7,14 +7,12 @@ import ( "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk/rotation" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type RotationService interface { ListPolicies(ctx context.Context, req *sdk.GetRotationPoliciesRequest) (sdk.RotationPoliciesResponse, error) - CreatePolicy(ctx context.Context, req *sdk.CreateRotationPolicyRequest) (*sdk.RotationPolicyResponse, error) GetPolicy(ctx context.Context, id string) (*sdk.RotationPolicyResponse, error) - DeletePolicy(ctx context.Context, id string) (*sdk.ErrorResponse, error) - UpdatePolicy(ctx context.Context, id string, req *sdk.UpdateRotationPolicyRequest) (*sdk.RotationPolicyResponse, error) TriggerRotation(ctx context.Context, id string) (*sdk.TriggerRotationResponse, error) GetRotationStates(ctx context.Context, id string) (sdk.RotationStatesResponse, error) RevokeExpiredCredentials(ctx context.Context) (*sdk.RevokeOldCredentialResponse, error) @@ -31,34 +29,37 @@ func NewRotationService() RotationService { } } -func (s *rotationService) ListPolicies(ctx context.Context, req *sdk.GetRotationPoliciesRequest) (sdk.RotationPoliciesResponse, error) { +func (s *rotationService) ListPolicies(ctx context.Context, req *sdk.GetRotationPoliciesRequest) (result sdk.RotationPoliciesResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.rotation.list_policies") + defer end(&err) + return s.client.GetRotationPolicies(ctx, req) } -func (s *rotationService) CreatePolicy(ctx context.Context, req *sdk.CreateRotationPolicyRequest) (*sdk.RotationPolicyResponse, error) { - return s.client.CreateRotationPolicy(ctx, req) -} +func (s *rotationService) GetPolicy(ctx context.Context, id string) (result *sdk.RotationPolicyResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.rotation.get_policy") + defer end(&err) -func (s *rotationService) GetPolicy(ctx context.Context, id string) (*sdk.RotationPolicyResponse, error) { return s.client.GetRotationPolicy(ctx, id) } -func (s *rotationService) DeletePolicy(ctx context.Context, id string) (*sdk.ErrorResponse, error) { - return s.client.DeleteRotationPolicy(ctx, id) -} +func (s *rotationService) TriggerRotation(ctx context.Context, id string) (result *sdk.TriggerRotationResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.rotation.trigger_rotation") + defer end(&err) -func (s *rotationService) UpdatePolicy(ctx context.Context, id string, req *sdk.UpdateRotationPolicyRequest) (*sdk.RotationPolicyResponse, error) { - return s.client.UpdateRotationPolicy(ctx, id, req) -} - -func (s *rotationService) TriggerRotation(ctx context.Context, id string) (*sdk.TriggerRotationResponse, error) { return s.client.TriggerRotation(ctx, id) } -func (s *rotationService) GetRotationStates(ctx context.Context, id string) (sdk.RotationStatesResponse, error) { +func (s *rotationService) GetRotationStates(ctx context.Context, id string) (result sdk.RotationStatesResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.rotation.get_rotation_states") + defer end(&err) + return s.client.GetRotationStates(ctx, id) } -func (s *rotationService) RevokeExpiredCredentials(ctx context.Context) (*sdk.RevokeOldCredentialResponse, error) { +func (s *rotationService) RevokeExpiredCredentials(ctx context.Context) (result *sdk.RevokeOldCredentialResponse, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.rotation.revoke_expired_credentials") + defer end(&err) + return s.client.RevokeExpiredCredentials(ctx) } diff --git a/packages/envsync-cli/internal/services/saml.go b/packages/envsync-cli/internal/services/saml.go deleted file mode 100644 index 588930d3..00000000 --- a/packages/envsync-cli/internal/services/saml.go +++ /dev/null @@ -1,151 +0,0 @@ -package services - -import ( - "context" - "fmt" - - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" - "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository" - - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" -) - -type SamlService interface { - CreateProvider(ctx context.Context, input domain.CreateSamlProviderInput) (*domain.SamlProvider, error) - ListProviders(ctx context.Context) ([]domain.SamlProvider, error) - GetProvider(ctx context.Context, id string) (*domain.SamlProvider, error) - UpdateProvider(ctx context.Context, input domain.UpdateSamlProviderInput) error - DeleteProvider(ctx context.Context, id string) error - GetMetadata(ctx context.Context, orgSlug string) (string, error) - InitiateSso(ctx context.Context, orgSlug string, providerID string) (*domain.SamlSsoResult, error) -} - -type samlService struct{} - -func NewSamlService() SamlService { - return &samlService{} -} - -func (s *samlService) CreateProvider(ctx context.Context, input domain.CreateSamlProviderInput) (*domain.SamlProvider, error) { - client := repository.GetManagementClient() - - providerType, err := sdk.NewCreateSamlProviderRequestProviderTypeFromString(input.ProviderType) - if err != nil { - return nil, err - } - - req := &sdk.CreateSamlProviderRequest{ - ProviderType: providerType, - Name: input.Name, - EntityId: strPtrIfNonEmpty(input.EntityID), - SsoUrl: strPtrIfNonEmpty(input.SsoURL), - Certificate: strPtrIfNonEmpty(input.Certificate), - } - - resp, err := client.SamlProviders.CreateSamlProvider(ctx, req) - if err != nil { - return nil, err - } - - return mapSamlResponseToDomain(resp), nil -} - -func (s *samlService) ListProviders(ctx context.Context) ([]domain.SamlProvider, error) { - client := repository.GetManagementClient() - - resp, err := client.SamlProviders.GetAllSamlProviders(ctx) - if err != nil { - return nil, err - } - - providers := make([]domain.SamlProvider, 0, len(resp)) - for _, r := range resp { - providers = append(providers, *mapSamlResponseToDomain(r)) - } - - return providers, nil -} - -func (s *samlService) GetProvider(ctx context.Context, id string) (*domain.SamlProvider, error) { - client := repository.GetManagementClient() - - resp, err := client.SamlProviders.GetSamlProvider(ctx, id) - if err != nil { - return nil, err - } - - return mapSamlResponseToDomain(resp), nil -} - -func (s *samlService) UpdateProvider(ctx context.Context, input domain.UpdateSamlProviderInput) error { - client := repository.GetManagementClient() - - req := &sdk.UpdateSamlProviderRequest{ - Name: strPtrIfNonEmpty(input.Name), - EntityId: strPtrIfNonEmpty(input.EntityID), - SsoUrl: strPtrIfNonEmpty(input.SsoURL), - Certificate: strPtrIfNonEmpty(input.Certificate), - Enabled: input.Enabled, - } - - _, err := client.SamlProviders.UpdateSamlProvider(ctx, input.ID, req) - return err -} - -func (s *samlService) DeleteProvider(ctx context.Context, id string) error { - client := repository.GetManagementClient() - - _, err := client.SamlProviders.DeleteSamlProvider(ctx, id) - return err -} - -func (s *samlService) GetMetadata(ctx context.Context, orgSlug string) (string, error) { - client := repository.GetManagementClient() - me, err := client.Authentication.Whoami(ctx) - if err != nil { - return "", err - } - - orgID := "" - if me.Org != nil && me.Org.Slug == orgSlug { - orgID = me.Org.Id - } - for _, membership := range me.Memberships { - if membership != nil && membership.OrgSlug == orgSlug { - orgID = membership.OrgId - break - } - } - if orgID == "" { - return "", fmt.Errorf("organization slug %q not found in the current session", orgSlug) - } - - return repository.GetPublicSamlMetadata(ctx, orgID) -} - -func (s *samlService) InitiateSso(ctx context.Context, orgSlug string, providerID string) (*domain.SamlSsoResult, error) { - resp, err := repository.StartPublicSamlSso(ctx, orgSlug, providerID) - if err != nil { - return nil, err - } - - return &domain.SamlSsoResult{ - RedirectURL: resp.RedirectURL, - RequestID: resp.RequestID, - }, nil -} - -func mapSamlResponseToDomain(r *sdk.SamlProviderResponse) *domain.SamlProvider { - return &domain.SamlProvider{ - ID: r.Id, - OrgID: r.OrgId, - ProviderType: string(r.ProviderType), - Name: r.Name, - EntityID: r.EntityId, - SsoURL: r.SsoUrl, - Certificate: derefString(r.Certificate), - Enabled: r.Enabled, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, - } -} diff --git a/packages/envsync-cli/internal/services/secret.go b/packages/envsync-cli/internal/services/secret.go index b8ea0bac..349273f4 100644 --- a/packages/envsync-cli/internal/services/secret.go +++ b/packages/envsync-cli/internal/services/secret.go @@ -6,6 +6,7 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/mappers" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type SecretService interface { @@ -24,7 +25,10 @@ func NewSecretService() SecretService { } } -func (s *secretService) GetAllSecrets(ctx context.Context, appID, envTypeID string) ([]domain.Secret, error) { +func (s *secretService) GetAllSecrets(ctx context.Context, appID, envTypeID string) (result []domain.Secret, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.secret.get_all_secrets") + defer end(&err) + sec, err := s.repo.GetAll(ctx, appID, envTypeID) if err != nil { return nil, err @@ -38,7 +42,10 @@ func (s *secretService) GetAllSecrets(ctx context.Context, appID, envTypeID stri return secrets, nil } -func (s *secretService) RevelSecrets(ctx context.Context, appID string, envTypeID string, keys []string) ([]domain.Secret, error) { +func (s *secretService) RevelSecrets(ctx context.Context, appID string, envTypeID string, keys []string) (result []domain.Secret, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.secret.revel_secrets") + defer end(&err) + sec, err := s.repo.Reveal(ctx, appID, envTypeID, keys) if err != nil { return nil, err diff --git a/packages/envsync-cli/internal/services/service_token.go b/packages/envsync-cli/internal/services/service_token.go index ac8a3596..9ff0b3da 100644 --- a/packages/envsync-cli/internal/services/service_token.go +++ b/packages/envsync-cli/internal/services/service_token.go @@ -3,15 +3,16 @@ package services import ( "context" - sdk "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" - + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/mappers" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type ServiceTokenService interface { - CreateServiceToken(ctx context.Context, req *sdk.CreateServiceTokenRequest) (*sdk.CreateServiceTokenResponse, error) - GetAllServiceTokens(ctx context.Context) (sdk.ServiceTokensResponse, error) - GetServiceToken(ctx context.Context, id string) (*sdk.ServiceTokenResponse, error) + CreateServiceToken(ctx context.Context, st *domain.ServiceToken) (domain.ServiceToken, error) + GetAllServiceTokens(ctx context.Context) ([]domain.ServiceToken, error) + GetServiceToken(ctx context.Context, id string) (domain.ServiceToken, error) DeleteServiceToken(ctx context.Context, id string) error } @@ -26,18 +27,52 @@ func NewServiceTokenService() ServiceTokenService { } } -func (s *serviceTokenService) CreateServiceToken(ctx context.Context, req *sdk.CreateServiceTokenRequest) (*sdk.CreateServiceTokenResponse, error) { - return s.repo.Create(ctx, req) +func (s *serviceTokenService) CreateServiceToken(ctx context.Context, st *domain.ServiceToken) (result domain.ServiceToken, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.service_token.create_service_token") + defer end(&err) + + req := mappers.DomainToServiceTokenRequest(st) + + res, err := s.repo.Create(ctx, req) + if err != nil { + return domain.ServiceToken{}, err + } + + return mappers.ServiceTokenResponseToDomain(res), nil } -func (s *serviceTokenService) GetAllServiceTokens(ctx context.Context) (sdk.ServiceTokensResponse, error) { - return s.repo.GetAll(ctx) +func (s *serviceTokenService) GetAllServiceTokens(ctx context.Context) (result []domain.ServiceToken, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.service_token.get_all_service_tokens") + defer end(&err) + + res, err := s.repo.GetAll(ctx) + if err != nil { + return nil, err + } + + tokens := make([]domain.ServiceToken, len(res)) + for i, tokenResp := range res { + tokens[i] = mappers.ServiceTokenResponseToDomain(tokenResp) + } + + return tokens, nil } -func (s *serviceTokenService) GetServiceToken(ctx context.Context, id string) (*sdk.ServiceTokenResponse, error) { - return s.repo.GetByID(ctx, id) +func (s *serviceTokenService) GetServiceToken(ctx context.Context, id string) (result domain.ServiceToken, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.service_token.get_service_token") + defer end(&err) + + res, err := s.repo.GetByID(ctx, id) + if err != nil { + return domain.ServiceToken{}, err + } + + return mappers.ServiceTokenResponseToDomain(res), nil } -func (s *serviceTokenService) DeleteServiceToken(ctx context.Context, id string) error { +func (s *serviceTokenService) DeleteServiceToken(ctx context.Context, id string) (err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.service_token.delete_service_token") + defer end(&err) + return s.repo.Delete(ctx, id) } diff --git a/packages/envsync-cli/internal/services/sync.go b/packages/envsync-cli/internal/services/sync.go index fa39e796..09633af9 100644 --- a/packages/envsync-cli/internal/services/sync.go +++ b/packages/envsync-cli/internal/services/sync.go @@ -12,6 +12,7 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/mappers" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type SyncService interface { @@ -88,7 +89,10 @@ func (s *sync) WriteConfigData(cfg domain.SyncConfig) error { return nil } -func (s *sync) ReadRemoteEnv(ctx context.Context) ([]*domain.EnvironmentVariable, error) { +func (s *sync) ReadRemoteEnv(ctx context.Context) (result []*domain.EnvironmentVariable, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.sync.read_remote_env") + defer end(&err) + envRes, err := s.repo.GetAllEnv(ctx) if err != nil { return nil, err @@ -129,7 +133,10 @@ func (s *sync) WriteLocalEnv(env map[string]string) error { return godotenv.Write(env, ".env") } -func (s *sync) WriteRemoteEnv(ctx context.Context, env *domain.EnvironmentSync) error { +func (s *sync) WriteRemoteEnv(ctx context.Context, env *domain.EnvironmentSync) (err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.sync.write_remote_env") + defer end(&err) + toCreate := env.ToAdd toUpdate := env.ToUpdate toDelete := env.ToDelete diff --git a/packages/envsync-cli/internal/services/user.go b/packages/envsync-cli/internal/services/user.go index 8aee3649..00826133 100644 --- a/packages/envsync-cli/internal/services/user.go +++ b/packages/envsync-cli/internal/services/user.go @@ -6,6 +6,7 @@ import ( "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/domain" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/mappers" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/repository" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/telemetry" ) type UserService interface { @@ -27,7 +28,10 @@ func NewUserService() UserService { } } -func (u *user) GetAllUsers(ctx context.Context) ([]domain.User, error) { +func (u *user) GetAllUsers(ctx context.Context) (result []domain.User, err error) { + ctx, end := telemetry.StartSpan(ctx, "svc.user.get_all_users") + defer end(&err) + userRes, err := u.userRepo.GetAll(ctx) if err != nil { return nil, err diff --git a/packages/envsync-cli/internal/telemetry/http.go b/packages/envsync-cli/internal/telemetry/http.go new file mode 100644 index 00000000..4eafbbaa --- /dev/null +++ b/packages/envsync-cli/internal/telemetry/http.go @@ -0,0 +1,67 @@ +package telemetry + +import ( + "net/http" + "time" + + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.uber.org/zap" + + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/logger" +) + +// NewInstrumentedTransport wraps base with OTEL HTTP client spans plus one log +// record per API call. All repository traffic funnels through the client +// constructors in internal/repository, so the repository layer needs no +// per-method instrumentation: every present and future repository call is +// covered here. The span carries method, URL, and status and already marks +// 4xx/5xx and transport failures as errors (otelhttp); this wrapper adds the +// log trail that ties each call to the command's trace via trace_id. +// +// Log records deliberately carry only method, path, status, and duration: +// query strings, headers, and request/response bodies (tokens, secret values) +// never reach logs or span attributes. The span name is "METHOD /path" — a CLI +// has no route templates, and the bounded API surface makes the cardinality +// cost worth the debuggability. +func NewInstrumentedTransport(base http.RoundTripper) http.RoundTripper { + return otelhttp.NewTransport(&loggedTransport{base: base}, + otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string { + return r.Method + " " + r.URL.Path + }), + ) +} + +type loggedTransport struct { + base http.RoundTripper +} + +func (t *loggedTransport) RoundTrip(req *http.Request) (*http.Response, error) { + start := time.Now() + resp, err := t.base.RoundTrip(req) + + // req.Context() carries the CLI logger and the otelhttp span started for + // this call, so the record lands in the log file and the OTEL logs with + // the trace that caused it. + l := logger.FromContext(req.Context()) + fields := []zap.Field{ + zap.String("method", req.Method), + zap.String("path", req.URL.Path), + zap.Duration("duration", time.Since(start)), + } + + if err != nil { + l.Error("api call failed", append(fields, zap.Error(err))...) + return resp, err + } + + fields = append(fields, zap.Int("status", resp.StatusCode)) + switch { + case resp.StatusCode >= 500: + l.Error("api call failed", fields...) + case resp.StatusCode >= 400: + l.Warn("api call rejected", fields...) + default: + l.Info("api call", fields...) + } + return resp, err +} diff --git a/packages/envsync-cli/internal/telemetry/http_test.go b/packages/envsync-cli/internal/telemetry/http_test.go new file mode 100644 index 00000000..591a8e17 --- /dev/null +++ b/packages/envsync-cli/internal/telemetry/http_test.go @@ -0,0 +1,106 @@ +package telemetry + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/constants" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func runWithLogs(t *testing.T, rt http.RoundTripper, req *http.Request) *observer.ObservedLogs { + t.Helper() + core, recorded := observer.New(zapcore.InfoLevel) + ctx := context.WithValue(req.Context(), constants.LoggerKey, zap.New(core)) + resp, err := rt.RoundTrip(req.WithContext(ctx)) + if err == nil { + resp.Body.Close() + } + return recorded +} + +// The transport is the repository layer's only instrumentation: its records +// must identify the call and its outcome without leaking the query string, +// headers, or bodies (tokens, secret values). +func TestLoggedTransportRecordsCallWithoutSecrets(t *testing.T) { + rt := NewInstrumentedTransport(roundTripFunc(func(r *http.Request) (*http.Response, error) { + return httptest.NewRecorder().Result(), nil + })) + + logs := runWithLogs(t, rt, httptest.NewRequest("GET", "http://api.test/api/app?token=supersecret", nil)) + + entries := logs.All() + if len(entries) != 1 { + t.Fatalf("got %d log entries, want 1", len(entries)) + } + if entries[0].Level != zapcore.InfoLevel { + t.Errorf("level = %v, want Info", entries[0].Level) + } + fields := entries[0].ContextMap() + if fields["method"] != "GET" || fields["path"] != "/api/app" { + t.Errorf("call fields = %v, want method GET and path /api/app", fields) + } + for _, v := range fields { + if s, ok := v.(string); ok && strings.Contains(s, "supersecret") { + t.Errorf("log field %v leaks the query string", v) + } + } +} + +func TestLoggedTransportLevels(t *testing.T) { + cases := []struct { + name string + rt http.RoundTripper + level zapcore.Level + msg string + }{ + { + name: "5xx logs at error", + rt: roundTripFunc(func(*http.Request) (*http.Response, error) { return responseWithStatus(503), nil }), + level: zapcore.ErrorLevel, + msg: "api call failed", + }, + { + name: "4xx logs at warn", + rt: roundTripFunc(func(*http.Request) (*http.Response, error) { return responseWithStatus(401), nil }), + level: zapcore.WarnLevel, + msg: "api call rejected", + }, + { + name: "transport failure logs at error", + rt: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("dial refused") }), + level: zapcore.ErrorLevel, + msg: "api call failed", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + logs := runWithLogs(t, NewInstrumentedTransport(tc.rt), httptest.NewRequest("GET", "http://api.test/api/app", nil)) + entries := logs.All() + if len(entries) != 1 { + t.Fatalf("got %d log entries, want 1", len(entries)) + } + if entries[0].Level != tc.level || entries[0].Message != tc.msg { + t.Errorf("got %s/%q, want %s/%q", entries[0].Level, entries[0].Message, tc.level, tc.msg) + } + }) + } +} + +func responseWithStatus(status int) *http.Response { + rec := httptest.NewRecorder() + rec.Code = status + return rec.Result() +} diff --git a/packages/envsync-cli/internal/telemetry/telemetry.go b/packages/envsync-cli/internal/telemetry/telemetry.go index fa30f55c..19f5fc07 100644 --- a/packages/envsync-cli/internal/telemetry/telemetry.go +++ b/packages/envsync-cli/internal/telemetry/telemetry.go @@ -2,8 +2,10 @@ package telemetry import ( "context" + "fmt" "net/url" "os" + "strings" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/codes" @@ -15,8 +17,11 @@ import ( sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.26.0" "go.opentelemetry.io/otel/trace" + "go.uber.org/zap" "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/config" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/constants" + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/logger" ) var version = "dev" @@ -26,17 +31,14 @@ const tracerName = "envsync-cli" func Init(ctx context.Context) (shutdown func(context.Context) error, lp *sdklog.LoggerProvider, err error) { noop := func(context.Context) error { return nil } - if os.Getenv("OTEL_SDK_DISABLED") == "true" { + if os.Getenv(constants.EnvOTELDisabled) == "true" { return noop, nil, nil } - endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") - if endpoint == "" { - endpoint = os.Getenv("ENVSYNC_TELEMETRY_ENDPOINT") - } + endpoint := os.Getenv(constants.EnvOTELURL) if endpoint == "" { cfg := config.New() - endpoint = cfg.TelemetryURL + endpoint = cfg.OTELConfig.Endpoint } if endpoint == "" { return noop, nil, nil @@ -57,15 +59,19 @@ func Init(ctx context.Context) (shutdown func(context.Context) error, lp *sdklog return noop, nil, err } - host, useHTTP := parseEndpoint(endpoint) - - traceOpts := []otlptracehttp.Option{ - otlptracehttp.WithEndpoint(host), + // WithEndpointURL consumes the path exactly as given and never appends the + // signal path, so the configured base endpoint must be completed here. + traceURL, err := signalURL(endpoint, "/v1/traces") + if err != nil { + return noop, nil, err } - if useHTTP { - traceOpts = append(traceOpts, otlptracehttp.WithInsecure()) + logURL, err := signalURL(endpoint, "/v1/logs") + if err != nil { + return noop, nil, err } - traceExp, err := otlptracehttp.New(ctx, traceOpts...) + + // The URL scheme selects TLS vs. plain HTTP, so WithInsecure is not needed. + traceExp, err := otlptracehttp.New(ctx, otlptracehttp.WithEndpointURL(traceURL)) if err != nil { return noop, nil, err } @@ -80,13 +86,7 @@ func Init(ctx context.Context) (shutdown func(context.Context) error, lp *sdklog propagation.Baggage{}, )) - logOpts := []otlploghttp.Option{ - otlploghttp.WithEndpoint(host), - } - if useHTTP { - logOpts = append(logOpts, otlploghttp.WithInsecure()) - } - logExp, err := otlploghttp.New(ctx, logOpts...) + logExp, err := otlploghttp.New(ctx, otlploghttp.WithEndpointURL(logURL)) if err != nil { return tp.Shutdown, nil, nil } @@ -108,43 +108,70 @@ func Tracer() trace.Tracer { return otel.Tracer(tracerName) } -func RecordError(ctx context.Context, err error) { - if err == nil { - return +// StartSpan starts a named span for one CLI operation and returns the context +// carrying it together with an end function. Use it with a named error result: +// +// ctx, end := telemetry.StartSpan(ctx, "app.create") +// defer end(&err) +// +// When err is non-nil at return time, end records it on the span (exception +// event + error status) and logs the failure through the context-bound logger +// before ending the span, so failures show up in the local log file and in the +// exported OTEL logs and traces alike. Results without an error pass nil: +// defer end(nil). +func StartSpan(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, func(*error)) { + ctx, span := otel.Tracer(tracerName).Start(ctx, name, opts...) + end := func(err *error) { + if err != nil && *err != nil { + span.RecordError(*err) + span.SetStatus(codes.Error, (*err).Error()) + logger.FromContext(ctx).Error(name+" failed", zap.Error(*err)) + } + span.End() } - span := trace.SpanFromContext(ctx) - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) + return ctx, end } -func parseEndpoint(endpoint string) (host string, insecure bool) { - if endpoint == "" { - return "", true +// signalURL joins a configured OTLP endpoint with a signal path such as +// "/v1/traces". WithEndpointURL consumes the path exactly as given and never +// appends the signal path, so the base must be completed here — otherwise +// telemetry is posted to the collector root and any collector mounted under a +// prefix answers 404. +// +// The endpoint is shared by the traces and logs exporters, so it is treated as +// a collector base URL. A trailing signal path is tolerated and stripped +// because both forms appear in config files and in ENVSYNC_TELEMETRY_URL, and +// there is no CLI command to correct the value once it is stored. +func signalURL(base, signal string) (string, error) { + raw := strings.TrimSpace(base) + if raw == "" { + return "", fmt.Errorf("telemetry endpoint is empty") + } + // A bare host:port is accepted as plain HTTP, matching WithEndpoint's format. + if !strings.Contains(raw, "://") { + raw = "http://" + raw + } + + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("invalid telemetry endpoint %q: %w", base, err) } - - if len(endpoint) > 8 && endpoint[:8] == "https://" { - u, err := url.Parse(endpoint) - if err != nil { - return endpoint, false - } - h := u.Host - if u.Port() == "" { - h = h + ":443" - } - return h, false + if u.Host == "" { + return "", fmt.Errorf("invalid telemetry endpoint %q: missing host", base) } - if len(endpoint) > 7 && endpoint[:7] == "http://" { - u, err := url.Parse(endpoint) - if err != nil { - return endpoint, true - } - h := u.Host - if u.Port() == "" { - h = h + ":80" + u.Path = strings.TrimSuffix(trimSignalPath(u.Path), "/") + signal + u.RawQuery, u.Fragment = "", "" + return u.String(), nil +} + +// trimSignalPath drops a trailing OTLP signal path so an endpoint may be given +// either as a collector base URL or as a concrete signal URL. +func trimSignalPath(p string) string { + for _, s := range []string{"/v1/traces", "/v1/logs", "/v1/metrics"} { + if strings.HasSuffix(p, s) { + return strings.TrimSuffix(p, s) } - return h, true } - - return endpoint, true + return p } diff --git a/packages/envsync-cli/internal/telemetry/telemetry_test.go b/packages/envsync-cli/internal/telemetry/telemetry_test.go new file mode 100644 index 00000000..792d19f2 --- /dev/null +++ b/packages/envsync-cli/internal/telemetry/telemetry_test.go @@ -0,0 +1,212 @@ +package telemetry + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + + "github.com/EnvSync-Cloud/envsync/packages/envsync-cli/internal/constants" +) + +func TestSignalURL(t *testing.T) { + tests := []struct { + name string + base string + signal string + want string + }{ + { + name: "collector mounted under a prefix keeps that prefix", + base: "https://t.envsync.cloud/obs", + signal: "/v1/traces", + want: "https://t.envsync.cloud/obs/v1/traces", + }, + { + name: "trailing slash on the base is normalized away", + base: "https://t.envsync.cloud/obs/", + signal: "/v1/traces", + want: "https://t.envsync.cloud/obs/v1/traces", + }, + { + name: "bare origin gets just the signal path", + base: "https://t.envsync.cloud", + signal: "/v1/traces", + want: "https://t.envsync.cloud/v1/traces", + }, + { + name: "logs signal path", + base: "https://t.envsync.cloud/obs", + signal: "/v1/logs", + want: "https://t.envsync.cloud/obs/v1/logs", + }, + { + name: "explicit http scheme is preserved", + base: "http://localhost:4318", + signal: "/v1/traces", + want: "http://localhost:4318/v1/traces", + }, + { + name: "bare host:port is treated as plain http", + base: "localhost:4318", + signal: "/v1/traces", + want: "http://localhost:4318/v1/traces", + }, + { + name: "query and fragment are dropped", + base: "https://t.envsync.cloud/obs?x=1#frag", + signal: "/v1/traces", + want: "https://t.envsync.cloud/obs/v1/traces", + }, + { + name: "endpoint already carrying the traces path is not double-suffixed", + base: "https://t.envsync.cloud/obs/v1/traces", + signal: "/v1/traces", + want: "https://t.envsync.cloud/obs/v1/traces", + }, + { + name: "logs derived from an endpoint carrying the traces path", + base: "https://t.envsync.cloud/obs/v1/traces", + signal: "/v1/logs", + want: "https://t.envsync.cloud/obs/v1/logs", + }, + { + name: "endpoint already carrying the logs path", + base: "https://t.envsync.cloud/obs/v1/logs", + signal: "/v1/traces", + want: "https://t.envsync.cloud/obs/v1/traces", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := signalURL(tc.base, tc.signal) + if err != nil { + t.Fatalf("signalURL(%q, %q) failed: %v", tc.base, tc.signal, err) + } + if got != tc.want { + t.Errorf("signalURL(%q, %q) = %q, want %q", tc.base, tc.signal, got, tc.want) + } + }) + } +} + +func TestStartSpanRecordsErrorOnFailure(t *testing.T) { + rec := tracetest.NewSpanRecorder() + prev := otel.GetTracerProvider() + otel.SetTracerProvider(sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(rec))) + t.Cleanup(func() { otel.SetTracerProvider(prev) }) + + _, end := StartSpan(context.Background(), "app.create") + boom := errors.New("boom") + end(&boom) + + ended := rec.Ended() + if len(ended) != 1 { + t.Fatalf("got %d ended spans, want 1", len(ended)) + } + span := ended[0] + if span.Name() != "app.create" { + t.Errorf("span name = %q, want %q", span.Name(), "app.create") + } + if span.Status().Code != codes.Error { + t.Errorf("status = %v, want Error", span.Status().Code) + } + var sawException bool + for _, e := range span.Events() { + if e.Name == "exception" { + sawException = true + } + } + if !sawException { + t.Errorf("span events = %v, want an exception event", span.Events()) + } +} + +func TestStartSpanSuccessLeavesStatusUnset(t *testing.T) { + rec := tracetest.NewSpanRecorder() + prev := otel.GetTracerProvider() + otel.SetTracerProvider(sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(rec))) + t.Cleanup(func() { otel.SetTracerProvider(prev) }) + + _, end := StartSpan(context.Background(), "app.list") + end(nil) + + ended := rec.Ended() + if len(ended) != 1 { + t.Fatalf("got %d ended spans, want 1", len(ended)) + } + if got := ended[0].Status().Code; got != codes.Unset { + t.Errorf("status = %v, want Unset", got) + } +} + +func TestSignalURLRejectsUnusableEndpoints(t *testing.T) { + for _, base := range []string{"", " ", "https://", "://nope"} { + if got, err := signalURL(base, "/v1/traces"); err == nil { + t.Errorf("signalURL(%q) = %q, want an error", base, got) + } + } +} + +// Regression: parseEndpoint returned only u.Host, so a base endpoint carrying a +// path prefix (https://host/obs) had that prefix stripped and spans were posted +// to /v1/traces instead of /obs/v1/traces — every export failed with 404. +// +// Both accepted endpoint forms must land on the same request path: appending the +// signal path to an endpoint that already ends in it produced +// /obs/v1/traces/v1/traces, which is a 404 as well. +func TestInitPostsTracesToConfiguredPath(t *testing.T) { + forms := map[string]string{ + "collector base URL": "/obs", + "concrete traces signal URL": "/obs/v1/traces", + } + + for name, suffix := range forms { + t.Run(name, func(t *testing.T) { + var mu sync.Mutex + var paths []string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + paths = append(paths, r.URL.Path) + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + t.Setenv(constants.EnvOTELDisabled, "") + t.Setenv(constants.EnvOTELURL, server.URL+suffix) + + shutdown, _, err := Init(context.Background()) + if err != nil { + t.Fatalf("Init() failed: %v", err) + } + + _, span := Tracer().Start(context.Background(), "test-span") + span.End() + + if err := shutdown(context.Background()); err != nil { + t.Fatalf("shutdown() failed: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(paths) == 0 { + t.Fatal("no export request reached the collector") + } + for _, p := range paths { + if p != "/obs/v1/traces" { + t.Errorf("export posted to %q, want %q (full set: %v)", p, "/obs/v1/traces", paths) + } + } + }) + } +}