From 9a6fbf2edbb4167008539e823ea7e39fd5c9111d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20BARRAS=20HAMPEL?= Date: Wed, 20 May 2026 10:07:24 +0200 Subject: [PATCH] Add licence activate/get and beta bootstrap-first-org commands Expose org licence management and first-install bootstrap on the CLI, wrapping existing middleware (InitFirstOrg, ActivateLicence) and a new GetLicence helper. Includes shared cyargs helpers, middleware and e2e tests, and a changelog entry. testcfg still provisions via middleware.InitFirstOrg on be-reset; the CLI bootstrap path is kept commented in pkg/testcfg/bootstrap.go after staging validation showed in-process cobra bootstrap polluted viper state for some middleware tests. Co-authored-by: Cursor --- .../CLI-ADDED-20260519-licence-bootstrap.yaml | 17 ++++ .../beta/bootstrap_first_org/bootstrap.go | 83 ++++++++++++++++ cmd/cycloid/beta/bootstrap_first_org/cmd.go | 42 ++++++++ cmd/cycloid/beta/cmd.go | 2 + cmd/cycloid/middleware/init_first_org_test.go | 47 +++++++++ cmd/cycloid/middleware/middleware.go | 1 + .../organization_components_test.go | 1 + .../middleware/organization_licence.go | 19 +++- .../middleware/organization_licence_test.go | 42 ++++++++ .../organization_project_environment_test.go | 1 + .../middleware/organization_projects_test.go | 1 + cmd/cycloid/organizations/cmd.go | 3 + cmd/cycloid/organizations/licence/activate.go | 53 +++++++++++ cmd/cycloid/organizations/licence/cmd.go | 17 ++++ cmd/cycloid/organizations/licence/get.go | 43 +++++++++ e2e/licence_test.go | 95 +++++++++++++++++++ internal/cyargs/bootstrap.go | 70 ++++++++++++++ internal/cyargs/licence.go | 95 +++++++++++++++++++ pkg/testcfg/bootstrap.go | 86 +++++++++++++++++ 19 files changed, 717 insertions(+), 1 deletion(-) create mode 100644 changelog/unreleased/CLI-ADDED-20260519-licence-bootstrap.yaml create mode 100644 cmd/cycloid/beta/bootstrap_first_org/bootstrap.go create mode 100644 cmd/cycloid/beta/bootstrap_first_org/cmd.go create mode 100644 cmd/cycloid/middleware/init_first_org_test.go create mode 100644 cmd/cycloid/middleware/organization_licence_test.go create mode 100644 cmd/cycloid/organizations/licence/activate.go create mode 100644 cmd/cycloid/organizations/licence/cmd.go create mode 100644 cmd/cycloid/organizations/licence/get.go create mode 100644 e2e/licence_test.go create mode 100644 internal/cyargs/bootstrap.go create mode 100644 internal/cyargs/licence.go create mode 100644 pkg/testcfg/bootstrap.go diff --git a/changelog/unreleased/CLI-ADDED-20260519-licence-bootstrap.yaml b/changelog/unreleased/CLI-ADDED-20260519-licence-bootstrap.yaml new file mode 100644 index 00000000..bf108c2d --- /dev/null +++ b/changelog/unreleased/CLI-ADDED-20260519-licence-bootstrap.yaml @@ -0,0 +1,17 @@ +component: CLI +kind: ADDED +body: Added `cy organization licence activate` and `cy organization licence get` commands +time: 2026-05-19T12:00:00Z +custom: + DETAILS: "" + PR: "" + TYPE: CLI +--- +component: CLI +kind: ADDED +body: Added `cy beta bootstrap-first-org` for first-install organization bootstrap +time: 2026-05-19T12:00:00Z +custom: + DETAILS: "" + PR: "" + TYPE: CLI diff --git a/cmd/cycloid/beta/bootstrap_first_org/bootstrap.go b/cmd/cycloid/beta/bootstrap_first_org/bootstrap.go new file mode 100644 index 00000000..f417627b --- /dev/null +++ b/cmd/cycloid/beta/bootstrap_first_org/bootstrap.go @@ -0,0 +1,83 @@ +package bootstrapfirstorg + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/cycloidio/cycloid-cli/cmd/cycloid/common" + "github.com/cycloidio/cycloid-cli/cmd/cycloid/middleware" + "github.com/cycloidio/cycloid-cli/internal/cyargs" + "github.com/cycloidio/cycloid-cli/internal/cyout" + "github.com/cycloidio/cycloid-cli/printer" +) + +var bootstrapTableOptions = printer.Options{ + Columns: []string{"Org", "Username", "Email", "Token", "APIKey", "CredentialCanonical"}, + Transform: func(obj interface{}) map[string]string { + data, ok := obj.(*middleware.FirstOrgData) + if !ok || data == nil { + return nil + } + + row := map[string]string{ + "Org": data.Org, + "Username": data.Username, + "Email": data.Email, + "Token": maskSecret(data.Token), + "Password": maskSecret(data.Password), + } + if data.APIKey != nil { + row["APIKey"] = maskSecret(*data.APIKey) + } + if data.CredentialCanonical != nil { + row["CredentialCanonical"] = *data.CredentialCanonical + } + return row + }, +} + +func bootstrap(cmd *cobra.Command, args []string) error { + org, err := cyargs.GetOrg(cmd) + if err != nil { + return err + } + + username, err := cmd.Flags().GetString("username") + if err != nil { + return err + } + fullName, err := cmd.Flags().GetString("full-name") + if err != nil { + return err + } + email, err := cmd.Flags().GetString("email") + if err != nil { + return err + } + password, err := cyargs.GetBootstrapPassword(cmd) + if err != nil { + return err + } + licence, err := cyargs.GetLicence(cmd) + if err != nil { + return err + } + apiKeyCanonical, err := cyargs.GetBootstrapAPIKeyCanonical(cmd) + if err != nil { + return err + } + + api := common.NewAPI() + m := middleware.NewMiddleware(api) + + result, _, err := m.InitFirstOrg(org, username, fullName, email, password, licence, apiKeyCanonical) + return cyout.PrintWithOptions(cmd, result, err, "failed to bootstrap first organization", bootstrapTableOptions) +} + +func maskSecret(value string) string { + if len(value) <= 5 { + return strings.Repeat("*", len(value)) + } + return "***" + value[len(value)-5:] +} diff --git a/cmd/cycloid/beta/bootstrap_first_org/cmd.go b/cmd/cycloid/beta/bootstrap_first_org/cmd.go new file mode 100644 index 00000000..db05dc8b --- /dev/null +++ b/cmd/cycloid/beta/bootstrap_first_org/cmd.go @@ -0,0 +1,42 @@ +package bootstrapfirstorg + +import ( + "github.com/spf13/cobra" + + "github.com/cycloidio/cycloid-cli/internal/cyargs" +) + +func NewCommands() *cobra.Command { + cmd := &cobra.Command{ + Use: "bootstrap-first-org", + Short: "Bootstrap the first organization on a fresh Cycloid install", + Long: `Bootstrap the very first organization on a fresh Cycloid install. + +Chains: + 1. POST /user — signup (ignores 409 if user already exists) + 2. POST /user/login — login with email + password + 3. POST /organizations — create the org (ignores 409 if it already exists) + 4. GET /user/refresh_token?organization_canonical= + — refresh token to get org scope + 5. POST /organizations//licence — activate the licence + +Optionally, when --api-key-canonical is set: + 6. Creates an admin api-key under that canonical + 7. Stores it in a custom credential under the same canonical + +Intended for first-install bootstrap or fully scripted environment recreation. +All steps tolerate "already exists" responses, so re-running is safe. + +This command is BETA — output shape and flag names may change.`, + Args: cobra.NoArgs, + RunE: bootstrap, + } + + cyargs.AddBootstrapUserFlags(cmd) + cyargs.AddLicenceFlag(cmd) + cyargs.AddLicenceFileFlag(cmd) + cyargs.AddBootstrapAPIKeyCanonicalFlag(cmd) + cmd.MarkFlagsMutuallyExclusive("licence", "licence-file") + + return cmd +} diff --git a/cmd/cycloid/beta/cmd.go b/cmd/cycloid/beta/cmd.go index ac4354e9..f3828bb7 100644 --- a/cmd/cycloid/beta/cmd.go +++ b/cmd/cycloid/beta/cmd.go @@ -3,6 +3,7 @@ package beta import ( "github.com/spf13/cobra" + bootstrapfirstorg "github.com/cycloidio/cycloid-cli/cmd/cycloid/beta/bootstrap_first_org" "github.com/cycloidio/cycloid-cli/cmd/cycloid/beta/config" ) @@ -16,6 +17,7 @@ Those commands are feature in testing, retro-compatibility is not guaranteed.`, cmd.AddCommand( config.NewCommands(), + bootstrapfirstorg.NewCommands(), ) return cmd } diff --git a/cmd/cycloid/middleware/init_first_org_test.go b/cmd/cycloid/middleware/init_first_org_test.go new file mode 100644 index 00000000..53ac9e81 --- /dev/null +++ b/cmd/cycloid/middleware/init_first_org_test.go @@ -0,0 +1,47 @@ +package middleware_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInitFirstOrgIdempotent(t *testing.T) { + m := config.Middleware + + licenceKey, ok := os.LookupEnv("API_LICENCE_KEY") + require.True(t, ok, "API_LICENCE_KEY must be set for bootstrap tests") + + apiKeyCanonical := "admin-init-first-org-test" + result, _, err := m.InitFirstOrg( + config.Org, + "administrator", + "administrator", + "admin@cycloid.io", + "cycloidadmin", + licenceKey, + &apiKeyCanonical, + ) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, config.Org, result.Org) + assert.NotEmpty(t, result.Token) + require.NotNil(t, result.APIKey) + assert.NotEmpty(t, *result.APIKey) + + resultAgain, _, err := m.InitFirstOrg( + config.Org, + "administrator", + "administrator", + "admin@cycloid.io", + "cycloidadmin", + licenceKey, + &apiKeyCanonical, + ) + require.NoError(t, err) + require.NotNil(t, resultAgain) + assert.Equal(t, config.Org, resultAgain.Org) + assert.NotEmpty(t, resultAgain.Token) +} diff --git a/cmd/cycloid/middleware/middleware.go b/cmd/cycloid/middleware/middleware.go index 7342fd34..4c755db4 100644 --- a/cmd/cycloid/middleware/middleware.go +++ b/cmd/cycloid/middleware/middleware.go @@ -17,6 +17,7 @@ type Middleware interface { UserSignup(username, email, password, fullName string) (*http.Response, error) RefreshToken(org, childOrg *string, token string) (*models.UserSession, *http.Response, error) + GetLicence(org string) (*models.Licence, *http.Response, error) ActivateLicence(org, licence string) (*http.Response, error) // cycloid diff --git a/cmd/cycloid/middleware/organization_components_test.go b/cmd/cycloid/middleware/organization_components_test.go index f56b5287..91a74efa 100644 --- a/cmd/cycloid/middleware/organization_components_test.go +++ b/cmd/cycloid/middleware/organization_components_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/cycloidio/cycloid-cli/client/models" + "github.com/cycloidio/cycloid-cli/cmd/cycloid/middleware" ) func TestComponentCRUD(t *testing.T) { diff --git a/cmd/cycloid/middleware/organization_licence.go b/cmd/cycloid/middleware/organization_licence.go index 6d1f3413..5a78b3f0 100644 --- a/cmd/cycloid/middleware/organization_licence.go +++ b/cmd/cycloid/middleware/organization_licence.go @@ -1,6 +1,23 @@ package middleware -import "net/http" +import ( + "net/http" + + "github.com/cycloidio/cycloid-cli/client/models" +) + +func (m *middleware) GetLicence(org string) (*models.Licence, *http.Response, error) { + var result *models.Licence + resp, err := m.GenericRequest(Request{ + Method: "GET", + Organization: &org, + Route: []string{"organizations", org, "licence"}, + }, &result) + if err != nil { + return nil, resp, err + } + return result, resp, nil +} func (m *middleware) ActivateLicence(org, licence string) (*http.Response, error) { body := map[string]string{"key": licence} diff --git a/cmd/cycloid/middleware/organization_licence_test.go b/cmd/cycloid/middleware/organization_licence_test.go new file mode 100644 index 00000000..b6b488f5 --- /dev/null +++ b/cmd/cycloid/middleware/organization_licence_test.go @@ -0,0 +1,42 @@ +package middleware_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetLicence(t *testing.T) { + m := config.Middleware + + licence, _, err := m.GetLicence(config.Org) + require.NoError(t, err) + require.NotNil(t, licence) + require.NotNil(t, licence.Key) + assert.NotEmpty(t, *licence.Key) +} + +func TestActivateLicenceOverwrite(t *testing.T) { + m := config.Middleware + + licenceKey, ok := os.LookupEnv("API_LICENCE_KEY") + require.True(t, ok, "API_LICENCE_KEY must be set for licence tests") + + _, err := m.ActivateLicence(config.Org, licenceKey) + require.NoError(t, err) + + got, _, err := m.GetLicence(config.Org) + require.NoError(t, err) + require.NotNil(t, got.Key) + assert.Equal(t, licenceKey, *got.Key) + + _, err = m.ActivateLicence(config.Org, licenceKey) + require.NoError(t, err) + + gotAgain, _, err := m.GetLicence(config.Org) + require.NoError(t, err) + require.NotNil(t, gotAgain.Key) + assert.Equal(t, licenceKey, *gotAgain.Key) +} diff --git a/cmd/cycloid/middleware/organization_project_environment_test.go b/cmd/cycloid/middleware/organization_project_environment_test.go index e6303ae8..26367966 100644 --- a/cmd/cycloid/middleware/organization_project_environment_test.go +++ b/cmd/cycloid/middleware/organization_project_environment_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/assert" + "github.com/cycloidio/cycloid-cli/cmd/cycloid/middleware" "github.com/cycloidio/cycloid-cli/pkg/testcfg" ) diff --git a/cmd/cycloid/middleware/organization_projects_test.go b/cmd/cycloid/middleware/organization_projects_test.go index 136b6caf..2dab496c 100644 --- a/cmd/cycloid/middleware/organization_projects_test.go +++ b/cmd/cycloid/middleware/organization_projects_test.go @@ -5,6 +5,7 @@ import ( "github.com/stretchr/testify/assert" + "github.com/cycloidio/cycloid-cli/cmd/cycloid/middleware" "github.com/cycloidio/cycloid-cli/pkg/testcfg" ) diff --git a/cmd/cycloid/organizations/cmd.go b/cmd/cycloid/organizations/cmd.go index ca305413..4e76931b 100644 --- a/cmd/cycloid/organizations/cmd.go +++ b/cmd/cycloid/organizations/cmd.go @@ -2,6 +2,8 @@ package organizations import ( "github.com/spf13/cobra" + + "github.com/cycloidio/cycloid-cli/cmd/cycloid/organizations/licence" ) func NewCommands() *cobra.Command { @@ -24,6 +26,7 @@ func NewCommands() *cobra.Command { NewCreateChildCommand(), NewGetCommand(), NewSubscriptionCommands(), + licence.NewCommands(), ) return cmd } diff --git a/cmd/cycloid/organizations/licence/activate.go b/cmd/cycloid/organizations/licence/activate.go new file mode 100644 index 00000000..2d1b19b0 --- /dev/null +++ b/cmd/cycloid/organizations/licence/activate.go @@ -0,0 +1,53 @@ +package licence + +import ( + "github.com/spf13/cobra" + + "github.com/cycloidio/cycloid-cli/cmd/cycloid/common" + "github.com/cycloidio/cycloid-cli/cmd/cycloid/middleware" + "github.com/cycloidio/cycloid-cli/internal/cyargs" + "github.com/cycloidio/cycloid-cli/internal/cyout" + "github.com/cycloidio/cycloid-cli/printer" +) + +func NewActivateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "activate", + Short: "Activate or replace the Cycloid licence on this organization", + Long: `Activate (or replace) the Cycloid licence on this organization. + +The API overwrites any existing licence in place; running this command twice +is safe and the second run replaces the first. + +Examples: + cy organization licence activate --org root-org --key eyJhbG... + cy organization licence activate --org root-org --key-file ./licence.jwt + cat licence.jwt | cy organization licence activate --org root-org`, + Args: cobra.NoArgs, + RunE: activate, + } + + cyargs.AddLicenceKeyFlag(cmd) + cyargs.AddLicenceKeyFileFlag(cmd) + cmd.MarkFlagsMutuallyExclusive("key", "key-file") + + return cmd +} + +func activate(cmd *cobra.Command, args []string) error { + org, err := cyargs.GetOrg(cmd) + if err != nil { + return err + } + + key, err := cyargs.GetLicenceKey(cmd) + if err != nil { + return err + } + + api := common.NewAPI() + m := middleware.NewMiddleware(api) + + _, err = m.ActivateLicence(org, key) + return cyout.PrintWithOptions(cmd, nil, err, "failed to activate licence", printer.Options{}) +} diff --git a/cmd/cycloid/organizations/licence/cmd.go b/cmd/cycloid/organizations/licence/cmd.go new file mode 100644 index 00000000..7e603345 --- /dev/null +++ b/cmd/cycloid/organizations/licence/cmd.go @@ -0,0 +1,17 @@ +package licence + +import "github.com/spf13/cobra" + +func NewCommands() *cobra.Command { + cmd := &cobra.Command{ + Use: "licence", + Aliases: []string{"license"}, + Short: "Manage the organization's Cycloid licence", + Args: cobra.NoArgs, + } + cmd.AddCommand( + NewActivateCommand(), + NewGetCommand(), + ) + return cmd +} diff --git a/cmd/cycloid/organizations/licence/get.go b/cmd/cycloid/organizations/licence/get.go new file mode 100644 index 00000000..99a5248d --- /dev/null +++ b/cmd/cycloid/organizations/licence/get.go @@ -0,0 +1,43 @@ +package licence + +import ( + "github.com/spf13/cobra" + + "github.com/cycloidio/cycloid-cli/client/models" + "github.com/cycloidio/cycloid-cli/cmd/cycloid/common" + "github.com/cycloidio/cycloid-cli/cmd/cycloid/middleware" + "github.com/cycloidio/cycloid-cli/internal/cyargs" + "github.com/cycloidio/cycloid-cli/internal/cyout" + "github.com/cycloidio/cycloid-cli/printer" +) + +var licenceTableOptions = printer.Options{ + Columns: []string{"CompanyName", "EmailAddress", "ExpiresAt", "MembersCount", "Version", "Key"}, + Identifier: "CompanyName", +} + +func NewGetCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "get", + Short: "Get the currently active licence", + Example: `cy organization licence get --org root-org -o yaml`, + Args: cobra.NoArgs, + RunE: get, + } + + cyout.RegisterModel(cmd, models.Licence{}) + return cmd +} + +func get(cmd *cobra.Command, args []string) error { + org, err := cyargs.GetOrg(cmd) + if err != nil { + return err + } + + api := common.NewAPI() + m := middleware.NewMiddleware(api) + + licence, _, err := m.GetLicence(org) + return cyout.PrintWithOptions(cmd, licence, err, "failed to get licence", licenceTableOptions) +} diff --git a/e2e/licence_test.go b/e2e/licence_test.go new file mode 100644 index 00000000..aacefc0d --- /dev/null +++ b/e2e/licence_test.go @@ -0,0 +1,95 @@ +package e2e_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/matryer/is" + "github.com/stretchr/testify/require" + + "github.com/cycloidio/cycloid-cli/client/models" +) + +func TestLicence(t *testing.T) { + licenceKey, ok := os.LookupEnv("API_LICENCE_KEY") + require.True(t, ok, "API_LICENCE_KEY must be set for licence e2e tests") + + t.Run("SuccessLicenceActivateAndGet", func(t *testing.T) { + is := is.New(t) + + _, cmdErr := executeCommand([]string{ + "--org", config.Org, + "organization", "licence", "activate", + "--key", licenceKey, + }) + is.NoErr(cmdErr) + + cmdOut, cmdErr := executeCommand([]string{ + "--output", "json", + "--org", config.Org, + "organization", "licence", "get", + }) + is.NoErr(cmdErr) + + var out models.Licence + err := json.Unmarshal([]byte(cmdOut), &out) + is.NoErr(err) + require.NotNil(t, out.Key) + is.Equal(licenceKey, *out.Key) + }) + + t.Run("SuccessLicenceActivateFromFile", func(t *testing.T) { + is := is.New(t) + + dir := t.TempDir() + licenceFile := filepath.Join(dir, "licence.jwt") + err := os.WriteFile(licenceFile, []byte(licenceKey), 0o600) + require.NoError(t, err) + + _, cmdErr := executeCommand([]string{ + "--org", config.Org, + "organization", "licence", "activate", + "--key-file", licenceFile, + }) + is.NoErr(cmdErr) + }) + + t.Run("SuccessLicenceActivateFromStdin", func(t *testing.T) { + is := is.New(t) + + _, _, err := executeCommandStdin(licenceKey, []string{ + "--org", config.Org, + "organization", "licence", "activate", + }) + is.NoErr(err) + }) + + t.Run("SuccessLicenceActivateOverwrite", func(t *testing.T) { + is := is.New(t) + + _, cmdErr := executeCommand([]string{ + "--org", config.Org, + "organization", "licence", "activate", + "--key", licenceKey, + }) + is.NoErr(cmdErr) + + _, cmdErr = executeCommand([]string{ + "--org", config.Org, + "organization", "licence", "activate", + "--key", licenceKey, + }) + is.NoErr(cmdErr) + + cmdOut, cmdErr := executeCommand([]string{ + "--output", "json", + "--org", config.Org, + "organization", "licence", "get", + }) + is.NoErr(cmdErr) + is.True(strings.Contains(cmdOut, licenceKey)) + }) +} diff --git a/internal/cyargs/bootstrap.go b/internal/cyargs/bootstrap.go new file mode 100644 index 00000000..a72be18e --- /dev/null +++ b/internal/cyargs/bootstrap.go @@ -0,0 +1,70 @@ +package cyargs + +import ( + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" +) + +func AddBootstrapUserFlags(cmd *cobra.Command) { + cmd.Flags().String("username", "", "admin username for the first user") + cmd.Flags().String("full-name", "", "full name for the first user") + cmd.Flags().String("email", "", "email for the first user") + cmd.Flags().String("password", "", "password for the first user") + cmd.Flags().Bool("password-stdin", false, "read the password from stdin") + + _ = cmd.MarkFlagRequired("username") + _ = cmd.MarkFlagRequired("full-name") + _ = cmd.MarkFlagRequired("email") + + cmd.MarkFlagsMutuallyExclusive("password", "password-stdin") +} + +func AddBootstrapAPIKeyCanonicalFlag(cmd *cobra.Command) string { + flagName := "api-key-canonical" + cmd.Flags().String(flagName, "", "optional canonical for an admin API key and matching credential") + return flagName +} + +func GetBootstrapPassword(cmd *cobra.Command) (string, error) { + fromStdin, err := cmd.Flags().GetBool("password-stdin") + if err != nil { + return "", err + } + password, err := cmd.Flags().GetString("password") + if err != nil { + return "", err + } + + if fromStdin && password != "" { + return "", fmt.Errorf("only one of --password and --password-stdin may be set") + } + if fromStdin { + content, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return "", fmt.Errorf("failed to read password from stdin: %w", err) + } + password = strings.TrimSpace(string(content)) + if password == "" { + return "", fmt.Errorf("stdin looks empty, please provide a password") + } + return password, nil + } + if password == "" { + return "", fmt.Errorf("password is required: pass --password or --password-stdin") + } + return password, nil +} + +func GetBootstrapAPIKeyCanonical(cmd *cobra.Command) (*string, error) { + canonical, err := cmd.Flags().GetString("api-key-canonical") + if err != nil { + return nil, err + } + if canonical == "" { + return nil, nil + } + return &canonical, nil +} diff --git a/internal/cyargs/licence.go b/internal/cyargs/licence.go new file mode 100644 index 00000000..8b34084a --- /dev/null +++ b/internal/cyargs/licence.go @@ -0,0 +1,95 @@ +package cyargs + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/cycloidio/cycloid-cli/cmd/cycloid/common" +) + +func AddLicenceKeyFlag(cmd *cobra.Command) string { + flagName := "key" + cmd.Flags().String(flagName, "", "licence key value") + return flagName +} + +func AddLicenceKeyFileFlag(cmd *cobra.Command) string { + flagName := "key-file" + cmd.Flags().String(flagName, "", "path to a file containing the licence key") + cmd.RegisterFlagCompletionFunc(flagName, completeFilePath) + return flagName +} + +func AddLicenceFlag(cmd *cobra.Command) string { + flagName := "licence" + cmd.Flags().String(flagName, "", "licence key value") + return flagName +} + +func AddLicenceFileFlag(cmd *cobra.Command) string { + flagName := "licence-file" + cmd.Flags().String(flagName, "", "path to a file containing the licence key") + cmd.RegisterFlagCompletionFunc(flagName, completeFilePath) + return flagName +} + +func GetLicenceKey(cmd *cobra.Command) (string, error) { + return readSecretValue(cmd, "key", "key-file") +} + +func GetLicence(cmd *cobra.Command) (string, error) { + return readSecretValue(cmd, "licence", "licence-file") +} + +func readSecretValue(cmd *cobra.Command, valueFlag, fileFlag string) (string, error) { + value, err := cmd.Flags().GetString(valueFlag) + if err != nil { + return "", err + } + file, err := cmd.Flags().GetString(fileFlag) + if err != nil { + return "", err + } + + if value != "" && file != "" { + return "", fmt.Errorf("only one of --%s and --%s may be set", valueFlag, fileFlag) + } + if value != "" { + return strings.TrimSpace(value), nil + } + if file != "" { + content, err := os.ReadFile(file) + if err != nil { + return "", fmt.Errorf("failed to read licence from %q: %w", file, err) + } + key := strings.TrimSpace(string(content)) + if key == "" { + return "", fmt.Errorf("licence file %q is empty", file) + } + return key, nil + } + if common.DetectStdinInput() { + content, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return "", fmt.Errorf("failed to read licence from stdin: %w", err) + } + key := strings.TrimSpace(string(content)) + if key == "" { + return "", fmt.Errorf("stdin looks empty, please provide a licence key") + } + return key, nil + } + + return "", fmt.Errorf( + "licence key required: pass --%s, --%s, or pipe the key via stdin", + valueFlag, fileFlag, + ) +} + +func completeFilePath(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveFilterFileExt +} diff --git a/pkg/testcfg/bootstrap.go b/pkg/testcfg/bootstrap.go new file mode 100644 index 00000000..61f25928 --- /dev/null +++ b/pkg/testcfg/bootstrap.go @@ -0,0 +1,86 @@ +package testcfg + +// CLI bootstrap provisioning for be-reset / TestMain. +// +// Validated manually and via e2e (PR #441): `cy beta bootstrap-first-org` works +// against staging. testcfg still calls middleware.InitFirstOrg directly because +// in-process CLI bootstrap left viper/CY_API_KEY state that broke some middleware +// tests (403 Need to refresh token). Re-enable by swapping the provision block +// in config.go when we want testcfg to exercise the cobra path again. +// +// Uncomment bootstrapFirstOrgCLI and use it from NewConfig provisionAPI block: +// +// init, err := bootstrapFirstOrgCLI(bootstrapFirstOrgParams{ +// APIURL: config.APIUrl, +// Org: config.Org, +// Username: username, +// FullName: fullName, +// Email: email, +// Password: password, +// Licence: licence, +// APIKeyCanonical: apiKeyCanonical, +// }) +// if err != nil { +// return nil, fmt.Errorf("failed to init console: %w", err) +// } +// config.APIKey = *init.APIKey +// api.Config.Token = *init.APIKey +// +// /* +// +// import ( +// "bytes" +// "encoding/json" +// "fmt" +// +// "github.com/cycloidio/cycloid-cli/cmd" +// "github.com/cycloidio/cycloid-cli/cmd/cycloid/middleware" +// ) +// +// type bootstrapFirstOrgParams struct { +// APIURL string +// Org string +// Username string +// FullName string +// Email string +// Password string +// Licence string +// APIKeyCanonical string +// } +// +// func bootstrapFirstOrgCLI(params bootstrapFirstOrgParams) (*middleware.FirstOrgData, error) { +// rootCmd := cmd.NewRootCommand() +// +// stdout := new(bytes.Buffer) +// stderr := new(bytes.Buffer) +// rootCmd.SetOut(stdout) +// rootCmd.SetErr(stderr) +// rootCmd.SetArgs([]string{ +// "--api-url", params.APIURL, +// "--output", "json", +// "--org", params.Org, +// "beta", "bootstrap-first-org", +// "--username", params.Username, +// "--full-name", params.FullName, +// "--email", params.Email, +// "--password", params.Password, +// "--licence", params.Licence, +// "--api-key-canonical", params.APIKeyCanonical, +// }) +// +// if err := rootCmd.Execute(); err != nil { +// return nil, fmt.Errorf("bootstrap-first-org CLI failed: %w (stderr: %s)", err, stderr.String()) +// } +// +// var result middleware.FirstOrgData +// if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { +// return nil, fmt.Errorf("failed to decode bootstrap-first-org JSON output: %w (stdout: %s)", err, stdout.String()) +// } +// if result.APIKey == nil || *result.APIKey == "" { +// return nil, fmt.Errorf("bootstrap-first-org succeeded but APIKey is missing in output") +// } +// +// return &result, nil +// } +// +// */