diff --git a/cmd/provider/add.go b/cmd/provider/add.go index 712152d8e..383703f73 100644 --- a/cmd/provider/add.go +++ b/cmd/provider/add.go @@ -143,11 +143,11 @@ func (cmd *AddCmd) Run(ctx context.Context, devsyConfig *config.Config, args []s return fmt.Errorf("configure provider: %w", configureErr) } - return nil + return writeDefaultProvider(cmd.Context, providerConfig.Name) } - log.Infof("To use the provider, please run the following command:") - log.Infof("devsy provider use %s", providerConfig.Name) + log.Infof("To configure the provider, please run the following command:") + log.Infof("devsy provider configure %s", providerConfig.Name) return nil } diff --git a/cmd/provider/configure.go b/cmd/provider/configure.go new file mode 100644 index 000000000..ccc8fc311 --- /dev/null +++ b/cmd/provider/configure.go @@ -0,0 +1,76 @@ +package provider + +import ( + "github.com/devsy-org/devsy/cmd/completion" + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/workspace" + "github.com/spf13/cobra" +) + +// ConfigureCmd holds flags for the `provider configure` subcommand. +type ConfigureCmd struct { + *flags.GlobalFlags + Reconfigure bool + SingleMachine bool + Options []string + SkipInit bool +} + +// NewConfigureCmd creates the cobra command for `provider configure`. +func NewConfigureCmd(f *flags.GlobalFlags) *cobra.Command { + cmd := &ConfigureCmd{GlobalFlags: f} + configureCmd := &cobra.Command{ + Use: "configure [name]", + Short: "Re-run init and option resolution for an existing provider", + Args: cobra.MaximumNArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + if err != nil { + return err + } + name, err := resolveProviderName(args, devsyConfig.Current().DefaultProvider) + if err != nil { + return err + } + p, err := workspace.FindProvider(devsyConfig, name) + if err != nil { + return err + } + return ConfigureProvider(cobraCmd.Context(), ProviderOptionsConfig{ + Provider: p.Config, + Context: devsyConfig.DefaultContext, + UserOptions: cmd.Options, + Reconfigure: cmd.Reconfigure, + SkipRequired: false, + SkipInit: cmd.SkipInit, + SkipSubOptions: false, + SingleMachine: &cmd.SingleMachine, + }) + }, + ValidArgsFunction: func( + rootCmd *cobra.Command, + args []string, + toComplete string, + ) ([]string, cobra.ShellCompDirective) { + return completion.GetProviderSuggestions( + rootCmd, + cmd.Context, + cmd.Provider, + args, + toComplete, + cmd.Owner, + ) + }, + } + configureCmd.Flags(). + BoolVar(&cmd.Reconfigure, "reconfigure", false, "Force re-resolution of all options") + configureCmd.Flags(). + BoolVar(&cmd.SingleMachine, "single-machine", false, "Use a single machine for all workspaces") + configureCmd.Flags(). + StringArrayVarP(&cmd.Options, "option", "o", []string{}, "Provider option in the form KEY=VALUE") + configureCmd.Flags(). + BoolVar(&cmd.SkipInit, "skip-init", false, "Skip provider init (testing only)") + _ = configureCmd.Flags().MarkHidden("skip-init") + return configureCmd +} diff --git a/cmd/provider/use.go b/cmd/provider/configure_shared.go similarity index 52% rename from cmd/provider/use.go rename to cmd/provider/configure_shared.go index 2b045dcf4..8246593e6 100644 --- a/cmd/provider/use.go +++ b/cmd/provider/configure_shared.go @@ -6,123 +6,14 @@ import ( "fmt" "io" - "github.com/devsy-org/devsy/cmd/completion" - "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/client/clientimplementation" "github.com/devsy-org/devsy/pkg/config" cliErrors "github.com/devsy-org/devsy/pkg/errors" "github.com/devsy-org/devsy/pkg/log" options2 "github.com/devsy-org/devsy/pkg/options" provider2 "github.com/devsy-org/devsy/pkg/provider" - "github.com/devsy-org/devsy/pkg/workspace" - "github.com/spf13/cobra" ) -// UseCmd holds the use cmd flags. -type UseCmd struct { - *flags.GlobalFlags - - Reconfigure bool - SingleMachine bool - Options []string - - // only for testing - SkipInit bool -} - -// NewUseCmd creates a new command. -func NewUseCmd(flags *flags.GlobalFlags) *cobra.Command { - cmd := &UseCmd{ - GlobalFlags: flags, - } - useCmd := &cobra.Command{ - Use: "use [name]", - Short: "Configure an existing provider and set as default", - RunE: func(cobraCmd *cobra.Command, args []string) error { - if len(args) != 1 { - return fmt.Errorf("please specify the provider to use") - } - - return cmd.Run(cobraCmd.Context(), args[0]) - }, - ValidArgsFunction: func(rootCmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GetProviderSuggestions( - rootCmd, - cmd.Context, - cmd.Provider, - args, - toComplete, - cmd.Owner, - ) - }, - } - - AddFlags(useCmd, cmd) - return useCmd -} - -func AddFlags(useCmd *cobra.Command, cmd *UseCmd) { - useCmd.Flags(). - BoolVar(&cmd.SingleMachine, "single-machine", false, "If enabled will use a single machine for all workspaces") - useCmd.Flags(). - BoolVar(&cmd.Reconfigure, "reconfigure", false, "If enabled will not merge existing provider config") - useCmd.Flags(). - StringArrayVarP(&cmd.Options, "option", "o", []string{}, "Provider option in the form KEY=VALUE") - - useCmd.Flags(). - BoolVar(&cmd.SkipInit, "skip-init", false, "ONLY FOR TESTING: If true will skip init") - _ = useCmd.Flags().MarkHidden("skip-init") -} - -// Run runs the command logic. -func (cmd *UseCmd) Run(ctx context.Context, providerName string) error { - devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) - if err != nil { - return err - } - - providerWithOptions, err := workspace.FindProvider(devsyConfig, providerName) - if err != nil { - return err - } - - // should reconfigure? - shouldReconfigure := cmd.Reconfigure || len(cmd.Options) > 0 || - providerWithOptions.State == nil || - cmd.SingleMachine - if shouldReconfigure { - return ConfigureProvider(ctx, ProviderOptionsConfig{ - Provider: providerWithOptions.Config, - Context: devsyConfig.DefaultContext, - UserOptions: cmd.Options, - Reconfigure: cmd.Reconfigure, - SkipRequired: false, - SkipInit: cmd.SkipInit, - SkipSubOptions: false, - SingleMachine: &cmd.SingleMachine, - }) - } else { - log.Infof( - "To reconfigure provider %s, run with '--reconfigure' to reconfigure the provider", - providerWithOptions.Config.Name, - ) - } - - // set options - defaultContext := devsyConfig.Current() - defaultContext.DefaultProvider = providerWithOptions.Config.Name - - // save provider config - err = config.SaveConfig(devsyConfig) - if err != nil { - return fmt.Errorf("save config: %w", err) - } - - // print success message - log.Infof("switched default provider: providerName=%s", providerWithOptions.Config.Name) - return nil -} - type ProviderOptionsConfig struct { Provider *provider2.ProviderConfig Context string @@ -140,11 +31,8 @@ func ConfigureProvider(ctx context.Context, cfg ProviderOptionsConfig) error { return err } - // set options - defaultContext := devsyConfig.Current() - defaultContext.DefaultProvider = cfg.Provider.Name - - // save provider config + // save provider config (configureProviderOptions may have mutated state, + // e.g. via initProvider marking the provider Initialized) err = config.SaveConfig(devsyConfig) if err != nil { return fmt.Errorf("save config: %w", err) @@ -208,7 +96,7 @@ func configureProviderOptions( stderr := log.Writer(log.LevelError) defer func() { _ = stderr.Close() }() - err = initProvider(ctx, devsyConfig, cfg.Provider, stdout, stderr) + err = initProvider(ctx, devsyConfig, cfg.Provider, initIO{stdout: stdout, stderr: stderr}) if err != nil { return nil, err } @@ -217,11 +105,53 @@ func configureProviderOptions( return devsyConfig, nil } +// writeDefaultProvider reloads the config for the given context and writes providerName +// as the active context's DefaultProvider. +func writeDefaultProvider(contextName, providerName string) error { + cfg, err := config.LoadConfig(contextName, "") + if err != nil { + return fmt.Errorf("reload config: %w", err) + } + cfg.Current().DefaultProvider = providerName + if err := config.SaveConfig(cfg); err != nil { + return fmt.Errorf("save default provider: %w", err) + } + return nil +} + +// resolveProviderName returns the provider name from args[0] if present, else the fallback +// (typically the active context's DefaultProvider). Errors when neither is available. +func resolveProviderName(args []string, defaultProvider string) (string, error) { + if len(args) > 0 { + return args[0], nil + } + if defaultProvider == "" { + return "", fmt.Errorf("please specify a provider") + } + return defaultProvider, nil +} + +// assertProviderMatchesGlobal returns an error when both the resolved provider name and +// the --provider global flag are set but disagree. +func assertProviderMatchesGlobal(resolved, globalFlag string) error { + if resolved == "" || globalFlag == "" || resolved == globalFlag { + return nil + } + log.Infof("providerName=%+v", resolved) + log.Infof("GlobalFlags.Provider=%+v", globalFlag) + return fmt.Errorf("ambiguous provider configuration detected") +} + +type initIO struct { + stdout io.Writer + stderr io.Writer +} + func initProvider( ctx context.Context, devsyConfig *config.Config, provider *provider2.ProviderConfig, - stdout, stderr io.Writer, + io2 initIO, ) error { // Capture the sub-binary's stderr in parallel with forwarding it to the // regular log sink so that errors.Classify has the real provider output @@ -234,8 +164,8 @@ func initProvider( Context: devsyConfig.DefaultContext, Options: devsyConfig.ProviderOptions(provider.Name), Config: provider, - Stdout: stdout, - Stderr: io.MultiWriter(stderr, stderrBuf), + Stdout: io2.stdout, + Stderr: io.MultiWriter(io2.stderr, stderrBuf), }) if err != nil { return cliErrors.Classify(fmt.Errorf("init: %w", err), cliErrors.ClassifyContext{ diff --git a/cmd/provider/configure_test.go b/cmd/provider/configure_test.go new file mode 100644 index 000000000..b21c1f845 --- /dev/null +++ b/cmd/provider/configure_test.go @@ -0,0 +1,40 @@ +package provider + +import ( + "testing" + + "github.com/devsy-org/devsy/cmd/flags" +) + +func TestNewConfigureCmd(t *testing.T) { + cmd := NewConfigureCmd(&flags.GlobalFlags{}) + if cmd.Use != "configure [name]" { + t.Errorf("Use: got %q want %q", cmd.Use, "configure [name]") + } + if cmd.Short == "" { + t.Error("Short must be set") + } + // Verify flags exist + for _, flag := range []string{"reconfigure", "single-machine", "option", "skip-init"} { + if cmd.Flag(flag) == nil { + t.Errorf("missing flag %q", flag) + } + } + // Verify skip-init is hidden + if !cmd.Flag("skip-init").Hidden { + t.Error("skip-init must be hidden") + } +} + +func TestResolveProviderName(t *testing.T) { + if got, err := resolveProviderName([]string{testProviderFoo}, "fallback"); err != nil || + got != testProviderFoo { + t.Fatalf("explicit arg should win: got %q err %v", got, err) + } + if got, err := resolveProviderName([]string{}, "fallback"); err != nil || got != "fallback" { + t.Fatalf("fallback should be used: got %q err %v", got, err) + } + if _, err := resolveProviderName([]string{}, ""); err == nil { + t.Fatal("empty args + empty fallback must error") + } +} diff --git a/cmd/provider/default.go b/cmd/provider/default.go new file mode 100644 index 000000000..f29db9966 --- /dev/null +++ b/cmd/provider/default.go @@ -0,0 +1,58 @@ +package provider + +import ( + "fmt" + + "github.com/devsy-org/devsy/cmd/completion" + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/workspace" + "github.com/spf13/cobra" +) + +// DefaultCmd holds the cmd flags. +type DefaultCmd struct { + *flags.GlobalFlags +} + +// NewDefaultCmd creates the cobra command for `provider default`. +func NewDefaultCmd(f *flags.GlobalFlags) *cobra.Command { + cmd := &DefaultCmd{GlobalFlags: f} + defaultCmd := &cobra.Command{ + Use: "default ", + Short: "Set the default provider for the active context", + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + if err != nil { + return err + } + p, err := workspace.FindProvider(devsyConfig, args[0]) + if err != nil { + return err + } + devsyConfig.Current().DefaultProvider = p.Config.Name + if err := config.SaveConfig(devsyConfig); err != nil { + return fmt.Errorf("save config: %w", err) + } + log.Infof("default provider: %s", p.Config.Name) + return nil + }, + ValidArgsFunction: func( + rootCmd *cobra.Command, + args []string, + toComplete string, + ) ([]string, cobra.ShellCompDirective) { + return completion.GetProviderSuggestions( + rootCmd, + cmd.Context, + cmd.Provider, + args, + toComplete, + cmd.Owner, + ) + }, + } + return defaultCmd +} diff --git a/cmd/provider/default_test.go b/cmd/provider/default_test.go new file mode 100644 index 000000000..ab6ce5cb4 --- /dev/null +++ b/cmd/provider/default_test.go @@ -0,0 +1,27 @@ +package provider + +import ( + "testing" + + "github.com/devsy-org/devsy/cmd/flags" +) + +func TestNewDefaultCmd_Wiring(t *testing.T) { + cmd := NewDefaultCmd(&flags.GlobalFlags{}) + if cmd.Use != "default " { + t.Errorf("Use: got %q want %q", cmd.Use, "default ") + } + if cmd.Short == "" { + t.Error("Short must be set") + } + // ExactArgs(1): passing 0 args must fail, passing 1 must accept (no Run here so just check Args fn). + if err := cmd.Args(cmd, []string{}); err == nil { + t.Error("expected error for 0 args") + } + if err := cmd.Args(cmd, []string{testProviderFoo}); err != nil { + t.Errorf("expected no error for 1 arg, got %v", err) + } + if err := cmd.Args(cmd, []string{testProviderFoo, testProviderBar}); err == nil { + t.Error("expected error for 2 args") + } +} diff --git a/cmd/provider/options.go b/cmd/provider/get.go similarity index 80% rename from cmd/provider/options.go rename to cmd/provider/get.go index ecd829c2c..61cb01f53 100644 --- a/cmd/provider/options.go +++ b/cmd/provider/get.go @@ -3,7 +3,6 @@ package provider import ( "context" "encoding/json" - "fmt" "maps" "os" "sort" @@ -12,7 +11,6 @@ import ( "github.com/devsy-org/devsy/cmd/completion" "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/config" - "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/table" "github.com/devsy-org/devsy/pkg/types" @@ -20,21 +18,22 @@ import ( "github.com/spf13/cobra" ) -// OptionsCmd holds the options cmd flags. -type OptionsCmd struct { +// GetCmd holds the get cmd flags. +type GetCmd struct { *flags.GlobalFlags Hidden bool } -// NewOptionsCmd creates a new command. -func NewOptionsCmd(flags *flags.GlobalFlags) *cobra.Command { - cmd := &OptionsCmd{ +// NewGetCmd creates a new command. +func NewGetCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &GetCmd{ GlobalFlags: flags, } - optionsCmd := &cobra.Command{ - Use: "options [provider]", - Short: "Show options of an existing provider", + getCmd := &cobra.Command{ + Use: "get [provider]", + Short: "Show provider info and current options", + Args: cobra.MaximumNArgs(1), RunE: func(cobraCmd *cobra.Command, args []string) error { return cmd.Run(cobraCmd.Context(), args) }, @@ -50,9 +49,9 @@ func NewOptionsCmd(flags *flags.GlobalFlags) *cobra.Command { }, } - optionsCmd.Flags(). + getCmd.Flags(). BoolVar(&cmd.Hidden, "hidden", false, "If true, will also show hidden options.") - return optionsCmd + return getCmd } type optionWithValue struct { @@ -63,25 +62,18 @@ type optionWithValue struct { } // Run runs the command logic. -func (cmd *OptionsCmd) Run(ctx context.Context, args []string) error { +func (cmd *GetCmd) Run(ctx context.Context, args []string) error { devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) if err != nil { return err } - providerName := devsyConfig.Current().DefaultProvider - if len(args) > 0 { - providerName = args[0] - } else if providerName == "" { - return fmt.Errorf("please specify a provider") + providerName, err := resolveProviderName(args, devsyConfig.Current().DefaultProvider) + if err != nil { + return err } - - if providerName != "" && cmd.Provider != "" { - if providerName != cmd.Provider { - log.Infof("providerName=%+v", providerName) - log.Infof("GlobalFlags.Provider=%+v", cmd.Provider) - return fmt.Errorf("ambiguous provider configuration detected") - } + if err := assertProviderMatchesGlobal(providerName, cmd.Provider); err != nil { + return err } providerWithOptions, err := workspace.FindProvider( diff --git a/cmd/provider/list.go b/cmd/provider/list.go index 658134334..446b21560 100644 --- a/cmd/provider/list.go +++ b/cmd/provider/list.go @@ -4,11 +4,16 @@ import ( "context" "encoding/json" "fmt" + "io" + "net/http" + "os" "sort" "strconv" + "strings" "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/config" + devsyhttp "github.com/devsy-org/devsy/pkg/http" "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/table" "github.com/devsy-org/devsy/pkg/types" @@ -19,6 +24,7 @@ import ( // ListCmd holds the list cmd flags. type ListCmd struct { *flags.GlobalFlags + Available bool } // NewListCmd creates a new command. @@ -29,13 +35,16 @@ func NewListCmd(flags *flags.GlobalFlags) *cobra.Command { listCmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, - Short: "List available providers", + Short: "List providers", Args: cobra.NoArgs, RunE: func(cobraCmd *cobra.Command, args []string) error { return cmd.Run(cobraCmd.Context()) }, } + listCmd.Flags(). + BoolVar(&cmd.Available, "available", false, "List providers available for installation rather than installed ones") + return listCmd } @@ -47,6 +56,14 @@ type ProviderWithDefault struct { // Run runs the command logic. func (cmd *ListCmd) Run(ctx context.Context) error { + if cmd.Available { + return cmd.runAvailable(ctx) + } + return cmd.runInstalled(ctx) +} + +// runInstalled lists installed providers. +func (cmd *ListCmd) runInstalled(_ context.Context) error { devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) if err != nil { return err @@ -68,49 +85,136 @@ func (cmd *ListCmd) Run(ctx context.Context) error { } switch mode { case output.ModePlain: - tableEntries := [][]string{} - for _, entry := range providers { - tableEntries = append(tableEntries, []string{ - entry.Config.Name, - entry.Config.Version, - strconv.FormatBool(devsyConfig.Current().DefaultProvider == entry.Config.Name), - strconv.FormatBool(entry.State != nil && entry.State.Initialized), - entry.Config.Description, - }) - } - sort.SliceStable(tableEntries, func(i, j int) bool { - return tableEntries[i][0] < tableEntries[j][0] + return cmd.renderInstalledPlain(devsyConfig, providers) + case output.ModeJSON: + return cmd.renderInstalledJSON(devsyConfig, configuredProviders, providers) + } + + return nil +} + +// renderInstalledPlain renders installed providers in plain text format. +func (cmd *ListCmd) renderInstalledPlain( + devsyConfig *config.Config, + providers map[string]*workspace.ProviderWithOptions, +) error { + tableEntries := [][]string{} + for _, entry := range providers { + tableEntries = append(tableEntries, []string{ + entry.Config.Name, + entry.Config.Version, + strconv.FormatBool(devsyConfig.Current().DefaultProvider == entry.Config.Name), + strconv.FormatBool(entry.State != nil && entry.State.Initialized), + entry.Config.Description, }) + } + sort.SliceStable(tableEntries, func(i, j int) bool { + return tableEntries[i][0] < tableEntries[j][0] + }) - table.Print([]string{ - "Name", - "Version", - "Default", - "Initialized", - "Description", - }, tableEntries) - case output.ModeJSON: - retMap := map[string]ProviderWithDefault{} - for k, entry := range providers { - var dynamicOptions map[string]*types.Option - if configuredProviders[entry.Config.Name] != nil { - dynamicOptions = configuredProviders[entry.Config.Name].DynamicOptions - } - - srcOptions := MergeDynamicOptions(entry.Config.Options, dynamicOptions) - entry.Config.Options = srcOptions - retMap[k] = ProviderWithDefault{ - ProviderWithOptions: *entry, - Default: devsyConfig.Current().DefaultProvider == entry.Config.Name, - } + table.Print([]string{ + "Name", + "Version", + "Default", + "Initialized", + "Description", + }, tableEntries) + + return nil +} + +// renderInstalledJSON renders installed providers in JSON format. +func (cmd *ListCmd) renderInstalledJSON( + devsyConfig *config.Config, + configuredProviders map[string]*config.ProviderConfig, + providers map[string]*workspace.ProviderWithOptions, +) error { + retMap := map[string]ProviderWithDefault{} + for k, entry := range providers { + var dynamicOptions map[string]*types.Option + if configuredProviders[entry.Config.Name] != nil { + dynamicOptions = configuredProviders[entry.Config.Name].DynamicOptions } - out, err := json.MarshalIndent(retMap, "", " ") - if err != nil { - return err + srcOptions := MergeDynamicOptions(entry.Config.Options, dynamicOptions) + entry.Config.Options = srcOptions + retMap[k] = ProviderWithDefault{ + ProviderWithOptions: *entry, + Default: devsyConfig.Current().DefaultProvider == entry.Config.Name, } - fmt.Print(string(out)) } + out, err := json.MarshalIndent(retMap, "", " ") + if err != nil { + return err + } + //nolint:forbidigo + fmt.Print(string(out)) + return nil } + +// runAvailable lists providers available for installation. +func (cmd *ListCmd) runAvailable(ctx context.Context) error { + jsonResult, err := fetchProviderRepos(ctx) + if err != nil { + return err + } + + _, _ = fmt.Fprintln(os.Stdout, "List of available providers from "+config.RepoOwner+":") + var rows [][]string + for _, v := range jsonResult { + name, ok := v["name"].(string) + if !ok || name == "" { + continue + } + if after, ok0 := strings.CutPrefix(name, config.ProviderPrefix); ok0 { + rows = append(rows, []string{after}) + } + } + table.Print([]string{"Provider"}, rows) + + return nil +} + +func fetchProviderRepos(ctx context.Context) ([]map[string]any, error) { + const perPage = 100 + var all []map[string]any + for page := 1; ; page++ { + pageRepos, err := fetchProviderReposPage(ctx, page, perPage) + if err != nil { + return nil, err + } + all = append(all, pageRepos...) + if len(pageRepos) < perPage { + return all, nil + } + } +} + +func fetchProviderReposPage(ctx context.Context, page, perPage int) ([]map[string]any, error) { + url := fmt.Sprintf("%s/repos?per_page=%d&page=%d", config.GitHubAPIUserURL, perPage, page) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + resp, err := devsyhttp.GetHTTPClient().Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) + } + + var pageRepos []map[string]any + if err := json.Unmarshal(body, &pageRepos); err != nil { + return nil, err + } + return pageRepos, nil +} diff --git a/cmd/provider/list_available.go b/cmd/provider/list_available.go deleted file mode 100644 index c6e1ace18..000000000 --- a/cmd/provider/list_available.go +++ /dev/null @@ -1,94 +0,0 @@ -package provider - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "strings" - - "github.com/devsy-org/devsy/cmd/flags" - "github.com/devsy-org/devsy/pkg/config" - devsyhttp "github.com/devsy-org/devsy/pkg/http" - "github.com/devsy-org/devsy/pkg/table" - "github.com/spf13/cobra" -) - -// ListAvailableCmd holds the list cmd flags. -type ListAvailableCmd struct { - *flags.GlobalFlags -} - -// NewListAvailableCmd creates a new command. -func NewListAvailableCmd(flags *flags.GlobalFlags) *cobra.Command { - cmd := &ListAvailableCmd{ - GlobalFlags: flags, - } - listAvailableCmd := &cobra.Command{ - Use: "list-available", - Short: "List providers available for installation", - Args: cobra.NoArgs, - RunE: func(cobraCmd *cobra.Command, args []string) error { - return cmd.Run(cobraCmd.Context()) - }, - } - - return listAvailableCmd -} - -// Run runs the command logic. -func (cmd *ListAvailableCmd) Run(ctx context.Context) error { - jsonResult, err := fetchProviderRepos(ctx) - if err != nil { - return err - } - - _, _ = fmt.Fprintln(os.Stdout, "List of available providers from "+config.RepoOwner+":") - var rows [][]string - for _, v := range jsonResult { - name, ok := v["name"].(string) - if !ok || name == "" { - continue - } - if after, ok0 := strings.CutPrefix(name, config.ProviderPrefix); ok0 { - rows = append(rows, []string{after}) - } - } - table.Print([]string{"Provider"}, rows) - - return nil -} - -func fetchProviderRepos(ctx context.Context) ([]map[string]any, error) { - req, err := http.NewRequestWithContext(ctx, - "GET", - config.GitHubAPIUserURL+"/repos?per_page=100", - nil, - ) - if err != nil { - return nil, err - } - resp, err := devsyhttp.GetHTTPClient().Do(req) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - - result, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(result)) - } - - var jsonResult []map[string]any - if err := json.Unmarshal(result, &jsonResult); err != nil { - return nil, err - } - - return jsonResult, nil -} diff --git a/cmd/provider/provider.go b/cmd/provider/provider.go index c740b4dd1..36b806128 100644 --- a/cmd/provider/provider.go +++ b/cmd/provider/provider.go @@ -12,14 +12,15 @@ func NewProviderCmd(flags *flags.GlobalFlags) *cobra.Command { Short: "Devsy Provider commands", } - providerCmd.AddCommand(NewListCmd(flags)) - providerCmd.AddCommand(NewListAvailableCmd(flags)) - providerCmd.AddCommand(NewUseCmd(flags)) - providerCmd.AddCommand(NewOptionsCmd(flags)) - providerCmd.AddCommand(NewDeleteCmd(flags)) providerCmd.AddCommand(NewAddCmd(flags)) - providerCmd.AddCommand(NewUpdateCmd(flags)) - providerCmd.AddCommand(NewSetOptionsCmd(flags)) + providerCmd.AddCommand(NewConfigureCmd(flags)) + providerCmd.AddCommand(NewDefaultCmd(flags)) + providerCmd.AddCommand(NewRemoveCmd(flags)) + providerCmd.AddCommand(NewListCmd(flags)) + providerCmd.AddCommand(NewGetCmd(flags)) providerCmd.AddCommand(NewRenameCmd(flags)) + providerCmd.AddCommand(NewSetCmd(flags)) + providerCmd.AddCommand(NewUpdateCmd(flags)) + providerCmd.AddCommand(NewVersionsCmd(flags)) return providerCmd } diff --git a/cmd/provider/delete.go b/cmd/provider/remove.go similarity index 92% rename from cmd/provider/delete.go rename to cmd/provider/remove.go index 0561c0692..91404e651 100644 --- a/cmd/provider/delete.go +++ b/cmd/provider/remove.go @@ -15,22 +15,22 @@ import ( "github.com/spf13/cobra" ) -// DeleteCmd holds the delete cmd flags. -type DeleteCmd struct { +// RemoveCmd holds the remove cmd flags. +type RemoveCmd struct { *flags.GlobalFlags IgnoreNotFound bool Force bool } -// NewDeleteCmd creates a new command. -func NewDeleteCmd(flags *flags.GlobalFlags) *cobra.Command { - cmd := &DeleteCmd{ +// NewRemoveCmd creates a new command. +func NewRemoveCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &RemoveCmd{ GlobalFlags: flags, } deleteCmd := &cobra.Command{ - Use: "delete [name]", - Short: "Delete a provider", + Use: "remove [name]", + Short: "Remove a provider", Args: cobra.MaximumNArgs(1), RunE: func(cobraCmd *cobra.Command, args []string) error { return cmd.Run(cobraCmd.Context(), args) @@ -55,7 +55,7 @@ func NewDeleteCmd(flags *flags.GlobalFlags) *cobra.Command { return deleteCmd } -func (cmd *DeleteCmd) Run(ctx context.Context, args []string) error { +func (cmd *RemoveCmd) Run(ctx context.Context, args []string) error { devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) if err != nil { return err diff --git a/cmd/provider/delete_test.go b/cmd/provider/remove_test.go similarity index 54% rename from cmd/provider/delete_test.go rename to cmd/provider/remove_test.go index ea9c4f79b..a18f48dcc 100644 --- a/cmd/provider/delete_test.go +++ b/cmd/provider/remove_test.go @@ -6,11 +6,11 @@ import ( "github.com/devsy-org/devsy/cmd/flags" ) -func TestDeleteCmd_RejectsMultipleArgs(t *testing.T) { +func TestRemoveCmd_RejectsMultipleArgs(t *testing.T) { globalFlags := &flags.GlobalFlags{} - cmd := NewDeleteCmd(globalFlags) + cmd := NewRemoveCmd(globalFlags) err := cmd.Args(cmd, []string{"provider1", "provider2"}) if err == nil { - t.Fatal("expected error when passing multiple arguments to delete, got nil") + t.Fatal("expected error when passing multiple arguments to remove, got nil") } } diff --git a/cmd/provider/set_options.go b/cmd/provider/set.go similarity index 74% rename from cmd/provider/set_options.go rename to cmd/provider/set.go index e11b24f38..3d9935419 100644 --- a/cmd/provider/set_options.go +++ b/cmd/provider/set.go @@ -13,8 +13,8 @@ import ( "github.com/spf13/cobra" ) -// SetOptionsCmd holds the use cmd flags. -type SetOptionsCmd struct { +// SetCmd holds the set cmd flags. +type SetCmd struct { *flags.GlobalFlags Dry bool @@ -25,14 +25,14 @@ type SetOptionsCmd struct { Options []string } -// NewSetOptionsCmd creates a new command. -func NewSetOptionsCmd(f *flags.GlobalFlags) *cobra.Command { - cmd := &SetOptionsCmd{ +// NewSetCmd creates a new command. +func NewSetCmd(f *flags.GlobalFlags) *cobra.Command { + cmd := &SetCmd{ GlobalFlags: f, } - setOptionsCmd := &cobra.Command{ - Use: "set-options [provider]", - Short: "Sets options for the given provider. Similar to 'devsy provider use', but does not switch the default provider.", + setCmd := &cobra.Command{ + Use: "set [provider]", + Short: "Set provider options", RunE: func(cobraCmd *cobra.Command, args []string) error { return cmd.Run(cobraCmd.Context(), args) }, @@ -48,37 +48,21 @@ func NewSetOptionsCmd(f *flags.GlobalFlags) *cobra.Command { }, } - setOptionsCmd.Flags(). + setCmd.Flags(). BoolVar(&cmd.SingleMachine, "single-machine", false, "If enabled will use a single machine for all workspaces") - setOptionsCmd.Flags(). + setCmd.Flags(). BoolVar(&cmd.Reconfigure, "reconfigure", false, "If enabled will not merge existing provider config") - setOptionsCmd.Flags(). + setCmd.Flags(). StringArrayVarP(&cmd.Options, "option", "o", []string{}, "Provider option in the form KEY=VALUE") - setOptionsCmd.Flags(). + setCmd.Flags(). BoolVar(&cmd.Dry, "dry", false, "Dry will not persist the options to file and instead return the new filled options") - setOptionsCmd.Flags(). + setCmd.Flags(). BoolVar(&cmd.SkipInit, "skip-init", false, "If true will skip running the provider init command") - return setOptionsCmd + return setCmd } -func (cmd *SetOptionsCmd) Run(ctx context.Context, args []string) error { - devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) - if err != nil { - return err - } - - providerName, err := resolveProviderName(args, devsyConfig.Current().DefaultProvider) - if err != nil { - return err - } - log.Debugf("providerName=%+v", providerName) - - if os.Getenv(config.EnvUI) == "" && len(cmd.Options) == 0 { - return fmt.Errorf("please specify option") - } - log.Debugf("Options=%+v", cmd.Options) - - providerWithOptions, err := workspace.FindProvider(devsyConfig, providerName) +func (cmd *SetCmd) Run(ctx context.Context, args []string) error { + devsyConfig, providerWithOptions, err := cmd.loadProvider(args) if err != nil { return err } @@ -105,17 +89,33 @@ func (cmd *SetOptionsCmd) Run(ctx context.Context, args []string) error { return nil } -func resolveProviderName(args []string, defaultProvider string) (string, error) { - if len(args) > 0 { - return args[0], nil +func (cmd *SetCmd) loadProvider( + args []string, +) (*config.Config, *workspace.ProviderWithOptions, error) { + devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + if err != nil { + return nil, nil, err + } + + providerName, err := resolveProviderName(args, devsyConfig.Current().DefaultProvider) + if err != nil { + return nil, nil, err } - if defaultProvider == "" { - return "", fmt.Errorf("please specify a provider") + log.Debugf("providerName=%+v", providerName) + + if os.Getenv(config.EnvUI) == "" && len(cmd.Options) == 0 { + return nil, nil, fmt.Errorf("please specify option") + } + log.Debugf("Options=%+v", cmd.Options) + + providerWithOptions, err := workspace.FindProvider(devsyConfig, providerName) + if err != nil { + return nil, nil, err } - return defaultProvider, nil + return devsyConfig, providerWithOptions, nil } -func (cmd *SetOptionsCmd) saveOrPrintConfig( +func (cmd *SetCmd) saveOrPrintConfig( devsyConfig *config.Config, providerWithOptions *workspace.ProviderWithOptions, ) error { diff --git a/cmd/provider/testconst_test.go b/cmd/provider/testconst_test.go new file mode 100644 index 000000000..f1fe0b20b --- /dev/null +++ b/cmd/provider/testconst_test.go @@ -0,0 +1,6 @@ +package provider + +const ( + testProviderFoo = "foo" + testProviderBar = "bar" +) diff --git a/cmd/provider/update.go b/cmd/provider/update.go index 6e203cb61..8aa778654 100644 --- a/cmd/provider/update.go +++ b/cmd/provider/update.go @@ -16,6 +16,7 @@ type UpdateCmd struct { *flags.GlobalFlags Use bool + Version string Options []string } @@ -40,12 +41,30 @@ func NewUpdateCmd(flags *flags.GlobalFlags) *cobra.Command { updateCmd.Flags(). BoolVar(&cmd.Use, "use", true, "If enabled will automatically activate the provider") + updateCmd.Flags(). + StringVar(&cmd.Version, "version", "", "Pin the provider to a specific version tag") updateCmd.Flags(). StringArrayVarP(&cmd.Options, "option", "o", []string{}, "Provider option in the form KEY=VALUE") return updateCmd } func (cmd *UpdateCmd) Run(ctx context.Context, devsyConfig *config.Config, args []string) error { + if cmd.Version != "" { + if len(args) == 0 { + return fmt.Errorf("provider name must be provided when using --version") + } + if len(args) > 1 { + return fmt.Errorf("--version and a source argument are mutually exclusive") + } + providerName := args[0] + if err := workspace.SetProviderVersion(devsyConfig, providerName, cmd.Version); err != nil { + return err + } + log.Infof("pinned provider %s to version %s", providerName, cmd.Version) + return nil + } + + // Standard update mode: requires provider name and optional source if len(args) != 1 && len(args) != 2 { return fmt.Errorf("please specify either a local file, URL or Git repository. " + "E.g. devsy provider update my-provider " + config.ProviderPrefix + "gcloud") @@ -79,16 +98,16 @@ func (cmd *UpdateCmd) Run(ctx context.Context, devsyConfig *config.Config, args }) if err != nil { log.Errorf( - "Error configuring provider, please retry with 'devsy provider use %s --reconfigure'", + "Error configuring provider, please retry with 'devsy provider configure %s --reconfigure'", providerConfig.Name, ) return fmt.Errorf("configure provider: %w", err) } - return nil + return writeDefaultProvider(cmd.Context, providerConfig.Name) } - log.Infof("To use the provider, please run the following command:") - log.Infof("devsy provider use %s", providerConfig.Name) + log.Infof("To configure the provider, please run the following command:") + log.Infof("devsy provider configure %s", providerConfig.Name) return nil } diff --git a/cmd/provider/update_test.go b/cmd/provider/update_test.go new file mode 100644 index 000000000..e8a86e956 --- /dev/null +++ b/cmd/provider/update_test.go @@ -0,0 +1,14 @@ +package provider + +import ( + "testing" + + "github.com/devsy-org/devsy/cmd/flags" +) + +func TestUpdateCmd_VersionFlag(t *testing.T) { + cmd := NewUpdateCmd(&flags.GlobalFlags{}) + if cmd.Flag("version") == nil { + t.Fatal("expected --version flag") + } +} diff --git a/cmd/provider/versions.go b/cmd/provider/versions.go new file mode 100644 index 000000000..851b0fe74 --- /dev/null +++ b/cmd/provider/versions.go @@ -0,0 +1,103 @@ +package provider + +import ( + "encoding/json" + "fmt" + "io" + "os" + "text/tabwriter" + + "github.com/devsy-org/devsy/cmd/completion" + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/workspace" + "github.com/spf13/cobra" +) + +// VersionsCmd holds the cmd flags for `provider versions`. +type VersionsCmd struct { + *flags.GlobalFlags + JSON bool + IncludePrerelease bool + NoCache bool +} + +// NewVersionsCmd creates the cobra command for `provider versions`. +func NewVersionsCmd(f *flags.GlobalFlags) *cobra.Command { + cmd := &VersionsCmd{GlobalFlags: f} + versionsCmd := &cobra.Command{ + Use: "versions [name]", + Short: "List available upstream versions for a provider", + Args: cobra.MaximumNArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + if err != nil { + return err + } + name, err := resolveProviderName(args, devsyConfig.Current().DefaultProvider) + if err != nil { + return err + } + versions, err := workspace.ListProviderVersions( + devsyConfig, + name, + workspace.ListVersionsOptions{ + UseCache: !cmd.NoCache, + IncludePrerelease: cmd.IncludePrerelease, + }, + ) + if err != nil { + return fmt.Errorf("list versions for %s: %w", name, err) + } + if cmd.JSON { + return json.NewEncoder(os.Stdout).Encode(versions) + } + return renderVersionsTable(os.Stdout, versions) + }, + ValidArgsFunction: completeProviderName(cmd), + } + versionsCmd.Flags().BoolVar(&cmd.JSON, "json", false, "Output JSON") + versionsCmd.Flags().BoolVar(&cmd.IncludePrerelease, "prerelease", false, "Include prereleases") + versionsCmd.Flags().BoolVar(&cmd.NoCache, "no-cache", false, "Bypass the version cache") + return versionsCmd +} + +func completeProviderName( + cmd *VersionsCmd, +) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(rootCmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.GetProviderSuggestions( + rootCmd, + cmd.Context, + cmd.Provider, + args, + toComplete, + cmd.Owner, + ) + } +} + +func renderVersionsTable(w io.Writer, versions []workspace.ProviderVersion) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "TAG\tPUBLISHED\tSTATUS"); err != nil { + return err + } + for _, v := range versions { + status := "" + if v.Current { + status = "current" + } else if v.Prerelease { + status = "prerelease" + } + if _, err := fmt.Fprintf( + tw, + "%s\t%s\t%s\n", + v.Tag, + v.PublishedAt.Format("2006-01-02"), + status, + ); err != nil { + return err + } + } + return tw.Flush() +} diff --git a/cmd/provider/versions_test.go b/cmd/provider/versions_test.go new file mode 100644 index 000000000..2606f94f7 --- /dev/null +++ b/cmd/provider/versions_test.go @@ -0,0 +1,29 @@ +package provider + +import ( + "testing" + + "github.com/devsy-org/devsy/cmd/flags" +) + +func TestNewVersionsCmd_Wiring(t *testing.T) { + cmd := NewVersionsCmd(&flags.GlobalFlags{}) + if cmd.Use != "versions [name]" { + t.Errorf("Use: got %q want %q", cmd.Use, "versions [name]") + } + for _, name := range []string{"json", "prerelease", "no-cache"} { + if cmd.Flag(name) == nil { + t.Errorf("missing flag %q", name) + } + } + // MaximumNArgs(1): 0 and 1 ok, 2 not. + if err := cmd.Args(cmd, []string{}); err != nil { + t.Errorf("0 args should be ok, got %v", err) + } + if err := cmd.Args(cmd, []string{testProviderFoo}); err != nil { + t.Errorf("1 arg should be ok, got %v", err) + } + if err := cmd.Args(cmd, []string{testProviderFoo, testProviderBar}); err == nil { + t.Error("2 args should fail") + } +} diff --git a/cmd/use/use.go b/cmd/use/use.go index 830a5e428..24cd51ad5 100644 --- a/cmd/use/use.go +++ b/cmd/use/use.go @@ -3,7 +3,6 @@ package use import ( "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/cmd/ide" - "github.com/devsy-org/devsy/cmd/provider" "github.com/spf13/cobra" ) @@ -14,11 +13,6 @@ func NewUseCmd(flags *flags.GlobalFlags) *cobra.Command { Short: "Use Devsy resources", } - // use provider - useProviderCmd := provider.NewUseCmd(flags) - useProviderCmd.Use = "provider" - useCmd.AddCommand(useProviderCmd) - // use ide useIDECmd := ide.NewUseCmd(flags) useIDECmd.Use = "ide" diff --git a/desktop/e2e/fixtures/mock-devsy.cjs b/desktop/e2e/fixtures/mock-devsy.cjs index 26c8f14ee..7b6bd7ce5 100755 --- a/desktop/e2e/fixtures/mock-devsy.cjs +++ b/desktop/e2e/fixtures/mock-devsy.cjs @@ -180,6 +180,7 @@ switch (cmd) { case "list": out(state.providers) break + case "get": case "options": out({ DOCKER_HOST: { @@ -203,6 +204,9 @@ switch (cmd) { case "add": { const provName = extra if (provName) { + for (const key of Object.keys(state.providers)) { + state.providers[key].default = false + } state.providers[provName] = { config: { name: provName, @@ -214,13 +218,14 @@ switch (cmd) { optionGroups: [], }, state: { initialized: false }, - default: false, + default: true, } saveState(state) } out("") break } + case "remove": case "delete": { const provName = extra if (provName && state.providers[provName]) { @@ -243,19 +248,32 @@ switch (cmd) { out("") break } - case "use": { + case "use": + case "configure": { + const provName = extra + if (provName && state.providers[provName]) { + state.providers[provName].state.initialized = true + saveState(state) + } + out("") + break + } + case "default": { const provName = extra if (provName && state.providers[provName]) { for (const key of Object.keys(state.providers)) { state.providers[key].default = false } - state.providers[provName].state.initialized = true state.providers[provName].default = true saveState(state) } out("") break } + case "versions": + out([]) + break + case "set": case "set-options": case "update": out("") diff --git a/desktop/e2e/workspaces.e2e.ts b/desktop/e2e/workspaces.e2e.ts index e559bbb9f..32676a22a 100644 --- a/desktop/e2e/workspaces.e2e.ts +++ b/desktop/e2e/workspaces.e2e.ts @@ -65,16 +65,14 @@ test.describe.serial("Create Workspace Wizard", () => { timeout: 10000, }) - // Continue is disabled until a provider is selected + // The default provider is pre-selected, so Continue is enabled const continueBtn = dialog.getByRole("button", { name: /^continue$/i }) - await expect(continueBtn).toBeDisabled() + await expect(continueBtn).toBeEnabled() }) test("should advance to source step with templates", async () => { const dialog = page.locator('[role="dialog"]').first() - // Select the docker provider (the initialized one from the mock) - await dialog.locator("button", { hasText: "docker" }).first().click() - + // Default provider (docker) is already pre-selected; just continue const continueBtn = dialog.getByRole("button", { name: /^continue$/i }) await expect(continueBtn).toBeEnabled() await continueBtn.click() diff --git a/desktop/src/main/__tests__/cli.test.ts b/desktop/src/main/__tests__/cli.test.ts index 5c6c2022e..3eff3fa3e 100644 --- a/desktop/src/main/__tests__/cli.test.ts +++ b/desktop/src/main/__tests__/cli.test.ts @@ -91,7 +91,7 @@ describe("CliRunner", () => { ) const rejection = await cli - .run(["provider", "set-options", "aws"]) + .run(["provider", "set", "aws"]) .catch((e) => e as Error & { cliError?: typeof cliErrorPayload }) expect(rejection).toBeInstanceOf(Error) expect(rejection.cliError).toEqual(cliErrorPayload) diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 8e87ecfa5..14afa9c2e 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -156,7 +156,11 @@ app.whenReady().then(() => { }) // Register IPC handlers - const { tunnelProcesses } = registerIpcHandlers({ + const { + tunnelProcesses, + scheduleProviderUpdateCheck, + runInitialProviderUpdateCheck, + } = registerIpcHandlers({ cli, state, logStore, @@ -171,7 +175,8 @@ app.whenReady().then(() => { state, getMainWindow: () => mainWindow, }) - watcher.start() + void watcher.start().then(runInitialProviderUpdateCheck) + scheduleProviderUpdateCheck() // Set up system tray const appTray = new AppTray({ diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 072fe730f..a8339c3e0 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -28,6 +28,17 @@ import { type ProviderEntry, parseProviderEntries } from "./watcher.js" const execFileAsync = promisify(execFile) +// Cache for provider update checks. Seeded on launch and refreshed every 6 hours. +type UpdateInfo = { + current: string + latest: string + updateAvailable: boolean + unsupported: boolean + error?: string +} + +let providerUpdateCache: Record = {} + interface SshKeyInfo { name: string keyType: string @@ -51,10 +62,60 @@ function formatLogLine(line: string, level: "INFO" | "ERROR" = "INFO"): string { return `${new Date().toISOString()}\t${level}\t${line}` } -export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: Map } { +export function registerIpcHandlers(deps: IpcDependencies): { + tunnelProcesses: Map + scheduleProviderUpdateCheck: () => void + runInitialProviderUpdateCheck: () => void +} { const { cli, state, logStore } = deps const tunnelProcesses = new Map() + /** + * Compute provider update information by querying the CLI for all installed providers. + */ + async function computeUpdateChecks(): Promise> { + const providers = state.providerList() + const out: Record = {} + await Promise.all( + providers.map(async (p) => { + const version = typeof p.version === "string" ? p.version : "" + try { + const versions = await cli.run>( + ["provider", "versions", p.name, "--json", "--no-cache"], + ) + const list = versions ?? [] + const current = list.find((v) => v.current)?.tag ?? version + const latest = list[0]?.tag ?? "" + out[p.name] = { + current, + latest, + updateAvailable: latest !== "" && latest !== current, + unsupported: false, + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (msg.includes("does not support version listing")) { + out[p.name] = { + current: version, + latest: "", + updateAvailable: false, + unsupported: true, + } + } else { + out[p.name] = { + current: version, + latest: "", + updateAvailable: false, + unsupported: false, + error: msg, + } + } + } + }), + ) + return out + } + // ── Workspaces ── ipcMain.handle("workspace_list", () => state.workspaceList()) @@ -117,11 +178,11 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: M ipcMain.handle("provider_delete", async (_event, args: { name: string }) => { trackEvent("provider_remove") - await cli.runRaw(["provider", "delete", args.name]) + await cli.runRaw(["provider", "remove", args.name]) }) ipcMain.handle("provider_use", async (_event, args: { name: string }) => { - await cli.runRaw(["provider", "use", args.name]) + await cli.runRaw(["provider", "default", args.name]) }) // Returns an envelope rather than throwing so a structured cliError survives @@ -130,7 +191,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: M // own-properties, so a thrown Error with a .cliError attached would lose it. ipcMain.handle("provider_init", async (_event, args: { name: string }) => { try { - await cli.runRaw(["provider", "set-options", args.name]) + await cli.runRaw(["provider", "configure", args.name]) return { ok: true } as const } catch (err) { const cliError = (err as { cliError?: CLIError }).cliError @@ -146,7 +207,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: M const win = deps.getMainWindow() await cli.runStreaming( - ["provider", "set-options", args.name], + ["provider", "configure", args.name], (line, _stream, meta) => { const formatted = formatLogLine(line) win?.webContents.send("command-progress", { @@ -180,13 +241,13 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: M }) ipcMain.handle("provider_options", async (_event, args: { name: string }) => { - return cli.run(["provider", "options", args.name]) + return cli.run(["provider", "get", args.name]) }) ipcMain.handle( "provider_set_options", async (_event, args: { name: string; options: string[] }) => { - const cliArgs = ["provider", "set-options", args.name, "--skip-init"] + const cliArgs = ["provider", "set", args.name, "--skip-init"] for (const opt of args.options) { cliArgs.push("-o", opt) } @@ -201,6 +262,41 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: M }, ) + ipcMain.handle( + "provider_list_versions", + async (_event, args: { name: string; noCache?: boolean }) => { + const cliArgs = ["provider", "versions", args.name, "--json"] + if (args.noCache) cliArgs.push("--no-cache") + try { + const versions = await cli.run(cliArgs) + return { versions: versions ?? [], unsupported: false } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (msg.includes("does not support version listing")) { + return { versions: [], unsupported: true } + } + return { versions: [], unsupported: false, error: msg } + } + }, + ) + + ipcMain.handle( + "provider_set_version", + async (_event, args: { name: string; tag: string }) => { + await cli.runRaw(["provider", "update", args.name, "--version", args.tag]) + }, + ) + + ipcMain.handle("provider_check_updates", async () => { + const out = await computeUpdateChecks() + providerUpdateCache = out + return out + }) + + ipcMain.handle("provider_get_update_cache", async () => { + return providerUpdateCache + }) + // ── Machines ── ipcMain.handle("machine_list", () => state.machineList()) @@ -863,7 +959,25 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: M }, ) - return { tunnelProcesses } + function runUpdateCheck(): void { + void (async () => { + try { + providerUpdateCache = await computeUpdateChecks() + } catch { + // Silently swallow background errors. + } + })() + } + + function scheduleUpdates(): void { + setInterval(runUpdateCheck, 6 * 60 * 60 * 1000) + } + + return { + tunnelProcesses, + scheduleProviderUpdateCheck: scheduleUpdates, + runInitialProviderUpdateCheck: runUpdateCheck, + } } function sanitizeAnalyticsProperties( diff --git a/desktop/src/main/watcher.ts b/desktop/src/main/watcher.ts index d32c1ed85..d264a2c1a 100644 --- a/desktop/src/main/watcher.ts +++ b/desktop/src/main/watcher.ts @@ -58,7 +58,7 @@ export class Watcher { constructor(private deps: WatcherDeps) {} - start(): void { + start(): Promise { this.pollTimer = setInterval(() => this.schedulePoll(), 3000) const devsyDir = join(homedir(), ".devsy") @@ -71,7 +71,7 @@ export class Watcher { this.fsWatcher.on("all", () => this.schedulePoll()) } - this.schedulePoll() + return this.pollOnce() } stop(): void { diff --git a/desktop/src/renderer/src/lib/components/provider/ProviderCard.svelte b/desktop/src/renderer/src/lib/components/provider/ProviderCard.svelte index f8293324f..b135bfe2b 100644 --- a/desktop/src/renderer/src/lib/components/provider/ProviderCard.svelte +++ b/desktop/src/renderer/src/lib/components/provider/ProviderCard.svelte @@ -2,6 +2,7 @@ import { badgeVariants } from "$lib/components/ui/badge/index.js" import ProviderIcon from "./ProviderIcon.svelte" import ProviderSheet from "./ProviderSheet.svelte" +import { providerVersions } from "$lib/stores/providerVersions.js" import type { Provider } from "$lib/types/index.js" let { provider }: { provider: Provider } = $props() @@ -17,9 +18,15 @@ function sourceDisplay(p: Provider): string { - + {#if !provider.isDefault} + + {/if} diff --git a/desktop/src/renderer/src/lib/components/provider/ProviderSheet.test.ts b/desktop/src/renderer/src/lib/components/provider/ProviderSheet.test.ts index 780d0fd90..c4149be6c 100644 --- a/desktop/src/renderer/src/lib/components/provider/ProviderSheet.test.ts +++ b/desktop/src/renderer/src/lib/components/provider/ProviderSheet.test.ts @@ -173,4 +173,32 @@ describe("ProviderSheet", () => { expect(elapsed).toBeLessThan(TIMING_BUDGET_MS) unmount() }) + + it("hides 'Set Default' button when provider is default", async () => { + const { unmount } = render(ProviderSheet, { + props: { provider: makeProvider("ssh", { isDefault: true }), open: true }, + }) + + await flushAsync() + + // Find the "Set Default" button by exact text match + const buttons = Array.from(document.querySelectorAll("button")) + const setDefaultButton = buttons.find((btn) => btn.textContent?.trim() === "Set Default") + expect(setDefaultButton).toBeUndefined() + unmount() + }) + + it("shows 'Set Default' button when provider is not default", async () => { + const { unmount } = render(ProviderSheet, { + props: { provider: makeProvider("ssh"), open: true }, + }) + + await flushAsync() + + // Find the "Set Default" button by exact text match + const buttons = Array.from(document.querySelectorAll("button")) + const setDefaultButton = buttons.find((btn) => btn.textContent?.trim() === "Set Default") + expect(setDefaultButton).toBeDefined() + unmount() + }) }) diff --git a/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte b/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte index 569f481a4..1954fa461 100644 --- a/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte +++ b/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte @@ -20,7 +20,6 @@ import { providerList, providerOptions, providerSetOptions, - providerUse, } from "$lib/ipc/commands.js" import { onCommandProgress } from "$lib/ipc/events.js" import { providers } from "$lib/stores/providers.js" @@ -295,17 +294,6 @@ function handleSkipInit() { refreshAndComplete() } -async function handleSetDefault() { - try { - await providerUse(providerName) - const updated = await providerList() - providers.set(updated) - toasts.success(`Set ${providerName} as default provider`) - } catch (err) { - toasts.error(`Failed to set default: ${extractErrorMessage(err)}`) - } -} - function handleDone() { open = false oncomplete?.(providerName) @@ -591,14 +579,9 @@ function handleDone() { -
- - -
+ {/if} diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index da4b5e759..c06cf0b7a 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -17,6 +17,7 @@ import * as Popover from "$lib/components/ui/popover/index.js" import * as Dialog from "$lib/components/ui/dialog/index.js" import * as Alert from "$lib/components/ui/alert/index.js" import { Progress } from "$lib/components/ui/progress/index.js" +import { badgeVariants } from "$lib/components/ui/badge/index.js" import LanguageIcon from "$lib/components/workspace/LanguageIcon.svelte" import ConfirmDialog from "$lib/components/layout/ConfirmDialog.svelte" import LogTable from "$lib/components/log/LogTable.svelte" @@ -129,7 +130,9 @@ let { let currentStep = $state("provider") // Form state -let selectedProvider = $state("") +let selectedProvider = $state( + $providers.find((p) => p.isDefault && p.state?.initialized)?.name ?? "" +) let source = $state("") let workspaceFolder = $state("") let advancedOpen = $state(false) @@ -224,7 +227,7 @@ function clearWatchdog() { function reset() { currentStep = "provider" - selectedProvider = "" + selectedProvider = $providers.find((p) => p.isDefault && p.state?.initialized)?.name ?? "" source = "" workspaceFolder = "" advancedOpen = false @@ -465,7 +468,12 @@ function selectTemplate(t: { name: string; source: string }) { >
-
{p.name}
+
+
{p.name}
+ {#if p.isDefault} + Default + {/if} +
{#if p.description}
{p.description}
{/if} diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index 210cd3ab8..84d6c38ea 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -6,6 +6,8 @@ import type { OptionValue, Provider, ProviderOption, + ProviderVersion, + ProviderVersionCheckResult, SshKeyInfo, Workspace, } from "$lib/types/index.js" @@ -138,6 +140,21 @@ export async function providerRename( return invoke("provider_rename", { name, newName }) } +export async function providerListVersions(name: string, noCache?: boolean) { + return invoke<{ versions: ProviderVersion[]; unsupported: boolean; error?: string }>( + "provider_list_versions", + { name, noCache }, + ) +} + +export async function providerSetVersion(name: string, tag: string): Promise { + return invoke("provider_set_version", { name, tag }) +} + +export async function providerCheckUpdates() { + return invoke>("provider_check_updates") +} + // Machine commands export async function machineList(): Promise { return invoke("machine_list") diff --git a/desktop/src/renderer/src/lib/stores/providerVersions.test.ts b/desktop/src/renderer/src/lib/stores/providerVersions.test.ts new file mode 100644 index 000000000..0fa5b37dc --- /dev/null +++ b/desktop/src/renderer/src/lib/stores/providerVersions.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { get } from "svelte/store" + +vi.mock("$lib/ipc/commands.js", () => ({ + providerListVersions: vi.fn(), + providerCheckUpdates: vi.fn(), +})) + +import { + providerVersions, + refreshUpdates, + loadVersionsFor, + resetProviderVersionsStore, +} from "./providerVersions.js" +import { + providerListVersions, + providerCheckUpdates, +} from "$lib/ipc/commands.js" + +describe("providerVersions store", () => { + beforeEach(() => { + resetProviderVersionsStore() + vi.clearAllMocks() + }) + + it("seeds via refreshUpdates", async () => { + vi.mocked(providerCheckUpdates).mockResolvedValue({ + aws: { + current: "v1.0", + latest: "v1.1", + updateAvailable: true, + unsupported: false, + }, + }) + await refreshUpdates() + const state = get(providerVersions) + expect(state.updates.aws.updateAvailable).toBe(true) + expect(state.lastCheckedAt).not.toBeNull() + }) + + it("populates byProvider via loadVersionsFor", async () => { + vi.mocked(providerListVersions).mockResolvedValue({ + versions: [ + { + tag: "v2.0", + publishedAt: "2026-01-01T00:00:00Z", + prerelease: false, + current: true, + }, + ], + unsupported: false, + }) + await loadVersionsFor("gcp") + const state = get(providerVersions) + expect(state.byProvider.gcp.versions).toHaveLength(1) + expect(state.byProvider.gcp.versions[0].tag).toBe("v2.0") + expect(state.byProvider.gcp.unsupported).toBe(false) + }) + + it("resets via resetProviderVersionsStore", async () => { + vi.mocked(providerListVersions).mockResolvedValue({ + versions: [ + { + tag: "v2.0", + publishedAt: "2026-01-01T00:00:00Z", + prerelease: false, + current: true, + }, + ], + unsupported: false, + }) + await loadVersionsFor("gcp") + resetProviderVersionsStore() + const state = get(providerVersions) + expect(state.byProvider).toEqual({}) + expect(state.updates).toEqual({}) + expect(state.lastCheckedAt).toBeNull() + }) +}) diff --git a/desktop/src/renderer/src/lib/stores/providerVersions.ts b/desktop/src/renderer/src/lib/stores/providerVersions.ts new file mode 100644 index 000000000..6838d3cbc --- /dev/null +++ b/desktop/src/renderer/src/lib/stores/providerVersions.ts @@ -0,0 +1,45 @@ +import { writable } from "svelte/store" +import type { + ProviderVersion, + ProviderVersionCheckResult, +} from "$lib/types/index.js" +import { + providerListVersions, + providerCheckUpdates, +} from "$lib/ipc/commands.js" + +type State = { + byProvider: Record< + string, + { versions: ProviderVersion[]; unsupported: boolean; error?: string } + > + updates: Record + lastCheckedAt: Date | null +} + +const initial: State = { + byProvider: {}, + updates: {}, + lastCheckedAt: null, +} + +const internal = writable(initial) + +export const providerVersions = { subscribe: internal.subscribe } + +export async function refreshUpdates(): Promise { + const updates = await providerCheckUpdates() + internal.update((s) => ({ ...s, updates, lastCheckedAt: new Date() })) +} + +export async function loadVersionsFor(name: string): Promise { + const result = await providerListVersions(name) + internal.update((s) => ({ + ...s, + byProvider: { ...s.byProvider, [name]: result }, + })) +} + +export function resetProviderVersionsStore(): void { + internal.set(initial) +} diff --git a/desktop/src/renderer/src/lib/types/index.ts b/desktop/src/renderer/src/lib/types/index.ts index d3101fd4a..3d591cfe7 100644 --- a/desktop/src/renderer/src/lib/types/index.ts +++ b/desktop/src/renderer/src/lib/types/index.ts @@ -81,6 +81,21 @@ export interface Provider { } } +export interface ProviderVersion { + tag: string + publishedAt: string + prerelease: boolean + current: boolean +} + +export interface ProviderVersionCheckResult { + current: string + latest: string + updateAvailable: boolean + unsupported: boolean + error?: string +} + export interface MachineProviderConfig { name?: string } diff --git a/desktop/src/renderer/src/pages/ProviderDetailPage.svelte b/desktop/src/renderer/src/pages/ProviderDetailPage.svelte index 24448e048..3e724102f 100644 --- a/desktop/src/renderer/src/pages/ProviderDetailPage.svelte +++ b/desktop/src/renderer/src/pages/ProviderDetailPage.svelte @@ -12,6 +12,11 @@ import { TriangleAlert } from "@lucide/svelte" import ConfirmDialog from "$lib/components/layout/ConfirmDialog.svelte" import ProviderIcon from "$lib/components/provider/ProviderIcon.svelte" import { providers } from "$lib/stores/providers.js" +import { + providerVersions, + loadVersionsFor, + refreshUpdates, +} from "$lib/stores/providerVersions.js" import { providerInit, providerUse, @@ -19,6 +24,7 @@ import { providerDelete, providerOptions, providerSetOptions, + providerSetVersion, } from "$lib/ipc/commands.js" import { toasts } from "$lib/stores/toasts.js" import { Skeleton } from "$lib/components/ui/skeleton/index.js" @@ -43,6 +49,22 @@ let loading = $state(true) let confirmDeleteOpen = $state(false) let deleting = $state(false) let initializing = $state(false) +let confirmSwitchOpen = $state(false) +let targetTag = $state("") +let switching = $state(false) + +function openVersionSwitch(tag: string) { + targetTag = tag + confirmSwitchOpen = true +} + +let deleteDescription = $derived.by(() => { + const others = $providers.filter((p) => p.name !== id && p.state?.initialized) + if (provider?.isDefault && others.length > 0) { + return `Deleting '${id}' will leave no default provider. Pick a new default from the list after deletion, or use the \`--provider\` flag on CLI commands.` + } + return `This will remove provider '${id}' and its configuration. Any workspaces using this provider will need a new one.` +}) let isDirty = $derived.by(() => { for (const key of Object.keys(optionValues)) { @@ -105,6 +127,7 @@ onMount(async () => { } finally { loading = false } + loadVersionsFor(id).catch(() => {}) }) async function handleSetDefault() { @@ -181,24 +204,57 @@ async function handleSaveOptions() {

{id}

- {#if provider?.version} + {#if $providerVersions.byProvider[id] && !$providerVersions.byProvider[id].unsupported && ($providerVersions.byProvider[id].versions?.length ?? 0) > 0} + {@const entry = $providerVersions.byProvider[id]} + {@const currentTag = provider?.version ?? entry.versions.find((v) => v.current)?.tag ?? ""} + { + if (v && v !== currentTag) openVersionSwitch(v) + }} + > + + {currentTag || "Select version"} + + + {#each entry.versions as v (v.tag)} + + {/each} + + + {:else if provider?.version} {provider.version} {/if} {#if provider?.state?.initialized} initialized {/if} + {#if provider?.isDefault} + Default + {/if}
{#if provider} + {#if $providerVersions.updates[id]?.updateAvailable === true} + + Update available: {$providerVersions.updates[id].latest} + + + + + {/if}
{#if !isInitialized} - {:else} + {:else if !provider?.isDefault} {/if} +
{/if} @@ -321,8 +377,30 @@ async function handleSaveOptions() { + + { + switching = true + try { + await providerSetVersion(id, targetTag) + toasts.success(`Switched ${id} to ${targetTag}`) + await loadVersionsFor(id) + await refreshUpdates() + } catch (err) { + toasts.error(`Failed to switch version: ${extractErrorMessage(err)}`) + } finally { + switching = false + confirmSwitchOpen = false + } + }} +/> diff --git a/desktop/src/renderer/src/pages/ProvidersPage.svelte b/desktop/src/renderer/src/pages/ProvidersPage.svelte index f57337e67..edd4193eb 100644 --- a/desktop/src/renderer/src/pages/ProvidersPage.svelte +++ b/desktop/src/renderer/src/pages/ProvidersPage.svelte @@ -6,6 +6,7 @@ import { Plug, SearchX, } from "@lucide/svelte" +import { onMount } from "svelte" import { goto } from "$lib/router.js" import { Button } from "$lib/components/ui/button/index.js" import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js" @@ -13,10 +14,15 @@ import { Input } from "$lib/components/ui/input/index.js" import CardSkeleton from "$lib/components/ui/skeleton/CardSkeleton.svelte" import ProviderCard from "$lib/components/provider/ProviderCard.svelte" import { providers, providersLoading } from "$lib/stores/providers.js" +import { refreshUpdates } from "$lib/stores/providerVersions.js" let search = $state("") let sortBy = $state<"name" | "version">("name") +onMount(() => { + refreshUpdates().catch(() => {}) +}) + let filtered = $derived.by(() => { const q = search.toLowerCase() let list = $providers.filter((p) => { diff --git a/docs/pages/developing-providers/options.mdx b/docs/pages/developing-providers/options.mdx index 63874bc1f..090397281 100644 --- a/docs/pages/developing-providers/options.mdx +++ b/docs/pages/developing-providers/options.mdx @@ -59,16 +59,16 @@ Options will also be passed to the agent and can be used in the `agent.exec` sec Each option has a set of attributes that can modify how Devsy interprets it when configuring or adding the provider: -- `description`: Description shown in `devsy provider options` and in the Desktop App +- `description`: Description shown in `devsy provider get` and in the Desktop App - `default`: Default value of the option provided as a string. Can also reference other variables, e.g. `${MY_OTHER_VAR}-suffix` - `required`: Boolean if this option needs to be non-empty before using the provider. Devsy will ask in the CLI and make sure that this option is filled in the Desktop application. -- `password`: Boolean to indicate this is a sensitive value. Prevents this value from showing up in the `devsy provider options` command and will be a password field in the Desktop application. +- `password`: Boolean to indicate this is a sensitive value. Prevents this value from showing up in the `devsy provider get` command and will be a password field in the Desktop application. - `suggestions`: An array of suggestions for this option. Will be shown as auto complete options in the Devsy desktop application - `command`: A command to retrieve the option value automatically. Can also reference other variables in the command, e.g. `echo ${MY_OTHER_VAR}-suffix`. For compatibility reasons, this command will be executed in an emulated shell on Windows. - `local`: If true, the option will be filled individually for each machine / workspace - `global`: If true, the option will be reused for each machine / workspace - `cache`: If non-empty, Devsy will re-execute the command after the given timeout, e.g. if this is 5m, Devsy will re-execute the command after 5 minutes to re-fill this value. This is useful if you want to store a token or something that expires locally in a variable. -- `hidden`: If true, Devsy will not show this option in the Desktop application or through `devsy provider options`. Can be used to calculate variables internally or save tokens or other things internally. +- `hidden`: If true, Devsy will not show this option in the Desktop application or through `devsy provider get`. Can be used to calculate variables internally or save tokens or other things internally. ### Default values @@ -97,7 +97,7 @@ won't be shown when listing options. Example: ```sh -~$ devsy provider options civo +~$ devsy provider get civo NAME | REQUIRED | DESCRIPTION | DEFAULT | VALUE ----------------------------+----------+--------------------------------+--------------------------------------+--------------------------------------- diff --git a/docs/pages/managing-providers/add-provider.mdx b/docs/pages/managing-providers/add-provider.mdx index 0242f5927..82f293146 100644 --- a/docs/pages/managing-providers/add-provider.mdx +++ b/docs/pages/managing-providers/add-provider.mdx @@ -22,7 +22,7 @@ devsy provider add docker You can get a list of available 1st party providers by using the command: ``` -devsy provider list-available +devsy provider list --available ``` ## Via Devsy Desktop Application @@ -109,7 +109,7 @@ To manage options from the app, head over the `Providers` section, and click ### Managing options from Devsy CLI -Options for providers can be set during the `use` or `add` phase: +Options for providers can be set during the `configure` or `add` phase: ```sh devsy provider add -o KEY=value @@ -118,7 +118,7 @@ devsy provider add -o KEY=value or ```sh -devsy provider use -o KEY=value +devsy provider configure -o KEY=value ``` @@ -126,7 +126,7 @@ Else, to can manage options afterwards from CLI, you can list the table of provi ```sh -devsy provider options +devsy provider get ``` An example output for the AWS Provider is: @@ -158,16 +158,16 @@ INJECT_GIT_CREDENTIALS | false | If Devsy should inject git | true You can see this table as an overview, you can change each option using: ```sh -devsy provider set-options --option = +devsy provider set --option = ``` So for example, to change the default disk size from 40gb to 120gb, you could use: ```sh -devsy provider set-options aws --option AWS_DISK_SIZE=120 +devsy provider set aws --option AWS_DISK_SIZE=120 ``` -And check again the options for the provider using `devsy provider options aws`: +And check again the options for the provider using `devsy provider get aws`: ``` NAME | REQUIRED | DESCRIPTION | DEFAULT | VALUE @@ -204,7 +204,7 @@ In **the desktop app** the option is available in the option management interfac In **the CLI** you can set this option using: ```sh -devsy provider use --single-machine +devsy provider configure --single-machine ``` ## Default Provider @@ -219,7 +219,7 @@ In **the desktop app**, you can set a provider to be default in the option manag In **the CLI** you can set this option using: ```sh -devsy provider use +devsy provider default ``` ## Community Providers diff --git a/docs/pages/managing-providers/delete-provider.mdx b/docs/pages/managing-providers/remove-provider.mdx similarity index 86% rename from docs/pages/managing-providers/delete-provider.mdx rename to docs/pages/managing-providers/remove-provider.mdx index fc5f53fab..f9b1f2b11 100644 --- a/docs/pages/managing-providers/delete-provider.mdx +++ b/docs/pages/managing-providers/remove-provider.mdx @@ -1,6 +1,6 @@ --- -title: Delete a Provider -sidebar_label: Delete a Provider +title: Remove a Provider +sidebar_label: Remove a Provider --- ## Via Devsy Desktop Application @@ -16,7 +16,7 @@ remove that workspace, in order to then remove the provider. An already installed provider can be removed by using: ```sh -devsy provider delete +devsy provider remove ``` :::warning diff --git a/docs/pages/managing-providers/update-provider.mdx b/docs/pages/managing-providers/update-provider.mdx index b574b038e..394212648 100644 --- a/docs/pages/managing-providers/update-provider.mdx +++ b/docs/pages/managing-providers/update-provider.mdx @@ -10,10 +10,17 @@ To update a provider to its latest version, you can use: devsy provider update ``` -:::info -Be aware at this time, the desktop application does not have an update button -for a provider. -::: +To update to a specific version, pass `--version `: + +```sh +devsy provider update --version v0.2.0 +``` + +To list the available versions for an installed provider, use: + +```sh +devsy provider versions +``` ## From GitHub diff --git a/docs/pages/managing-providers/what-are-providers.mdx b/docs/pages/managing-providers/what-are-providers.mdx index 6780805d7..4d5009cc1 100644 --- a/docs/pages/managing-providers/what-are-providers.mdx +++ b/docs/pages/managing-providers/what-are-providers.mdx @@ -42,4 +42,4 @@ You can head over the next sections on how to manage Devsy providers: - [Add a Provider](./add-provider.mdx) - [Update a Provider](./update-provider.mdx) -- [Delete a provider](./delete-provider.mdx) +- [Remove a provider](./remove-provider.mdx) diff --git a/docs/pages/troubleshooting/linux-troubleshooting.mdx b/docs/pages/troubleshooting/linux-troubleshooting.mdx index 3e36dac97..84892affa 100644 --- a/docs/pages/troubleshooting/linux-troubleshooting.mdx +++ b/docs/pages/troubleshooting/linux-troubleshooting.mdx @@ -88,7 +88,7 @@ See the [Structured CLI errors](./troubleshooting.mdx) reference for the full li Rootless Podman places its socket at `$XDG_RUNTIME_DIR/podman/podman.sock`. If Devsy cannot connect, you will see the structured error code `PODMAN_SOCKET_UNAVAILABLE`. Either start the user socket with `systemctl --user start podman.socket`, or set `PODMAN_HOST` explicitly: ```bash -devsy provider set-options podman --option PODMAN_HOST=unix:///run/user/$(id -u)/podman/podman.sock +devsy provider set podman --option PODMAN_HOST=unix:///run/user/$(id -u)/podman/podman.sock ``` #### `podman compose` vs `docker-compose` diff --git a/docs/pages/tutorials/podman-provider-setup.mdx b/docs/pages/tutorials/podman-provider-setup.mdx index cad3728fa..e5bd30ed2 100644 --- a/docs/pages/tutorials/podman-provider-setup.mdx +++ b/docs/pages/tutorials/podman-provider-setup.mdx @@ -87,13 +87,13 @@ The Podman provider exposes three options: Set options after adding the provider using `--option` flags: ```bash -devsy provider set-options podman --option PODMAN_PATH=/usr/local/bin/podman +devsy provider set podman --option PODMAN_PATH=/usr/local/bin/podman ``` To see available options before setting them: ```bash -devsy provider options podman +devsy provider get podman ``` Or pass options inline at add time: @@ -168,7 +168,7 @@ ls -la $XDG_RUNTIME_DIR/podman/podman.sock If the socket path differs from the default, set `PODMAN_HOST` to the correct path: ```bash -devsy provider set-options podman --option PODMAN_HOST=unix:///run/user/$(id -u)/podman/podman.sock +devsy provider set podman --option PODMAN_HOST=unix:///run/user/$(id -u)/podman/podman.sock ``` ### Dockerfile features not supported during image build diff --git a/docs/sidebars.js b/docs/sidebars.js index 8b80ef3d0..ed188f97a 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -137,7 +137,7 @@ module.exports = { }, { type: "doc", - id: "managing-providers/delete-provider", + id: "managing-providers/remove-provider", }, { type: "doc", diff --git a/e2e/framework/command.go b/e2e/framework/command.go index 803a2168b..31736d4c3 100644 --- a/e2e/framework/command.go +++ b/e2e/framework/command.go @@ -17,6 +17,8 @@ const ( flagResultFormat = "--result-format" formatJSON = "json" cmdList = "list" + cmdGet = "get" + cmdProvider = "provider" ) func (f *Framework) FindWorkspace(ctx context.Context, id string) (*provider2.Workspace, error) { @@ -201,7 +203,7 @@ func (f *Framework) DevsyProviderOptionsCheckNamespaceDescription( ctx context.Context, provider, searchStr string, ) error { - err := f.ExecCommand(ctx, true, true, searchStr, []string{"provider", "options", provider}) + err := f.ExecCommand(ctx, true, true, searchStr, []string{cmdProvider, cmdGet, provider}) if err != nil { return fmt.Errorf( "did not found value %s in devsy provider options output. error: %s", @@ -213,7 +215,7 @@ func (f *Framework) DevsyProviderOptionsCheckNamespaceDescription( } func (f *Framework) DevsyProviderList(ctx context.Context, extraArgs ...string) error { - baseArgs := []string{"provider", cmdList} + baseArgs := []string{cmdProvider, cmdList} err := f.ExecCommand(ctx, false, true, "", append(baseArgs, extraArgs...)) if err != nil { return fmt.Errorf("devsy provider list failed: %s", err.Error()) @@ -226,10 +228,10 @@ func (f *Framework) DevsyProviderUse( provider string, extraArgs ...string, ) error { - baseArgs := []string{"provider", "use", provider} + baseArgs := []string{cmdProvider, "configure", provider} err := f.ExecCommand(ctx, false, true, "", append(baseArgs, extraArgs...)) if err != nil { - return fmt.Errorf("devsy provider use failed: %s", err.Error()) + return fmt.Errorf("devsy provider configure failed: %s", err.Error()) } return nil } @@ -274,7 +276,7 @@ func (f *Framework) DevsyDown(ctx context.Context, workspace string) error { } func (f *Framework) DevsyProviderAdd(ctx context.Context, args ...string) error { - baseArgs := []string{"provider", "add"} + baseArgs := []string{cmdProvider, "add"} baseArgs = append(baseArgs, args...) _, stderr, err := f.ExecCommandCapture(ctx, baseArgs) if err != nil { @@ -290,7 +292,7 @@ func (f *Framework) DevsyProviderAdd(ctx context.Context, args ...string) error } func (f *Framework) DevsyProviderDelete(ctx context.Context, args ...string) error { - baseArgs := []string{"provider", "delete"} + baseArgs := []string{cmdProvider, "remove"} baseArgs = append(baseArgs, args...) err := f.ExecCommand(ctx, false, false, "", baseArgs) if err != nil { @@ -306,7 +308,7 @@ func (f *Framework) DevsyProviderRename( oldName, newName string, args ...string, ) error { - baseArgs := []string{"provider", "rename", oldName, newName} + baseArgs := []string{cmdProvider, "rename", oldName, newName} baseArgs = append(baseArgs, args...) err := f.ExecCommand(ctx, false, false, "", baseArgs) if err != nil { @@ -332,12 +334,12 @@ func (f *Framework) DevsyRename( return nil } -// DevsyProviderOptionsJSON executes `devsy provider options --output json` and returns the raw JSON. +// DevsyProviderOptionsJSON executes `devsy provider get --result-format json` and returns the raw JSON. func (f *Framework) DevsyProviderOptionsJSON( ctx context.Context, providerName string, ) (string, error) { - args := []string{"provider", "options", providerName, flagResultFormat, formatJSON} + args := []string{cmdProvider, cmdGet, providerName, flagResultFormat, formatJSON} stdout, _, err := f.ExecCommandCapture(ctx, args) if err != nil { return "", fmt.Errorf("devsy provider options failed: %s", err.Error()) @@ -346,7 +348,7 @@ func (f *Framework) DevsyProviderOptionsJSON( } func (f *Framework) DevsyProviderUpdate(ctx context.Context, args ...string) error { - baseArgs := []string{"provider", "update"} + baseArgs := []string{cmdProvider, "update"} baseArgs = append(baseArgs, args...) err := f.ExecCommand(ctx, false, false, "", baseArgs) if err != nil { @@ -467,10 +469,10 @@ func (f *Framework) DevsyProviderFindOption( searchStr string, extraArgs ...string, ) error { - baseArgs := []string{"provider", "options", provider} + baseArgs := []string{cmdProvider, cmdGet, provider} err := f.ExecCommand(ctx, false, true, searchStr, append(baseArgs, extraArgs...)) if err != nil { - return fmt.Errorf("devsy provider use failed: %s", err.Error()) + return fmt.Errorf("devsy provider get failed: %s", err.Error()) } return nil } diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index 8580ebe0d..b197a1758 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -259,7 +259,7 @@ type ProviderBinary struct { } type ProviderCommands struct { - // Init is run directly after `devsy provider use` + // Init is run directly after `devsy provider configure` Init types.StrArray `json:"init,omitempty"` // Command executes a command on the server diff --git a/pkg/telemetry/collect.go b/pkg/telemetry/collect.go index a98f5454f..b4303fd4b 100644 --- a/pkg/telemetry/collect.go +++ b/pkg/telemetry/collect.go @@ -35,7 +35,6 @@ var UIEventsExceptions []string = []string{ config.BinaryName + " pro check-update", config.BinaryName + " ide list", config.BinaryName + " ide use", - config.BinaryName + " provider use", config.BinaryName + " version", config.BinaryName + " context options", } diff --git a/pkg/workspace/provider_version_cache.go b/pkg/workspace/provider_version_cache.go new file mode 100644 index 000000000..300e99437 --- /dev/null +++ b/pkg/workspace/provider_version_cache.go @@ -0,0 +1,92 @@ +package workspace + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "time" + + "github.com/devsy-org/devsy/pkg/config" +) + +const providerVersionCacheTTL = 6 * time.Hour + +type providerVersionCacheEntry struct { + SourceHash string `json:"sourceHash"` + Versions []ProviderVersion `json:"versions"` + FetchedAt time.Time `json:"fetchedAt"` +} + +type providerVersionCache map[string]providerVersionCacheEntry + +func providerVersionCachePath() (string, error) { + // Check for DEVSY_HOME override (primarily used in tests). + if home := os.Getenv(config.EnvHome); home != "" { + return filepath.Join(home, "cache", "provider-versions.json"), nil + } + dir, err := config.GetConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "cache", "provider-versions.json"), nil +} + +func LoadProviderVersionCache() (providerVersionCache, error) { + path, err := providerVersionCachePath() + if err != nil { + return nil, err + } + // #nosec G304 -- path is derived from providerVersionCachePath(), which controls the directory structure. + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return providerVersionCache{}, nil + } + return nil, err + } + cache := providerVersionCache{} + if err := json.Unmarshal(data, &cache); err != nil { + // Corrupt cache → start fresh. + return providerVersionCache{}, nil + } + return cache, nil +} + +func SaveProviderVersionCache(c providerVersionCache) error { + path, err := providerVersionCachePath() + if err != nil { + return err + } + // #nosec G301 -- cache directory should be world-readable for library use. + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return err + } + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + // #nosec G306 -- cache file is read-only user data, safe at 0o600. + return os.WriteFile(path, data, 0o600) +} + +func (c providerVersionCache) Get(name, sourceHash string) (providerVersionCacheEntry, bool) { + entry, ok := c[name] + if !ok { + return providerVersionCacheEntry{}, false + } + if entry.SourceHash != sourceHash { + return entry, false + } + if time.Since(entry.FetchedAt) > providerVersionCacheTTL { + return entry, false + } + return entry, true +} + +func hashProviderSource(source string) string { + sum := sha256.Sum256([]byte(source)) + return hex.EncodeToString(sum[:]) +} diff --git a/pkg/workspace/provider_version_cache_test.go b/pkg/workspace/provider_version_cache_test.go new file mode 100644 index 000000000..b58f44ae4 --- /dev/null +++ b/pkg/workspace/provider_version_cache_test.go @@ -0,0 +1,69 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestCacheRoundtrip(t *testing.T) { + dir := t.TempDir() + t.Setenv("DEVSY_HOME", dir) + + c := providerVersionCache{ + "foo": providerVersionCacheEntry{ + SourceHash: testNameABC, + Versions: []ProviderVersion{{Tag: testTagV100, PublishedAt: time.Now()}}, + FetchedAt: time.Now(), + }, + } + if err := SaveProviderVersionCache(c); err != nil { + t.Fatal(err) + } + loaded, err := LoadProviderVersionCache() + if err != nil { + t.Fatal(err) + } + if loaded["foo"].SourceHash != testNameABC { + t.Fatalf("roundtrip mismatch: %+v", loaded) + } + if _, err := os.Stat( + filepath.Join(dir, "."+"devsy", "cache", "provider-versions.json"), + ); err != nil { + // Path layout: DEVSY_HOME is the devsy config dir directly (per `config.GetConfigDir()`), + // so cache lives at $DEVSY_HOME/cache/provider-versions.json. If the assertion above fails, + // adjust to filepath.Join(dir, "cache", "provider-versions.json") instead. + alt := filepath.Join(dir, "cache", "provider-versions.json") + if _, err2 := os.Stat(alt); err2 != nil { + t.Fatalf("cache file not found at either expected location: %v / %v", err, err2) + } + } +} + +func TestCacheGet_FreshVsStale(t *testing.T) { + c := providerVersionCache{ + "foo": providerVersionCacheEntry{SourceHash: testNameABC, FetchedAt: time.Now()}, + testNameBar: providerVersionCacheEntry{ + SourceHash: testNameABC, + FetchedAt: time.Now().Add(-7 * time.Hour), + }, + } + if _, fresh := c.Get("foo", testNameABC); !fresh { + t.Fatal("expected fresh for foo") + } + if _, fresh := c.Get(testNameBar, testNameABC); fresh { + t.Fatal("expected stale for bar (older than TTL)") + } + if _, fresh := c.Get("foo", "different-hash"); fresh { + t.Fatal("source-hash mismatch must be treated as stale") + } +} + +func TestHashProviderSource_Stable(t *testing.T) { + a := hashProviderSource("github.com/foo/bar@v1.0.0") + b := hashProviderSource("github.com/foo/bar@v1.0.0") + if a != b || a == "" { + t.Fatalf("hash must be stable and non-empty: %q vs %q", a, b) + } +} diff --git a/pkg/workspace/provider_versions.go b/pkg/workspace/provider_versions.go new file mode 100644 index 000000000..ff6059ab6 --- /dev/null +++ b/pkg/workspace/provider_versions.go @@ -0,0 +1,246 @@ +package workspace + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/devsy-org/devsy/pkg/config" +) + +// ErrVersionListUnsupported indicates the provider's source type does not expose a list of versions. +var ErrVersionListUnsupported = errors.New("provider source does not support version listing") + +// ErrVersionListRateLimited indicates upstream rate-limiting hit the lister. +var ErrVersionListRateLimited = errors.New("provider version list rate-limited") + +// githubAPIBaseURL is the base URL for GitHub API calls; overridden in tests. +var githubAPIBaseURL = "https://api.github.com" + +// ProviderVersion describes one upstream release. +type ProviderVersion struct { + Tag string `json:"tag"` + PublishedAt time.Time `json:"publishedAt"` + Prerelease bool `json:"prerelease"` + Current bool `json:"current"` +} + +// ListProviderVersions returns available versions for the named provider, newest first. +// Returns ErrVersionListUnsupported when the source type can't be enumerated. +func ListProviderVersions( + devsyConfig *config.Config, + providerName string, + opts ListVersionsOptions, +) ([]ProviderVersion, error) { + source, err := ResolveProviderSource(devsyConfig, providerName) + if err != nil { + return nil, fmt.Errorf("resolve provider source: %w", err) + } + versions, err := listVersionsForSourceCached(providerName, source, opts) + if err != nil { + return nil, err + } + return markCurrent(versions, source), nil +} + +// listVersionsForSourceCached wraps listVersionsForSource with cache read/write. +// providerName is used as the cache key. When UseCache is true the cache is consulted; +// successful fetches always update the cache regardless. +func listVersionsForSourceCached( + providerName, source string, + opts ListVersionsOptions, +) ([]ProviderVersion, error) { + hash := hashProviderSource(source) + + if opts.UseCache { + if cache, err := LoadProviderVersionCache(); err == nil { + if entry, fresh := cache.Get(providerName, hash); fresh { + return append([]ProviderVersion(nil), entry.Versions...), nil + } + } + } + + versions, err := listVersionsForSource(source, opts) + if err != nil { + return nil, err + } + storeVersionCacheEntry(providerName, hash, versions) + return versions, nil +} + +// storeVersionCacheEntry persists the given versions under the provider name. +// Errors are intentionally swallowed — cache write failure should not block the lister. +func storeVersionCacheEntry(name, sourceHash string, versions []ProviderVersion) { + cache, err := LoadProviderVersionCache() + if err != nil || cache == nil { + cache = providerVersionCache{} + } + cache[name] = providerVersionCacheEntry{ + SourceHash: sourceHash, + Versions: versions, + FetchedAt: time.Now(), + } + _ = SaveProviderVersionCache(cache) +} + +// listVersionsForSource dispatches to the appropriate lister based on source shape. +// Separated from ListProviderVersions so it can be tested without a real Config. +func listVersionsForSource(source string, opts ListVersionsOptions) ([]ProviderVersion, error) { + switch classifyVersionSource(source) { + case sourceGitHub: + org, repo, ok := parseGitHubSourcePath(source) + if !ok { + return nil, fmt.Errorf("invalid github source: %s", source) + } + return listGitHubReleases(githubAPIBaseURL, org, repo, opts.IncludePrerelease) + case sourceManifestURL: + return listManifestVersions(source, opts.IncludePrerelease) + case sourceLocal, sourceUnknown: + return nil, ErrVersionListUnsupported + } + return nil, ErrVersionListUnsupported +} + +// markCurrent flags the version whose tag matches the source's pinned tag. +func markCurrent(versions []ProviderVersion, canonicalSource string) []ProviderVersion { + _, currentTag := splitSourceAndTag(canonicalSource) + if currentTag == "" { + return versions + } + for i := range versions { + if versions[i].Tag == currentTag { + versions[i].Current = true + } + } + return versions +} + +// ListVersionsOptions tunes the lister. +type ListVersionsOptions struct { + UseCache bool + IncludePrerelease bool +} + +// rewriteSourceTag returns the source with its version tag replaced by the given one. +func rewriteSourceTag(source, tag string) (string, error) { + if tag == "" { + return "", fmt.Errorf("version tag must not be empty") + } + base, _ := splitSourceAndTag(source) + return base + "@" + tag, nil +} + +// SetProviderVersion switches the provider to the given tag. +func SetProviderVersion(devsyConfig *config.Config, providerName, tag string) error { + source, err := ResolveProviderSource(devsyConfig, providerName) + if err != nil { + return fmt.Errorf("resolve provider source: %w", err) + } + rewritten, err := rewriteSourceTag(source, tag) + if err != nil { + return err + } + _, err = UpdateProvider(devsyConfig, providerName, rewritten) + return err +} + +type sourceKind int + +const ( + sourceUnknown sourceKind = iota + sourceGitHub + sourceManifestURL + sourceLocal +) + +func classifyVersionSource(canonical string) sourceKind { + // Strip @version suffix if present; only the leftmost @ counts as a tag separator. + bare := canonical + if before, _, ok := strings.Cut(canonical, "@"); ok { + bare = before + } + switch { + case strings.HasPrefix(bare, "github.com/"): + return sourceGitHub + case strings.HasPrefix(bare, "https://"), strings.HasPrefix(bare, "http://"): + return sourceManifestURL + case strings.HasPrefix(bare, "/"), + strings.HasPrefix(bare, "./"), + strings.HasPrefix(bare, "../"): + return sourceLocal + default: + return sourceUnknown + } +} + +func splitSourceAndTag(canonical string) (base, tag string) { + if before, after, ok := strings.Cut(canonical, "@"); ok { + return before, after + } + return canonical, "" +} + +// ProviderVersionCheckResult is the per-provider result from CheckAllProviderVersions. +type ProviderVersionCheckResult struct { + Current string `json:"current"` + Latest string `json:"latest"` + UpdateAvailable bool `json:"updateAvailable"` + Unsupported bool `json:"unsupported"` + Error string `json:"error,omitempty"` +} + +// CheckAllProviderVersions queries the latest version for every installed provider whose +// source supports version listing. Per-provider errors are recorded in the result map, not +// returned as a fatal error. Bypasses the cache so callers always see the freshest data. +func CheckAllProviderVersions( + devsyConfig *config.Config, +) (map[string]ProviderVersionCheckResult, error) { + providers, err := LoadAllProviders(devsyConfig) + if err != nil { + return nil, err + } + out := map[string]ProviderVersionCheckResult{} + for name, p := range providers { + out[name] = checkOneProviderVersion(devsyConfig, name, p) + } + return out, nil +} + +func checkOneProviderVersion( + devsyConfig *config.Config, + name string, + p *ProviderWithOptions, +) ProviderVersionCheckResult { + source, err := ResolveProviderSource(devsyConfig, name) + if err != nil { + return ProviderVersionCheckResult{Error: err.Error()} + } + _, currentTag := splitSourceAndTag(source) + if currentTag == "" && p != nil && p.Config != nil { + currentTag = p.Config.Version + } + versions, err := ListProviderVersions(devsyConfig, name, ListVersionsOptions{UseCache: false}) + if errors.Is(err, ErrVersionListUnsupported) { + return ProviderVersionCheckResult{Current: currentTag, Unsupported: true} + } + if err != nil { + return ProviderVersionCheckResult{Current: currentTag, Error: err.Error()} + } + return buildVersionCheckResult(currentTag, versions) +} + +func buildVersionCheckResult( + currentTag string, + versions []ProviderVersion, +) ProviderVersionCheckResult { + latest := "" + if len(versions) > 0 { + latest = versions[0].Tag + } + return ProviderVersionCheckResult{ + Current: currentTag, + Latest: latest, + UpdateAvailable: latest != "" && latest != currentTag, + } +} diff --git a/pkg/workspace/provider_versions_github.go b/pkg/workspace/provider_versions_github.go new file mode 100644 index 000000000..29d4c15bb --- /dev/null +++ b/pkg/workspace/provider_versions_github.go @@ -0,0 +1,96 @@ +package workspace + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "time" + + devsyhttp "github.com/devsy-org/devsy/pkg/http" +) + +type githubRelease struct { + TagName string `json:"tag_name"` + PublishedAt time.Time `json:"published_at"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` +} + +// listGitHubReleases calls the GitHub Releases API (rooted at baseURL — pass "https://api.github.com" in production) +// and returns versions newest first. Drafts are always filtered out; prereleases only when includePrerelease is true. +func listGitHubReleases( + baseURL, org, repo string, + includePrerelease bool, +) ([]ProviderVersion, error) { + releases, err := fetchGitHubReleases(baseURL, org, repo) + if err != nil { + return nil, err + } + return filterAndSortReleases(releases, includePrerelease), nil +} + +func fetchGitHubReleases(baseURL, org, repo string) ([]githubRelease, error) { + url := fmt.Sprintf("%s/repos/%s/%s/releases?per_page=50", baseURL, org, repo) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + resp, err := devsyhttp.GetHTTPClient().Do(req) + if err != nil { + return nil, fmt.Errorf("github releases request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusForbidden && resp.Header.Get("X-RateLimit-Remaining") == "0" { + return nil, ErrVersionListRateLimited + } + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("github releases %s: %d %s", url, resp.StatusCode, string(body)) + } + + var releases []githubRelease + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return nil, fmt.Errorf("decode github releases: %w", err) + } + return releases, nil +} + +func filterAndSortReleases(releases []githubRelease, includePrerelease bool) []ProviderVersion { + out := make([]ProviderVersion, 0, len(releases)) + for _, r := range releases { + if r.Draft { + continue + } + if r.Prerelease && !includePrerelease { + continue + } + out = append(out, ProviderVersion{ + Tag: r.TagName, + PublishedAt: r.PublishedAt, + Prerelease: r.Prerelease, + }) + } + sort.SliceStable(out, func(i, j int) bool { + return out[i].PublishedAt.After(out[j].PublishedAt) + }) + return out +} + +func parseGitHubSourcePath(canonical string) (org, repo string, ok bool) { + base, _ := splitSourceAndTag(canonical) + const prefix = "github.com/" + if len(base) <= len(prefix) { + return "", "", false + } + rest := base[len(prefix):] + for i := 0; i < len(rest); i++ { + if rest[i] == '/' { + return rest[:i], rest[i+1:], true + } + } + return "", "", false +} diff --git a/pkg/workspace/provider_versions_github_test.go b/pkg/workspace/provider_versions_github_test.go new file mode 100644 index 000000000..2f2ab7b79 --- /dev/null +++ b/pkg/workspace/provider_versions_github_test.go @@ -0,0 +1,91 @@ +package workspace + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestListGitHubReleases_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/devsy-org/provider-aws/releases" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode([]githubRelease{ + { + TagName: "v1.2.0", + PublishedAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), + Prerelease: false, + Draft: false, + }, + { + TagName: "v1.2.0-rc1", + PublishedAt: time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC), + Prerelease: true, + Draft: false, + }, + { + TagName: "v1.1.0", + PublishedAt: time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), + Prerelease: false, + Draft: false, + }, + {TagName: "draft-1", Draft: true}, + }) + })) + defer server.Close() + + versions, err := listGitHubReleases(server.URL, "devsy-org", "provider-aws", false) + if err != nil { + t.Fatal(err) + } + if len(versions) != 2 { + t.Fatalf("expected 2 non-prerelease releases, got %d", len(versions)) + } + if versions[0].Tag != "v1.2.0" { + t.Fatalf("expected newest first, got %q", versions[0].Tag) + } +} + +func TestListGitHubReleases_IncludePrerelease(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode([]githubRelease{ + {TagName: "v1.2.0-rc1", Prerelease: true, PublishedAt: time.Now()}, + }) + })) + defer server.Close() + + versions, err := listGitHubReleases(server.URL, "x", "y", true) + if err != nil || len(versions) != 1 || !versions[0].Prerelease { + t.Fatalf("prerelease must be included when flag set: %+v %v", versions, err) + } +} + +func TestListGitHubReleases_RateLimit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-RateLimit-Remaining", "0") + http.Error(w, "rate limited", http.StatusForbidden) + })) + defer server.Close() + _, err := listGitHubReleases(server.URL, "x", "y", false) + if err == nil || !errors.Is(err, ErrVersionListRateLimited) { + t.Fatalf("expected rate-limited sentinel, got %v", err) + } +} + +func TestParseGitHubSourcePath(t *testing.T) { + org, repo, ok := parseGitHubSourcePath("github.com/devsy-org/provider-aws@v1.0.0") + if !ok || org != "devsy-org" || repo != "provider-aws" { + t.Fatalf("got org=%q repo=%q ok=%v", org, repo, ok) + } + org, repo, ok = parseGitHubSourcePath("github.com/devsy-org/provider-aws") + if !ok || org != "devsy-org" || repo != "provider-aws" { + t.Fatalf("got org=%q repo=%q ok=%v", org, repo, ok) + } + if _, _, ok := parseGitHubSourcePath("github.com/onlyorg"); ok { + t.Fatal("missing repo segment must fail") + } +} diff --git a/pkg/workspace/provider_versions_manifest.go b/pkg/workspace/provider_versions_manifest.go new file mode 100644 index 000000000..776542024 --- /dev/null +++ b/pkg/workspace/provider_versions_manifest.go @@ -0,0 +1,83 @@ +package workspace + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "sort" + "time" + + devsyhttp "github.com/devsy-org/devsy/pkg/http" +) + +type versionsManifestEntry struct { + Tag string `json:"tag"` + PublishedAt time.Time `json:"published_at"` + Prerelease bool `json:"prerelease"` +} + +type versionsManifest struct { + Versions []versionsManifestEntry `json:"versions"` +} + +func listManifestVersions( + canonicalSource string, + includePrerelease bool, +) ([]ProviderVersion, error) { + base, _ := splitSourceAndTag(canonicalSource) + u, err := url.Parse(base) + if err != nil { + return nil, err + } + u.Path = path.Join(path.Dir(u.Path), "versions.json") + resp, err := devsyhttp.GetHTTPClient().Get(u.String()) + if err != nil { + return nil, fmt.Errorf("manifest fetch: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + manifest, err := decodeManifest(resp) + if err != nil { + return nil, err + } + + return filterAndSortManifestVersions(manifest, includePrerelease), nil +} + +func decodeManifest(resp *http.Response) (*versionsManifest, error) { + if resp.StatusCode == http.StatusNotFound { + return nil, ErrVersionListUnsupported + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("manifest: status %d", resp.StatusCode) + } + + var manifest versionsManifest + if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil { + return nil, fmt.Errorf("decode manifest: %w", err) + } + return &manifest, nil +} + +func filterAndSortManifestVersions( + manifest *versionsManifest, + includePrerelease bool, +) []ProviderVersion { + out := make([]ProviderVersion, 0, len(manifest.Versions)) + for _, v := range manifest.Versions { + if v.Prerelease && !includePrerelease { + continue + } + out = append(out, ProviderVersion{ + Tag: v.Tag, + PublishedAt: v.PublishedAt, + Prerelease: v.Prerelease, + }) + } + sort.SliceStable(out, func(i, j int) bool { + return out[i].PublishedAt.After(out[j].PublishedAt) + }) + return out +} diff --git a/pkg/workspace/provider_versions_manifest_test.go b/pkg/workspace/provider_versions_manifest_test.go new file mode 100644 index 000000000..fea430c13 --- /dev/null +++ b/pkg/workspace/provider_versions_manifest_test.go @@ -0,0 +1,40 @@ +package workspace + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestListManifestVersions_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/foo/versions.json" { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(versionsManifest{ + Versions: []versionsManifestEntry{ + {Tag: testTagV100, PublishedAt: time.Now(), Prerelease: false}, + }, + }) + })) + defer server.Close() + got, err := listManifestVersions(server.URL+"/foo/provider.yaml", false) + if err != nil || len(got) != 1 || got[0].Tag != testTagV100 { + t.Fatalf("got %+v err %v", got, err) + } +} + +func TestListManifestVersions_Missing(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + _, err := listManifestVersions(server.URL+"/foo/provider.yaml", false) + if !errors.Is(err, ErrVersionListUnsupported) { + t.Fatalf("expected ErrVersionListUnsupported, got %v", err) + } +} diff --git a/pkg/workspace/provider_versions_test.go b/pkg/workspace/provider_versions_test.go new file mode 100644 index 000000000..79cd5bd76 --- /dev/null +++ b/pkg/workspace/provider_versions_test.go @@ -0,0 +1,183 @@ +package workspace + +import ( + "encoding/json" + "errors" + "testing" + "time" +) + +func TestErrVersionListUnsupported(t *testing.T) { + if ErrVersionListUnsupported == nil { + t.Fatal("ErrVersionListUnsupported must be defined") + } + wrapped := errors.New("wrapped: " + ErrVersionListUnsupported.Error()) + if !errors.Is(errors.Join(ErrVersionListUnsupported, wrapped), ErrVersionListUnsupported) { + t.Fatal("errors.Is must work against ErrVersionListUnsupported") + } +} + +func TestProviderVersionFields(t *testing.T) { + v := ProviderVersion{Tag: testTagV100, Current: true} + if v.Tag != testTagV100 || !v.Current { + t.Fatal("fields must round-trip") + } +} + +func TestClassifyVersionSource(t *testing.T) { + cases := []struct { + in string + kind sourceKind + }{ + {"github.com/devsy-org/devsy-provider-aws@v1.2.0", sourceGitHub}, + {"github.com/devsy-org/devsy-provider-aws", sourceGitHub}, + {"https://example.com/foo/provider.yaml", sourceManifestURL}, + {"https://example.com/foo/provider.yaml@v1.0.0", sourceManifestURL}, + {"/abs/path/provider.yaml", sourceLocal}, + {"./relative/provider.yaml", sourceLocal}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + got := classifyVersionSource(c.in) + if got != c.kind { + t.Fatalf("got %v, want %v", got, c.kind) + } + }) + } +} + +func TestListVersionsForSource_LocalUnsupported(t *testing.T) { + _, err := listVersionsForSource("/abs/path/provider.yaml", ListVersionsOptions{}) + if !errors.Is(err, ErrVersionListUnsupported) { + t.Fatalf("local source must be unsupported, got %v", err) + } +} + +func TestListVersionsForSource_UnknownUnsupported(t *testing.T) { + _, err := listVersionsForSource("totally-bogus-source", ListVersionsOptions{}) + if !errors.Is(err, ErrVersionListUnsupported) { + t.Fatalf("unknown source must be unsupported, got %v", err) + } +} + +func TestListVersionsForSource_GitHubInvalid(t *testing.T) { + _, err := listVersionsForSource("github.com/missingrepo", ListVersionsOptions{}) + if err == nil || errors.Is(err, ErrVersionListUnsupported) { + t.Fatalf("github source missing repo segment must error (not unsupported): %v", err) + } +} + +func TestMarkCurrent(t *testing.T) { + versions := []ProviderVersion{{Tag: testTagV100}, {Tag: "v0.9.0"}} + got := markCurrent(versions, "github.com/foo/bar@v1.0.0") + if !got[0].Current || got[1].Current { + t.Fatalf("only v1.0.0 should be marked current: %+v", got) + } +} + +func TestMarkCurrent_NoTag(t *testing.T) { + versions := []ProviderVersion{{Tag: testTagV100}} + got := markCurrent(versions, "github.com/foo/bar") + if got[0].Current { + t.Fatal("no pinned tag → none current") + } +} + +func TestRewriteSourceTag(t *testing.T) { + got, err := rewriteSourceTag("github.com/foo/bar@v1.0.0", "v2.0.0") + if err != nil || got != "github.com/foo/bar@v2.0.0" { + t.Fatalf("got %q err %v", got, err) + } + got, err = rewriteSourceTag("github.com/foo/bar", "v2.0.0") + if err != nil || got != "github.com/foo/bar@v2.0.0" { + t.Fatalf("got %q err %v", got, err) + } + if _, err := rewriteSourceTag("github.com/foo/bar", ""); err == nil { + t.Fatal("empty tag must error") + } +} + +func TestListVersionsForSource_CachesResults(t *testing.T) { + dir := t.TempDir() + t.Setenv("DEVSY_HOME", dir) + + source := "github.com/foo/bar@v1.0.0" + hash := hashProviderSource(source) + + // Prime cache with a synthetic entry that doesn't match any real upstream. + cached := providerVersionCache{ + "myprov": { + SourceHash: hash, + Versions: []ProviderVersion{{Tag: testTagV999}}, + FetchedAt: time.Now(), + }, + } + if err := SaveProviderVersionCache(cached); err != nil { + t.Fatal(err) + } + + // listVersionsForSourceCached must read the cache when UseCache is set and the entry is fresh. + got, err := listVersionsForSourceCached("myprov", source, ListVersionsOptions{UseCache: true}) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Tag != testTagV999 { + t.Fatalf("expected cache hit, got %+v", got) + } +} + +func TestListVersionsForSource_BypassesCache(t *testing.T) { + dir := t.TempDir() + t.Setenv("DEVSY_HOME", dir) + + source := "/local/path/provider.yaml" + hash := hashProviderSource(source) + cached := providerVersionCache{ + "myprov": { + SourceHash: hash, + Versions: []ProviderVersion{{Tag: testTagV999}}, + FetchedAt: time.Now(), + }, + } + if err := SaveProviderVersionCache(cached); err != nil { + t.Fatal(err) + } + + // With UseCache=false the cache is ignored and the underlying classifier runs. + // Local source → ErrVersionListUnsupported. + _, err := listVersionsForSourceCached("myprov", source, ListVersionsOptions{UseCache: false}) + if !errors.Is(err, ErrVersionListUnsupported) { + t.Fatalf( + "expected ErrVersionListUnsupported when bypassing cache for local source, got %v", + err, + ) + } +} + +func TestProviderVersionCheckResult_UnsupportedShape(t *testing.T) { + // Verify the struct shape and JSON tags by marshalling. + r := ProviderVersionCheckResult{ + Current: testTagV100, + Unsupported: true, + } + data, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + expected := `{"current":"v1.0.0","latest":"","updateAvailable":false,"unsupported":true}` + if string(data) != expected { + t.Fatalf("JSON shape wrong:\ngot: %s\nwant: %s", data, expected) + } +} + +func TestProviderVersionCheckResult_ErrorShape(t *testing.T) { + r := ProviderVersionCheckResult{Error: "boom"} + data, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + expected := `{"current":"","latest":"","updateAvailable":false,"unsupported":false,"error":"boom"}` + if string(data) != expected { + t.Fatalf("JSON shape wrong:\ngot: %s\nwant: %s", data, expected) + } +} diff --git a/pkg/workspace/provider_versions_testconst_test.go b/pkg/workspace/provider_versions_testconst_test.go new file mode 100644 index 000000000..22c4299b1 --- /dev/null +++ b/pkg/workspace/provider_versions_testconst_test.go @@ -0,0 +1,8 @@ +package workspace + +const ( + testTagV100 = "v1.0.0" + testTagV999 = "v9.9.9" + testNameABC = "abc" + testNameBar = "bar" +) diff --git a/pkg/workspace/workspace.go b/pkg/workspace/workspace.go index e6a747353..3bca9d842 100644 --- a/pkg/workspace/workspace.go +++ b/pkg/workspace/workspace.go @@ -334,7 +334,7 @@ func createWorkspace( return nil, nil, nil, err } else if provider.State == nil || !provider.State.Initialized { return nil, nil, nil, fmt.Errorf( - "provider '%s' is not initialized, please make sure to run 'devsy provider use %s' "+ + "provider '%s' is not initialized, please make sure to run 'devsy provider configure %s' "+ "at least once before using this provider", provider.Config.Name, provider.Config.Name,