From 9d4f4ed4f9755da8b27cd8245e9e1555a84a5860 Mon Sep 17 00:00:00 2001 From: Jeroen Ketelaar Date: Tue, 3 Mar 2026 12:49:11 +0100 Subject: [PATCH 1/4] Rewrite sync with inventory-driven targeting and introduce ssh command --- README.md | 33 +++- cmd/inventory-helper.go | 61 ++++++ cmd/login.go | 260 ++++++++++++++----------- cmd/onboarding.go | 198 ++++++------------- cmd/root.go | 13 +- cmd/sync-config.go | 24 +++ cmd/sync-database.go | 248 ++++++++++++++++++++++++ cmd/sync-files.go | 151 +++++++++++++-- cmd/sync-resolve.go | 305 +++++++++++++++++++++++++++++ cmd/sync.go | 153 +++++++++------ go.mod | 5 + go.sum | 10 + util/appconfig/config.go | 89 +++++++++ util/auth/clerk.go | 407 +++++++++++++++++++++++++++++++++++++++ util/auth/session.go | 25 +++ util/auth/store.go | 110 +++++++++++ util/configuration.go | 25 ++- util/inventory/client.go | 392 +++++++++++++++++++++++++++++++++++++ 18 files changed, 2155 insertions(+), 354 deletions(-) create mode 100644 cmd/inventory-helper.go create mode 100644 cmd/sync-config.go create mode 100644 cmd/sync-database.go create mode 100644 cmd/sync-resolve.go create mode 100644 util/appconfig/config.go create mode 100644 util/auth/clerk.go create mode 100644 util/auth/session.go create mode 100644 util/auth/store.go create mode 100644 util/inventory/client.go diff --git a/README.md b/README.md index b76c288..61918a0 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,38 @@ intercube onboarding The wizard can help you: - configure login defaults (`username`, `password`, `scope`, `auth_method`, `instance_url`) -- optionally set sync defaults (`remote_user`, `file_syncing.from_server`, `file_syncing.path`) -- verify local prerequisites such as Boundary CLI and the Intercube sync helper +- optionally configure file path mappings for `intercube sync` +- verify local prerequisites such as Boundary CLI and `rsync` After onboarding, use: ```bash -intercube login +intercube ssh ``` If required settings are missing (or the config file does not exist), the CLI will prompt only when needed and save values automatically: -- `intercube login` prompts for required login settings -- `intercube sync files ...` prompts for missing sync defaults -- `intercube map` prompts to create mappings when none exist +- `intercube ssh` prompts for required login settings +- `intercube sync` prompts for missing file path mappings +- `intercube map --interactive` prompts to create mappings when none exist + +`intercube login` is kept as a deprecated alias and prints a warning to use `intercube ssh`. + +### Sync + +Use sync from a source environment host: + +```bash +intercube sync +intercube sync staging.example.com +intercube sync --files +intercube sync --database +intercube sync --dry-run +``` + +Behavior: +- always fetches current site inventory at runtime +- interactive target selection when no argument is passed +- argument auto-resolves against site ID/domain/server/user when possible +- stores only file path mappings in config (`sync.files.items`) +- database details are requested interactively for each run (not persisted) diff --git a/cmd/inventory-helper.go b/cmd/inventory-helper.go new file mode 100644 index 0000000..8085725 --- /dev/null +++ b/cmd/inventory-helper.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/intercube/cli/util/appconfig" + authutil "github.com/intercube/cli/util/auth" + "github.com/intercube/cli/util/inventory" + "github.com/spf13/cobra" +) + +func newInventoryClient(cmd *cobra.Command, organizationOverride string) (*inventory.Client, string, error) { + appconfig.LoadFromEnv() + + if err := appconfig.ValidateClerk(); err != nil { + return nil, "", fmt.Errorf("%w (set via env/.env or build-time)", err) + } + + if err := appconfig.ValidateInventory(); err != nil { + return nil, "", fmt.Errorf("%w (set via env/.env or build-time)", err) + } + + store, err := authutil.NewSessionStore("intercube-cli") + if err != nil { + return nil, "", err + } + + organizationID := strings.TrimSpace(organizationOverride) + if organizationID == "" { + session, sessionErr := store.Load(cmd.Context()) + if sessionErr == nil { + organizationID = strings.TrimSpace(session.OrganizationID) + } + } + + if organizationID == "" { + organizationID = strings.TrimSpace(appconfig.OrganizationID) + } + + clerkClient := &authutil.ClerkClient{ + Issuer: appconfig.ClerkIssuer, + ClientID: appconfig.ClerkClientID, + Audience: appconfig.ClerkAudience, + Scopes: appconfig.ClerkScopes, + CallbackPort: appconfig.ParsedCallbackPort(), + } + + return inventory.NewClient(appconfig.InventoryAPIBaseURL, organizationID, store, clerkClient), organizationID, nil +} + +func findSiteByID(sites []inventory.SiteServer, siteID string) (*inventory.SiteServer, bool) { + needle := strings.TrimSpace(siteID) + for i := range sites { + if strings.EqualFold(strings.TrimSpace(sites[i].ID), needle) { + return &sites[i], true + } + } + + return nil, false +} diff --git a/cmd/login.go b/cmd/login.go index f0bb1bb..d9c2316 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -33,161 +33,179 @@ import ( var sshUsername = "root" -// loginCmd represents the login command -var loginCmd = &cobra.Command{ - Use: "login [host-filter]", - Short: "Login with your API token", +var sshCmd = &cobra.Command{ + Use: "ssh [host-filter]", + Short: "Connect to a host over Boundary SSH", Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - if err := ensureLoginConfiguration(); err != nil { - fmt.Printf("Unable to continue login: %v\n", err) - return - } + runBoundarySSH(args, false) + }, +} - boundaryUrl := config.Login.InstanceUrl - if boundaryUrl == "" { - boundaryUrl = "https://controller.boundary.intercube.cloud" - } +// loginCmd is deprecated; use sshCmd. +var loginCmd = &cobra.Command{ + Use: "login [host-filter]", + Short: "Deprecated: use `intercube ssh`", + Args: cobra.MaximumNArgs(1), + Deprecated: "use `intercube ssh` instead", + Run: func(cmd *cobra.Command, args []string) { + runBoundarySSH(args, true) + }, +} - boundaryPath, err := exec.LookPath("boundary") - if err != nil { - panic("Boundary not installed on this machine. Download & install boundary before using the login function (https://learn.hashicorp.com/tutorials/boundary/getting-started-install)") - } +func runBoundarySSH(args []string, fromDeprecatedLogin bool) { + if fromDeprecatedLogin { + fmt.Println("Warning: `intercube login` is deprecated. Use `intercube ssh` instead.") + } - apiConfig, _ := api.DefaultConfig() - apiConfig.Addr = boundaryUrl + if err := ensureLoginConfiguration(); err != nil { + fmt.Printf("Unable to continue SSH session: %v\n", err) + return + } - client, err := api.NewClient(apiConfig) - if err != nil { - panic(err) - } + boundaryUrl := config.Login.InstanceUrl + if boundaryUrl == "" { + boundaryUrl = "https://controller.boundary.intercube.cloud" + } - credentials := map[string]interface{}{ - "login_name": config.Login.Username, - "password": config.Login.Password, - } + boundaryPath, err := exec.LookPath("boundary") + if err != nil { + panic("Boundary is not installed. Download and install Boundary before using `intercube ssh` (https://learn.hashicorp.com/tutorials/boundary/getting-started-install)") + } - am := authmethods.NewClient(client) + apiConfig, _ := api.DefaultConfig() + apiConfig.Addr = boundaryUrl - at, err := am.Authenticate(context.Background(), config.Login.AuthMethod, "login", credentials) - if err != nil { - panic(err) - } + client, err := api.NewClient(apiConfig) + if err != nil { + panic(err) + } - var token string - if x, found := at.Attributes["token"]; found { - token, _ = x.(string) - } + credentials := map[string]interface{}{ + "login_name": config.Login.Username, + "password": config.Login.Password, + } - err = os.Setenv("BOUNDARY_TOKEN", token) - if err != nil { - panic(err) - } - client.SetToken(token) + am := authmethods.NewClient(client) - scopes := scopes.NewClient(client) - ctx := context.Background() - scopeResults, _ := scopes.List(ctx, config.Login.Scope) + at, err := am.Authenticate(context.Background(), config.Login.AuthMethod, "login", credentials) + if err != nil { + panic(err) + } - catalogsClient := hostcatalogs.NewClient(client) + var token string + if x, found := at.Attributes["token"]; found { + token, _ = x.(string) + } - var catalogs []*hostcatalogs.HostCatalog + err = os.Setenv("BOUNDARY_TOKEN", token) + if err != nil { + panic(err) + } + client.SetToken(token) - for _, item := range scopeResults.Items { - catalogsResult, _ := catalogsClient.List(ctx, item.Id) - catalogs = append(catalogs, catalogsResult.Items...) - } + scopes := scopes.NewClient(client) + ctx := context.Background() + scopeResults, _ := scopes.List(ctx, config.Login.Scope) - var hostsList []*hosts.Host + catalogsClient := hostcatalogs.NewClient(client) - hostClient := hosts.NewClient(client) - for _, hostCatalog := range catalogs { - hostsResult, _ := hostClient.List(ctx, hostCatalog.Id) - hostsList = append(hostsList, hostsResult.Items...) - } + var catalogs []*hostcatalogs.HostCatalog - sort.Slice(hostsList[:], func(i, j int) bool { - return hostsList[i].Name < hostsList[j].Name - }) + for _, item := range scopeResults.Items { + catalogsResult, _ := catalogsClient.List(ctx, item.Id) + catalogs = append(catalogs, catalogsResult.Items...) + } - filteredHosts := hostsList - if len(args) == 1 { - searchTerm := strings.ToLower(strings.TrimSpace(args[0])) - filteredHosts = make([]*hosts.Host, 0, len(hostsList)) + var hostsList []*hosts.Host - for _, host := range hostsList { - if strings.Contains(strings.ToLower(host.Name), searchTerm) { - filteredHosts = append(filteredHosts, host) - } - } + hostClient := hosts.NewClient(client) + for _, hostCatalog := range catalogs { + hostsResult, _ := hostClient.List(ctx, hostCatalog.Id) + hostsList = append(hostsList, hostsResult.Items...) + } + + sort.Slice(hostsList[:], func(i, j int) bool { + return hostsList[i].Name < hostsList[j].Name + }) - switch len(filteredHosts) { - case 0: - fmt.Printf("No hosts matched %q\n", args[0]) - return - case 1: - fmt.Printf("Connecting to host: %s\n", filteredHosts[0].Name) - connectToHost(boundaryPath, boundaryUrl, filteredHosts[0]) - return + filteredHosts := hostsList + if len(args) == 1 { + searchTerm := strings.ToLower(strings.TrimSpace(args[0])) + filteredHosts = make([]*hosts.Host, 0, len(hostsList)) + + for _, host := range hostsList { + if strings.Contains(strings.ToLower(host.Name), searchTerm) { + filteredHosts = append(filteredHosts, host) } } - fmt.Printf("Total of %v hosts available\n", len(hostsList)) - if len(args) == 1 { - fmt.Printf("%v hosts match %q\n\n", len(filteredHosts), args[0]) - } else { - fmt.Println() + switch len(filteredHosts) { + case 0: + fmt.Printf("No hosts matched %q\n", args[0]) + return + case 1: + fmt.Printf("Connecting to host: %s\n", filteredHosts[0].Name) + connectToHost(boundaryPath, boundaryUrl, filteredHosts[0]) + return } + } + + fmt.Printf("Total of %v hosts available\n", len(hostsList)) + if len(args) == 1 { + fmt.Printf("%v hosts match %q\n\n", len(filteredHosts), args[0]) + } else { + fmt.Println() + } - detailsTemplate := ` + detailsTemplate := ` --------- Host ---------- {{ "Name:" | faint }} {{ .Name }} {{range $key, $value := .Attributes}}{{ $key }}{{ ":" | faint }} {{ $value }}{{end}} ` - if len(args) == 1 { - detailsTemplate = "" - } + if len(args) == 1 { + detailsTemplate = "" + } - templates := &promptui.SelectTemplates{ - Label: "{{ . }}?", - Active: "\U0001F9CA {{ .Name | red }}", - Inactive: " {{ .Name | cyan }}", - Selected: "\U0001F9CA {{ .Name | red | cyan }}", - Details: detailsTemplate, - } + templates := &promptui.SelectTemplates{ + Label: "{{ . }}?", + Active: "\U0001F9CA {{ .Name | red }}", + Inactive: " {{ .Name | cyan }}", + Selected: "\U0001F9CA {{ .Name | red | cyan }}", + Details: detailsTemplate, + } - searcher := func(input string, index int) bool { - host := filteredHosts[index] - name := strings.Replace(strings.ToLower(host.Name), " ", "", -1) - input = strings.Replace(strings.ToLower(input), " ", "", -1) + searcher := func(input string, index int) bool { + host := filteredHosts[index] + name := strings.Replace(strings.ToLower(host.Name), " ", "", -1) + input = strings.Replace(strings.ToLower(input), " ", "", -1) - return strings.Contains(name, input) - } + return strings.Contains(name, input) + } - promptSize := 8 - if len(filteredHosts) < promptSize { - promptSize = len(filteredHosts) - } + promptSize := 8 + if len(filteredHosts) < promptSize { + promptSize = len(filteredHosts) + } - prompt := promptui.Select{ - Label: "Which host would you like to connect to?", - Items: filteredHosts, - Templates: templates, - Size: promptSize, - Searcher: searcher, - Stdout: &bellSkipper{}, - } + prompt := promptui.Select{ + Label: "Which host would you like to connect to?", + Items: filteredHosts, + Templates: templates, + Size: promptSize, + Searcher: searcher, + Stdout: &bellSkipper{}, + } - i, _, err := prompt.Run() + i, _, err := prompt.Run() - if err != nil { - fmt.Printf("Prompt failed %v\n", err) - return - } + if err != nil { + fmt.Printf("Prompt failed %v\n", err) + return + } - fmt.Printf("Connecting to host: %s\n", filteredHosts[i].Name) - connectToHost(boundaryPath, boundaryUrl, filteredHosts[i]) - }, + fmt.Printf("Connecting to host: %s\n", filteredHosts[i].Name) + connectToHost(boundaryPath, boundaryUrl, filteredHosts[i]) } func connectToHost(boundaryPath, boundaryURL string, host *hosts.Host) { @@ -226,8 +244,16 @@ func (bs *bellSkipper) Close() error { } func init() { + rootCmd.AddCommand(sshCmd) rootCmd.AddCommand(loginCmd) + sshCmd.PersistentFlags().StringVar( + &sshUsername, + "ssh_username", + "root", + "Username used to connect with the server", + ) + loginCmd.PersistentFlags().StringVar( &sshUsername, "ssh_username", diff --git a/cmd/onboarding.go b/cmd/onboarding.go index 605a94d..b0c47f4 100644 --- a/cmd/onboarding.go +++ b/cmd/onboarding.go @@ -8,7 +8,7 @@ import ( "path/filepath" "strings" - "github.com/asaskevich/govalidator" + "github.com/intercube/cli/util" "github.com/mitchellh/go-homedir" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -35,8 +35,8 @@ func runOnboarding() { boundaryPath, boundaryErr := exec.LookPath("boundary") boundaryInstalled := boundaryErr == nil - _, rsyncErr := os.Stat("/usr/local/bin/intercube-rsync") - syncHelperInstalled := rsyncErr == nil + rsyncPath, rsyncErr := exec.LookPath("rsync") + rsyncInstalled := rsyncErr == nil fmt.Println("Intercube CLI onboarding") fmt.Println() @@ -45,13 +45,13 @@ func runOnboarding() { if boundaryInstalled { fmt.Printf("Boundary CLI: installed (%s)\n", boundaryPath) } else { - fmt.Println("Boundary CLI: not found (required for `intercube login`)") + fmt.Println("Boundary CLI: not found (required for `intercube ssh`)") } - if syncHelperInstalled { - fmt.Println("Sync helper: installed (/usr/local/bin/intercube-rsync)") + if rsyncInstalled { + fmt.Printf("rsync: installed (%s)\n", rsyncPath) } else { - fmt.Println("Sync helper: not found (run `intercube install` when needed)") + fmt.Println("rsync: not found (required for `intercube sync --files`)") } fmt.Println() @@ -142,39 +142,10 @@ func runOnboarding() { return } - remoteUser := viper.GetString("remote_user") - fromServer := viper.GetStringMapString("file_syncing")["from_server"] - filesPath := viper.GetStringMapString("file_syncing")["path"] + syncItems := config.Sync.Files.Items if configureSyncDefaults { - remoteUser, err = promptText( - "Default sync remote user", - remoteUser, - optionalValue, - 0, - ) - if err != nil { - fmt.Printf("Onboarding cancelled: %v\n", err) - return - } - - fromServer, err = promptText( - "Default sync source server", - fromServer, - optionalDNSValue, - 0, - ) - if err != nil { - fmt.Printf("Onboarding cancelled: %v\n", err) - return - } - - filesPath, err = promptText( - "Default sync files path", - filesPath, - optionalValue, - 0, - ) + syncItems, err = promptSyncFileItems(syncItems) if err != nil { fmt.Printf("Onboarding cancelled: %v\n", err) return @@ -186,9 +157,7 @@ func runOnboarding() { viper.Set("login.scope", scope) viper.Set("login.auth_method", authMethod) viper.Set("login.instance_url", instanceURL) - viper.Set("remote_user", remoteUser) - viper.Set("file_syncing.from_server", fromServer) - viper.Set("file_syncing.path", filesPath) + viper.Set("sync.files.items", toMapSlice(syncItems)) if err := writeOnboardingConfig(configPath); err != nil { panic(err) @@ -201,12 +170,14 @@ func runOnboarding() { fmt.Println() fmt.Printf("Saved configuration to %s\n", configPath) fmt.Println("Next steps:") - fmt.Println("- Run `intercube login` to connect to a host") + fmt.Println("- Run `intercube ssh` to connect to a host") + fmt.Println("- Run `intercube auth login` to sign in for API calls") + fmt.Println("- Run `intercube auth status` to inspect local API session state") if !boundaryInstalled { fmt.Println("- Install Boundary CLI first: https://developer.hashicorp.com/boundary/downloads") } - if !syncHelperInstalled { - fmt.Println("- Run `intercube install` if you need sync support") + if !rsyncInstalled { + fmt.Println("- Install rsync to enable `intercube sync --files`") } } @@ -330,75 +301,6 @@ func ensureLoginConfiguration() error { return nil } -func ensureSyncConfiguration(fromServer, filesPath, remoteUser string) (string, string, string, error) { - if strings.TrimSpace(fromServer) != "" && strings.TrimSpace(filesPath) != "" && strings.TrimSpace(remoteUser) != "" { - return fromServer, filesPath, remoteUser, nil - } - - configPath, err := resolveOnboardingConfigPath() - if err != nil { - return "", "", "", err - } - - fmt.Println("Sync configuration is missing. Let's set it up.") - - prompted := false - - if strings.TrimSpace(fromServer) == "" { - prompted = true - fromServer, err = promptText( - "Sync source server", - viper.GetStringMapString("file_syncing")["from_server"], - requiredDNSValue, - 0, - ) - if err != nil { - return "", "", "", err - } - } - - if strings.TrimSpace(filesPath) == "" { - prompted = true - filesPath, err = promptText( - "Sync files path", - viper.GetStringMapString("file_syncing")["path"], - requiredValue, - 0, - ) - if err != nil { - return "", "", "", err - } - } - - if strings.TrimSpace(remoteUser) == "" { - prompted = true - remoteUser, err = promptText( - "Sync remote user", - viper.GetString("remote_user"), - requiredValue, - 0, - ) - if err != nil { - return "", "", "", err - } - } - - if prompted { - viper.Set("file_syncing.from_server", fromServer) - viper.Set("file_syncing.path", filesPath) - viper.Set("remote_user", remoteUser) - - if err := saveConfigAndReload(configPath); err != nil { - return "", "", "", err - } - - fmt.Printf("Saved sync configuration to %s\n", configPath) - fmt.Println() - } - - return fromServer, filesPath, remoteUser, nil -} - func ensureMappingsConfiguration() error { if len(config.Mappings) > 0 { return nil @@ -449,6 +351,52 @@ func ensureMappingsConfiguration() error { return nil } +func promptSyncFileItems(current []util.SyncFileItem) ([]util.SyncFileItem, error) { + items := make([]util.SyncFileItem, 0, len(current)) + items = append(items, current...) + + if len(items) > 0 { + keepExisting, err := chooseYesNo("Keep existing sync file mappings?") + if err != nil { + return nil, err + } + + if !keepExisting { + items = []util.SyncFileItem{} + } + } + + for { + source, err := promptText("Sync source path", "", requiredValue, 0) + if err != nil { + return nil, err + } + + target, err := promptText("Sync target path", source, requiredValue, 0) + if err != nil { + return nil, err + } + + excludeRaw, err := promptText("Exclude patterns (comma-separated, optional)", "", optionalValue, 0) + if err != nil { + return nil, err + } + + items = append(items, util.SyncFileItem{Source: source, Target: target, Exclude: splitCSV(excludeRaw)}) + + addAnother, err := chooseYesNo("Add another sync mapping?") + if err != nil { + return nil, err + } + + if !addAnother { + break + } + } + + return items, nil +} + func saveConfigAndReload(configPath string) error { if err := writeOnboardingConfig(configPath); err != nil { return err @@ -553,30 +501,6 @@ func optionalValue(_ string) error { return nil } -func optionalDNSValue(input string) error { - if strings.TrimSpace(input) == "" { - return nil - } - - if !govalidator.IsDNSName(input) { - return errors.New("provide a valid hostname") - } - - return nil -} - -func requiredDNSValue(input string) error { - if strings.TrimSpace(input) == "" { - return errors.New("value is required") - } - - if !govalidator.IsDNSName(input) { - return errors.New("provide a valid hostname") - } - - return nil -} - func init() { rootCmd.AddCommand(onboardingCmd) } diff --git a/cmd/root.go b/cmd/root.go index 842495f..4148f28 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -31,13 +31,11 @@ var Verbose bool // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "intercube", - Short: "A brief description of your application", - Long: `A longer description that spans multiple lines and likely contains -examples and usage of using your application. For example: + Short: "Intercube CLI", + Long: `Intercube CLI for host access, API operations, and environment sync. -Cobra is a CLI library for Go that empowers applications. -This application is a tool to generate the needed files -to quickly create a Cobra application.`, +Tip: use "intercube ssh" for host access. +The "intercube login" command is kept as a deprecated alias.`, } func Execute() { @@ -73,8 +71,7 @@ func initConfig() { viper.AutomaticEnv() - viper.SetDefault("file_syncing", map[string]string{}) - viper.SetDefault("remote_user", "") + viper.SetDefault("sync", map[string]interface{}{}) if err := viper.ReadInConfig(); err == nil { if Verbose { diff --git a/cmd/sync-config.go b/cmd/sync-config.go new file mode 100644 index 0000000..36a64c1 --- /dev/null +++ b/cmd/sync-config.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/viper" + + "github.com/intercube/cli/util" +) + +type SyncSettings = util.Sync + +func loadSyncSettings() (SyncSettings, error) { + var settings SyncSettings + if err := viper.UnmarshalKey("sync", &settings); err != nil { + return SyncSettings{}, fmt.Errorf("unable to decode sync config: %w", err) + } + + if settings.Files.Items == nil { + settings.Files.Items = []util.SyncFileItem{} + } + + return settings, nil +} diff --git a/cmd/sync-database.go b/cmd/sync-database.go new file mode 100644 index 0000000..67fdcf1 --- /dev/null +++ b/cmd/sync-database.go @@ -0,0 +1,248 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/spf13/cobra" +) + +type mysqlSyncConfig struct { + SourceDatabase string + SourceUser string + SourceHost string + SourcePort int + SourcePasswordEnv string + TargetDatabase string + TargetUser string + TargetHost string + TargetPort int + TargetPasswordEnv string + DumpFlags []string +} + +func runDatabaseSync(cmd *cobra.Command, target ResolvedSyncTarget, _ *SyncSettings, dryRun bool, autoApprove bool) error { + if err := ensureCommandAvailable("mysqldump"); err != nil { + return err + } + if err := ensureCommandAvailable("ssh"); err != nil { + return err + } + + databaseConfig, err := promptMySQLSyncConfig(target) + if err != nil { + return err + } + + if isSameDatabaseTarget(target, databaseConfig) { + return fmt.Errorf("source and target resolve to the same database destination") + } + + fmt.Println("Database sync plan:") + fmt.Printf(" Source: %s@%s:%d/%s\n", databaseConfig.SourceUser, databaseConfig.SourceHost, databaseConfig.SourcePort, databaseConfig.SourceDatabase) + fmt.Printf(" Target: %s@%s:%d/%s (via %s@%s:%d)\n", databaseConfig.TargetUser, databaseConfig.TargetHost, databaseConfig.TargetPort, databaseConfig.TargetDatabase, target.Username, target.Host, target.Port) + + if !autoApprove { + confirmed, confirmErr := promptYesNo("Continue with MySQL import into target?") + if confirmErr != nil { + return confirmErr + } + + if !confirmed { + fmt.Println("Database sync cancelled.") + return nil + } + } + + sourcePassword := os.Getenv(databaseConfig.SourcePasswordEnv) + if strings.TrimSpace(sourcePassword) == "" { + return fmt.Errorf("source password env var %q is empty", databaseConfig.SourcePasswordEnv) + } + + targetPassword := os.Getenv(databaseConfig.TargetPasswordEnv) + if strings.TrimSpace(targetPassword) == "" { + return fmt.Errorf("target password env var %q is empty", databaseConfig.TargetPasswordEnv) + } + + dumpArgs := buildMySQLDumpArgs(databaseConfig) + remoteCommand := buildRemoteMySQLImportCommand(databaseConfig, targetPassword) + sshArgs := []string{"-p", strconv.Itoa(target.Port), fmt.Sprintf("%s@%s", target.Username, target.Host), remoteCommand} + + fmt.Printf("Running: MYSQL_PWD= mysqldump %s | ssh %s\n", strings.Join(dumpArgs, " "), strings.Join(sshArgs, " ")) + + if dryRun { + return nil + } + + sshCommand := exec.CommandContext(cmd.Context(), "ssh", sshArgs...) + sshCommand.Stdout = os.Stdout + sshCommand.Stderr = os.Stderr + + stdinPipe, err := sshCommand.StdinPipe() + if err != nil { + return err + } + + if err := sshCommand.Start(); err != nil { + return err + } + + dumpCommand := exec.CommandContext(cmd.Context(), "mysqldump", dumpArgs...) + dumpCommand.Stdout = stdinPipe + dumpCommand.Stderr = os.Stderr + dumpCommand.Env = append(os.Environ(), "MYSQL_PWD="+sourcePassword) + + dumpErr := dumpCommand.Run() + _ = stdinPipe.Close() + sshErr := sshCommand.Wait() + + if dumpErr != nil { + return dumpErr + } + + if sshErr != nil { + return sshErr + } + + return nil +} + +func ensureCommandAvailable(name string) error { + if _, err := exec.LookPath(name); err != nil { + return fmt.Errorf("required command %q not found", name) + } + + return nil +} + +func promptMySQLSyncConfig(target ResolvedSyncTarget) (mysqlSyncConfig, error) { + sourceDatabase, err := promptText("Source MySQL database", "", requiredValue, 0) + if err != nil { + return mysqlSyncConfig{}, err + } + + sourceUser, err := promptText("Source MySQL user", "root", requiredValue, 0) + if err != nil { + return mysqlSyncConfig{}, err + } + + sourceHost, err := promptText("Source MySQL host", "127.0.0.1", requiredValue, 0) + if err != nil { + return mysqlSyncConfig{}, err + } + + sourcePort, err := promptPort("Source MySQL port", "3306") + if err != nil { + return mysqlSyncConfig{}, err + } + + sourcePasswordEnv, err := promptText("Source DB password env var", "SYNC_SOURCE_DB_PASSWORD", requiredValue, 0) + if err != nil { + return mysqlSyncConfig{}, err + } + + targetDatabase, err := promptText("Target MySQL database", sourceDatabase, requiredValue, 0) + if err != nil { + return mysqlSyncConfig{}, err + } + + targetUser, err := promptText("Target MySQL user", sourceUser, requiredValue, 0) + if err != nil { + return mysqlSyncConfig{}, err + } + + targetHost, err := promptText("Target MySQL host (on target server)", "127.0.0.1", requiredValue, 0) + if err != nil { + return mysqlSyncConfig{}, err + } + + targetPort, err := promptPort("Target MySQL port", "3306") + if err != nil { + return mysqlSyncConfig{}, err + } + + targetPasswordEnv, err := promptText("Target DB password env var", "SYNC_TARGET_DB_PASSWORD", requiredValue, 0) + if err != nil { + return mysqlSyncConfig{}, err + } + + flagsRaw, err := promptText("Extra mysqldump flags (space-separated, optional)", "--single-transaction --quick", optionalValue, 0) + if err != nil { + return mysqlSyncConfig{}, err + } + + return mysqlSyncConfig{ + SourceDatabase: strings.TrimSpace(sourceDatabase), + SourceUser: strings.TrimSpace(sourceUser), + SourceHost: strings.TrimSpace(sourceHost), + SourcePort: sourcePort, + SourcePasswordEnv: strings.TrimSpace(sourcePasswordEnv), + TargetDatabase: strings.TrimSpace(targetDatabase), + TargetUser: strings.TrimSpace(targetUser), + TargetHost: strings.TrimSpace(targetHost), + TargetPort: targetPort, + TargetPasswordEnv: strings.TrimSpace(targetPasswordEnv), + DumpFlags: strings.Fields(strings.TrimSpace(flagsRaw)), + }, nil +} + +func promptPort(label, defaultValue string) (int, error) { + value, err := promptText(label, defaultValue, requiredValue, 0) + if err != nil { + return 0, err + } + + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || parsed <= 0 { + return 0, fmt.Errorf("invalid port %q", value) + } + + return parsed, nil +} + +func buildMySQLDumpArgs(config mysqlSyncConfig) []string { + args := []string{ + fmt.Sprintf("--host=%s", config.SourceHost), + fmt.Sprintf("--port=%d", config.SourcePort), + fmt.Sprintf("--user=%s", config.SourceUser), + } + + args = append(args, config.DumpFlags...) + args = append(args, config.SourceDatabase) + return args +} + +func buildRemoteMySQLImportCommand(config mysqlSyncConfig, targetPassword string) string { + return fmt.Sprintf( + "MYSQL_PWD=%s mysql --host=%s --port=%d --user=%s %s", + shellQuote(targetPassword), + shellQuote(config.TargetHost), + config.TargetPort, + shellQuote(config.TargetUser), + shellQuote(config.TargetDatabase), + ) +} + +func shellQuote(value string) string { + escaped := strings.ReplaceAll(value, "'", "'\\''") + return "'" + escaped + "'" +} + +func isSameDatabaseTarget(target ResolvedSyncTarget, config mysqlSyncConfig) bool { + if !strings.EqualFold(strings.TrimSpace(target.Host), strings.TrimSpace(config.SourceHost)) { + return false + } + + if config.TargetPort != config.SourcePort { + return false + } + + if !strings.EqualFold(strings.TrimSpace(config.TargetHost), strings.TrimSpace(config.SourceHost)) { + return false + } + + return strings.EqualFold(strings.TrimSpace(config.SourceDatabase), strings.TrimSpace(config.TargetDatabase)) +} diff --git a/cmd/sync-files.go b/cmd/sync-files.go index 207d171..a1541cf 100644 --- a/cmd/sync-files.go +++ b/cmd/sync-files.go @@ -2,28 +2,145 @@ package cmd import ( "fmt" - "github.com/briandowns/spinner" - "github.com/zloylos/grsync" - "time" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/intercube/cli/util" ) -func syncFiles(destination string, filesPath string, remoteUser string) { - fmt.Printf("Syncing files from server %v and path %v\n", destination, filesPath) +func runFileSync(cmd *cobra.Command, target ResolvedSyncTarget, settings *SyncSettings, dryRun bool) error { + items, err := ensureFileSyncItems(settings) + if err != nil { + return err + } + + for _, item := range items { + source := strings.TrimSpace(item.Source) + if source == "" { + return fmt.Errorf("sync file source path is required") + } + + targetPath := strings.TrimSpace(item.Target) + if targetPath == "" { + return fmt.Errorf("sync file target path is required") + } + + destination := fmt.Sprintf("%s@%s:%s", target.Username, target.Host, targetPath) + + args := []string{"-az", "-e", fmt.Sprintf("ssh -p %d", target.Port)} + if dryRun { + args = append(args, "--dry-run") + } + + for _, pattern := range item.Exclude { + trimmed := strings.TrimSpace(pattern) + if trimmed != "" { + args = append(args, "--exclude", trimmed) + } + } + + args = append(args, source, destination) + + fmt.Printf("File sync: %s -> %s\n", source, destination) + fmt.Printf("Running: rsync %s\n", strings.Join(args, " ")) + + if dryRun { + continue + } + + command := exec.CommandContext(cmd.Context(), "rsync", args...) + command.Stdout = os.Stdout + command.Stderr = os.Stderr + if err := command.Run(); err != nil { + return err + } + } + + return nil +} + +func ensureFileSyncItems(settings *SyncSettings) ([]util.SyncFileItem, error) { + if len(settings.Files.Items) > 0 { + return settings.Files.Items, nil + } + + fmt.Println("No file sync paths configured yet. Let's add them now.") + + items := make([]util.SyncFileItem, 0, 1) + for { + source, err := promptText("Sync source path", "", requiredValue, 0) + if err != nil { + return nil, err + } + + target, err := promptText("Sync target path", source, requiredValue, 0) + if err != nil { + return nil, err + } + + excludeRaw, err := promptText("Exclude patterns (comma-separated, optional)", "", optionalValue, 0) + if err != nil { + return nil, err + } + + excludes := splitCSV(excludeRaw) + items = append(items, util.SyncFileItem{Source: source, Target: target, Exclude: excludes}) - task := grsync.NewTask( - fmt.Sprintf("%v@%v:%v", remoteUser, destination, filesPath), - "./", - grsync.RsyncOptions{}, - ) + addAnother, err := chooseYesNo("Add another file sync mapping?") + if err != nil { + return nil, err + } - go func() { - s := spinner.New(spinner.CharSets[43], 150*time.Millisecond) - s.Start() - }() + if !addAnother { + break + } + } + + settings.Files.Items = items + viper.Set("sync.files.items", toMapSlice(items)) + + configPath, err := resolveOnboardingConfigPath() + if err != nil { + return nil, err + } + + if err := saveConfigAndReload(configPath); err != nil { + return nil, err + } + + fmt.Printf("Saved sync file mappings to %s\n", configPath) + return items, nil +} + +func toMapSlice(items []util.SyncFileItem) []map[string]interface{} { + encoded := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + entry := map[string]interface{}{ + "source": item.Source, + "target": item.Target, + } + if len(item.Exclude) > 0 { + entry["exclude"] = item.Exclude + } + encoded = append(encoded, entry) + } + + return encoded +} - if err := task.Run(); err != nil { - panic(err) +func splitCSV(input string) []string { + parts := strings.Split(input, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } } - fmt.Println("\nEverything is synced!") + return result } diff --git a/cmd/sync-resolve.go b/cmd/sync-resolve.go new file mode 100644 index 0000000..ea8bbd3 --- /dev/null +++ b/cmd/sync-resolve.go @@ -0,0 +1,305 @@ +package cmd + +import ( + "fmt" + "os" + "sort" + "strconv" + "strings" + + "github.com/intercube/cli/util/inventory" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" +) + +type ResolvedSyncTarget struct { + SiteID string + DisplayName string + Host string + Username string + Port int +} + +type ResolvedSyncSource struct { + SiteID string + DisplayName string +} + +func resolveSyncTarget(cmd *cobra.Command, inventoryClient *inventory.Client, query string, explicitSiteID string) (ResolvedSyncTarget, ResolvedSyncSource, error) { + sites, err := inventoryClient.ListSites(cmd.Context()) + if err != nil { + if shouldPromptForOrganizationError(err) { + return ResolvedSyncTarget{}, ResolvedSyncSource{}, fmt.Errorf("organization context is required. Run `intercube auth org` (or pass --org-id)") + } + + return ResolvedSyncTarget{}, ResolvedSyncSource{}, err + } + + if len(sites) == 0 { + return ResolvedSyncTarget{}, ResolvedSyncSource{}, fmt.Errorf("no sites available for your account") + } + + sourceSite := resolveSourceSite(sites) + source := ResolvedSyncSource{DisplayName: "unknown"} + if sourceSite != nil { + source = ResolvedSyncSource{SiteID: sourceSite.ID, DisplayName: syncSiteDisplayName(*sourceSite)} + } + + candidates := excludeSourceSite(sites, sourceSite) + if len(candidates) == 0 { + return ResolvedSyncTarget{}, source, fmt.Errorf("no target sites available") + } + + selectedSite, err := selectTargetSite(candidates, sites, query, explicitSiteID) + if err != nil { + return ResolvedSyncTarget{}, source, err + } + + target, err := promptTargetAccessDetails(selectedSite) + if err != nil { + return ResolvedSyncTarget{}, source, err + } + + return target, source, nil +} + +func shouldPromptForOrganizationError(err error) bool { + if err == nil { + return false + } + + message := strings.ToLower(err.Error()) + return strings.Contains(message, "x-organization-id") || strings.Contains(message, "organization") +} + +func resolveSourceSite(sites []inventory.SiteServer) *inventory.SiteServer { + hostname, err := os.Hostname() + if err != nil { + return nil + } + + trimmed := strings.TrimSpace(hostname) + if trimmed == "" { + return nil + } + + matches := findSiteMatches(sites, trimmed) + if len(matches) == 1 { + return matches[0] + } + + return nil +} +func selectTargetSite(candidates []inventory.SiteServer, allSites []inventory.SiteServer, query string, explicitSiteID string) (*inventory.SiteServer, error) { + if strings.TrimSpace(explicitSiteID) != "" { + selected, found := findSiteByID(allSites, explicitSiteID) + if !found { + return nil, fmt.Errorf("site %q not found", explicitSiteID) + } + + return selected, nil + } + + if strings.TrimSpace(query) != "" { + matches := findSiteMatches(candidates, query) + if len(matches) == 1 { + return matches[0], nil + } + + if len(matches) > 1 { + matchedSites := make([]inventory.SiteServer, 0, len(matches)) + for _, match := range matches { + matchedSites = append(matchedSites, *match) + } + + fmt.Printf("Query %q matched multiple sites, please choose:\n", query) + selected, err := selectSiteFromList(matchedSites) + if err != nil { + return nil, err + } + + return selected, nil + } + + return nil, fmt.Errorf("no target site matched %q", query) + } + + selected, err := selectSiteFromList(candidates) + if err != nil { + return nil, err + } + + return selected, nil +} + +func selectSiteFromList(sites []inventory.SiteServer) (*inventory.SiteServer, error) { + if len(sites) == 0 { + return nil, fmt.Errorf("no sites available") + } + + if len(sites) == 1 { + return &sites[0], nil + } + + templates := &promptui.SelectTemplates{ + Label: "{{ . }}?", + Active: "\U0001F9CA {{ . | red }}", + Inactive: " {{ . | cyan }}", + Selected: "\U0001F9CA {{ . | cyan }}", + } + + labels := make([]string, 0, len(sites)) + for _, site := range sites { + labels = append(labels, syncSiteDisplayName(site)) + } + + prompt := promptui.Select{ + Label: "Select target site", + Items: labels, + Templates: templates, + Size: minInt(10, len(labels)), + Stdout: &bellSkipper{}, + } + + index, _, err := prompt.Run() + if err != nil { + return nil, err + } + + return &sites[index], nil +} + +func syncSiteDisplayName(site inventory.SiteServer) string { + parts := make([]string, 0, 3) + if strings.TrimSpace(site.MainDomain) != "" { + parts = append(parts, strings.TrimSpace(site.MainDomain)) + } + if strings.TrimSpace(site.ServerName) != "" { + parts = append(parts, strings.TrimSpace(site.ServerName)) + } + if strings.TrimSpace(site.ID) != "" { + parts = append(parts, strings.TrimSpace(site.ID)) + } + + if len(parts) == 0 { + return "(unnamed site)" + } + + return strings.Join(parts, " | ") +} + +func minInt(a, b int) int { + if a < b { + return a + } + + return b +} + +func findSiteMatches(sites []inventory.SiteServer, value string) []*inventory.SiteServer { + needle := normalizeToken(value) + if needle == "" { + return nil + } + + exact := make([]*inventory.SiteServer, 0, 1) + contains := make([]*inventory.SiteServer, 0, 2) + + for i := range sites { + candidates := []string{sites[i].ID, sites[i].MainDomain, sites[i].ServerName, sites[i].Username} + matchedExact := false + matchedContains := false + for _, candidate := range candidates { + normalized := normalizeToken(candidate) + if normalized == "" { + continue + } + + if normalized == needle { + matchedExact = true + break + } + + if strings.Contains(normalized, needle) { + matchedContains = true + } + } + + if matchedExact { + exact = append(exact, &sites[i]) + continue + } + + if matchedContains { + contains = append(contains, &sites[i]) + } + } + + if len(exact) > 0 { + return exact + } + + return contains +} + +func normalizeToken(value string) string { + trimmed := strings.ToLower(strings.TrimSpace(value)) + trimmed = strings.ReplaceAll(trimmed, " ", "") + return trimmed +} + +func excludeSourceSite(sites []inventory.SiteServer, sourceSite *inventory.SiteServer) []inventory.SiteServer { + excludedID := "" + if sourceSite != nil { + excludedID = strings.TrimSpace(sourceSite.ID) + } + + result := make([]inventory.SiteServer, 0, len(sites)) + for _, site := range sites { + if excludedID != "" && strings.EqualFold(strings.TrimSpace(site.ID), excludedID) { + continue + } + + result = append(result, site) + } + + sort.SliceStable(result, func(i, j int) bool { + return syncSiteDisplayName(result[i]) < syncSiteDisplayName(result[j]) + }) + + return result +} + +func promptTargetAccessDetails(site *inventory.SiteServer) (ResolvedSyncTarget, error) { + hostDefault := strings.TrimSpace(site.MainDomain) + userDefault := strings.TrimSpace(site.Username) + portDefault := "22" + + host, err := promptText("Target SSH host", hostDefault, requiredValue, 0) + if err != nil { + return ResolvedSyncTarget{}, err + } + + username, err := promptText("Target SSH user", userDefault, requiredValue, 0) + if err != nil { + return ResolvedSyncTarget{}, err + } + + portString, err := promptText("Target SSH port", portDefault, requiredValue, 0) + if err != nil { + return ResolvedSyncTarget{}, err + } + + port, err := strconv.Atoi(strings.TrimSpace(portString)) + if err != nil || port <= 0 { + return ResolvedSyncTarget{}, fmt.Errorf("invalid SSH port %q", portString) + } + + return ResolvedSyncTarget{ + SiteID: strings.TrimSpace(site.ID), + DisplayName: syncSiteDisplayName(*site), + Host: strings.TrimSpace(host), + Username: strings.TrimSpace(username), + Port: port, + }, nil +} diff --git a/cmd/sync.go b/cmd/sync.go index 615daa6..b7d903c 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -16,92 +16,117 @@ limitations under the License. package cmd import ( - "errors" "fmt" - "github.com/spf13/viper" "strings" "github.com/spf13/cobra" ) -import "github.com/asaskevich/govalidator" -var syncType string -var fromServer string -var filesPath string -var remoteUser string +type syncMode struct { + runFiles bool + runDatabase bool + dryRun bool + autoApprove bool +} + +var ( + syncOnlyFiles bool + syncOnlyDatabase bool + syncAll bool + syncDryRun bool + syncYes bool + syncSiteID string + syncOrgID string +) var syncCmd = &cobra.Command{ - Use: "sync [files|database] [destination]", - Short: "Syncs files or database from one of your servers to another", - Long: ``, - Run: func(cmd *cobra.Command, args []string) { - sync() - }, - PreRunE: func(cmd *cobra.Command, args []string) error { - if len(args) < 1 { - return errors.New("The type argument is required") - } else { - syncType = args[0] - if len(args) > 1 { - fromServer = args[1] - } else { - fromServer = viper.GetStringMapString("file_syncing")["from_server"] - } + Use: "sync [env-or-host]", + Short: "Sync files and MySQL data to another environment", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + query := "" + if len(args) == 1 { + query = strings.TrimSpace(args[0]) + } - if len(args) > 2 { - filesPath = args[2] - } else { - filesPath = viper.GetStringMapString("file_syncing")["path"] - } + settings, err := loadSyncSettings() + if err != nil { + return err + } - if len(args) > 3 { - remoteUser = args[3] - } else { - remoteUser = viper.GetString("remote_user") - } + mode, err := resolveSyncMode() + if err != nil { + return err + } - if syncType != "database" && syncType != "files" { - return errors.New("The type argument either has to be 'database' or 'files'") - } + inventoryClient, _, err := newInventoryClient(cmd, syncOrgID) + if err != nil { + return err + } - if syncType == "files" { - var err error - fromServer, filesPath, remoteUser, err = ensureSyncConfiguration(fromServer, filesPath, remoteUser) - if err != nil { - return fmt.Errorf("unable to continue sync: %w", err) - } - } + target, source, err := resolveSyncTarget(cmd, inventoryClient, query, strings.TrimSpace(syncSiteID)) + if err != nil { + return err + } - if !govalidator.IsDNSName(fromServer) { - return errors.New("Provide a valid destination hostname") - } + fmt.Printf("Source: %s\n", source.DisplayName) + fmt.Printf("Target: %s (%s@%s:%d)\n", target.DisplayName, target.Username, target.Host, target.Port) - if syncType == "files" && strings.TrimSpace(filesPath) == "" { - return errors.New("Provide a valid files path") + if mode.runFiles { + if err := runFileSync(cmd, target, &settings, mode.dryRun); err != nil { + return err } + } - if syncType == "files" && strings.TrimSpace(remoteUser) == "" { - return errors.New("Provide a valid remote user") + if mode.runDatabase { + if err := runDatabaseSync(cmd, target, &settings, mode.dryRun, mode.autoApprove); err != nil { + return err } - - return nil } + + fmt.Println("Sync finished.") + return nil }, } -func init() { - rootCmd.AddCommand(syncCmd) +func resolveSyncMode() (syncMode, error) { + mode := syncMode{ + dryRun: syncDryRun, + autoApprove: syncYes, + } - syncCmd.PersistentFlags().StringVar(&syncType, "type", "", "Either 'database' or 'files'") - syncCmd.PersistentFlags().StringVar(&fromServer, "from_server", "", "Provide the hostname of the server to pull the data from") - syncCmd.PersistentFlags().StringVar(&filesPath, "files_path", "", "Provide the location of where the files are located") - syncCmd.PersistentFlags().StringVar(&remoteUser, "remote_user", "", "Provide the user to connect to the server") -} + if syncAll { + mode.runFiles = true + mode.runDatabase = true + return mode, nil + } + + if syncOnlyFiles { + mode.runFiles = true + } -func sync() { - if syncType == "files" { - syncFiles(fromServer, filesPath, remoteUser) - } else { - //syncDatabase() + if syncOnlyDatabase { + mode.runDatabase = true } + + if mode.runFiles || mode.runDatabase { + return mode, nil + } + + mode.runFiles = true + mode.runDatabase = true + + return mode, nil +} + +func init() { + rootCmd.AddCommand(syncCmd) + + syncCmd.Flags().BoolVar(&syncOnlyFiles, "files", false, "sync files only") + syncCmd.Flags().BoolVar(&syncOnlyDatabase, "database", false, "sync database only") + syncCmd.Flags().BoolVar(&syncAll, "all", false, "sync both files and database") + syncCmd.Flags().BoolVar(&syncDryRun, "dry-run", false, "print planned commands without executing") + syncCmd.Flags().BoolVar(&syncYes, "yes", false, "skip confirmation prompts") + syncCmd.Flags().StringVar(&syncSiteID, "site-id", "", "target site id override") + syncCmd.Flags().StringVar(&syncOrgID, "org-id", "", "organization id for inventory requests") } diff --git a/go.mod b/go.mod index 99a8f76..616da3e 100644 --- a/go.mod +++ b/go.mod @@ -16,15 +16,18 @@ require ( ) require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chzyer/readline v1.5.1 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-kms-wrapping/v2 v2.0.20 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect @@ -34,6 +37,7 @@ require ( github.com/hashicorp/go-sockaddr v1.0.7 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect @@ -51,6 +55,7 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/zalando/go-keyring v0.2.6 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.47.0 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/go.sum b/go.sum index 538c1cb..84b76f5 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= @@ -18,6 +20,8 @@ github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3 github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -30,6 +34,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 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/hashicorp/boundary/api v0.0.60 h1:HWxdWVZs2yDNhbpbk5/g68tYxHiUtpPBea5weW1yj48= @@ -54,6 +60,8 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -111,6 +119,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0= github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= github.com/zloylos/grsync v1.7.0 h1:7JjxC4CdzA7Inh771VelfUWdxIiMcXCXF5qV1Vx+W6E= github.com/zloylos/grsync v1.7.0/go.mod h1:0Ue43fnWwx3doC5GkfmwmUwCAvQ54h06FRHXXX3ZWls= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= diff --git a/util/appconfig/config.go b/util/appconfig/config.go new file mode 100644 index 0000000..327bd02 --- /dev/null +++ b/util/appconfig/config.go @@ -0,0 +1,89 @@ +package appconfig + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +var ClerkIssuer = "https://clerk.intercube.io" +var ClerkClientID = "Oi68oAK1xuK1088Z" +var ClerkAudience = "" +var ClerkScopes = "openid profile email offline_access" +var ClerkCallbackPort = "8976" +var InventoryAPIBaseURL = "http://inventory-nexus.dev-c8s.intercube.dev/" +var OrganizationID = "" + +const ( + EnvClerkIssuer = "INTERCUBE_AUTH_CLERK_ISSUER" + EnvClerkClientID = "INTERCUBE_AUTH_CLERK_CLIENT_ID" + EnvClerkAudience = "INTERCUBE_AUTH_CLERK_AUDIENCE" + EnvClerkScopes = "INTERCUBE_AUTH_CLERK_SCOPES" + EnvClerkCallbackPort = "INTERCUBE_AUTH_CLERK_CALLBACK_PORT" + EnvInventoryAPIURL = "INTERCUBE_INVENTORY_API_BASE_URL" + EnvOrganizationID = "INTERCUBE_ORGANIZATION_ID" +) + +func LoadFromEnv() { + if value := strings.TrimSpace(os.Getenv(EnvClerkIssuer)); value != "" { + ClerkIssuer = value + } + + if value := strings.TrimSpace(os.Getenv(EnvClerkClientID)); value != "" { + ClerkClientID = value + } + + if value := strings.TrimSpace(os.Getenv(EnvClerkAudience)); value != "" { + ClerkAudience = value + } + + if value := strings.TrimSpace(os.Getenv(EnvClerkScopes)); value != "" { + ClerkScopes = value + } + + if value := strings.TrimSpace(os.Getenv(EnvClerkCallbackPort)); value != "" { + ClerkCallbackPort = value + } + + if value := strings.TrimSpace(os.Getenv(EnvInventoryAPIURL)); value != "" { + InventoryAPIBaseURL = value + } + + if value := strings.TrimSpace(os.Getenv(EnvOrganizationID)); value != "" { + OrganizationID = value + } +} + +func ValidateClerk() error { + missing := make([]string, 0, 2) + if strings.TrimSpace(ClerkIssuer) == "" { + missing = append(missing, "ClerkIssuer") + } + + if strings.TrimSpace(ClerkClientID) == "" { + missing = append(missing, "ClerkClientID") + } + + if len(missing) == 0 { + return nil + } + + return fmt.Errorf("missing internal auth config: %s", strings.Join(missing, ", ")) +} + +func ValidateInventory() error { + if strings.TrimSpace(InventoryAPIBaseURL) == "" { + return fmt.Errorf("missing internal inventory config: InventoryAPIBaseURL") + } + + return nil +} +func ParsedCallbackPort() int { + port, err := strconv.Atoi(strings.TrimSpace(ClerkCallbackPort)) + if err != nil || port < 1 || port > 65535 { + return 8976 + } + + return port +} diff --git a/util/auth/clerk.go b/util/auth/clerk.go new file mode 100644 index 0000000..e6117e4 --- /dev/null +++ b/util/auth/clerk.go @@ -0,0 +1,407 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "os/exec" + "runtime" + "strings" + "time" +) + +type ClerkClient struct { + Issuer string + ClientID string + Audience string + Scopes string + CallbackPort int + HTTPClient *http.Client +} + +type oidcMetadata struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + RevocationEndpoint string `json:"revocation_endpoint"` +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + ExpiresIn int64 `json:"expires_in"` + Error string `json:"error"` +} + +type callbackResult struct { + Code string + Error string + State string +} + +func (c *ClerkClient) Login(ctx context.Context) (*Session, error) { + if strings.TrimSpace(c.Issuer) == "" || strings.TrimSpace(c.ClientID) == "" { + return nil, errors.New("missing Clerk issuer or client ID") + } + + metadata, err := c.fetchMetadata(ctx) + if err != nil { + return nil, err + } + + state, err := randomString(24) + if err != nil { + return nil, err + } + + verifier, err := randomString(48) + if err != nil { + return nil, err + } + + challenge := pkceChallenge(verifier) + redirectURI := c.redirectURI() + + resultCh, shutdown, err := startCallbackServer(c.callbackPort(), state) + if err != nil { + return nil, err + } + defer shutdown(context.Background()) + + authURL, err := c.buildAuthURL(metadata.AuthorizationEndpoint, redirectURI, state, challenge) + if err != nil { + return nil, err + } + + if err := openBrowser(authURL); err != nil { + return nil, err + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case result := <-resultCh: + if result.Error != "" { + return nil, fmt.Errorf("clerk login failed: %s", result.Error) + } + + if result.State != state { + return nil, errors.New("received invalid auth state") + } + + return c.exchangeCode(ctx, metadata.TokenEndpoint, result.Code, verifier, redirectURI) + } +} + +func (c *ClerkClient) RefreshSession(ctx context.Context, session *Session) (*Session, error) { + if session == nil || strings.TrimSpace(session.RefreshToken) == "" { + return nil, errors.New("missing refresh token") + } + + metadata, err := c.fetchMetadata(ctx) + if err != nil { + return nil, err + } + + values := url.Values{} + values.Set("grant_type", "refresh_token") + values.Set("refresh_token", session.RefreshToken) + values.Set("client_id", c.ClientID) + + body, err := c.postForm(ctx, metadata.TokenEndpoint, values) + if err != nil { + return nil, err + } + + if body.Error != "" { + return nil, errors.New(body.Error) + } + + if body.AccessToken == "" { + return nil, errors.New("token refresh returned empty access token") + } + + if body.RefreshToken == "" { + body.RefreshToken = session.RefreshToken + } + + updated := tokenResponseToSession(body) + updated.OrganizationID = session.OrganizationID + updated.KnownOrgIDs = append([]string(nil), session.KnownOrgIDs...) + + return updated, nil +} + +func (c *ClerkClient) RevokeRefreshToken(ctx context.Context, refreshToken string) error { + if strings.TrimSpace(refreshToken) == "" { + return nil + } + + metadata, err := c.fetchMetadata(ctx) + if err != nil { + return err + } + + if strings.TrimSpace(metadata.RevocationEndpoint) == "" { + return nil + } + + values := url.Values{} + values.Set("token", refreshToken) + values.Set("token_type_hint", "refresh_token") + values.Set("client_id", c.ClientID) + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, metadata.RevocationEndpoint, strings.NewReader(values.Encode())) + if err != nil { + return err + } + + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + response, err := c.client().Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + if response.StatusCode >= 300 { + return fmt.Errorf("revocation failed with status %d", response.StatusCode) + } + + return nil +} + +func (c *ClerkClient) exchangeCode(ctx context.Context, tokenEndpoint, code, verifier, redirectURI string) (*Session, error) { + values := url.Values{} + values.Set("grant_type", "authorization_code") + values.Set("code", code) + values.Set("client_id", c.ClientID) + values.Set("redirect_uri", redirectURI) + values.Set("code_verifier", verifier) + + body, err := c.postForm(ctx, tokenEndpoint, values) + if err != nil { + return nil, err + } + + if body.Error != "" { + return nil, errors.New(body.Error) + } + + if body.AccessToken == "" { + return nil, errors.New("token exchange returned empty access token") + } + + return tokenResponseToSession(body), nil +} + +func (c *ClerkClient) postForm(ctx context.Context, endpoint string, values url.Values) (*tokenResponse, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(values.Encode())) + if err != nil { + return nil, err + } + + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + response, err := c.client().Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + var body tokenResponse + if err := json.NewDecoder(response.Body).Decode(&body); err != nil { + return nil, err + } + + if response.StatusCode >= 300 && body.Error == "" { + body.Error = fmt.Sprintf("request failed with status %d", response.StatusCode) + } + + return &body, nil +} + +func (c *ClerkClient) fetchMetadata(ctx context.Context) (*oidcMetadata, error) { + issuer := strings.TrimRight(strings.TrimSpace(c.Issuer), "/") + if issuer == "" { + return nil, errors.New("missing Clerk issuer") + } + + metadataURL := issuer + "/.well-known/openid-configuration" + request, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil) + if err != nil { + return nil, err + } + + response, err := c.client().Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode >= 300 { + return nil, fmt.Errorf("unable to load oidc metadata: status %d", response.StatusCode) + } + + var metadata oidcMetadata + if err := json.NewDecoder(response.Body).Decode(&metadata); err != nil { + return nil, err + } + + if metadata.AuthorizationEndpoint == "" || metadata.TokenEndpoint == "" { + return nil, errors.New("oidc metadata missing authorization or token endpoint") + } + + return &metadata, nil +} + +func (c *ClerkClient) buildAuthURL(endpoint, redirectURI, state, challenge string) (string, error) { + parsed, err := url.Parse(endpoint) + if err != nil { + return "", err + } + + query := parsed.Query() + query.Set("response_type", "code") + query.Set("client_id", c.ClientID) + query.Set("redirect_uri", redirectURI) + query.Set("scope", c.scopeString()) + query.Set("state", state) + query.Set("code_challenge", challenge) + query.Set("code_challenge_method", "S256") + + if strings.TrimSpace(c.Audience) != "" { + query.Set("audience", c.Audience) + } + + parsed.RawQuery = query.Encode() + return parsed.String(), nil +} + +func (c *ClerkClient) scopeString() string { + scopes := strings.TrimSpace(c.Scopes) + if scopes == "" { + return "openid profile email offline_access" + } + + return scopes +} + +func (c *ClerkClient) callbackPort() int { + if c.CallbackPort > 0 { + return c.CallbackPort + } + + return 8976 +} + +func (c *ClerkClient) redirectURI() string { + return fmt.Sprintf("http://127.0.0.1:%d/callback", c.callbackPort()) +} + +func (c *ClerkClient) client() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + + return &http.Client{Timeout: 15 * time.Second} +} + +func startCallbackServer(port int, expectedState string) (<-chan callbackResult, func(context.Context) error, error) { + resultCh := make(chan callbackResult, 1) + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(writer http.ResponseWriter, request *http.Request) { + sendResult := func(result callbackResult) { + select { + case resultCh <- result: + default: + } + } + + query := request.URL.Query() + state := query.Get("state") + + if query.Get("error") != "" { + _, _ = writer.Write([]byte("Authentication failed. You can close this tab.")) + sendResult(callbackResult{Error: query.Get("error"), State: state}) + return + } + + if query.Get("code") == "" { + _, _ = writer.Write([]byte("Missing authorization code. You can close this tab.")) + sendResult(callbackResult{Error: "missing authorization code", State: state}) + return + } + + if state != expectedState { + _, _ = writer.Write([]byte("State mismatch. You can close this tab.")) + sendResult(callbackResult{Error: "state mismatch", State: state}) + return + } + + _, _ = writer.Write([]byte("Authentication complete. You can close this tab and return to the terminal.")) + sendResult(callbackResult{Code: query.Get("code"), State: state}) + }) + + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return nil, nil, err + } + + server := &http.Server{Handler: mux} + go func() { + _ = server.Serve(listener) + }() + + return resultCh, server.Shutdown, nil +} + +func randomString(size int) (string, error) { + buffer := make([]byte, size) + if _, err := rand.Read(buffer); err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(buffer), nil +} + +func pkceChallenge(verifier string) string { + hash := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(hash[:]) +} + +func openBrowser(url string) error { + var command *exec.Cmd + if runtime.GOOS == "darwin" { + command = exec.Command("open", url) + } else if runtime.GOOS == "windows" { + command = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + } else { + command = exec.Command("xdg-open", url) + } + + return command.Start() +} + +func tokenResponseToSession(body *tokenResponse) *Session { + expiresIn := body.ExpiresIn + if expiresIn <= 0 { + expiresIn = 3600 + } + + return &Session{ + AccessToken: body.AccessToken, + RefreshToken: body.RefreshToken, + TokenType: body.TokenType, + Scope: body.Scope, + ExpiresAt: time.Now().Add(time.Duration(expiresIn) * time.Second), + } +} diff --git a/util/auth/session.go b/util/auth/session.go new file mode 100644 index 0000000..538913a --- /dev/null +++ b/util/auth/session.go @@ -0,0 +1,25 @@ +package auth + +import "time" + +type Session struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + ExpiresAt time.Time `json:"expires_at"` + OrganizationID string `json:"organization_id,omitempty"` + KnownOrgIDs []string `json:"known_org_ids,omitempty"` +} + +func (s *Session) ExpiresSoon(leeway time.Duration) bool { + if s == nil || s.AccessToken == "" { + return true + } + + if s.ExpiresAt.IsZero() { + return true + } + + return time.Now().Add(leeway).After(s.ExpiresAt) +} diff --git a/util/auth/store.go b/util/auth/store.go new file mode 100644 index 0000000..29c11f0 --- /dev/null +++ b/util/auth/store.go @@ -0,0 +1,110 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + + "github.com/zalando/go-keyring" +) + +var ErrNoSession = errors.New("no stored auth session") + +type SessionStore struct { + service string + account string + path string +} + +func NewSessionStore(service string) (*SessionStore, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return nil, err + } + + return &SessionStore{ + service: service, + account: "clerk-session", + path: filepath.Join(configDir, "intercube", "session.json"), + }, nil +} + +func (s *SessionStore) Save(_ context.Context, session *Session) error { + payload, err := json.Marshal(session) + if err != nil { + return err + } + + if err := keyring.Set(s.service, s.account, string(payload)); err == nil { + return nil + } + + return s.saveToFile(payload) +} + +func (s *SessionStore) Load(_ context.Context) (*Session, error) { + payload, err := keyring.Get(s.service, s.account) + if err == nil { + return decodeSession([]byte(payload)) + } + + session, fileErr := s.loadFromFile() + if fileErr == nil { + return session, nil + } + + if errors.Is(fileErr, ErrNoSession) { + return nil, ErrNoSession + } + + return nil, fileErr +} + +func (s *SessionStore) Clear(_ context.Context) error { + if err := keyring.Delete(s.service, s.account); err != nil && !errors.Is(err, keyring.ErrNotFound) { + return err + } + + err := os.Remove(s.path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + return nil +} + +func (s *SessionStore) saveToFile(payload []byte) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil { + return err + } + + return os.WriteFile(s.path, payload, 0600) +} + +func (s *SessionStore) loadFromFile() (*Session, error) { + payload, err := os.ReadFile(s.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNoSession + } + + return nil, err + } + + return decodeSession(payload) +} + +func decodeSession(payload []byte) (*Session, error) { + var session Session + if err := json.Unmarshal(payload, &session); err != nil { + return nil, err + } + + if session.AccessToken == "" { + return nil, ErrNoSession + } + + return &session, nil +} diff --git a/util/configuration.go b/util/configuration.go index 522c7e7..18e508c 100644 --- a/util/configuration.go +++ b/util/configuration.go @@ -4,11 +4,26 @@ type Configuration struct { Mappings []Map `mapstructure:"mappings"` MagentoBaseUrls []MagentoBaseUrl `mapstructure:"magento_base_urls"` Login Login `mapstructure:"login"` + Sync Sync `mapstructure:"sync"` +} + +type Sync struct { + Files SyncFiles `mapstructure:"files"` +} + +type SyncFiles struct { + Items []SyncFileItem `mapstructure:"items"` +} + +type SyncFileItem struct { + Source string `mapstructure:"source"` + Target string `mapstructure:"target"` + Exclude []string `mapstructure:"exclude"` } type Map struct { - From string - To string + From string `mapstructure:"from"` + To string `mapstructure:"to"` } type MagentoBaseUrl struct { @@ -18,9 +33,9 @@ type MagentoBaseUrl struct { } type Login struct { - Username string - Password string - Scope string + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + Scope string `mapstructure:"scope"` AuthMethod string `mapstructure:"auth_method"` InstanceUrl string `mapstructure:"instance_url"` } diff --git a/util/inventory/client.go b/util/inventory/client.go new file mode 100644 index 0000000..0addb44 --- /dev/null +++ b/util/inventory/client.go @@ -0,0 +1,392 @@ +package inventory + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + authutil "github.com/intercube/cli/util/auth" +) + +type Client struct { + BaseURL string + OrgID string + Store *authutil.SessionStore + Clerk *authutil.ClerkClient + HTTPClient *http.Client + mu sync.Mutex +} + +type SiteServer struct { + ID string `json:"id"` + Username string `json:"username"` + MainDomain string `json:"maindomain"` + ServerID string `json:"serverid"` + ServerName string `json:"servername"` +} + +type AuthorizationKey struct { + ID string `json:"id"` + Content string `json:"content"` + Comment string `json:"comment"` +} + +type OrganizationSSHKey struct { + ID string `json:"id"` + Content string `json:"content"` + Comment string `json:"comment"` + ExpirationDate string `json:"expirationdate"` + CreatedAtUTC string `json:"createdatutc"` + UpdatedAtUTC string `json:"updatedatutc"` + SiteIDs []int `json:"siteids"` +} + +type EnvironmentVariable struct { + ID string `json:"id"` + Name string `json:"name"` + Value string `json:"value"` + Secret bool `json:"secret"` +} + +type Redirect struct { + ID string `json:"id"` + Domain string `json:"domain"` + ReturnCode int `json:"returncode"` + Location string `json:"location"` + Value string `json:"value"` +} + +type CurrentUserOrganization struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Role string `json:"role"` +} + +type CreateAuthorizationKeyRequest struct { + Content string `json:"content"` + Comment string `json:"comment,omitempty"` +} + +type OrganizationSSHKeyRequest struct { + Content string `json:"content"` + Comment string `json:"comment,omitempty"` + ExpirationDate string `json:"expirationdate,omitempty"` +} + +type EnvironmentVariableMutate struct { + Name string `json:"name"` + Value string `json:"value"` + Secret bool `json:"secret"` +} + +type RedirectMutate struct { + Domain string `json:"domain"` + ReturnCode int `json:"returncode"` + Location string `json:"location"` + Value string `json:"value"` +} + +type pagedMeta struct { + Offset int `json:"offset"` + Limit int `json:"limit"` + Count int `json:"count"` + Total int `json:"total"` + HasNext bool `json:"hasnext"` + HasPrevious bool `json:"hasprevious"` +} + +type organizationSSHKeyPagedResponse struct { + Items []OrganizationSSHKey `json:"items"` + Meta pagedMeta `json:"meta"` +} + +type redirectPagedResponse struct { + Items []Redirect `json:"items"` + Meta pagedMeta `json:"meta"` +} + +func NewClient(baseURL, organizationID string, store *authutil.SessionStore, clerk *authutil.ClerkClient) *Client { + return &Client{ + BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), + OrgID: strings.TrimSpace(organizationID), + Store: store, + Clerk: clerk, + HTTPClient: &http.Client{ + Timeout: 20 * time.Second, + }, + } +} + +func (c *Client) ListSites(ctx context.Context) ([]SiteServer, error) { + var sites []SiteServer + if err := c.doJSON(ctx, http.MethodGet, "/sites", nil, &sites); err != nil { + return nil, err + } + + return sites, nil +} + +func (c *Client) ListSiteAuthorizationKeys(ctx context.Context, siteID string) ([]AuthorizationKey, error) { + var keys []AuthorizationKey + if err := c.doJSON(ctx, http.MethodGet, fmt.Sprintf("/site/%s/authorization-keys", siteID), nil, &keys); err != nil { + return nil, err + } + + return keys, nil +} + +func (c *Client) ListOrganizationSSHKeys(ctx context.Context) ([]OrganizationSSHKey, error) { + keys := make([]OrganizationSSHKey, 0) + offset := 0 + limit := 100 + + for { + var page organizationSSHKeyPagedResponse + path := fmt.Sprintf("/organization/ssh-keys?offset=%d&limit=%d", offset, limit) + if err := c.doJSON(ctx, http.MethodGet, path, nil, &page); err != nil { + return nil, err + } + + keys = append(keys, page.Items...) + if !page.Meta.HasNext { + break + } + + offset += limit + } + + return keys, nil +} + +func (c *Client) CreateOrganizationSSHKey(ctx context.Context, request OrganizationSSHKeyRequest) (*OrganizationSSHKey, error) { + var key OrganizationSSHKey + if err := c.doJSON(ctx, http.MethodPost, "/organization/ssh-keys", request, &key); err != nil { + return nil, err + } + + return &key, nil +} + +func (c *Client) AssignOrganizationSSHKeyToSite(ctx context.Context, keyID, siteID string) error { + path := fmt.Sprintf("/organization/ssh-keys/%s/sites/%s", keyID, siteID) + return c.doJSON(ctx, http.MethodPost, path, nil, nil) +} + +func (c *Client) UnassignOrganizationSSHKeyFromSite(ctx context.Context, keyID, siteID string, deleteIfUnassigned bool) error { + path := fmt.Sprintf("/organization/ssh-keys/%s/sites/%s", keyID, siteID) + if deleteIfUnassigned { + path += "?deleteIfUnassigned=true" + } + + return c.doJSON(ctx, http.MethodDelete, path, nil, nil) +} + +func (c *Client) ListCurrentUserOrganizations(ctx context.Context) ([]CurrentUserOrganization, error) { + var organizations []CurrentUserOrganization + if err := c.doJSON(ctx, http.MethodGet, "/me/organizations", nil, &organizations); err != nil { + return nil, err + } + + return organizations, nil +} + +func (c *Client) ListSiteEnvironmentVariables(ctx context.Context, siteID string) ([]EnvironmentVariable, error) { + var variables []EnvironmentVariable + if err := c.doJSON(ctx, http.MethodGet, fmt.Sprintf("/site/%s/environment-variables", siteID), nil, &variables); err != nil { + return nil, err + } + + return variables, nil +} + +func (c *Client) CreateSiteEnvironmentVariable(ctx context.Context, siteID string, request EnvironmentVariableMutate) (*EnvironmentVariable, error) { + var variable EnvironmentVariable + if err := c.doJSON(ctx, http.MethodPost, fmt.Sprintf("/site/%s/environment-variables", siteID), request, &variable); err != nil { + return nil, err + } + + return &variable, nil +} + +func (c *Client) UpdateSiteEnvironmentVariable(ctx context.Context, siteID, variableID string, request EnvironmentVariableMutate) error { + path := fmt.Sprintf("/site/%s/environment-variables/%s", siteID, variableID) + return c.doJSON(ctx, http.MethodPut, path, request, nil) +} + +func (c *Client) DeleteSiteEnvironmentVariable(ctx context.Context, siteID, variableID string) error { + path := fmt.Sprintf("/site/%s/environment-variables/%s", siteID, variableID) + return c.doJSON(ctx, http.MethodDelete, path, nil, nil) +} + +func (c *Client) ListSiteRedirects(ctx context.Context, siteID string) ([]Redirect, error) { + redirects := make([]Redirect, 0) + offset := 0 + limit := 100 + + for { + var page redirectPagedResponse + path := fmt.Sprintf("/site/%s/redirects?offset=%d&limit=%d", siteID, offset, limit) + if err := c.doJSON(ctx, http.MethodGet, path, nil, &page); err != nil { + return nil, err + } + + redirects = append(redirects, page.Items...) + if !page.Meta.HasNext { + break + } + + offset += limit + } + + return redirects, nil +} + +func (c *Client) CreateSiteRedirect(ctx context.Context, siteID string, request RedirectMutate) (*Redirect, error) { + var redirect Redirect + if err := c.doJSON(ctx, http.MethodPost, fmt.Sprintf("/site/%s/redirects", siteID), request, &redirect); err != nil { + return nil, err + } + + return &redirect, nil +} + +func (c *Client) DeleteSiteRedirect(ctx context.Context, siteID, redirectID string) error { + path := fmt.Sprintf("/site/%s/redirects/%s", siteID, redirectID) + return c.doJSON(ctx, http.MethodDelete, path, nil, nil) +} + +func (c *Client) CreateSiteAuthorizationKey(ctx context.Context, siteID string, request CreateAuthorizationKeyRequest) (*AuthorizationKey, error) { + var key AuthorizationKey + if err := c.doJSON(ctx, http.MethodPost, fmt.Sprintf("/site/%s/authorization-keys", siteID), request, &key); err != nil { + return nil, err + } + + return &key, nil +} + +func (c *Client) doJSON(ctx context.Context, method, path string, payload interface{}, out interface{}) error { + body, err := marshalPayload(payload) + if err != nil { + return err + } + + response, responseBody, err := c.doRequest(ctx, method, path, body, false) + if err != nil { + return err + } + + if response.StatusCode == http.StatusUnauthorized { + response, responseBody, err = c.doRequest(ctx, method, path, body, true) + if err != nil { + return err + } + } + + if response.StatusCode >= 300 { + return fmt.Errorf("inventory API %s %s failed with status %d: %s", method, path, response.StatusCode, strings.TrimSpace(string(responseBody))) + } + + if out == nil || len(responseBody) == 0 { + return nil + } + + if err := json.Unmarshal(responseBody, out); err != nil { + return err + } + + return nil +} + +func (c *Client) doRequest(ctx context.Context, method, path string, body []byte, forceRefresh bool) (*http.Response, []byte, error) { + token, err := c.accessToken(ctx, forceRefresh) + if err != nil { + return nil, nil, err + } + + url := c.BaseURL + ensureLeadingSlash(path) + request, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + + request.Header.Set("Authorization", "Bearer "+token) + if c.OrgID != "" { + request.Header.Set("X-Organization-Id", c.OrgID) + } + request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", "intercube-cli") + if len(body) > 0 { + request.Header.Set("Content-Type", "application/json") + } + + response, err := c.HTTPClient.Do(request) + if err != nil { + return nil, nil, err + } + defer response.Body.Close() + + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return nil, nil, err + } + + return response, responseBody, nil +} + +func (c *Client) accessToken(ctx context.Context, forceRefresh bool) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + + session, err := c.Store.Load(ctx) + if err != nil { + if errors.Is(err, authutil.ErrNoSession) { + return "", errors.New("you are not authenticated, run `intercube auth login`") + } + + return "", err + } + + if forceRefresh || session.ExpiresSoon(60*time.Second) { + refreshed, refreshErr := c.Clerk.RefreshSession(ctx, session) + if refreshErr != nil { + return "", fmt.Errorf("unable to refresh auth session: %w", refreshErr) + } + + if saveErr := c.Store.Save(ctx, refreshed); saveErr != nil { + return "", saveErr + } + + session = refreshed + } + + if strings.TrimSpace(session.AccessToken) == "" { + return "", errors.New("missing access token, run `intercube auth login`") + } + + return session.AccessToken, nil +} + +func marshalPayload(payload interface{}) ([]byte, error) { + if payload == nil { + return nil, nil + } + + return json.Marshal(payload) +} + +func ensureLeadingSlash(path string) string { + trimmed := strings.TrimSpace(path) + if strings.HasPrefix(trimmed, "/") { + return trimmed + } + + return "/" + trimmed +} From 67ca8b3005de351104700e22a415ecbc94e9aed2 Mon Sep 17 00:00:00 2001 From: Jeroen Ketelaar Date: Tue, 3 Mar 2026 12:49:53 +0100 Subject: [PATCH 2/4] Make map non-interactive by default for pipeline usage --- cmd/map.go | 147 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 128 insertions(+), 19 deletions(-) diff --git a/cmd/map.go b/cmd/map.go index 756f0c2..b47ccbc 100644 --- a/cmd/map.go +++ b/cmd/map.go @@ -17,61 +17,170 @@ package cmd import ( "fmt" - "github.com/spf13/cobra" "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/term" ) var override = false +var interactiveMapSetup = false + var mapCmd = &cobra.Command{ Use: "map", Short: "Maps files based on config yaml file", - Run: func(cmd *cobra.Command, args []string) { - if err := ensureMappingsConfiguration(); err != nil { - fmt.Printf("Unable to continue map: %v\n", err) - return + RunE: func(cmd *cobra.Command, args []string) error { + if len(config.Mappings) == 0 { + if !interactiveMapSetup { + return fmt.Errorf("no mappings configured. set `mappings` in config or run `intercube onboarding` interactively") + } + + if !stdinIsTerminal() { + return fmt.Errorf("interactive setup requires a terminal") + } + + if err := ensureMappingsConfiguration(); err != nil { + return fmt.Errorf("unable to continue map: %w", err) + } } for _, mapping := range config.Mappings { - symlink(mapping.From, mapping.To) + if err := symlink(mapping.From, mapping.To); err != nil { + return err + } } + + return nil }, } func init() { rootCmd.AddCommand(mapCmd) mapCmd.PersistentFlags().BoolVarP(&override, "override", "o", false, "Overrides existing destination file") + mapCmd.PersistentFlags().BoolVar(&interactiveMapSetup, "interactive", false, "Prompt to create mappings when missing") } -func symlink(from string, to string) { - if !fileExists(from) { - fmt.Print(fmt.Errorf("Origin file %v does not exists\n", to)) +func symlink(from string, to string) error { + resolvedFrom, err := expandHomePath(from) + if err != nil { + return err + } + + resolvedTo, err := expandHomePath(to) + if err != nil { + return err + } + + if strings.TrimSpace(resolvedFrom) == "" || strings.TrimSpace(resolvedTo) == "" { + return fmt.Errorf("mapping source and destination are required") + } + + if !fileExists(resolvedFrom) { + return fmt.Errorf("origin file %v does not exist", resolvedFrom) } else { - if !fileExists(to) || override { + alreadyMapped, mappedErr := destinationMatchesSource(resolvedFrom, resolvedTo) + if mappedErr == nil && alreadyMapped { + fmt.Printf("Already mapped %v to %v\n", resolvedFrom, resolvedTo) + return nil + } + + if !fileExists(resolvedTo) || override { if override { - if destination, err := os.Lstat(to); err == nil { + if destination, err := os.Lstat(resolvedTo); err == nil { if destination.IsDir() { - os.RemoveAll(to) - fmt.Printf("Removed directory %v\n", to) + if err := os.RemoveAll(resolvedTo); err != nil { + return err + } + fmt.Printf("Removed directory %v\n", resolvedTo) } else { - os.Remove(to) - fmt.Printf("Removed file %v\n", to) + if err := os.Remove(resolvedTo); err != nil { + return err + } + fmt.Printf("Removed file %v\n", resolvedTo) } } } - err := os.Symlink(from, to) + err := os.Symlink(resolvedFrom, resolvedTo) if err != nil { - panic(fmt.Errorf("Unable to map: %s \n", err)) + return fmt.Errorf("unable to map %v -> %v: %w", resolvedFrom, resolvedTo, err) } else { - fmt.Printf("Mapped %v to %v\n", from, to) + fmt.Printf("Mapped %v to %v\n", resolvedFrom, resolvedTo) + return nil } } else { - fmt.Print(fmt.Errorf("Destination file %v already exists\n", to)) + return fmt.Errorf("destination file %v already exists", resolvedTo) } } + + return nil } func fileExists(filename string) bool { _, err := os.Stat(filename) return !os.IsNotExist(err) } + +func stdinIsTerminal() bool { + if os.Stdin == nil { + return false + } + + return term.IsTerminal(int(os.Stdin.Fd())) +} + +func expandHomePath(path string) (string, error) { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return "", nil + } + + if trimmed == "~" || strings.HasPrefix(trimmed, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + if trimmed == "~" { + return home, nil + } + + return filepath.Join(home, strings.TrimPrefix(trimmed, "~/")), nil + } + + return trimmed, nil +} + +func destinationMatchesSource(source string, destination string) (bool, error) { + info, err := os.Lstat(destination) + if err != nil { + return false, err + } + + if info.Mode()&os.ModeSymlink == 0 { + return false, nil + } + + linkTarget, err := os.Readlink(destination) + if err != nil { + return false, err + } + + if !filepath.IsAbs(linkTarget) { + linkTarget = filepath.Join(filepath.Dir(destination), linkTarget) + } + + absSource, err := filepath.Abs(source) + if err != nil { + return false, err + } + + absTarget, err := filepath.Abs(linkTarget) + if err != nil { + return false, err + } + + return filepath.Clean(absSource) == filepath.Clean(absTarget), nil +} From ea2f18c61833d5a6540b5d47f1b2165e4d0669d4 Mon Sep 17 00:00:00 2001 From: Jeroen Ketelaar Date: Tue, 3 Mar 2026 22:49:58 +0100 Subject: [PATCH 3/4] Use HTTPS default for Inventory API base URL --- util/appconfig/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/util/appconfig/config.go b/util/appconfig/config.go index 327bd02..18fcc0e 100644 --- a/util/appconfig/config.go +++ b/util/appconfig/config.go @@ -12,7 +12,7 @@ var ClerkClientID = "Oi68oAK1xuK1088Z" var ClerkAudience = "" var ClerkScopes = "openid profile email offline_access" var ClerkCallbackPort = "8976" -var InventoryAPIBaseURL = "http://inventory-nexus.dev-c8s.intercube.dev/" +var InventoryAPIBaseURL = "https://inventory-nexus.dev-c8s.intercube.dev/" var OrganizationID = "" const ( From 707b55db83ce6c16e2b985f9354d6b686fa1f03d Mon Sep 17 00:00:00 2001 From: Jeroen Ketelaar Date: Tue, 3 Mar 2026 23:48:40 +0100 Subject: [PATCH 4/4] Polish selectors and add auth/site management commands --- cmd/auth-login.go | 82 +++++++++ cmd/auth-logout.go | 42 +++++ cmd/auth-org-select.go | 275 ++++++++++++++++++++++++++++++ cmd/auth-org-show.go | 43 +++++ cmd/auth-org.go | 15 ++ cmd/auth-status.go | 63 +++++++ cmd/auth.go | 12 ++ cmd/login.go | 15 +- cmd/onboarding.go | 9 +- cmd/org-ssh-key.go | 321 +++++++++++++++++++++++++++++++++++ cmd/org.go | 12 ++ cmd/select-ui.go | 42 +++++ cmd/site-add-ssh-key.go | 345 +++++++++++++++++++++++++++++++++++++ cmd/site-env.go | 356 +++++++++++++++++++++++++++++++++++++++ cmd/site-redirect.go | 255 ++++++++++++++++++++++++++++ cmd/site-selection.go | 34 ++++ cmd/site.go | 12 ++ cmd/sync-resolve.go | 47 +++--- util/appconfig/config.go | 2 +- util/auth/clerk.go | 2 +- 20 files changed, 1943 insertions(+), 41 deletions(-) create mode 100644 cmd/auth-login.go create mode 100644 cmd/auth-logout.go create mode 100644 cmd/auth-org-select.go create mode 100644 cmd/auth-org-show.go create mode 100644 cmd/auth-org.go create mode 100644 cmd/auth-status.go create mode 100644 cmd/auth.go create mode 100644 cmd/org-ssh-key.go create mode 100644 cmd/org.go create mode 100644 cmd/select-ui.go create mode 100644 cmd/site-add-ssh-key.go create mode 100644 cmd/site-env.go create mode 100644 cmd/site-redirect.go create mode 100644 cmd/site-selection.go create mode 100644 cmd/site.go diff --git a/cmd/auth-login.go b/cmd/auth-login.go new file mode 100644 index 0000000..c6c9c48 --- /dev/null +++ b/cmd/auth-login.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/intercube/cli/util/appconfig" + authutil "github.com/intercube/cli/util/auth" + "github.com/spf13/cobra" +) + +var authLoginCmd = &cobra.Command{ + Use: "login", + Short: "Sign in with Clerk in your browser", + RunE: func(cmd *cobra.Command, args []string) error { + if err := appconfig.ValidateClerk(); err != nil { + return fmt.Errorf("%w (set via env/.env or build-time)", err) + } + + store, err := authutil.NewSessionStore("intercube-cli") + if err != nil { + return err + } + + var previousSession *authutil.Session + storedSession, loadErr := store.Load(cmd.Context()) + if loadErr == nil { + previousSession = storedSession + } else if !errors.Is(loadErr, authutil.ErrNoSession) { + return loadErr + } + + clerkClient := &authutil.ClerkClient{ + Issuer: appconfig.ClerkIssuer, + ClientID: appconfig.ClerkClientID, + Audience: appconfig.ClerkAudience, + Scopes: appconfig.ClerkScopes, + CallbackPort: appconfig.ParsedCallbackPort(), + } + + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Minute) + defer cancel() + + fmt.Println("Opening browser for Clerk sign-in...") + session, err := clerkClient.Login(ctx) + if err != nil { + return err + } + + if previousSession != nil { + session.OrganizationID = strings.TrimSpace(previousSession.OrganizationID) + for _, known := range previousSession.KnownOrgIDs { + session.KnownOrgIDs = addKnownOrganizationID(session.KnownOrgIDs, known) + } + } + + if appconfig.OrganizationID != "" { + session.KnownOrgIDs = addKnownOrganizationID(session.KnownOrgIDs, appconfig.OrganizationID) + } + + session.KnownOrgIDs = addKnownOrganizationID(session.KnownOrgIDs, session.OrganizationID) + + if err := store.Save(ctx, session); err != nil { + return err + } + + fmt.Printf("Authenticated. Session expires at %s\n", session.ExpiresAt.Format(time.RFC3339)) + + if selectErr := runAuthOrgSelect(cmd, nil, true); selectErr != nil { + return selectErr + } + + return nil + }, +} + +func init() { + authCmd.AddCommand(authLoginCmd) +} diff --git a/cmd/auth-logout.go b/cmd/auth-logout.go new file mode 100644 index 0000000..4bc8a75 --- /dev/null +++ b/cmd/auth-logout.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "fmt" + + "github.com/intercube/cli/util/appconfig" + authutil "github.com/intercube/cli/util/auth" + "github.com/spf13/cobra" +) + +var authLogoutCmd = &cobra.Command{ + Use: "logout", + Short: "Clear local API auth session", + RunE: func(cmd *cobra.Command, args []string) error { + store, err := authutil.NewSessionStore("intercube-cli") + if err != nil { + return err + } + + session, err := store.Load(cmd.Context()) + if err == nil && session != nil { + if appconfig.ValidateClerk() == nil { + clerkClient := &authutil.ClerkClient{ + Issuer: appconfig.ClerkIssuer, + ClientID: appconfig.ClerkClientID, + } + _ = clerkClient.RevokeRefreshToken(cmd.Context(), session.RefreshToken) + } + } + + if err := store.Clear(cmd.Context()); err != nil { + return err + } + + fmt.Println("Signed out.") + return nil + }, +} + +func init() { + authCmd.AddCommand(authLogoutCmd) +} diff --git a/cmd/auth-org-select.go b/cmd/auth-org-select.go new file mode 100644 index 0000000..29a02e5 --- /dev/null +++ b/cmd/auth-org-select.go @@ -0,0 +1,275 @@ +package cmd + +import ( + "errors" + "fmt" + "sort" + "strings" + + "github.com/intercube/cli/util/appconfig" + authutil "github.com/intercube/cli/util/auth" + "github.com/intercube/cli/util/inventory" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" +) + +var orgIDInput string + +var authOrgSelectCmd = &cobra.Command{ + Use: "select [org_id]", + Short: "Select organization id for API calls", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAuthOrgSelect(cmd, args, false) + }, +} + +func runAuthOrgSelect(cmd *cobra.Command, args []string, forcePrompt bool) error { + store, err := authutil.NewSessionStore("intercube-cli") + if err != nil { + return err + } + + session, err := store.Load(cmd.Context()) + if err != nil { + if errors.Is(err, authutil.ErrNoSession) { + return errors.New("you are not authenticated, run `intercube auth login` first") + } + + return err + } + + orgID := strings.TrimSpace(orgIDInput) + if len(args) > 0 { + orgID = strings.TrimSpace(args[0]) + } + + organizations, orgErr := listOrganizationsForSelection(cmd, store) + if orgErr == nil { + for _, organization := range organizations { + session.KnownOrgIDs = addKnownOrganizationID(session.KnownOrgIDs, organization.ID) + } + } else if forcePrompt { + fmt.Printf("Could not load organizations automatically: %v\n", orgErr) + } + + if forcePrompt || orgID == "" { + orgID, err = selectOrPromptOrgID(session.OrganizationID, appconfig.OrganizationID, session.KnownOrgIDs, organizations, forcePrompt) + if err != nil { + return err + } + } else if len(organizations) > 0 && !organizationIDExists(organizations, orgID) { + return fmt.Errorf("organization %q is not available for your account", orgID) + } + + orgID = strings.TrimSpace(orgID) + if orgID == "" { + return fmt.Errorf("organization id is required") + } + + session.OrganizationID = orgID + session.KnownOrgIDs = addKnownOrganizationID(session.KnownOrgIDs, orgID) + if appconfig.OrganizationID != "" { + session.KnownOrgIDs = addKnownOrganizationID(session.KnownOrgIDs, appconfig.OrganizationID) + } + + if err := store.Save(cmd.Context(), session); err != nil { + return err + } + + fmt.Printf("Selected organization: %s\n", orgID) + return nil +} + +func init() { + authOrgCmd.AddCommand(authOrgSelectCmd) + authOrgSelectCmd.Flags().StringVar(&orgIDInput, "org-id", "", "organization id to set") +} + +func selectOrPromptOrgID(currentValue, configuredValue string, knownOrgIDs []string, organizations []inventory.CurrentUserOrganization, forcePrompt bool) (string, error) { + if len(organizations) > 0 { + return selectOrganizationFromMemberships(currentValue, organizations) + } + + candidates := organizationCandidates(currentValue, configuredValue, knownOrgIDs) + if len(candidates) == 0 { + return promptOrgID(currentValue) + } + + if !forcePrompt && len(candidates) == 1 && strings.TrimSpace(currentValue) == "" { + return strings.TrimSpace(candidates[0]), nil + } + + items := append([]string{}, candidates...) + items = append(items, "Enter organization ID manually") + + prompt := promptui.Select{ + Label: "Select organization", + Items: items, + Size: selectSize(len(items)), + Stdout: &bellSkipper{}, + Templates: simpleSelectTemplates("organization"), + } + + index, _, err := prompt.Run() + if err != nil { + return "", err + } + + if index == len(items)-1 { + return promptOrgID(currentValue) + } + + return strings.TrimSpace(items[index]), nil +} + +func organizationCandidates(currentValue, configuredValue string, knownOrgIDs []string) []string { + result := make([]string, 0, len(knownOrgIDs)+2) + seen := make(map[string]struct{}) + + appendIfUnique := func(value string) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return + } + + if _, ok := seen[trimmed]; ok { + return + } + + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + + appendIfUnique(currentValue) + for _, candidate := range knownOrgIDs { + appendIfUnique(candidate) + } + appendIfUnique(configuredValue) + + return result +} + +func addKnownOrganizationID(values []string, orgID string) []string { + trimmed := strings.TrimSpace(orgID) + if trimmed == "" { + return values + } + + for _, value := range values { + if strings.TrimSpace(value) == trimmed { + return values + } + } + + return append(values, trimmed) +} + +func listOrganizationsForSelection(cmd *cobra.Command, store *authutil.SessionStore) ([]inventory.CurrentUserOrganization, error) { + if err := appconfig.ValidateInventory(); err != nil { + return nil, err + } + + if err := appconfig.ValidateClerk(); err != nil { + return nil, err + } + + clerkClient := &authutil.ClerkClient{ + Issuer: appconfig.ClerkIssuer, + ClientID: appconfig.ClerkClientID, + Audience: appconfig.ClerkAudience, + Scopes: appconfig.ClerkScopes, + CallbackPort: appconfig.ParsedCallbackPort(), + } + + inventoryClient := inventory.NewClient(appconfig.InventoryAPIBaseURL, "", store, clerkClient) + return inventoryClient.ListCurrentUserOrganizations(cmd.Context()) +} + +func selectOrganizationFromMemberships(currentValue string, organizations []inventory.CurrentUserOrganization) (string, error) { + if len(organizations) == 1 { + return strings.TrimSpace(organizations[0].ID), nil + } + + sort.SliceStable(organizations, func(i, j int) bool { + return organizationLabel(organizations[i]) < organizationLabel(organizations[j]) + }) + + items := make([]string, 0, len(organizations)) + defaultIndex := 0 + for i, organization := range organizations { + items = append(items, organizationLabel(organization)) + if strings.EqualFold(strings.TrimSpace(currentValue), strings.TrimSpace(organization.ID)) { + defaultIndex = i + } + } + + prompt := promptui.Select{ + Label: "Select organization", + Items: items, + Size: selectSize(len(items)), + CursorPos: defaultIndex, + Stdout: &bellSkipper{}, + Templates: simpleSelectTemplates("organization"), + } + + index, _, err := prompt.Run() + if err != nil { + return "", err + } + + return strings.TrimSpace(organizations[index].ID), nil +} + +func organizationIDExists(organizations []inventory.CurrentUserOrganization, organizationID string) bool { + needle := strings.TrimSpace(organizationID) + for _, organization := range organizations { + if strings.EqualFold(strings.TrimSpace(organization.ID), needle) { + return true + } + } + + return false +} + +func organizationLabel(organization inventory.CurrentUserOrganization) string { + parts := make([]string, 0, 3) + if strings.TrimSpace(organization.Name) != "" { + parts = append(parts, organization.Name) + } + if strings.TrimSpace(organization.Slug) != "" { + parts = append(parts, organization.Slug) + } + if strings.TrimSpace(organization.Role) != "" { + parts = append(parts, organization.Role) + } + + prefix := strings.Join(parts, " | ") + if prefix == "" { + prefix = "Organization" + } + + return fmt.Sprintf("%s (%s)", prefix, organization.ID) +} + +func promptOrgID(defaultValue string) (string, error) { + prompt := promptui.Prompt{ + Label: "Organization ID", + Default: strings.TrimSpace(defaultValue), + Validate: func(input string) error { + if strings.TrimSpace(input) == "" { + return fmt.Errorf("organization id is required") + } + + return nil + }, + Stdout: &bellSkipper{}, + } + + value, err := prompt.Run() + if err != nil { + return "", err + } + + return strings.TrimSpace(value), nil +} diff --git a/cmd/auth-org-show.go b/cmd/auth-org-show.go new file mode 100644 index 0000000..ff10be1 --- /dev/null +++ b/cmd/auth-org-show.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "errors" + "fmt" + "strings" + + authutil "github.com/intercube/cli/util/auth" + "github.com/spf13/cobra" +) + +var authOrgShowCmd = &cobra.Command{ + Use: "show", + Short: "Show selected organization id", + RunE: func(cmd *cobra.Command, args []string) error { + store, err := authutil.NewSessionStore("intercube-cli") + if err != nil { + return err + } + + session, err := store.Load(cmd.Context()) + if err != nil { + if errors.Is(err, authutil.ErrNoSession) { + return errors.New("you are not authenticated, run `intercube auth login` first") + } + + return err + } + + orgID := strings.TrimSpace(session.OrganizationID) + if orgID == "" { + fmt.Println("No organization selected. Run `intercube auth org select`.") + return nil + } + + fmt.Println(orgID) + return nil + }, +} + +func init() { + authOrgCmd.AddCommand(authOrgShowCmd) +} diff --git a/cmd/auth-org.go b/cmd/auth-org.go new file mode 100644 index 0000000..09b4c92 --- /dev/null +++ b/cmd/auth-org.go @@ -0,0 +1,15 @@ +package cmd + +import "github.com/spf13/cobra" + +var authOrgCmd = &cobra.Command{ + Use: "org", + Short: "Manage selected organization context", + RunE: func(cmd *cobra.Command, args []string) error { + return runAuthOrgSelect(cmd, nil, true) + }, +} + +func init() { + authCmd.AddCommand(authOrgCmd) +} diff --git a/cmd/auth-status.go b/cmd/auth-status.go new file mode 100644 index 0000000..8f2a5c9 --- /dev/null +++ b/cmd/auth-status.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "errors" + "fmt" + "strings" + "time" + + authutil "github.com/intercube/cli/util/auth" + "github.com/spf13/cobra" +) + +var authStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show current API auth session status", + RunE: func(cmd *cobra.Command, args []string) error { + store, err := authutil.NewSessionStore("intercube-cli") + if err != nil { + return err + } + + session, err := store.Load(cmd.Context()) + if err != nil { + if errors.Is(err, authutil.ErrNoSession) { + fmt.Println("API auth session: not signed in") + fmt.Println("Run `intercube auth login` to sign in.") + return nil + } + + return err + } + + remaining := time.Until(session.ExpiresAt) + if remaining <= 0 { + fmt.Println("API auth session: expired") + } else { + fmt.Println("API auth session: active") + fmt.Printf("Expires in: %s\n", remaining.Round(time.Second)) + } + + fmt.Printf("Expires at: %s\n", session.ExpiresAt.Format(time.RFC3339)) + if session.RefreshToken != "" { + fmt.Println("Refresh token: present") + } else { + fmt.Println("Refresh token: missing") + } + + selectedOrg := strings.TrimSpace(session.OrganizationID) + if selectedOrg == "" { + fmt.Println("Selected org: none") + } else { + fmt.Printf("Selected org: %s\n", selectedOrg) + } + + fmt.Printf("Access token: %s\n", session.AccessToken) + + return nil + }, +} + +func init() { + authCmd.AddCommand(authStatusCmd) +} diff --git a/cmd/auth.go b/cmd/auth.go new file mode 100644 index 0000000..ac25086 --- /dev/null +++ b/cmd/auth.go @@ -0,0 +1,12 @@ +package cmd + +import "github.com/spf13/cobra" + +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Authenticate for API calls", +} + +func init() { + rootCmd.AddCommand(authCmd) +} diff --git a/cmd/login.go b/cmd/login.go index d9c2316..b4f991d 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -169,9 +169,9 @@ func runBoundarySSH(args []string, fromDeprecatedLogin bool) { templates := &promptui.SelectTemplates{ Label: "{{ . }}?", - Active: "\U0001F9CA {{ .Name | red }}", - Inactive: " {{ .Name | cyan }}", - Selected: "\U0001F9CA {{ .Name | red | cyan }}", + Active: "> {{ .Name | cyan }}", + Inactive: " {{ .Name }}", + Selected: "Selected host: {{ .Name | cyan }}", Details: detailsTemplate, } @@ -183,16 +183,11 @@ func runBoundarySSH(args []string, fromDeprecatedLogin bool) { return strings.Contains(name, input) } - promptSize := 8 - if len(filteredHosts) < promptSize { - promptSize = len(filteredHosts) - } - prompt := promptui.Select{ Label: "Which host would you like to connect to?", Items: filteredHosts, Templates: templates, - Size: promptSize, + Size: selectSize(len(filteredHosts)), Searcher: searcher, Stdout: &bellSkipper{}, } @@ -240,7 +235,7 @@ func (bs *bellSkipper) Write(b []byte) (int, error) { // Close implements an io.WriterCloser over os.Stderr. func (bs *bellSkipper) Close() error { - return os.Stderr.Close() + return nil } func init() { diff --git a/cmd/onboarding.go b/cmd/onboarding.go index b0c47f4..77908cd 100644 --- a/cmd/onboarding.go +++ b/cmd/onboarding.go @@ -443,10 +443,11 @@ func writeOnboardingConfig(path string) error { func chooseYesNo(label string) (bool, error) { items := []string{"Yes", "No"} prompt := promptui.Select{ - Label: label, - Items: items, - Size: len(items), - Stdout: &bellSkipper{}, + Label: label, + Items: items, + Size: selectSize(len(items)), + Stdout: &bellSkipper{}, + Templates: simpleSelectTemplates("option"), } index, _, err := prompt.Run() diff --git a/cmd/org-ssh-key.go b/cmd/org-ssh-key.go new file mode 100644 index 0000000..c65b775 --- /dev/null +++ b/cmd/org-ssh-key.go @@ -0,0 +1,321 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/intercube/cli/util/inventory" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" +) + +var ( + orgSSHKeyOrgID string + orgSSHKeyCreatePath string + orgSSHKeyCreateComment string + orgSSHKeyCreateExpiration string + orgSSHKeyAssignKeyID string + orgSSHKeyAssignSiteID string + orgSSHKeyUnassignKeyID string + orgSSHKeyUnassignSiteID string + orgSSHKeyUnassignDeleteKey bool +) + +var orgSSHKeyCmd = &cobra.Command{ + Use: "ssh-key", + Short: "Manage organization SSH key vault", +} + +var orgSSHKeyListCmd = &cobra.Command{ + Use: "list", + Short: "List organization SSH keys", + RunE: func(cmd *cobra.Command, args []string) error { + inventoryClient, _, err := newInventoryClient(cmd, orgSSHKeyOrgID) + if err != nil { + return err + } + + keys, err := inventoryClient.ListOrganizationSSHKeys(cmd.Context()) + if err != nil { + if shouldPromptForOrganization(err) { + return fmt.Errorf("organization context is required. Run `intercube auth org` (or pass --org-id)") + } + + return err + } + + if len(keys) == 0 { + fmt.Println("No organization SSH keys found.") + return nil + } + + sort.SliceStable(keys, func(i, j int) bool { + return strings.TrimSpace(keys[i].ID) < strings.TrimSpace(keys[j].ID) + }) + + for _, key := range keys { + comment := strings.TrimSpace(key.Comment) + if comment == "" { + comment = "no comment" + } + + fmt.Printf("%s | %s | assigned sites: %d\n", key.ID, comment, len(key.SiteIDs)) + } + + return nil + }, +} + +var orgSSHKeyCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create organization SSH key from local public key", + RunE: func(cmd *cobra.Command, args []string) error { + inventoryClient, _, err := newInventoryClient(cmd, orgSSHKeyOrgID) + if err != nil { + return err + } + + selectedKey, err := resolveLocalPublicKeySelection(orgSSHKeyCreatePath) + if err != nil { + return err + } + + comment := strings.TrimSpace(orgSSHKeyCreateComment) + if comment == "" { + comment = strings.TrimSpace(selectedKey.Comment) + if comment == "" { + comment = selectedKey.Name + } + } + + keys, err := inventoryClient.ListOrganizationSSHKeys(cmd.Context()) + if err != nil { + if shouldPromptForOrganization(err) { + return fmt.Errorf("organization context is required. Run `intercube auth org` (or pass --org-id)") + } + + return err + } + + for _, key := range keys { + if strings.TrimSpace(key.Content) == strings.TrimSpace(selectedKey.Content) { + fmt.Printf("SSH key already exists in organization vault: %s\n", key.ID) + return nil + } + } + + created, err := inventoryClient.CreateOrganizationSSHKey(cmd.Context(), inventory.OrganizationSSHKeyRequest{ + Content: selectedKey.Content, + Comment: comment, + ExpirationDate: strings.TrimSpace(orgSSHKeyCreateExpiration), + }) + if err != nil { + return err + } + + fmt.Printf("Created organization SSH key %s\n", created.ID) + return nil + }, +} + +var orgSSHKeyAssignCmd = &cobra.Command{ + Use: "assign", + Short: "Assign organization SSH key to a site", + RunE: func(cmd *cobra.Command, args []string) error { + inventoryClient, _, err := newInventoryClient(cmd, orgSSHKeyOrgID) + if err != nil { + return err + } + + keys, err := inventoryClient.ListOrganizationSSHKeys(cmd.Context()) + if err != nil { + if shouldPromptForOrganization(err) { + return fmt.Errorf("organization context is required. Run `intercube auth org` (or pass --org-id)") + } + + return err + } + + if len(keys) == 0 { + return fmt.Errorf("no organization SSH keys found") + } + + selectedKey, err := resolveOrganizationSSHKeySelection(keys, orgSSHKeyAssignKeyID) + if err != nil { + return err + } + + site, err := resolveSiteSelection(cmd, inventoryClient, orgSSHKeyAssignSiteID) + if err != nil { + return err + } + + if err := inventoryClient.AssignOrganizationSSHKeyToSite(cmd.Context(), selectedKey.ID, site.ID); err != nil { + return err + } + + fmt.Printf("Assigned key %s to site %s (%s)\n", selectedKey.ID, siteDisplayName(*site), site.ID) + return nil + }, +} + +var orgSSHKeyUnassignCmd = &cobra.Command{ + Use: "unassign", + Short: "Unassign organization SSH key from a site", + RunE: func(cmd *cobra.Command, args []string) error { + inventoryClient, _, err := newInventoryClient(cmd, orgSSHKeyOrgID) + if err != nil { + return err + } + + keys, err := inventoryClient.ListOrganizationSSHKeys(cmd.Context()) + if err != nil { + if shouldPromptForOrganization(err) { + return fmt.Errorf("organization context is required. Run `intercube auth org` (or pass --org-id)") + } + + return err + } + + if len(keys) == 0 { + return fmt.Errorf("no organization SSH keys found") + } + + selectedKey, err := resolveOrganizationSSHKeySelection(keys, orgSSHKeyUnassignKeyID) + if err != nil { + return err + } + + site, err := resolveSiteSelection(cmd, inventoryClient, orgSSHKeyUnassignSiteID) + if err != nil { + return err + } + + if err := inventoryClient.UnassignOrganizationSSHKeyFromSite(cmd.Context(), selectedKey.ID, site.ID, orgSSHKeyUnassignDeleteKey); err != nil { + return err + } + + fmt.Printf("Unassigned key %s from site %s (%s)\n", selectedKey.ID, siteDisplayName(*site), site.ID) + return nil + }, +} + +func init() { + orgCmd.AddCommand(orgSSHKeyCmd) + orgSSHKeyCmd.AddCommand(orgSSHKeyListCmd) + orgSSHKeyCmd.AddCommand(orgSSHKeyCreateCmd) + orgSSHKeyCmd.AddCommand(orgSSHKeyAssignCmd) + orgSSHKeyCmd.AddCommand(orgSSHKeyUnassignCmd) + + orgSSHKeyCmd.PersistentFlags().StringVar(&orgSSHKeyOrgID, "org-id", "", "organization id") + + orgSSHKeyCreateCmd.Flags().StringVar(&orgSSHKeyCreatePath, "path", "", "path to SSH public key file") + orgSSHKeyCreateCmd.Flags().StringVar(&orgSSHKeyCreateComment, "comment", "", "comment for the key") + orgSSHKeyCreateCmd.Flags().StringVar(&orgSSHKeyCreateExpiration, "expiration-date", "", "expiration date (YYYY-MM-DD)") + + orgSSHKeyAssignCmd.Flags().StringVar(&orgSSHKeyAssignKeyID, "key-id", "", "organization SSH key id") + orgSSHKeyAssignCmd.Flags().StringVar(&orgSSHKeyAssignSiteID, "site-id", "", "site id") + + orgSSHKeyUnassignCmd.Flags().StringVar(&orgSSHKeyUnassignKeyID, "key-id", "", "organization SSH key id") + orgSSHKeyUnassignCmd.Flags().StringVar(&orgSSHKeyUnassignSiteID, "site-id", "", "site id") + orgSSHKeyUnassignCmd.Flags().BoolVar(&orgSSHKeyUnassignDeleteKey, "delete-if-unassigned", false, "delete key if it has no assignments left") +} + +func resolveLocalPublicKeySelection(path string) (*localPublicKey, error) { + keyPath := strings.TrimSpace(path) + if keyPath == "" { + keys, err := discoverLocalPublicKeys() + if err != nil { + return nil, err + } + + return selectPublicKey(keys) + } + + absolutePath, err := filepath.Abs(keyPath) + if err != nil { + return nil, err + } + + content, err := os.ReadFile(absolutePath) + if err != nil { + return nil, err + } + + line := strings.TrimSpace(string(content)) + if !looksLikePublicKey(line) { + return nil, fmt.Errorf("file %s does not look like an SSH public key", absolutePath) + } + + comment := extractKeyComment(line) + if comment == "" { + comment = "no comment" + } + + return &localPublicKey{ + Path: absolutePath, + Name: filepath.Base(absolutePath), + Content: line, + Comment: comment, + }, nil +} + +func resolveOrganizationSSHKeySelection(keys []inventory.OrganizationSSHKey, keyID string) (*inventory.OrganizationSSHKey, error) { + id := strings.TrimSpace(keyID) + if id != "" { + for i := range keys { + if strings.EqualFold(strings.TrimSpace(keys[i].ID), id) { + return &keys[i], nil + } + } + + return nil, fmt.Errorf("organization SSH key %q not found", keyID) + } + + return selectOrganizationSSHKey(keys) +} + +func selectOrganizationSSHKey(keys []inventory.OrganizationSSHKey) (*inventory.OrganizationSSHKey, error) { + if len(keys) == 1 { + return &keys[0], nil + } + + sort.SliceStable(keys, func(i, j int) bool { + return strings.TrimSpace(keys[i].ID) < strings.TrimSpace(keys[j].ID) + }) + + type orgKeyChoice struct { + Key inventory.OrganizationSSHKey + Title string + Meta string + } + + items := make([]orgKeyChoice, 0, len(keys)) + for _, key := range keys { + title := strings.TrimSpace(key.Comment) + if title == "" { + title = "SSH key" + } + meta := strings.TrimSpace(key.ID) + items = append(items, orgKeyChoice{Key: key, Title: title, Meta: meta}) + } + + prompt := promptui.Select{ + Label: "Select organization SSH key", + Items: items, + Size: selectSize(len(items)), + Stdout: &bellSkipper{}, + Templates: titleMetaSelectTemplates("key"), + } + + index, _, err := prompt.Run() + if err != nil { + return nil, err + } + + selected := items[index].Key + return &selected, nil +} diff --git a/cmd/org.go b/cmd/org.go new file mode 100644 index 0000000..e594fd4 --- /dev/null +++ b/cmd/org.go @@ -0,0 +1,12 @@ +package cmd + +import "github.com/spf13/cobra" + +var orgCmd = &cobra.Command{ + Use: "org", + Short: "Manage organization resources", +} + +func init() { + rootCmd.AddCommand(orgCmd) +} diff --git a/cmd/select-ui.go b/cmd/select-ui.go new file mode 100644 index 0000000..4f340a6 --- /dev/null +++ b/cmd/select-ui.go @@ -0,0 +1,42 @@ +package cmd + +import "github.com/manifoldco/promptui" + +func simpleSelectTemplates(noun string) *promptui.SelectTemplates { + selectedLabel := "Selected" + if noun != "" { + selectedLabel = "Selected " + noun + } + + return &promptui.SelectTemplates{ + Label: "{{ . }}", + Active: "> {{ . | cyan }}", + Inactive: " {{ . }}", + Selected: selectedLabel + ": {{ . | cyan }}", + } +} + +func titleMetaSelectTemplates(noun string) *promptui.SelectTemplates { + selectedLabel := "Selected" + if noun != "" { + selectedLabel = "Selected " + noun + } + + return &promptui.SelectTemplates{ + Label: "{{ . }}", + Active: "> {{ .Title | cyan }}{{ if .Meta }} {{ .Meta | faint }}{{ end }}", + Inactive: " {{ .Title }}{{ if .Meta }} {{ .Meta | faint }}{{ end }}", + Selected: selectedLabel + ": {{ .Title | cyan }}{{ if .Meta }} {{ .Meta }}{{ end }}", + } +} + +func selectSize(total int) int { + if total < 2 { + return total + } + if total > 12 { + return 12 + } + + return total +} diff --git a/cmd/site-add-ssh-key.go b/cmd/site-add-ssh-key.go new file mode 100644 index 0000000..53de961 --- /dev/null +++ b/cmd/site-add-ssh-key.go @@ -0,0 +1,345 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/intercube/cli/util/appconfig" + authutil "github.com/intercube/cli/util/auth" + "github.com/intercube/cli/util/inventory" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" +) + +type localPublicKey struct { + Path string + Name string + Content string + Comment string +} + +var siteOrgID string + +var siteAddSSHKeyCmd = &cobra.Command{ + Use: "add-ssh-key", + Short: "Add one of your local SSH public keys to a site", + RunE: func(cmd *cobra.Command, args []string) error { + if err := appconfig.ValidateClerk(); err != nil { + return fmt.Errorf("%w (set via env/.env or build-time)", err) + } + + if err := appconfig.ValidateInventory(); err != nil { + return fmt.Errorf("%w (set via env/.env or build-time)", err) + } + + store, err := authutil.NewSessionStore("intercube-cli") + if err != nil { + return err + } + + clerkClient := &authutil.ClerkClient{ + Issuer: appconfig.ClerkIssuer, + ClientID: appconfig.ClerkClientID, + Audience: appconfig.ClerkAudience, + Scopes: appconfig.ClerkScopes, + CallbackPort: appconfig.ParsedCallbackPort(), + } + + organizationID := strings.TrimSpace(siteOrgID) + if organizationID == "" { + session, sessionErr := store.Load(cmd.Context()) + if sessionErr == nil { + organizationID = strings.TrimSpace(session.OrganizationID) + } + } + + if organizationID == "" { + organizationID = strings.TrimSpace(appconfig.OrganizationID) + } + + inventoryClient := inventory.NewClient(appconfig.InventoryAPIBaseURL, organizationID, store, clerkClient) + + sites, err := inventoryClient.ListSites(cmd.Context()) + if err != nil { + if shouldPromptForOrganization(err) { + return errors.New("organization context is required. Run `intercube auth org` (or pass --org-id)") + } + + return err + } + + if len(sites) == 0 { + return fmt.Errorf("no sites available for your account") + } + + selectedSite, err := selectSite(sites) + if err != nil { + return err + } + + keys, err := discoverLocalPublicKeys() + if err != nil { + return err + } + + selectedKey, err := selectPublicKey(keys) + if err != nil { + return err + } + + existingKeys, err := inventoryClient.ListOrganizationSSHKeys(cmd.Context()) + if err != nil { + return err + } + + var matchedKey *inventory.OrganizationSSHKey + for _, existing := range existingKeys { + if strings.TrimSpace(existing.Content) == strings.TrimSpace(selectedKey.Content) { + existingCopy := existing + matchedKey = &existingCopy + break + } + } + + comment := strings.TrimSpace(selectedKey.Comment) + if comment == "" { + comment = selectedKey.Name + } + + if matchedKey == nil { + createdKey, createErr := inventoryClient.CreateOrganizationSSHKey(cmd.Context(), inventory.OrganizationSSHKeyRequest{ + Content: selectedKey.Content, + Comment: comment, + }) + if createErr != nil { + return createErr + } + + matchedKey = createdKey + fmt.Printf("Created organization SSH key %s from %s\n", strings.TrimSpace(createdKey.ID), selectedKey.Name) + } else { + fmt.Printf("Using existing organization SSH key %s\n", strings.TrimSpace(matchedKey.ID)) + } + + if strings.TrimSpace(matchedKey.ID) == "" { + return fmt.Errorf("organization SSH key id is empty") + } + + if assignErr := inventoryClient.AssignOrganizationSSHKeyToSite(cmd.Context(), matchedKey.ID, selectedSite.ID); assignErr != nil { + return assignErr + } + + fmt.Printf("Added SSH key to site %s (%s)\n", siteDisplayName(*selectedSite), selectedSite.ID) + if strings.TrimSpace(matchedKey.ID) != "" { + fmt.Printf("Key ID: %s\n", matchedKey.ID) + } + + return nil + }, +} + +func init() { + siteCmd.AddCommand(siteAddSSHKeyCmd) + siteAddSSHKeyCmd.Flags().StringVar(&siteOrgID, "org-id", "", "organization id (sets X-Organization-Id for inventory requests)") +} + +func selectSite(sites []inventory.SiteServer) (*inventory.SiteServer, error) { + if len(sites) == 1 { + return &sites[0], nil + } + + sort.Slice(sites, func(i, j int) bool { + return siteDisplayName(sites[i]) < siteDisplayName(sites[j]) + }) + + type siteChoice struct { + Site inventory.SiteServer + Title string + Meta string + } + + items := make([]siteChoice, 0, len(sites)) + for _, site := range sites { + title, meta := siteSelectionLabel(site) + items = append(items, siteChoice{Site: site, Title: title, Meta: meta}) + } + + prompt := promptui.Select{ + Label: "Select a site", + Items: items, + Templates: titleMetaSelectTemplates("site"), + Size: selectSize(len(items)), + Stdout: &bellSkipper{}, + Searcher: func(input string, index int) bool { + site := items[index].Site + needle := strings.ReplaceAll(strings.ToLower(strings.TrimSpace(input)), " ", "") + haystack := strings.ToLower(site.ID + " " + site.Username + " " + site.MainDomain + " " + site.ServerName) + haystack = strings.ReplaceAll(haystack, " ", "") + return strings.Contains(haystack, needle) + }, + } + + index, _, err := prompt.Run() + if err != nil { + return nil, err + } + + selected := items[index].Site + return &selected, nil +} + +func siteSelectionLabel(site inventory.SiteServer) (string, string) { + username := strings.TrimSpace(site.Username) + domain := strings.TrimSpace(site.MainDomain) + server := strings.TrimSpace(site.ServerName) + + title := domain + if title == "" { + title = username + } + if title == "" { + title = server + } + if title == "" { + title = "(unnamed site)" + } + + metaParts := make([]string, 0, 3) + if username != "" && !strings.EqualFold(username, title) { + metaParts = append(metaParts, username) + } + if server != "" && !strings.EqualFold(server, title) { + metaParts = append(metaParts, server) + } + + return title, strings.Join(metaParts, " | ") +} + +func selectPublicKey(keys []localPublicKey) (*localPublicKey, error) { + if len(keys) == 0 { + return nil, fmt.Errorf("no SSH public keys found in ~/.ssh") + } + + if len(keys) == 1 { + return &keys[0], nil + } + + type keyChoice struct { + Key localPublicKey + Title string + Meta string + } + + items := make([]keyChoice, 0, len(keys)) + for _, key := range keys { + items = append(items, keyChoice{Key: key, Title: key.Name, Meta: key.Comment}) + } + + prompt := promptui.Select{ + Label: "Select an SSH public key", + Items: items, + Templates: titleMetaSelectTemplates("key"), + Size: selectSize(len(items)), + Stdout: &bellSkipper{}, + Searcher: func(input string, index int) bool { + item := items[index].Key + needle := strings.ReplaceAll(strings.ToLower(strings.TrimSpace(input)), " ", "") + haystack := strings.ToLower(item.Name + " " + item.Comment + " " + item.Path) + haystack = strings.ReplaceAll(haystack, " ", "") + return strings.Contains(haystack, needle) + }, + } + + index, _, err := prompt.Run() + if err != nil { + return nil, err + } + + selected := items[index].Key + return &selected, nil +} + +func discoverLocalPublicKeys() ([]localPublicKey, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + + paths, err := filepath.Glob(filepath.Join(home, ".ssh", "*.pub")) + if err != nil { + return nil, err + } + + sort.Strings(paths) + keys := make([]localPublicKey, 0, len(paths)) + + for _, path := range paths { + content, err := os.ReadFile(path) + if err != nil { + continue + } + + line := strings.TrimSpace(string(content)) + if !looksLikePublicKey(line) { + continue + } + + comment := extractKeyComment(line) + if comment == "" { + comment = "no comment" + } + + keys = append(keys, localPublicKey{ + Path: path, + Name: filepath.Base(path), + Content: line, + Comment: comment, + }) + } + + if len(keys) == 0 { + return nil, fmt.Errorf("no SSH public keys found in %s", filepath.Join(home, ".ssh")) + } + + return keys, nil +} + +func looksLikePublicKey(value string) bool { + fields := strings.Fields(value) + if len(fields) < 2 { + return false + } + + keyType := fields[0] + return strings.HasPrefix(keyType, "ssh-") || strings.HasPrefix(keyType, "ecdsa-") || strings.HasPrefix(keyType, "sk-") || strings.HasPrefix(keyType, "rsa-sha2-") +} + +func extractKeyComment(value string) string { + fields := strings.Fields(value) + if len(fields) <= 2 { + return "" + } + + return strings.Join(fields[2:], " ") +} + +func siteDisplayName(site inventory.SiteServer) string { + if strings.TrimSpace(site.MainDomain) != "" { + return site.MainDomain + } + + if strings.TrimSpace(site.Username) != "" { + return site.Username + } + + return site.ID +} + +func shouldPromptForOrganization(err error) bool { + message := strings.ToLower(err.Error()) + return strings.Contains(message, "organization id not found") || strings.Contains(message, "multiple organizations") +} diff --git a/cmd/site-env.go b/cmd/site-env.go new file mode 100644 index 0000000..4968bb8 --- /dev/null +++ b/cmd/site-env.go @@ -0,0 +1,356 @@ +package cmd + +import ( + "fmt" + "sort" + "strings" + + "github.com/intercube/cli/util/inventory" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" +) + +var ( + siteEnvListSiteID string + siteEnvSetSiteID string + siteEnvSetName string + siteEnvSetValue string + siteEnvSetSecret bool + siteEnvGetSiteID string + siteEnvGetName string + siteEnvGetID string + siteEnvDeleteSiteID string + siteEnvDeleteName string + siteEnvDeleteID string + siteEnvDeleteYes bool +) + +var siteEnvCmd = &cobra.Command{ + Use: "env", + Short: "Manage site environment variables", +} + +var siteEnvListCmd = &cobra.Command{ + Use: "list", + Short: "List environment variables for a site", + RunE: func(cmd *cobra.Command, args []string) error { + inventoryClient, _, err := newInventoryClient(cmd, "") + if err != nil { + return err + } + + site, err := resolveSiteSelection(cmd, inventoryClient, siteEnvListSiteID) + if err != nil { + return err + } + + variables, err := inventoryClient.ListSiteEnvironmentVariables(cmd.Context(), site.ID) + if err != nil { + return err + } + + if len(variables) == 0 { + fmt.Printf("No environment variables found for site %s (%s)\n", siteDisplayName(*site), site.ID) + return nil + } + + sort.SliceStable(variables, func(i, j int) bool { + return strings.ToLower(variables[i].Name) < strings.ToLower(variables[j].Name) + }) + + for _, variable := range variables { + secretLabel := "plain" + if variable.Secret { + secretLabel = "secret" + } + + fmt.Printf("%s=%s [%s] (id: %s)\n", variable.Name, variable.Value, secretLabel, variable.ID) + } + + return nil + }, +} + +var siteEnvSetCmd = &cobra.Command{ + Use: "set", + Short: "Create or update a site environment variable", + RunE: func(cmd *cobra.Command, args []string) error { + inventoryClient, _, err := newInventoryClient(cmd, "") + if err != nil { + return err + } + + site, err := resolveSiteSelection(cmd, inventoryClient, siteEnvSetSiteID) + if err != nil { + return err + } + + name := strings.TrimSpace(siteEnvSetName) + if name == "" { + name, err = promptRequiredText("Environment variable name", "") + if err != nil { + return err + } + } + + value := siteEnvSetValue + if value == "" { + value, err = promptRequiredText("Environment variable value", "") + if err != nil { + return err + } + } + + variables, err := inventoryClient.ListSiteEnvironmentVariables(cmd.Context(), site.ID) + if err != nil { + return err + } + + existing := findEnvironmentVariableByName(variables, name) + request := inventory.EnvironmentVariableMutate{Name: name, Value: value, Secret: siteEnvSetSecret} + + if existing == nil { + created, createErr := inventoryClient.CreateSiteEnvironmentVariable(cmd.Context(), site.ID, request) + if createErr != nil { + return createErr + } + + fmt.Printf("Created %s on site %s (%s)\n", created.Name, siteDisplayName(*site), site.ID) + return nil + } + + if err := inventoryClient.UpdateSiteEnvironmentVariable(cmd.Context(), site.ID, existing.ID, request); err != nil { + return err + } + + fmt.Printf("Updated %s on site %s (%s)\n", existing.Name, siteDisplayName(*site), site.ID) + return nil + }, +} + +var siteEnvGetCmd = &cobra.Command{ + Use: "get", + Short: "Get a site environment variable", + RunE: func(cmd *cobra.Command, args []string) error { + inventoryClient, _, err := newInventoryClient(cmd, "") + if err != nil { + return err + } + + site, err := resolveSiteSelection(cmd, inventoryClient, siteEnvGetSiteID) + if err != nil { + return err + } + + variables, err := inventoryClient.ListSiteEnvironmentVariables(cmd.Context(), site.ID) + if err != nil { + return err + } + + if len(variables) == 0 { + return fmt.Errorf("no environment variables found for site %s (%s)", siteDisplayName(*site), site.ID) + } + + selected, err := resolveEnvironmentVariableSelection(variables, siteEnvGetID, siteEnvGetName) + if err != nil { + return err + } + + secretLabel := "plain" + if selected.Secret { + secretLabel = "secret" + } + + fmt.Printf("%s=%s [%s] (id: %s)\n", selected.Name, selected.Value, secretLabel, selected.ID) + return nil + }, +} + +var siteEnvDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a site environment variable", + RunE: func(cmd *cobra.Command, args []string) error { + inventoryClient, _, err := newInventoryClient(cmd, "") + if err != nil { + return err + } + + site, err := resolveSiteSelection(cmd, inventoryClient, siteEnvDeleteSiteID) + if err != nil { + return err + } + + variables, err := inventoryClient.ListSiteEnvironmentVariables(cmd.Context(), site.ID) + if err != nil { + return err + } + + if len(variables) == 0 { + return fmt.Errorf("no environment variables found for site %s (%s)", siteDisplayName(*site), site.ID) + } + + selected, err := resolveEnvironmentVariableSelection(variables, siteEnvDeleteID, siteEnvDeleteName) + if err != nil { + return err + } + + if !siteEnvDeleteYes { + confirmed, confirmErr := promptYesNo(fmt.Sprintf("Delete %s from %s?", selected.Name, siteDisplayName(*site))) + if confirmErr != nil { + return confirmErr + } + + if !confirmed { + fmt.Println("Cancelled.") + return nil + } + } + + if err := inventoryClient.DeleteSiteEnvironmentVariable(cmd.Context(), site.ID, selected.ID); err != nil { + return err + } + + fmt.Printf("Deleted %s from site %s (%s)\n", selected.Name, siteDisplayName(*site), site.ID) + return nil + }, +} + +func init() { + siteCmd.AddCommand(siteEnvCmd) + siteEnvCmd.AddCommand(siteEnvListCmd) + siteEnvCmd.AddCommand(siteEnvSetCmd) + siteEnvCmd.AddCommand(siteEnvGetCmd) + siteEnvCmd.AddCommand(siteEnvDeleteCmd) + + siteEnvListCmd.Flags().StringVar(&siteEnvListSiteID, "site-id", "", "site id") + + siteEnvSetCmd.Flags().StringVar(&siteEnvSetSiteID, "site-id", "", "site id") + siteEnvSetCmd.Flags().StringVar(&siteEnvSetName, "name", "", "environment variable name") + siteEnvSetCmd.Flags().StringVar(&siteEnvSetValue, "value", "", "environment variable value") + siteEnvSetCmd.Flags().BoolVar(&siteEnvSetSecret, "secret", false, "mark environment variable as secret") + + siteEnvGetCmd.Flags().StringVar(&siteEnvGetSiteID, "site-id", "", "site id") + siteEnvGetCmd.Flags().StringVar(&siteEnvGetName, "name", "", "environment variable name") + siteEnvGetCmd.Flags().StringVar(&siteEnvGetID, "id", "", "environment variable id") + + siteEnvDeleteCmd.Flags().StringVar(&siteEnvDeleteSiteID, "site-id", "", "site id") + siteEnvDeleteCmd.Flags().StringVar(&siteEnvDeleteName, "name", "", "environment variable name") + siteEnvDeleteCmd.Flags().StringVar(&siteEnvDeleteID, "id", "", "environment variable id") + siteEnvDeleteCmd.Flags().BoolVar(&siteEnvDeleteYes, "yes", false, "delete without confirmation") +} + +func resolveEnvironmentVariableSelection(variables []inventory.EnvironmentVariable, variableID, variableName string) (*inventory.EnvironmentVariable, error) { + id := strings.TrimSpace(variableID) + if id != "" { + for i := range variables { + if strings.EqualFold(strings.TrimSpace(variables[i].ID), id) { + return &variables[i], nil + } + } + + return nil, fmt.Errorf("environment variable id %q not found", variableID) + } + + name := strings.TrimSpace(variableName) + if name != "" { + candidate := findEnvironmentVariableByName(variables, name) + if candidate == nil { + return nil, fmt.Errorf("environment variable %q not found", variableName) + } + + return candidate, nil + } + + return selectEnvironmentVariable(variables) +} + +func findEnvironmentVariableByName(variables []inventory.EnvironmentVariable, name string) *inventory.EnvironmentVariable { + needle := strings.TrimSpace(name) + for i := range variables { + if strings.EqualFold(strings.TrimSpace(variables[i].Name), needle) { + return &variables[i] + } + } + + return nil +} + +func selectEnvironmentVariable(variables []inventory.EnvironmentVariable) (*inventory.EnvironmentVariable, error) { + if len(variables) == 1 { + return &variables[0], nil + } + + sort.SliceStable(variables, func(i, j int) bool { + return strings.ToLower(variables[i].Name) < strings.ToLower(variables[j].Name) + }) + + type variableChoice struct { + Variable inventory.EnvironmentVariable + Title string + Meta string + } + + items := make([]variableChoice, 0, len(variables)) + for _, variable := range variables { + items = append(items, variableChoice{ + Variable: variable, + Title: strings.TrimSpace(variable.Name), + Meta: strings.TrimSpace(variable.Value), + }) + } + + prompt := promptui.Select{ + Label: "Select environment variable", + Items: items, + Size: selectSize(len(items)), + Stdout: &bellSkipper{}, + Templates: titleMetaSelectTemplates("variable"), + } + + index, _, err := prompt.Run() + if err != nil { + return nil, err + } + + selected := items[index].Variable + return &selected, nil +} + +func promptRequiredText(label, defaultValue string) (string, error) { + prompt := promptui.Prompt{ + Label: label, + Default: defaultValue, + Validate: func(input string) error { + if strings.TrimSpace(input) == "" { + return fmt.Errorf("value is required") + } + + return nil + }, + Stdout: &bellSkipper{}, + } + + value, err := prompt.Run() + if err != nil { + return "", err + } + + return strings.TrimSpace(value), nil +} + +func promptYesNo(label string) (bool, error) { + prompt := promptui.Select{ + Label: label, + Items: []string{"Yes", "No"}, + Size: 2, + Stdout: &bellSkipper{}, + Templates: simpleSelectTemplates("option"), + } + + index, _, err := prompt.Run() + if err != nil { + return false, err + } + + return index == 0, nil +} diff --git a/cmd/site-redirect.go b/cmd/site-redirect.go new file mode 100644 index 0000000..e32fc46 --- /dev/null +++ b/cmd/site-redirect.go @@ -0,0 +1,255 @@ +package cmd + +import ( + "fmt" + "sort" + "strings" + + "github.com/intercube/cli/util/inventory" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" +) + +var ( + siteRedirectListSiteID string + siteRedirectAddSiteID string + siteRedirectAddDomain string + siteRedirectAddCode int + siteRedirectAddLocation string + siteRedirectAddValue string + siteRedirectRemoveSiteID string + siteRedirectRemoveID string + siteRedirectRemoveYes bool +) + +var siteRedirectCmd = &cobra.Command{ + Use: "redirect", + Short: "Manage site redirects", +} + +var siteRedirectListCmd = &cobra.Command{ + Use: "list", + Short: "List redirects for a site", + RunE: func(cmd *cobra.Command, args []string) error { + inventoryClient, _, err := newInventoryClient(cmd, "") + if err != nil { + return err + } + + site, err := resolveSiteSelection(cmd, inventoryClient, siteRedirectListSiteID) + if err != nil { + return err + } + + redirects, err := inventoryClient.ListSiteRedirects(cmd.Context(), site.ID) + if err != nil { + return err + } + + if len(redirects) == 0 { + fmt.Printf("No redirects found for site %s (%s)\n", siteDisplayName(*site), site.ID) + return nil + } + + sort.SliceStable(redirects, func(i, j int) bool { + left := strings.ToLower(redirects[i].Domain + redirects[i].Location) + right := strings.ToLower(redirects[j].Domain + redirects[j].Location) + return left < right + }) + + for _, redirect := range redirects { + fmt.Printf("[%s] %s %d %s -> %s\n", redirect.ID, redirect.Domain, redirect.ReturnCode, redirect.Location, redirect.Value) + } + + return nil + }, +} + +var siteRedirectAddCmd = &cobra.Command{ + Use: "add", + Short: "Add a redirect to a site", + RunE: func(cmd *cobra.Command, args []string) error { + inventoryClient, _, err := newInventoryClient(cmd, "") + if err != nil { + return err + } + + site, err := resolveSiteSelection(cmd, inventoryClient, siteRedirectAddSiteID) + if err != nil { + return err + } + + domain := strings.TrimSpace(siteRedirectAddDomain) + if domain == "" { + domain, err = promptRequiredText("Redirect domain", site.MainDomain) + if err != nil { + return err + } + } + + location := strings.TrimSpace(siteRedirectAddLocation) + if location == "" { + location, err = promptRequiredText("Redirect source location", "/") + if err != nil { + return err + } + } + + value := strings.TrimSpace(siteRedirectAddValue) + if value == "" { + value, err = promptRequiredText("Redirect destination", "") + if err != nil { + return err + } + } + + returnCode := siteRedirectAddCode + if returnCode == 0 { + returnCode = 301 + } + if !isValidRedirectCode(returnCode) { + return fmt.Errorf("redirect return code must be one of 301, 302, 307, 308") + } + + created, err := inventoryClient.CreateSiteRedirect(cmd.Context(), site.ID, inventory.RedirectMutate{ + Domain: domain, + ReturnCode: returnCode, + Location: location, + Value: value, + }) + if err != nil { + return err + } + + fmt.Printf("Added redirect [%s] %s %d %s -> %s\n", created.ID, created.Domain, created.ReturnCode, created.Location, created.Value) + return nil + }, +} + +var siteRedirectRemoveCmd = &cobra.Command{ + Use: "remove", + Short: "Remove a redirect from a site", + RunE: func(cmd *cobra.Command, args []string) error { + inventoryClient, _, err := newInventoryClient(cmd, "") + if err != nil { + return err + } + + site, err := resolveSiteSelection(cmd, inventoryClient, siteRedirectRemoveSiteID) + if err != nil { + return err + } + + redirects, err := inventoryClient.ListSiteRedirects(cmd.Context(), site.ID) + if err != nil { + return err + } + + if len(redirects) == 0 { + return fmt.Errorf("no redirects found for site %s (%s)", siteDisplayName(*site), site.ID) + } + + selected, err := resolveRedirectSelection(redirects, siteRedirectRemoveID) + if err != nil { + return err + } + + if !siteRedirectRemoveYes { + confirmed, confirmErr := promptYesNo(fmt.Sprintf("Delete redirect %s %d %s -> %s?", selected.Domain, selected.ReturnCode, selected.Location, selected.Value)) + if confirmErr != nil { + return confirmErr + } + + if !confirmed { + fmt.Println("Cancelled.") + return nil + } + } + + if err := inventoryClient.DeleteSiteRedirect(cmd.Context(), site.ID, selected.ID); err != nil { + return err + } + + fmt.Printf("Deleted redirect %s (%s)\n", selected.Location, selected.ID) + return nil + }, +} + +func init() { + siteCmd.AddCommand(siteRedirectCmd) + siteRedirectCmd.AddCommand(siteRedirectListCmd) + siteRedirectCmd.AddCommand(siteRedirectAddCmd) + siteRedirectCmd.AddCommand(siteRedirectRemoveCmd) + + siteRedirectListCmd.Flags().StringVar(&siteRedirectListSiteID, "site-id", "", "site id") + + siteRedirectAddCmd.Flags().StringVar(&siteRedirectAddSiteID, "site-id", "", "site id") + siteRedirectAddCmd.Flags().StringVar(&siteRedirectAddDomain, "domain", "", "domain for redirect") + siteRedirectAddCmd.Flags().IntVar(&siteRedirectAddCode, "code", 301, "redirect return code (301,302,307,308)") + siteRedirectAddCmd.Flags().StringVar(&siteRedirectAddLocation, "location", "", "source location path") + siteRedirectAddCmd.Flags().StringVar(&siteRedirectAddValue, "value", "", "destination URL or path") + + siteRedirectRemoveCmd.Flags().StringVar(&siteRedirectRemoveSiteID, "site-id", "", "site id") + siteRedirectRemoveCmd.Flags().StringVar(&siteRedirectRemoveID, "id", "", "redirect id") + siteRedirectRemoveCmd.Flags().BoolVar(&siteRedirectRemoveYes, "yes", false, "delete without confirmation") +} + +func resolveRedirectSelection(redirects []inventory.Redirect, redirectID string) (*inventory.Redirect, error) { + id := strings.TrimSpace(redirectID) + if id != "" { + for i := range redirects { + if strings.EqualFold(strings.TrimSpace(redirects[i].ID), id) { + return &redirects[i], nil + } + } + + return nil, fmt.Errorf("redirect id %q not found", redirectID) + } + + return selectRedirect(redirects) +} + +func selectRedirect(redirects []inventory.Redirect) (*inventory.Redirect, error) { + if len(redirects) == 1 { + return &redirects[0], nil + } + + sort.SliceStable(redirects, func(i, j int) bool { + left := strings.ToLower(redirects[i].Domain + redirects[i].Location) + right := strings.ToLower(redirects[j].Domain + redirects[j].Location) + return left < right + }) + + type redirectChoice struct { + Redirect inventory.Redirect + Title string + Meta string + } + + items := make([]redirectChoice, 0, len(redirects)) + for _, redirect := range redirects { + title := fmt.Sprintf("%s %d %s", strings.TrimSpace(redirect.Domain), redirect.ReturnCode, strings.TrimSpace(redirect.Location)) + meta := strings.TrimSpace(redirect.Value) + items = append(items, redirectChoice{Redirect: redirect, Title: title, Meta: meta}) + } + + prompt := promptui.Select{ + Label: "Select redirect", + Items: items, + Size: selectSize(len(items)), + Stdout: &bellSkipper{}, + Templates: titleMetaSelectTemplates("redirect"), + } + + index, _, err := prompt.Run() + if err != nil { + return nil, err + } + + selected := items[index].Redirect + return &selected, nil +} + +func isValidRedirectCode(code int) bool { + return code == 301 || code == 302 || code == 307 || code == 308 +} diff --git a/cmd/site-selection.go b/cmd/site-selection.go new file mode 100644 index 0000000..e2d4ebe --- /dev/null +++ b/cmd/site-selection.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "fmt" + + "github.com/intercube/cli/util/inventory" + "github.com/spf13/cobra" +) + +func resolveSiteSelection(cmd *cobra.Command, inventoryClient *inventory.Client, siteID string) (*inventory.SiteServer, error) { + sites, err := inventoryClient.ListSites(cmd.Context()) + if err != nil { + if shouldPromptForOrganization(err) { + return nil, fmt.Errorf("organization context is required. Run `intercube auth org` (or pass --org-id)") + } + + return nil, err + } + + if len(sites) == 0 { + return nil, fmt.Errorf("no sites available for your account") + } + + if siteID != "" { + selected, found := findSiteByID(sites, siteID) + if !found { + return nil, fmt.Errorf("site %q not found", siteID) + } + + return selected, nil + } + + return selectSite(sites) +} diff --git a/cmd/site.go b/cmd/site.go new file mode 100644 index 0000000..8a66d07 --- /dev/null +++ b/cmd/site.go @@ -0,0 +1,12 @@ +package cmd + +import "github.com/spf13/cobra" + +var siteCmd = &cobra.Command{ + Use: "site", + Short: "Manage Intercube sites", +} + +func init() { + rootCmd.AddCommand(siteCmd) +} diff --git a/cmd/sync-resolve.go b/cmd/sync-resolve.go index ea8bbd3..2ba881a 100644 --- a/cmd/sync-resolve.go +++ b/cmd/sync-resolve.go @@ -141,13 +141,6 @@ func selectSiteFromList(sites []inventory.SiteServer) (*inventory.SiteServer, er return &sites[0], nil } - templates := &promptui.SelectTemplates{ - Label: "{{ . }}?", - Active: "\U0001F9CA {{ . | red }}", - Inactive: " {{ . | cyan }}", - Selected: "\U0001F9CA {{ . | cyan }}", - } - labels := make([]string, 0, len(sites)) for _, site := range sites { labels = append(labels, syncSiteDisplayName(site)) @@ -156,8 +149,8 @@ func selectSiteFromList(sites []inventory.SiteServer) (*inventory.SiteServer, er prompt := promptui.Select{ Label: "Select target site", Items: labels, - Templates: templates, - Size: minInt(10, len(labels)), + Templates: simpleSelectTemplates("target site"), + Size: selectSize(len(labels)), Stdout: &bellSkipper{}, } @@ -170,30 +163,34 @@ func selectSiteFromList(sites []inventory.SiteServer) (*inventory.SiteServer, er } func syncSiteDisplayName(site inventory.SiteServer) string { - parts := make([]string, 0, 3) - if strings.TrimSpace(site.MainDomain) != "" { - parts = append(parts, strings.TrimSpace(site.MainDomain)) - } - if strings.TrimSpace(site.ServerName) != "" { - parts = append(parts, strings.TrimSpace(site.ServerName)) + domain := strings.TrimSpace(site.MainDomain) + username := strings.TrimSpace(site.Username) + server := strings.TrimSpace(site.ServerName) + + title := domain + if title == "" { + title = username } - if strings.TrimSpace(site.ID) != "" { - parts = append(parts, strings.TrimSpace(site.ID)) + if title == "" { + title = server } - - if len(parts) == 0 { + if title == "" { return "(unnamed site)" } - return strings.Join(parts, " | ") -} + metaParts := make([]string, 0, 2) + if username != "" && !strings.EqualFold(username, title) { + metaParts = append(metaParts, username) + } + if server != "" && !strings.EqualFold(server, title) && !strings.EqualFold(server, username) { + metaParts = append(metaParts, server) + } -func minInt(a, b int) int { - if a < b { - return a + if len(metaParts) == 0 { + return title } - return b + return title + " - " + strings.Join(metaParts, " | ") } func findSiteMatches(sites []inventory.SiteServer, value string) []*inventory.SiteServer { diff --git a/util/appconfig/config.go b/util/appconfig/config.go index 18fcc0e..0212bfa 100644 --- a/util/appconfig/config.go +++ b/util/appconfig/config.go @@ -10,7 +10,7 @@ import ( var ClerkIssuer = "https://clerk.intercube.io" var ClerkClientID = "Oi68oAK1xuK1088Z" var ClerkAudience = "" -var ClerkScopes = "openid profile email offline_access" +var ClerkScopes = "openid profile email offline_access public_metadata" var ClerkCallbackPort = "8976" var InventoryAPIBaseURL = "https://inventory-nexus.dev-c8s.intercube.dev/" var OrganizationID = "" diff --git a/util/auth/clerk.go b/util/auth/clerk.go index e6117e4..8f99529 100644 --- a/util/auth/clerk.go +++ b/util/auth/clerk.go @@ -289,7 +289,7 @@ func (c *ClerkClient) buildAuthURL(endpoint, redirectURI, state, challenge strin func (c *ClerkClient) scopeString() string { scopes := strings.TrimSpace(c.Scopes) if scopes == "" { - return "openid profile email offline_access" + return "openid profile email offline_access public_metadata" } return scopes