Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions changelog/unreleased/CLI-ADDED-20260519-licence-bootstrap.yaml
Original file line number Diff line number Diff line change
@@ -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
83 changes: 83 additions & 0 deletions cmd/cycloid/beta/bootstrap_first_org/bootstrap.go
Original file line number Diff line number Diff line change
@@ -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:]
}
42 changes: 42 additions & 0 deletions cmd/cycloid/beta/bootstrap_first_org/cmd.go
Original file line number Diff line number Diff line change
@@ -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=<org>
— refresh token to get org scope
5. POST /organizations/<org>/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
}
2 changes: 2 additions & 0 deletions cmd/cycloid/beta/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -16,6 +17,7 @@ Those commands are feature in testing, retro-compatibility is not guaranteed.`,

cmd.AddCommand(
config.NewCommands(),
bootstrapfirstorg.NewCommands(),
)
return cmd
}
47 changes: 47 additions & 0 deletions cmd/cycloid/middleware/init_first_org_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions cmd/cycloid/middleware/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions cmd/cycloid/middleware/organization_components_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
19 changes: 18 additions & 1 deletion cmd/cycloid/middleware/organization_licence.go
Original file line number Diff line number Diff line change
@@ -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}
Expand Down
42 changes: 42 additions & 0 deletions cmd/cycloid/middleware/organization_licence_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down
1 change: 1 addition & 0 deletions cmd/cycloid/middleware/organization_projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down
3 changes: 3 additions & 0 deletions cmd/cycloid/organizations/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package organizations

import (
"github.com/spf13/cobra"

"github.com/cycloidio/cycloid-cli/cmd/cycloid/organizations/licence"
)

func NewCommands() *cobra.Command {
Expand All @@ -24,6 +26,7 @@ func NewCommands() *cobra.Command {
NewCreateChildCommand(),
NewGetCommand(),
NewSubscriptionCommands(),
licence.NewCommands(),
)
return cmd
}
53 changes: 53 additions & 0 deletions cmd/cycloid/organizations/licence/activate.go
Original file line number Diff line number Diff line change
@@ -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{})
}
17 changes: 17 additions & 0 deletions cmd/cycloid/organizations/licence/cmd.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading