From 2e472de9578af724dbce9b5c6719518fa95ace68 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 04:55:09 -0500 Subject: [PATCH 01/34] chore(provider): scaffold new CLI files --- cmd/provider/configure.go | 1 + cmd/provider/configure_shared.go | 1 + cmd/provider/default.go | 1 + cmd/provider/versions.go | 1 + 4 files changed, 4 insertions(+) create mode 100644 cmd/provider/configure.go create mode 100644 cmd/provider/configure_shared.go create mode 100644 cmd/provider/default.go create mode 100644 cmd/provider/versions.go diff --git a/cmd/provider/configure.go b/cmd/provider/configure.go new file mode 100644 index 000000000..4f504f668 --- /dev/null +++ b/cmd/provider/configure.go @@ -0,0 +1 @@ +package provider diff --git a/cmd/provider/configure_shared.go b/cmd/provider/configure_shared.go new file mode 100644 index 000000000..4f504f668 --- /dev/null +++ b/cmd/provider/configure_shared.go @@ -0,0 +1 @@ +package provider diff --git a/cmd/provider/default.go b/cmd/provider/default.go new file mode 100644 index 000000000..4f504f668 --- /dev/null +++ b/cmd/provider/default.go @@ -0,0 +1 @@ +package provider diff --git a/cmd/provider/versions.go b/cmd/provider/versions.go new file mode 100644 index 000000000..4f504f668 --- /dev/null +++ b/cmd/provider/versions.go @@ -0,0 +1 @@ +package provider From d09dfbada287857833bef99997cf180425fb3321 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 04:57:11 -0500 Subject: [PATCH 02/34] feat(workspace): scaffold ProviderVersion type and listing API --- pkg/workspace/provider_versions.go | 43 +++++++++++++++++++++++++ pkg/workspace/provider_versions_test.go | 23 +++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 pkg/workspace/provider_versions.go create mode 100644 pkg/workspace/provider_versions_test.go diff --git a/pkg/workspace/provider_versions.go b/pkg/workspace/provider_versions.go new file mode 100644 index 000000000..ee9352631 --- /dev/null +++ b/pkg/workspace/provider_versions.go @@ -0,0 +1,43 @@ +package workspace + +import ( + "errors" + "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") + +// 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) { + return nil, ErrVersionListUnsupported +} + +// ListVersionsOptions tunes the lister. +type ListVersionsOptions struct { + UseCache bool + IncludePrerelease bool +} + +// SetProviderVersion switches the provider to the given tag. +func SetProviderVersion(devsyConfig *config.Config, providerName, tag string) error { + return errors.New("not implemented") +} diff --git a/pkg/workspace/provider_versions_test.go b/pkg/workspace/provider_versions_test.go new file mode 100644 index 000000000..18e23a275 --- /dev/null +++ b/pkg/workspace/provider_versions_test.go @@ -0,0 +1,23 @@ +package workspace + +import ( + "errors" + "testing" +) + +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: "v1.0.0", Prerelease: false, Current: true} + if v.Tag != "v1.0.0" || !v.Current { + t.Fatal("fields must round-trip") + } +} From e57a33857d6a43c05a85bd13467c62e9a398ae3b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 05:25:56 -0500 Subject: [PATCH 03/34] feat(workspace): classify provider source shape for version listing --- pkg/workspace/provider_versions.go | 38 +++++++++++++++++++++++++ pkg/workspace/provider_versions_test.go | 22 ++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/pkg/workspace/provider_versions.go b/pkg/workspace/provider_versions.go index ee9352631..ab48dc0e5 100644 --- a/pkg/workspace/provider_versions.go +++ b/pkg/workspace/provider_versions.go @@ -2,6 +2,7 @@ package workspace import ( "errors" + "strings" "time" "github.com/devsy-org/devsy/pkg/config" @@ -41,3 +42,40 @@ type ListVersionsOptions struct { func SetProviderVersion(devsyConfig *config.Config, providerName, tag string) error { return errors.New("not implemented") } + +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 + } +} + +// nolint:unused // will be used in subsequent version-selection tasks +func splitSourceAndTag(canonical string) (base, tag string) { + if before, after, ok := strings.Cut(canonical, "@"); ok { + return before, after + } + return canonical, "" +} diff --git a/pkg/workspace/provider_versions_test.go b/pkg/workspace/provider_versions_test.go index 18e23a275..8fd557cf6 100644 --- a/pkg/workspace/provider_versions_test.go +++ b/pkg/workspace/provider_versions_test.go @@ -21,3 +21,25 @@ func TestProviderVersionFields(t *testing.T) { 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) + } + }) + } +} From ec0528024fa2deef80fa8c2ef464328a3fd745ad Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 05:30:31 -0500 Subject: [PATCH 04/34] feat(workspace): list provider versions from GitHub Releases --- pkg/workspace/provider_versions.go | 1 - pkg/workspace/provider_versions_github.go | 96 +++++++++++++++++++ .../provider_versions_github_test.go | 91 ++++++++++++++++++ 3 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 pkg/workspace/provider_versions_github.go create mode 100644 pkg/workspace/provider_versions_github_test.go diff --git a/pkg/workspace/provider_versions.go b/pkg/workspace/provider_versions.go index ab48dc0e5..2ffcc10ed 100644 --- a/pkg/workspace/provider_versions.go +++ b/pkg/workspace/provider_versions.go @@ -72,7 +72,6 @@ func classifyVersionSource(canonical string) sourceKind { } } -// nolint:unused // will be used in subsequent version-selection tasks func splitSourceAndTag(canonical string) (base, tag string) { if before, after, ok := strings.Cut(canonical, "@"); ok { return before, after 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") + } +} From 00eb2417802c6fe18a9d4c99a563b6e2dd2e0910 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 05:33:42 -0500 Subject: [PATCH 05/34] feat(workspace): list provider versions from sibling manifest --- pkg/workspace/provider_versions_manifest.go | 83 +++++++++++++++++++ .../provider_versions_manifest_test.go | 40 +++++++++ 2 files changed, 123 insertions(+) create mode 100644 pkg/workspace/provider_versions_manifest.go create mode 100644 pkg/workspace/provider_versions_manifest_test.go 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..f20e5edee --- /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: "v1.0.0", 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 != "v1.0.0" { + 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) + } +} From 5e1bf9ddf9ee945bec7b86f8a68195be40028296 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 05:37:28 -0500 Subject: [PATCH 06/34] feat(workspace): dispatch ListProviderVersions by source kind --- pkg/workspace/provider_versions.go | 44 +++++++++++++++++++++++++ pkg/workspace/provider_versions_test.go | 37 +++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/pkg/workspace/provider_versions.go b/pkg/workspace/provider_versions.go index 2ffcc10ed..18b7fb83f 100644 --- a/pkg/workspace/provider_versions.go +++ b/pkg/workspace/provider_versions.go @@ -2,6 +2,7 @@ package workspace import ( "errors" + "fmt" "strings" "time" @@ -14,6 +15,9 @@ var ErrVersionListUnsupported = errors.New("provider source does not support ver // 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"` @@ -29,9 +33,49 @@ func ListProviderVersions( 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 := listVersionsForSource(source, opts) + if err != nil { + return nil, err + } + return markCurrent(versions, source), nil +} + +// 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 diff --git a/pkg/workspace/provider_versions_test.go b/pkg/workspace/provider_versions_test.go index 8fd557cf6..92ec21953 100644 --- a/pkg/workspace/provider_versions_test.go +++ b/pkg/workspace/provider_versions_test.go @@ -43,3 +43,40 @@ func TestClassifyVersionSource(t *testing.T) { }) } } + +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: "v1.0.0"}, {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: "v1.0.0"}} + got := markCurrent(versions, "github.com/foo/bar") + if got[0].Current { + t.Fatal("no pinned tag → none current") + } +} From dcc8c06d04aad54b58c457f20b99c5b9662b19ee Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 05:40:00 -0500 Subject: [PATCH 07/34] feat(workspace): implement SetProviderVersion --- pkg/workspace/provider_versions.go | 29 ++++++++++++++++++++++++- pkg/workspace/provider_versions_test.go | 25 +++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/pkg/workspace/provider_versions.go b/pkg/workspace/provider_versions.go index 18b7fb83f..559b18884 100644 --- a/pkg/workspace/provider_versions.go +++ b/pkg/workspace/provider_versions.go @@ -15,6 +15,11 @@ var ErrVersionListUnsupported = errors.New("provider source does not support ver // ErrVersionListRateLimited indicates upstream rate-limiting hit the lister. var ErrVersionListRateLimited = errors.New("provider version list rate-limited") +// ErrInvalidProviderSourceForVersionSwap indicates a source string cannot be safely rewritten with a version tag. +var ErrInvalidProviderSourceForVersionSwap = errors.New( + "provider source cannot be safely rewritten with a version tag", +) + // githubAPIBaseURL is the base URL for GitHub API calls; overridden in tests. var githubAPIBaseURL = "https://api.github.com" @@ -82,9 +87,31 @@ type ListVersionsOptions struct { IncludePrerelease bool } +// rewriteSourceTag returns the source with its version tag replaced by the given one. +// Errors if the tag is empty or the source contains multiple @ signs in non-version positions. +func rewriteSourceTag(source, tag string) (string, error) { + if tag == "" { + return "", fmt.Errorf("version tag must not be empty") + } + base, _ := splitSourceAndTag(source) + if strings.Contains(base, "@") { + return "", ErrInvalidProviderSourceForVersionSwap + } + return base + "@" + tag, nil +} + // SetProviderVersion switches the provider to the given tag. func SetProviderVersion(devsyConfig *config.Config, providerName, tag string) error { - return errors.New("not implemented") + 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 diff --git a/pkg/workspace/provider_versions_test.go b/pkg/workspace/provider_versions_test.go index 92ec21953..c2820acac 100644 --- a/pkg/workspace/provider_versions_test.go +++ b/pkg/workspace/provider_versions_test.go @@ -80,3 +80,28 @@ func TestMarkCurrent_NoTag(t *testing.T) { 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 TestRewriteSourceTag_RejectsAmbiguousAt(t *testing.T) { + // If splitSourceAndTag yields a base that itself contains @, we cannot safely swap. + // Construct such a case: source with two @ signs. + // splitSourceAndTag splits on the FIRST @, so for "a@b@c", base="a", tag="b@c". + // base="a" does NOT contain @ — so this case passes. To get a base WITH @ we'd need a + // source like "a@@b" — base="a", tag="@b". Still base has no @. + // The check is defensive; without splitSourceAndTag changing semantics, this branch + // is hard to trigger naturally. Skip if not triggerable. + t.Skip("base-with-@ branch is defensive; not triggerable via splitSourceAndTag") +} From 32d6c6f046be51bd67bb659103497848435b4673 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 05:43:46 -0500 Subject: [PATCH 08/34] feat(workspace): persistent provider-version cache --- pkg/workspace/provider_version_cache.go | 92 ++++++++++++++++++++ pkg/workspace/provider_version_cache_test.go | 69 +++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 pkg/workspace/provider_version_cache.go create mode 100644 pkg/workspace/provider_version_cache_test.go 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..e01db3d18 --- /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: "abc", + Versions: []ProviderVersion{{Tag: "v1.0.0", 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 != "abc" { + 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: "abc", FetchedAt: time.Now()}, + "bar": providerVersionCacheEntry{ + SourceHash: "abc", + FetchedAt: time.Now().Add(-7 * time.Hour), + }, + } + if _, fresh := c.Get("foo", "abc"); !fresh { + t.Fatal("expected fresh for foo") + } + if _, fresh := c.Get("bar", "abc"); 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) + } +} From b47bdadd61eb3143d8fb889da154b3a90d1ec878 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 05:47:13 -0500 Subject: [PATCH 09/34] feat(workspace): cache provider version listings --- pkg/workspace/provider_versions.go | 42 +++++++++++++++++- pkg/workspace/provider_versions_test.go | 58 +++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/pkg/workspace/provider_versions.go b/pkg/workspace/provider_versions.go index 559b18884..10b799b83 100644 --- a/pkg/workspace/provider_versions.go +++ b/pkg/workspace/provider_versions.go @@ -42,13 +42,53 @@ func ListProviderVersions( if err != nil { return nil, fmt.Errorf("resolve provider source: %w", err) } - versions, err := listVersionsForSource(source, opts) + 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) { diff --git a/pkg/workspace/provider_versions_test.go b/pkg/workspace/provider_versions_test.go index c2820acac..0ad8305d5 100644 --- a/pkg/workspace/provider_versions_test.go +++ b/pkg/workspace/provider_versions_test.go @@ -3,6 +3,7 @@ package workspace import ( "errors" "testing" + "time" ) func TestErrVersionListUnsupported(t *testing.T) { @@ -105,3 +106,60 @@ func TestRewriteSourceTag_RejectsAmbiguousAt(t *testing.T) { // is hard to trigger naturally. Skip if not triggerable. t.Skip("base-with-@ branch is defensive; not triggerable via splitSourceAndTag") } + +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: "v9.9.9"}}, + 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 != "v9.9.9" { + 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: "v9.9.9"}}, + 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, + ) + } +} From d7bd7351e89f9b79ca2d6243fd36ddbae22c7864 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 05:50:43 -0500 Subject: [PATCH 10/34] feat(workspace): aggregate per-provider update checks via CheckAllProviderVersions --- pkg/workspace/provider_versions.go | 64 +++++++++++++++++++++++++ pkg/workspace/provider_versions_test.go | 29 +++++++++++ 2 files changed, 93 insertions(+) diff --git a/pkg/workspace/provider_versions.go b/pkg/workspace/provider_versions.go index 10b799b83..ae13991fb 100644 --- a/pkg/workspace/provider_versions.go +++ b/pkg/workspace/provider_versions.go @@ -189,3 +189,67 @@ func splitSourceAndTag(canonical string) (base, tag string) { } 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_test.go b/pkg/workspace/provider_versions_test.go index 0ad8305d5..9512bc836 100644 --- a/pkg/workspace/provider_versions_test.go +++ b/pkg/workspace/provider_versions_test.go @@ -1,6 +1,7 @@ package workspace import ( + "encoding/json" "errors" "testing" "time" @@ -163,3 +164,31 @@ func TestListVersionsForSource_BypassesCache(t *testing.T) { ) } } + +func TestProviderVersionCheckResult_UnsupportedShape(t *testing.T) { + // Verify the struct shape and JSON tags by marshalling. + r := ProviderVersionCheckResult{ + Current: "v1.0.0", + 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) + } +} From 2d5e7bd309e7968bab565fced60196d19eb2d3ab Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 05:56:44 -0500 Subject: [PATCH 11/34] refactor(provider): extract shared configure helpers; configure no longer writes default --- cmd/provider/configure_shared.go | 146 +++++++++++++++++++++++++++++ cmd/provider/use.go | 155 +------------------------------ 2 files changed, 149 insertions(+), 152 deletions(-) diff --git a/cmd/provider/configure_shared.go b/cmd/provider/configure_shared.go index 4f504f668..7016f5e25 100644 --- a/cmd/provider/configure_shared.go +++ b/cmd/provider/configure_shared.go @@ -1 +1,147 @@ package provider + +import ( + "bytes" + "context" + "fmt" + "io" + + "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" +) + +type ProviderOptionsConfig struct { + Provider *provider2.ProviderConfig + Context string + UserOptions []string + Reconfigure bool + SkipRequired bool + SkipInit bool + SkipSubOptions bool + SingleMachine *bool +} + +func ConfigureProvider(ctx context.Context, cfg ProviderOptionsConfig) error { + devsyConfig, err := configureProviderOptions(ctx, cfg) + if err != nil { + return err + } + + // 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) + } + + log.Infof("configured provider %s", cfg.Provider.Name) + return nil +} + +func mergeExistingOptions( + options map[string]string, + existingOptions map[string]config.OptionValue, +) { + for k, v := range existingOptions { + if _, ok := options[k]; !ok && v.UserProvided { + options[k] = v.Value + } + } +} + +func configureProviderOptions( + ctx context.Context, + cfg ProviderOptionsConfig, +) (*config.Config, error) { + devsyConfig, err := config.LoadConfig(cfg.Context, "") + if err != nil { + return nil, err + } + + cfg.UserOptions = options2.InheritOptionsFromEnvironment( + cfg.UserOptions, + cfg.Provider.Options, + config.EnvProviderPrefix+cfg.Provider.Name+"_", + ) + + // parse options + options, err := provider2.ParseOptions(cfg.UserOptions) + if err != nil { + return nil, fmt.Errorf("parse options: %w", err) + } + + // merge with old values + if !cfg.Reconfigure { + mergeExistingOptions(options, devsyConfig.ProviderOptions(cfg.Provider.Name)) + } + + // fill defaults + devsyConfig, err = options2.ResolveOptions( + ctx, devsyConfig, cfg.Provider, options, + cfg.SkipRequired, cfg.SkipSubOptions, cfg.SingleMachine, + ) + if err != nil { + return nil, fmt.Errorf("resolve options: %w", err) + } + + // run init command + if !cfg.SkipInit { + stdout := log.Writer(log.LevelInfo) + defer func() { _ = stdout.Close() }() + + stderr := log.Writer(log.LevelError) + defer func() { _ = stderr.Close() }() + + err = initProvider(ctx, devsyConfig, cfg.Provider, initIO{stdout: stdout, stderr: stderr}) + if err != nil { + return nil, err + } + } + + return devsyConfig, nil +} + +type initIO struct { + stdout io.Writer + stderr io.Writer +} + +func initProvider( + ctx context.Context, + devsyConfig *config.Config, + provider *provider2.ProviderConfig, + 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 + // to fingerprint, not just an opaque "exit status 1". + stderrBuf := &bytes.Buffer{} + err := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{ + Ctx: ctx, + Name: "init", + Command: provider.Exec.Init, + Context: devsyConfig.DefaultContext, + Options: devsyConfig.ProviderOptions(provider.Name), + Config: provider, + Stdout: io2.stdout, + Stderr: io.MultiWriter(io2.stderr, stderrBuf), + }) + if err != nil { + return cliErrors.Classify(fmt.Errorf("init: %w", err), cliErrors.ClassifyContext{ + Provider: provider.Name, + Stderr: stderrBuf.String(), + }) + } + if devsyConfig.Current().Providers == nil { + devsyConfig.Current().Providers = map[string]*config.ProviderConfig{} + } + if devsyConfig.Current().Providers[provider.Name] == nil { + devsyConfig.Current().Providers[provider.Name] = &config.ProviderConfig{} + } + devsyConfig.Current().Providers[provider.Name].Initialized = true + return nil +} diff --git a/cmd/provider/use.go b/cmd/provider/use.go index 2b045dcf4..0bd8db3d2 100644 --- a/cmd/provider/use.go +++ b/cmd/provider/use.go @@ -1,19 +1,13 @@ package provider import ( - "bytes" "context" "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" ) @@ -101,154 +95,11 @@ func (cmd *UseCmd) Run(ctx context.Context, providerName string) error { 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 - UserOptions []string - Reconfigure bool - SkipRequired bool - SkipInit bool - SkipSubOptions bool - SingleMachine *bool -} - -func ConfigureProvider(ctx context.Context, cfg ProviderOptionsConfig) error { - devsyConfig, err := configureProviderOptions(ctx, cfg) - if err != nil { - return err - } - - // set options - defaultContext := devsyConfig.Current() - defaultContext.DefaultProvider = cfg.Provider.Name - - // save provider config - err = config.SaveConfig(devsyConfig) - if err != nil { - return fmt.Errorf("save config: %w", err) - } - - log.Infof("configured provider %s", cfg.Provider.Name) - return nil -} - -func mergeExistingOptions( - options map[string]string, - existingOptions map[string]config.OptionValue, -) { - for k, v := range existingOptions { - if _, ok := options[k]; !ok && v.UserProvided { - options[k] = v.Value - } - } -} - -func configureProviderOptions( - ctx context.Context, - cfg ProviderOptionsConfig, -) (*config.Config, error) { - devsyConfig, err := config.LoadConfig(cfg.Context, "") - if err != nil { - return nil, err - } - - cfg.UserOptions = options2.InheritOptionsFromEnvironment( - cfg.UserOptions, - cfg.Provider.Options, - config.EnvProviderPrefix+cfg.Provider.Name+"_", - ) - - // parse options - options, err := provider2.ParseOptions(cfg.UserOptions) - if err != nil { - return nil, fmt.Errorf("parse options: %w", err) - } - - // merge with old values - if !cfg.Reconfigure { - mergeExistingOptions(options, devsyConfig.ProviderOptions(cfg.Provider.Name)) - } - - // fill defaults - devsyConfig, err = options2.ResolveOptions( - ctx, devsyConfig, cfg.Provider, options, - cfg.SkipRequired, cfg.SkipSubOptions, cfg.SingleMachine, + log.Infof( + "To reconfigure provider %s, run with '--reconfigure' to reconfigure the provider", + providerWithOptions.Config.Name, ) - if err != nil { - return nil, fmt.Errorf("resolve options: %w", err) - } - - // run init command - if !cfg.SkipInit { - stdout := log.Writer(log.LevelInfo) - defer func() { _ = stdout.Close() }() - - stderr := log.Writer(log.LevelError) - defer func() { _ = stderr.Close() }() - - err = initProvider(ctx, devsyConfig, cfg.Provider, stdout, stderr) - if err != nil { - return nil, err - } - } - - return devsyConfig, nil -} - -func initProvider( - ctx context.Context, - devsyConfig *config.Config, - provider *provider2.ProviderConfig, - stdout, stderr io.Writer, -) 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 - // to fingerprint, not just an opaque "exit status 1". - stderrBuf := &bytes.Buffer{} - err := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{ - Ctx: ctx, - Name: "init", - Command: provider.Exec.Init, - Context: devsyConfig.DefaultContext, - Options: devsyConfig.ProviderOptions(provider.Name), - Config: provider, - Stdout: stdout, - Stderr: io.MultiWriter(stderr, stderrBuf), - }) - if err != nil { - return cliErrors.Classify(fmt.Errorf("init: %w", err), cliErrors.ClassifyContext{ - Provider: provider.Name, - Stderr: stderrBuf.String(), - }) - } - if devsyConfig.Current().Providers == nil { - devsyConfig.Current().Providers = map[string]*config.ProviderConfig{} - } - if devsyConfig.Current().Providers[provider.Name] == nil { - devsyConfig.Current().Providers[provider.Name] = &config.ProviderConfig{} - } - devsyConfig.Current().Providers[provider.Name].Initialized = true return nil } From 49b14e08a65562bb1aaa0564828f777a202e4ca8 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 05:59:45 -0500 Subject: [PATCH 12/34] refactor(provider): add/update --use explicitly writes DefaultProvider --- cmd/provider/add.go | 10 ++++++++++ cmd/provider/update.go | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/cmd/provider/add.go b/cmd/provider/add.go index 712152d8e..002e28515 100644 --- a/cmd/provider/add.go +++ b/cmd/provider/add.go @@ -143,6 +143,16 @@ func (cmd *AddCmd) Run(ctx context.Context, devsyConfig *config.Config, args []s return fmt.Errorf("configure provider: %w", configureErr) } + // Write DefaultProvider explicitly after successful configure + freshConfig, err := config.LoadConfig(cmd.Context, "") + if err != nil { + return fmt.Errorf("reload config: %w", err) + } + freshConfig.Current().DefaultProvider = providerConfig.Name + if err := config.SaveConfig(freshConfig); err != nil { + return fmt.Errorf("save default provider: %w", err) + } + return nil } diff --git a/cmd/provider/update.go b/cmd/provider/update.go index 6e203cb61..18d473dc2 100644 --- a/cmd/provider/update.go +++ b/cmd/provider/update.go @@ -85,6 +85,16 @@ func (cmd *UpdateCmd) Run(ctx context.Context, devsyConfig *config.Config, args return fmt.Errorf("configure provider: %w", err) } + // Write DefaultProvider explicitly after successful configure + freshConfig, err := config.LoadConfig(cmd.Context, "") + if err != nil { + return fmt.Errorf("reload config: %w", err) + } + freshConfig.Current().DefaultProvider = providerConfig.Name + if err := config.SaveConfig(freshConfig); err != nil { + return fmt.Errorf("save default provider: %w", err) + } + return nil } From 7942b7a10b0d68a8b052e166a315b0ec5bda67f4 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 06:02:45 -0500 Subject: [PATCH 13/34] feat(provider): add 'configure' subcommand --- cmd/provider/configure.go | 75 ++++++++++++++++++++++++++++++++ cmd/provider/configure_shared.go | 12 +++++ cmd/provider/configure_test.go | 39 +++++++++++++++++ cmd/provider/provider.go | 11 ++--- cmd/provider/set_options.go | 10 ----- 5 files changed, 132 insertions(+), 15 deletions(-) create mode 100644 cmd/provider/configure_test.go diff --git a/cmd/provider/configure.go b/cmd/provider/configure.go index 4f504f668..ccc8fc311 100644 --- a/cmd/provider/configure.go +++ b/cmd/provider/configure.go @@ -1 +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/configure_shared.go b/cmd/provider/configure_shared.go index 7016f5e25..73dcb7d64 100644 --- a/cmd/provider/configure_shared.go +++ b/cmd/provider/configure_shared.go @@ -105,6 +105,18 @@ func configureProviderOptions( return devsyConfig, 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 +} + type initIO struct { stdout io.Writer stderr io.Writer diff --git a/cmd/provider/configure_test.go b/cmd/provider/configure_test.go new file mode 100644 index 000000000..37d1a9d1d --- /dev/null +++ b/cmd/provider/configure_test.go @@ -0,0 +1,39 @@ +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{"foo"}, "fallback"); err != nil || got != "foo" { + 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/provider.go b/cmd/provider/provider.go index c740b4dd1..d3247735b 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(NewAddCmd(flags)) + providerCmd.AddCommand(NewConfigureCmd(flags)) + providerCmd.AddCommand(NewDeleteCmd(flags)) 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(NewRenameCmd(flags)) + providerCmd.AddCommand(NewSetOptionsCmd(flags)) + providerCmd.AddCommand(NewUpdateCmd(flags)) + providerCmd.AddCommand(NewUseCmd(flags)) return providerCmd } diff --git a/cmd/provider/set_options.go b/cmd/provider/set_options.go index e11b24f38..60d72230d 100644 --- a/cmd/provider/set_options.go +++ b/cmd/provider/set_options.go @@ -105,16 +105,6 @@ 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 - } - if defaultProvider == "" { - return "", fmt.Errorf("please specify a provider") - } - return defaultProvider, nil -} - func (cmd *SetOptionsCmd) saveOrPrintConfig( devsyConfig *config.Config, providerWithOptions *workspace.ProviderWithOptions, From 289908fd77979041a641dbfd4b355cab62cd3877 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 06:05:56 -0500 Subject: [PATCH 14/34] feat(provider): add 'default' subcommand --- cmd/provider/default.go | 57 ++++++++++++++++++++++++++++++++++++ cmd/provider/default_test.go | 27 +++++++++++++++++ cmd/provider/provider.go | 1 + 3 files changed, 85 insertions(+) create mode 100644 cmd/provider/default_test.go diff --git a/cmd/provider/default.go b/cmd/provider/default.go index 4f504f668..f29db9966 100644 --- a/cmd/provider/default.go +++ b/cmd/provider/default.go @@ -1 +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..3c580263f --- /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{"foo"}); err != nil { + t.Errorf("expected no error for 1 arg, got %v", err) + } + if err := cmd.Args(cmd, []string{"foo", "bar"}); err == nil { + t.Error("expected error for 2 args") + } +} diff --git a/cmd/provider/provider.go b/cmd/provider/provider.go index d3247735b..75801b48d 100644 --- a/cmd/provider/provider.go +++ b/cmd/provider/provider.go @@ -14,6 +14,7 @@ func NewProviderCmd(flags *flags.GlobalFlags) *cobra.Command { providerCmd.AddCommand(NewAddCmd(flags)) providerCmd.AddCommand(NewConfigureCmd(flags)) + providerCmd.AddCommand(NewDefaultCmd(flags)) providerCmd.AddCommand(NewDeleteCmd(flags)) providerCmd.AddCommand(NewListCmd(flags)) providerCmd.AddCommand(NewListAvailableCmd(flags)) From a60f435951df6008e7f781923fb653dd9e16fc31 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 06:08:04 -0500 Subject: [PATCH 15/34] refactor(provider): rename delete subcommand to remove --- cmd/provider/provider.go | 2 +- cmd/provider/{delete.go => remove.go} | 16 ++++++++-------- cmd/provider/{delete_test.go => remove_test.go} | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) rename cmd/provider/{delete.go => remove.go} (92%) rename cmd/provider/{delete_test.go => remove_test.go} (54%) diff --git a/cmd/provider/provider.go b/cmd/provider/provider.go index 75801b48d..723b01fc3 100644 --- a/cmd/provider/provider.go +++ b/cmd/provider/provider.go @@ -15,7 +15,7 @@ func NewProviderCmd(flags *flags.GlobalFlags) *cobra.Command { providerCmd.AddCommand(NewAddCmd(flags)) providerCmd.AddCommand(NewConfigureCmd(flags)) providerCmd.AddCommand(NewDefaultCmd(flags)) - providerCmd.AddCommand(NewDeleteCmd(flags)) + providerCmd.AddCommand(NewRemoveCmd(flags)) providerCmd.AddCommand(NewListCmd(flags)) providerCmd.AddCommand(NewListAvailableCmd(flags)) providerCmd.AddCommand(NewOptionsCmd(flags)) 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") } } From fb35c9e31f7179829571f5a94d40a75e8eb4b26a Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 06:12:04 -0500 Subject: [PATCH 16/34] refactor(provider): merge list-available into list --available --- cmd/provider/list.go | 170 +++++++++++++++++++++++++-------- cmd/provider/list_available.go | 94 ------------------ cmd/provider/provider.go | 1 - 3 files changed, 132 insertions(+), 133 deletions(-) delete mode 100644 cmd/provider/list_available.go diff --git a/cmd/provider/list.go b/cmd/provider/list.go index 658134334..4eb501b94 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,126 @@ 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, + } + } + + 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}) } - fmt.Print(string(out)) } + 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/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 723b01fc3..52499af62 100644 --- a/cmd/provider/provider.go +++ b/cmd/provider/provider.go @@ -17,7 +17,6 @@ func NewProviderCmd(flags *flags.GlobalFlags) *cobra.Command { providerCmd.AddCommand(NewDefaultCmd(flags)) providerCmd.AddCommand(NewRemoveCmd(flags)) providerCmd.AddCommand(NewListCmd(flags)) - providerCmd.AddCommand(NewListAvailableCmd(flags)) providerCmd.AddCommand(NewOptionsCmd(flags)) providerCmd.AddCommand(NewRenameCmd(flags)) providerCmd.AddCommand(NewSetOptionsCmd(flags)) From ceb47af6e47000299bad02bbe3f3b2e49b4a234c Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 06:15:21 -0500 Subject: [PATCH 17/34] refactor(provider): rename options/set-options to get/set --- cmd/provider/{options.go => get.go} | 22 ++++++++--------- cmd/provider/provider.go | 4 ++-- cmd/provider/{set_options.go => set.go} | 32 ++++++++++++------------- e2e/framework/command.go | 8 +++---- 4 files changed, 33 insertions(+), 33 deletions(-) rename cmd/provider/{options.go => get.go} (91%) rename cmd/provider/{set_options.go => set.go} (81%) diff --git a/cmd/provider/options.go b/cmd/provider/get.go similarity index 91% rename from cmd/provider/options.go rename to cmd/provider/get.go index ecd829c2c..9c706a886 100644 --- a/cmd/provider/options.go +++ b/cmd/provider/get.go @@ -20,21 +20,21 @@ 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", RunE: func(cobraCmd *cobra.Command, args []string) error { return cmd.Run(cobraCmd.Context(), args) }, @@ -50,9 +50,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,7 +63,7 @@ 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 diff --git a/cmd/provider/provider.go b/cmd/provider/provider.go index 52499af62..0ad702225 100644 --- a/cmd/provider/provider.go +++ b/cmd/provider/provider.go @@ -17,9 +17,9 @@ func NewProviderCmd(flags *flags.GlobalFlags) *cobra.Command { providerCmd.AddCommand(NewDefaultCmd(flags)) providerCmd.AddCommand(NewRemoveCmd(flags)) providerCmd.AddCommand(NewListCmd(flags)) - providerCmd.AddCommand(NewOptionsCmd(flags)) + providerCmd.AddCommand(NewGetCmd(flags)) providerCmd.AddCommand(NewRenameCmd(flags)) - providerCmd.AddCommand(NewSetOptionsCmd(flags)) + providerCmd.AddCommand(NewSetCmd(flags)) providerCmd.AddCommand(NewUpdateCmd(flags)) providerCmd.AddCommand(NewUseCmd(flags)) return providerCmd diff --git a/cmd/provider/set_options.go b/cmd/provider/set.go similarity index 81% rename from cmd/provider/set_options.go rename to cmd/provider/set.go index 60d72230d..c39416264 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,20 +48,20 @@ 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 { +func (cmd *SetCmd) Run(ctx context.Context, args []string) error { devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) if err != nil { return err @@ -105,7 +105,7 @@ func (cmd *SetOptionsCmd) Run(ctx context.Context, args []string) error { return nil } -func (cmd *SetOptionsCmd) saveOrPrintConfig( +func (cmd *SetCmd) saveOrPrintConfig( devsyConfig *config.Config, providerWithOptions *workspace.ProviderWithOptions, ) error { diff --git a/e2e/framework/command.go b/e2e/framework/command.go index 803a2168b..8dd1c8c67 100644 --- a/e2e/framework/command.go +++ b/e2e/framework/command.go @@ -201,7 +201,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{"provider", "get", provider}) if err != nil { return fmt.Errorf( "did not found value %s in devsy provider options output. error: %s", @@ -332,12 +332,12 @@ func (f *Framework) DevsyRename( return nil } -// DevsyProviderOptionsJSON executes `devsy provider options --output json` and returns the raw JSON. +// DevsyProviderOptionsJSON executes `devsy provider get --output 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{"provider", "get", providerName, flagResultFormat, formatJSON} stdout, _, err := f.ExecCommandCapture(ctx, args) if err != nil { return "", fmt.Errorf("devsy provider options failed: %s", err.Error()) @@ -467,7 +467,7 @@ func (f *Framework) DevsyProviderFindOption( searchStr string, extraArgs ...string, ) error { - baseArgs := []string{"provider", "options", provider} + baseArgs := []string{"provider", "get", provider} err := f.ExecCommand(ctx, false, true, searchStr, append(baseArgs, extraArgs...)) if err != nil { return fmt.Errorf("devsy provider use failed: %s", err.Error()) From 92a4138dd25e5897015592ee33e76126979d233e Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 06:17:45 -0500 Subject: [PATCH 18/34] refactor(provider): delete 'use' subcommand --- cmd/provider/use.go | 105 -------------------------------------------- 1 file changed, 105 deletions(-) delete mode 100644 cmd/provider/use.go diff --git a/cmd/provider/use.go b/cmd/provider/use.go deleted file mode 100644 index 0bd8db3d2..000000000 --- a/cmd/provider/use.go +++ /dev/null @@ -1,105 +0,0 @@ -package provider - -import ( - "context" - "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" -) - -// 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, - }) - } - - log.Infof( - "To reconfigure provider %s, run with '--reconfigure' to reconfigure the provider", - providerWithOptions.Config.Name, - ) - return nil -} From bb12ea7297eca4e7bd2813d1626745b23dd7a6bd Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 06:19:45 -0500 Subject: [PATCH 19/34] feat(provider): update --version flag --- cmd/provider/add.go | 4 ++-- cmd/provider/provider.go | 2 +- cmd/provider/update.go | 23 ++++++++++++++++++++--- cmd/provider/update_test.go | 14 ++++++++++++++ cmd/provider/versions.go | 27 +++++++++++++++++++++++++++ cmd/use/use.go | 6 ------ pkg/provider/provider.go | 2 +- pkg/telemetry/collect.go | 1 - pkg/workspace/workspace.go | 2 +- 9 files changed, 66 insertions(+), 15 deletions(-) create mode 100644 cmd/provider/update_test.go diff --git a/cmd/provider/add.go b/cmd/provider/add.go index 002e28515..46b585127 100644 --- a/cmd/provider/add.go +++ b/cmd/provider/add.go @@ -156,8 +156,8 @@ func (cmd *AddCmd) Run(ctx context.Context, devsyConfig *config.Config, args []s return nil } - 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/provider.go b/cmd/provider/provider.go index 0ad702225..36b806128 100644 --- a/cmd/provider/provider.go +++ b/cmd/provider/provider.go @@ -21,6 +21,6 @@ func NewProviderCmd(flags *flags.GlobalFlags) *cobra.Command { providerCmd.AddCommand(NewRenameCmd(flags)) providerCmd.AddCommand(NewSetCmd(flags)) providerCmd.AddCommand(NewUpdateCmd(flags)) - providerCmd.AddCommand(NewUseCmd(flags)) + providerCmd.AddCommand(NewVersionsCmd(flags)) return providerCmd } diff --git a/cmd/provider/update.go b/cmd/provider/update.go index 18d473dc2..6fdee9eaf 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,28 @@ 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 != "" { + // --version mode: mutually exclusive with source argument + 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,7 +96,7 @@ 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) @@ -98,7 +115,7 @@ func (cmd *UpdateCmd) Run(ctx context.Context, devsyConfig *config.Config, args return nil } - 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 index 4f504f668..e3c98ce26 100644 --- a/cmd/provider/versions.go +++ b/cmd/provider/versions.go @@ -1 +1,28 @@ package provider + +import ( + "errors" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/spf13/cobra" +) + +// VersionsCmd holds the cmd flags. +type VersionsCmd struct { + *flags.GlobalFlags +} + +// NewVersionsCmd creates the cobra command for `provider versions`. +// Note: This is a stub for Task 2.9 registration; Task 2.10 fills in real flags and behavior. +func NewVersionsCmd(f *flags.GlobalFlags) *cobra.Command { + cmd := &VersionsCmd{GlobalFlags: f} + _ = cmd + return &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 { + return errors.New("versions subcommand not yet implemented") + }, + } +} 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/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/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, From 3104d90945c12ed60eef511414d0bd50091c8001 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 06:22:10 -0500 Subject: [PATCH 20/34] feat(provider): add 'versions' subcommand --- cmd/provider/versions.go | 86 ++++++++++++++++++++++++++++++++--- cmd/provider/versions_test.go | 29 ++++++++++++ 2 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 cmd/provider/versions_test.go diff --git a/cmd/provider/versions.go b/cmd/provider/versions.go index e3c98ce26..b48e4d6f4 100644 --- a/cmd/provider/versions.go +++ b/cmd/provider/versions.go @@ -1,28 +1,102 @@ package provider import ( - "errors" + "encoding/json" + "fmt" + "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. +// 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`. -// Note: This is a stub for Task 2.9 registration; Task 2.10 fills in real flags and behavior. func NewVersionsCmd(f *flags.GlobalFlags) *cobra.Command { cmd := &VersionsCmd{GlobalFlags: f} - _ = cmd - return &cobra.Command{ + 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 { - return errors.New("versions subcommand not yet implemented") + 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 *os.File, 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..b646cc079 --- /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{"foo"}); err != nil { + t.Errorf("1 arg should be ok, got %v", err) + } + if err := cmd.Args(cmd, []string{"foo", "bar"}); err == nil { + t.Error("2 args should fail") + } +} From be9238746e355a5005f06468f19d5417acf48826 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 06:23:23 -0500 Subject: [PATCH 21/34] refactor: update internal references to renamed provider subcommands --- e2e/framework/command.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/e2e/framework/command.go b/e2e/framework/command.go index 8dd1c8c67..e0b1a46d5 100644 --- a/e2e/framework/command.go +++ b/e2e/framework/command.go @@ -226,10 +226,10 @@ func (f *Framework) DevsyProviderUse( provider string, extraArgs ...string, ) error { - baseArgs := []string{"provider", "use", provider} + baseArgs := []string{"provider", "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 } @@ -290,7 +290,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{"provider", "remove"} baseArgs = append(baseArgs, args...) err := f.ExecCommand(ctx, false, false, "", baseArgs) if err != nil { From 1a39157bab2a681f8a53b93a4d4e48d9c7d14a97 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 06:26:31 -0500 Subject: [PATCH 22/34] fix(desktop): update IPC handlers to new provider CLI verbs --- desktop/src/main/__tests__/cli.test.ts | 2 +- desktop/src/main/ipc.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) 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/ipc.ts b/desktop/src/main/ipc.ts index 072fe730f..927a45172 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -117,11 +117,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 +130,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 +146,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 +180,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) } From f99e42d6d65bddbebe6af5bc6071e6592099538c Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 06:28:38 -0500 Subject: [PATCH 23/34] feat(desktop): add IPC for provider version listing, swapping, and update-check --- desktop/src/main/ipc.ts | 74 ++++++++++++++++++++ desktop/src/renderer/src/lib/ipc/commands.ts | 17 +++++ desktop/src/renderer/src/lib/types/index.ts | 15 ++++ 3 files changed, 106 insertions(+) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 927a45172..f9f4ec1b7 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -201,6 +201,80 @@ 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 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 + }) + // ── Machines ── ipcMain.handle("machine_list", () => state.machineList()) 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/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 } From ce9c987c7c4454747f54ec43707fcdccf097c075 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 06:30:33 -0500 Subject: [PATCH 24/34] feat(desktop): periodic provider update check on launch and every 6h --- desktop/src/main/index.ts | 5 +- desktop/src/main/ipc.ts | 135 +++++++++++++++++++++++++------------- 2 files changed, 92 insertions(+), 48 deletions(-) diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 8e87ecfa5..c054e8242 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -156,7 +156,7 @@ app.whenReady().then(() => { }) // Register IPC handlers - const { tunnelProcesses } = registerIpcHandlers({ + const { tunnelProcesses, scheduleProviderUpdateCheck } = registerIpcHandlers({ cli, state, logStore, @@ -173,6 +173,9 @@ app.whenReady().then(() => { }) watcher.start() + // Schedule periodic provider update checks after the watcher populates initial state + scheduleProviderUpdateCheck() + // Set up system tray const appTray = new AppTray({ state, diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index f9f4ec1b7..fcc93f00d 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,56 @@ 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: typeof scheduleProviderUpdateCheck } { 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()) @@ -227,54 +284,15 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: M ) ipcMain.handle("provider_check_updates", async () => { - 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, - } - } - } - }), - ) + const out = await computeUpdateChecks() + providerUpdateCache = out return out }) + ipcMain.handle("provider_get_update_cache", async () => { + return providerUpdateCache + }) + // ── Machines ── ipcMain.handle("machine_list", () => state.machineList()) @@ -937,7 +955,30 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: M }, ) - return { tunnelProcesses } + // Wrap computeUpdateChecks for scheduling. This closure captures it safely. + function scheduleUpdates(): void { + // Run immediately on launch. + void (async () => { + try { + providerUpdateCache = await computeUpdateChecks() + } catch { + // Silently swallow background errors. + } + })() + + // Then schedule every 6 hours. + setInterval(() => { + void (async () => { + try { + providerUpdateCache = await computeUpdateChecks() + } catch { + // Silently swallow background errors. + } + })() + }, 6 * 60 * 60 * 1000) + } + + return { tunnelProcesses, scheduleProviderUpdateCheck: scheduleUpdates } } function sanitizeAnalyticsProperties( From c398fdbc80a08f012d8f0683158f24be4f8de90f Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 06:32:24 -0500 Subject: [PATCH 25/34] feat(desktop): providerVersions store --- .../src/lib/stores/providerVersions.test.ts | 79 +++++++++++++++++++ .../src/lib/stores/providerVersions.ts | 45 +++++++++++ 2 files changed, 124 insertions(+) create mode 100644 desktop/src/renderer/src/lib/stores/providerVersions.test.ts create mode 100644 desktop/src/renderer/src/lib/stores/providerVersions.ts 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) +} From 3294b5c48547161990caf7e379a9f5fd477a86d2 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 06:34:03 -0500 Subject: [PATCH 26/34] feat(desktop): default badge + update indicator on provider cards --- .../src/lib/components/provider/ProviderCard.svelte | 9 ++++++++- desktop/src/renderer/src/pages/ProvidersPage.svelte | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) 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() + }) }) From a810e64c7ab6b6422dafd2a713249d04fd8ab711 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 06:39:40 -0500 Subject: [PATCH 28/34] feat(desktop): version selection and default badge on provider detail --- .../src/pages/ProviderDetailPage.svelte | 84 ++++++++++++++++++- 1 file changed, 81 insertions(+), 3 deletions(-) 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 + } + }} +/> From cc6221934ce7a687e8e35a71acc448b95db13138 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 06:41:06 -0500 Subject: [PATCH 29/34] feat(desktop): wizard removes default-promotion step --- .../components/provider/ProviderWizard.svelte | 23 +++---------------- 1 file changed, 3 insertions(+), 20 deletions(-) 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} From b7be02261c8268233cf8a461a3c00d25e0ea3cda Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 06:42:44 -0500 Subject: [PATCH 30/34] feat(desktop): pre-select default provider in workspace wizard --- .../components/workspace/WorkspaceWizard.svelte | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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} From d274e66d849520d008e763e2b9822609d607a6ec Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 06:46:53 -0500 Subject: [PATCH 31/34] docs: update provider CLI references to new subcommand layout --- docs/pages/developing-providers/options.mdx | 8 ++++---- docs/pages/managing-providers/add-provider.mdx | 18 +++++++++--------- ...delete-provider.mdx => remove-provider.mdx} | 6 +++--- .../managing-providers/update-provider.mdx | 12 ++++++++++++ .../managing-providers/what-are-providers.mdx | 2 +- .../troubleshooting/linux-troubleshooting.mdx | 2 +- docs/pages/tutorials/podman-provider-setup.mdx | 6 +++--- docs/sidebars.js | 2 +- 8 files changed, 34 insertions(+), 22 deletions(-) rename docs/pages/managing-providers/{delete-provider.mdx => remove-provider.mdx} (86%) 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..553384940 100644 --- a/docs/pages/managing-providers/update-provider.mdx +++ b/docs/pages/managing-providers/update-provider.mdx @@ -10,6 +10,18 @@ To update a provider to its latest version, you can use: devsy provider update ``` +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 +``` + :::info Be aware at this time, the desktop application does not have an update button for a provider. 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", From a83a1b603e5cb74dda9b0423f2e4df21d093ae87 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 07:41:07 -0500 Subject: [PATCH 32/34] fix: address CI lint, e2e, and commitlint failures - reduce cyclomatic complexity in provider get/set Run via helpers - extract goconst-flagged literals into shared test constants - update mock-devsy fixture for new provider verbs (remove/configure/default/versions/get/set) - update workspace wizard e2e to expect pre-selected default provider --- cmd/provider/configure_shared.go | 11 +++++ cmd/provider/configure_test.go | 3 +- cmd/provider/default_test.go | 4 +- cmd/provider/get.go | 19 +++----- cmd/provider/set.go | 44 ++++++++++++------- cmd/provider/testconst_test.go | 6 +++ cmd/provider/versions_test.go | 4 +- desktop/e2e/fixtures/mock-devsy.cjs | 19 +++++++- desktop/e2e/workspaces.e2e.ts | 8 ++-- e2e/framework/command.go | 20 +++++---- pkg/workspace/provider_version_cache_test.go | 16 +++---- .../provider_versions_manifest_test.go | 4 +- pkg/workspace/provider_versions_test.go | 16 +++---- .../provider_versions_testconst_test.go | 8 ++++ 14 files changed, 112 insertions(+), 70 deletions(-) create mode 100644 cmd/provider/testconst_test.go create mode 100644 pkg/workspace/provider_versions_testconst_test.go diff --git a/cmd/provider/configure_shared.go b/cmd/provider/configure_shared.go index 73dcb7d64..ff368d1a0 100644 --- a/cmd/provider/configure_shared.go +++ b/cmd/provider/configure_shared.go @@ -117,6 +117,17 @@ func resolveProviderName(args []string, defaultProvider string) (string, error) 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 diff --git a/cmd/provider/configure_test.go b/cmd/provider/configure_test.go index 37d1a9d1d..b21c1f845 100644 --- a/cmd/provider/configure_test.go +++ b/cmd/provider/configure_test.go @@ -27,7 +27,8 @@ func TestNewConfigureCmd(t *testing.T) { } func TestResolveProviderName(t *testing.T) { - if got, err := resolveProviderName([]string{"foo"}, "fallback"); err != nil || got != "foo" { + 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" { diff --git a/cmd/provider/default_test.go b/cmd/provider/default_test.go index 3c580263f..ab6ce5cb4 100644 --- a/cmd/provider/default_test.go +++ b/cmd/provider/default_test.go @@ -18,10 +18,10 @@ func TestNewDefaultCmd_Wiring(t *testing.T) { if err := cmd.Args(cmd, []string{}); err == nil { t.Error("expected error for 0 args") } - if err := cmd.Args(cmd, []string{"foo"}); err != nil { + 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{"foo", "bar"}); err == nil { + if err := cmd.Args(cmd, []string{testProviderFoo, testProviderBar}); err == nil { t.Error("expected error for 2 args") } } diff --git a/cmd/provider/get.go b/cmd/provider/get.go index 9c706a886..7e7c3c616 100644 --- a/cmd/provider/get.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" @@ -69,19 +67,12 @@ func (cmd *GetCmd) Run(ctx context.Context, args []string) error { 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/set.go b/cmd/provider/set.go index c39416264..3d9935419 100644 --- a/cmd/provider/set.go +++ b/cmd/provider/set.go @@ -62,23 +62,7 @@ func NewSetCmd(f *flags.GlobalFlags) *cobra.Command { } func (cmd *SetCmd) 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) + devsyConfig, providerWithOptions, err := cmd.loadProvider(args) if err != nil { return err } @@ -105,6 +89,32 @@ func (cmd *SetCmd) Run(ctx context.Context, args []string) error { return 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 + } + 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 devsyConfig, providerWithOptions, nil +} + func (cmd *SetCmd) saveOrPrintConfig( devsyConfig *config.Config, providerWithOptions *workspace.ProviderWithOptions, 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/versions_test.go b/cmd/provider/versions_test.go index b646cc079..2606f94f7 100644 --- a/cmd/provider/versions_test.go +++ b/cmd/provider/versions_test.go @@ -20,10 +20,10 @@ func TestNewVersionsCmd_Wiring(t *testing.T) { if err := cmd.Args(cmd, []string{}); err != nil { t.Errorf("0 args should be ok, got %v", err) } - if err := cmd.Args(cmd, []string{"foo"}); err != nil { + 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{"foo", "bar"}); err == nil { + if err := cmd.Args(cmd, []string{testProviderFoo, testProviderBar}); err == nil { t.Error("2 args should fail") } } diff --git a/desktop/e2e/fixtures/mock-devsy.cjs b/desktop/e2e/fixtures/mock-devsy.cjs index 26c8f14ee..4b5685816 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: { @@ -221,6 +222,7 @@ switch (cmd) { out("") break } + case "remove": case "delete": { const provName = extra if (provName && state.providers[provName]) { @@ -243,19 +245,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/e2e/framework/command.go b/e2e/framework/command.go index e0b1a46d5..6ed333002 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", "get", 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,7 +228,7 @@ func (f *Framework) DevsyProviderUse( provider string, extraArgs ...string, ) error { - baseArgs := []string{"provider", "configure", provider} + baseArgs := []string{cmdProvider, "configure", provider} err := f.ExecCommand(ctx, false, true, "", append(baseArgs, extraArgs...)) if err != nil { return fmt.Errorf("devsy provider configure failed: %s", err.Error()) @@ -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", "remove"} + 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 { @@ -337,7 +339,7 @@ func (f *Framework) DevsyProviderOptionsJSON( ctx context.Context, providerName string, ) (string, error) { - args := []string{"provider", "get", 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,7 +469,7 @@ func (f *Framework) DevsyProviderFindOption( searchStr string, extraArgs ...string, ) error { - baseArgs := []string{"provider", "get", 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()) diff --git a/pkg/workspace/provider_version_cache_test.go b/pkg/workspace/provider_version_cache_test.go index e01db3d18..b58f44ae4 100644 --- a/pkg/workspace/provider_version_cache_test.go +++ b/pkg/workspace/provider_version_cache_test.go @@ -13,8 +13,8 @@ func TestCacheRoundtrip(t *testing.T) { c := providerVersionCache{ "foo": providerVersionCacheEntry{ - SourceHash: "abc", - Versions: []ProviderVersion{{Tag: "v1.0.0", PublishedAt: time.Now()}}, + SourceHash: testNameABC, + Versions: []ProviderVersion{{Tag: testTagV100, PublishedAt: time.Now()}}, FetchedAt: time.Now(), }, } @@ -25,7 +25,7 @@ func TestCacheRoundtrip(t *testing.T) { if err != nil { t.Fatal(err) } - if loaded["foo"].SourceHash != "abc" { + if loaded["foo"].SourceHash != testNameABC { t.Fatalf("roundtrip mismatch: %+v", loaded) } if _, err := os.Stat( @@ -43,16 +43,16 @@ func TestCacheRoundtrip(t *testing.T) { func TestCacheGet_FreshVsStale(t *testing.T) { c := providerVersionCache{ - "foo": providerVersionCacheEntry{SourceHash: "abc", FetchedAt: time.Now()}, - "bar": providerVersionCacheEntry{ - SourceHash: "abc", + "foo": providerVersionCacheEntry{SourceHash: testNameABC, FetchedAt: time.Now()}, + testNameBar: providerVersionCacheEntry{ + SourceHash: testNameABC, FetchedAt: time.Now().Add(-7 * time.Hour), }, } - if _, fresh := c.Get("foo", "abc"); !fresh { + if _, fresh := c.Get("foo", testNameABC); !fresh { t.Fatal("expected fresh for foo") } - if _, fresh := c.Get("bar", "abc"); fresh { + if _, fresh := c.Get(testNameBar, testNameABC); fresh { t.Fatal("expected stale for bar (older than TTL)") } if _, fresh := c.Get("foo", "different-hash"); fresh { diff --git a/pkg/workspace/provider_versions_manifest_test.go b/pkg/workspace/provider_versions_manifest_test.go index f20e5edee..fea430c13 100644 --- a/pkg/workspace/provider_versions_manifest_test.go +++ b/pkg/workspace/provider_versions_manifest_test.go @@ -17,13 +17,13 @@ func TestListManifestVersions_Success(t *testing.T) { } _ = json.NewEncoder(w).Encode(versionsManifest{ Versions: []versionsManifestEntry{ - {Tag: "v1.0.0", PublishedAt: time.Now(), Prerelease: false}, + {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 != "v1.0.0" { + if err != nil || len(got) != 1 || got[0].Tag != testTagV100 { t.Fatalf("got %+v err %v", got, err) } } diff --git a/pkg/workspace/provider_versions_test.go b/pkg/workspace/provider_versions_test.go index 9512bc836..ecd644bdd 100644 --- a/pkg/workspace/provider_versions_test.go +++ b/pkg/workspace/provider_versions_test.go @@ -18,8 +18,8 @@ func TestErrVersionListUnsupported(t *testing.T) { } func TestProviderVersionFields(t *testing.T) { - v := ProviderVersion{Tag: "v1.0.0", Prerelease: false, Current: true} - if v.Tag != "v1.0.0" || !v.Current { + v := ProviderVersion{Tag: testTagV100, Current: true} + if v.Tag != testTagV100 || !v.Current { t.Fatal("fields must round-trip") } } @@ -68,7 +68,7 @@ func TestListVersionsForSource_GitHubInvalid(t *testing.T) { } func TestMarkCurrent(t *testing.T) { - versions := []ProviderVersion{{Tag: "v1.0.0"}, {Tag: "v0.9.0"}} + 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) @@ -76,7 +76,7 @@ func TestMarkCurrent(t *testing.T) { } func TestMarkCurrent_NoTag(t *testing.T) { - versions := []ProviderVersion{{Tag: "v1.0.0"}} + versions := []ProviderVersion{{Tag: testTagV100}} got := markCurrent(versions, "github.com/foo/bar") if got[0].Current { t.Fatal("no pinned tag → none current") @@ -119,7 +119,7 @@ func TestListVersionsForSource_CachesResults(t *testing.T) { cached := providerVersionCache{ "myprov": { SourceHash: hash, - Versions: []ProviderVersion{{Tag: "v9.9.9"}}, + Versions: []ProviderVersion{{Tag: testTagV999}}, FetchedAt: time.Now(), }, } @@ -132,7 +132,7 @@ func TestListVersionsForSource_CachesResults(t *testing.T) { if err != nil { t.Fatal(err) } - if len(got) != 1 || got[0].Tag != "v9.9.9" { + if len(got) != 1 || got[0].Tag != testTagV999 { t.Fatalf("expected cache hit, got %+v", got) } } @@ -146,7 +146,7 @@ func TestListVersionsForSource_BypassesCache(t *testing.T) { cached := providerVersionCache{ "myprov": { SourceHash: hash, - Versions: []ProviderVersion{{Tag: "v9.9.9"}}, + Versions: []ProviderVersion{{Tag: testTagV999}}, FetchedAt: time.Now(), }, } @@ -168,7 +168,7 @@ func TestListVersionsForSource_BypassesCache(t *testing.T) { func TestProviderVersionCheckResult_UnsupportedShape(t *testing.T) { // Verify the struct shape and JSON tags by marshalling. r := ProviderVersionCheckResult{ - Current: "v1.0.0", + Current: testTagV100, Unsupported: true, } data, err := json.Marshal(r) 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" +) From 6b44ffed5f80e43084b86a04e6ac281a24e89afa Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 10:13:05 -0500 Subject: [PATCH 33/34] fix: address coderabbit review comments on PR #465 - update --version with no provider name now returns clear error - run initial provider update check after watcher's first poll completes to avoid querying empty provider state - fix stale "provider use failed" error string and incorrect "--output json" comment in e2e framework - remove outdated docs note claiming desktop has no update button --- cmd/provider/update.go | 6 ++-- desktop/src/main/index.ts | 10 ++++--- desktop/src/main/ipc.ts | 29 +++++++++---------- desktop/src/main/watcher.ts | 4 +-- .../managing-providers/update-provider.mdx | 5 ---- e2e/framework/command.go | 4 +-- 6 files changed, 28 insertions(+), 30 deletions(-) diff --git a/cmd/provider/update.go b/cmd/provider/update.go index 6fdee9eaf..54298cbd9 100644 --- a/cmd/provider/update.go +++ b/cmd/provider/update.go @@ -50,8 +50,10 @@ func NewUpdateCmd(flags *flags.GlobalFlags) *cobra.Command { func (cmd *UpdateCmd) Run(ctx context.Context, devsyConfig *config.Config, args []string) error { if cmd.Version != "" { - // --version mode: mutually exclusive with source argument - if len(args) != 1 { + 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] diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index c054e8242..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, scheduleProviderUpdateCheck } = registerIpcHandlers({ + const { + tunnelProcesses, + scheduleProviderUpdateCheck, + runInitialProviderUpdateCheck, + } = registerIpcHandlers({ cli, state, logStore, @@ -171,9 +175,7 @@ app.whenReady().then(() => { state, getMainWindow: () => mainWindow, }) - watcher.start() - - // Schedule periodic provider update checks after the watcher populates initial state + void watcher.start().then(runInitialProviderUpdateCheck) scheduleProviderUpdateCheck() // Set up system tray diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index fcc93f00d..a8339c3e0 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -62,7 +62,11 @@ function formatLogLine(line: string, level: "INFO" | "ERROR" = "INFO"): string { return `${new Date().toISOString()}\t${level}\t${line}` } -export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: Map; scheduleProviderUpdateCheck: typeof scheduleProviderUpdateCheck } { +export function registerIpcHandlers(deps: IpcDependencies): { + tunnelProcesses: Map + scheduleProviderUpdateCheck: () => void + runInitialProviderUpdateCheck: () => void +} { const { cli, state, logStore } = deps const tunnelProcesses = new Map() @@ -955,9 +959,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: M }, ) - // Wrap computeUpdateChecks for scheduling. This closure captures it safely. - function scheduleUpdates(): void { - // Run immediately on launch. + function runUpdateCheck(): void { void (async () => { try { providerUpdateCache = await computeUpdateChecks() @@ -965,20 +967,17 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: M // Silently swallow background errors. } })() + } - // Then schedule every 6 hours. - setInterval(() => { - void (async () => { - try { - providerUpdateCache = await computeUpdateChecks() - } catch { - // Silently swallow background errors. - } - })() - }, 6 * 60 * 60 * 1000) + function scheduleUpdates(): void { + setInterval(runUpdateCheck, 6 * 60 * 60 * 1000) } - return { tunnelProcesses, scheduleProviderUpdateCheck: scheduleUpdates } + 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/docs/pages/managing-providers/update-provider.mdx b/docs/pages/managing-providers/update-provider.mdx index 553384940..394212648 100644 --- a/docs/pages/managing-providers/update-provider.mdx +++ b/docs/pages/managing-providers/update-provider.mdx @@ -22,11 +22,6 @@ To list the available versions for an installed provider, use: devsy provider versions ``` -:::info -Be aware at this time, the desktop application does not have an update button -for a provider. -::: - ## From GitHub Similar to adding a provider, you can update via: diff --git a/e2e/framework/command.go b/e2e/framework/command.go index 6ed333002..31736d4c3 100644 --- a/e2e/framework/command.go +++ b/e2e/framework/command.go @@ -334,7 +334,7 @@ func (f *Framework) DevsyRename( return nil } -// DevsyProviderOptionsJSON executes `devsy provider get --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, @@ -472,7 +472,7 @@ func (f *Framework) DevsyProviderFindOption( 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 } From f31aa964e6c2652b558b5781bb87f4e4e8ea56c3 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 30 May 2026 10:16:48 -0500 Subject: [PATCH 34/34] refactor: apply remaining coderabbit nits - extract writeDefaultProvider helper, deduplicating add/update - add cobra.MaximumNArgs(1) to provider get - paginate fetchProviderRepos beyond the first 100 results - renderVersionsTable accepts io.Writer - remove unreachable @ guard in rewriteSourceTag and its dead sentinel - mock-devsy.cjs: provider add now promotes new provider to default --- cmd/provider/add.go | 12 +-------- cmd/provider/configure_shared.go | 14 ++++++++++ cmd/provider/get.go | 1 + cmd/provider/list.go | 34 ++++++++++++++++--------- cmd/provider/update.go | 12 +-------- cmd/provider/versions.go | 3 ++- desktop/e2e/fixtures/mock-devsy.cjs | 5 +++- pkg/workspace/provider_versions.go | 9 ------- pkg/workspace/provider_versions_test.go | 11 -------- 9 files changed, 45 insertions(+), 56 deletions(-) diff --git a/cmd/provider/add.go b/cmd/provider/add.go index 46b585127..383703f73 100644 --- a/cmd/provider/add.go +++ b/cmd/provider/add.go @@ -143,17 +143,7 @@ func (cmd *AddCmd) Run(ctx context.Context, devsyConfig *config.Config, args []s return fmt.Errorf("configure provider: %w", configureErr) } - // Write DefaultProvider explicitly after successful configure - freshConfig, err := config.LoadConfig(cmd.Context, "") - if err != nil { - return fmt.Errorf("reload config: %w", err) - } - freshConfig.Current().DefaultProvider = providerConfig.Name - if err := config.SaveConfig(freshConfig); err != nil { - return fmt.Errorf("save default provider: %w", err) - } - - return nil + return writeDefaultProvider(cmd.Context, providerConfig.Name) } log.Infof("To configure the provider, please run the following command:") diff --git a/cmd/provider/configure_shared.go b/cmd/provider/configure_shared.go index ff368d1a0..8246593e6 100644 --- a/cmd/provider/configure_shared.go +++ b/cmd/provider/configure_shared.go @@ -105,6 +105,20 @@ 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) { diff --git a/cmd/provider/get.go b/cmd/provider/get.go index 7e7c3c616..61cb01f53 100644 --- a/cmd/provider/get.go +++ b/cmd/provider/get.go @@ -33,6 +33,7 @@ func NewGetCmd(flags *flags.GlobalFlags) *cobra.Command { 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) }, diff --git a/cmd/provider/list.go b/cmd/provider/list.go index 4eb501b94..446b21560 100644 --- a/cmd/provider/list.go +++ b/cmd/provider/list.go @@ -178,11 +178,23 @@ func (cmd *ListCmd) runAvailable(ctx context.Context) error { } func fetchProviderRepos(ctx context.Context) ([]map[string]any, error) { - req, err := http.NewRequestWithContext(ctx, - "GET", - config.GitHubAPIUserURL+"/repos?per_page=100", - nil, - ) + 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 } @@ -192,19 +204,17 @@ func fetchProviderRepos(ctx context.Context) ([]map[string]any, error) { } defer func() { _ = resp.Body.Close() }() - result, err := io.ReadAll(resp.Body) + 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(result)) + return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) } - var jsonResult []map[string]any - if err := json.Unmarshal(result, &jsonResult); err != nil { + var pageRepos []map[string]any + if err := json.Unmarshal(body, &pageRepos); err != nil { return nil, err } - - return jsonResult, nil + return pageRepos, nil } diff --git a/cmd/provider/update.go b/cmd/provider/update.go index 54298cbd9..8aa778654 100644 --- a/cmd/provider/update.go +++ b/cmd/provider/update.go @@ -104,17 +104,7 @@ func (cmd *UpdateCmd) Run(ctx context.Context, devsyConfig *config.Config, args return fmt.Errorf("configure provider: %w", err) } - // Write DefaultProvider explicitly after successful configure - freshConfig, err := config.LoadConfig(cmd.Context, "") - if err != nil { - return fmt.Errorf("reload config: %w", err) - } - freshConfig.Current().DefaultProvider = providerConfig.Name - if err := config.SaveConfig(freshConfig); err != nil { - return fmt.Errorf("save default provider: %w", err) - } - - return nil + return writeDefaultProvider(cmd.Context, providerConfig.Name) } log.Infof("To configure the provider, please run the following command:") diff --git a/cmd/provider/versions.go b/cmd/provider/versions.go index b48e4d6f4..851b0fe74 100644 --- a/cmd/provider/versions.go +++ b/cmd/provider/versions.go @@ -3,6 +3,7 @@ package provider import ( "encoding/json" "fmt" + "io" "os" "text/tabwriter" @@ -76,7 +77,7 @@ func completeProviderName( } } -func renderVersionsTable(w *os.File, versions []workspace.ProviderVersion) error { +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 diff --git a/desktop/e2e/fixtures/mock-devsy.cjs b/desktop/e2e/fixtures/mock-devsy.cjs index 4b5685816..7b6bd7ce5 100755 --- a/desktop/e2e/fixtures/mock-devsy.cjs +++ b/desktop/e2e/fixtures/mock-devsy.cjs @@ -204,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, @@ -215,7 +218,7 @@ switch (cmd) { optionGroups: [], }, state: { initialized: false }, - default: false, + default: true, } saveState(state) } diff --git a/pkg/workspace/provider_versions.go b/pkg/workspace/provider_versions.go index ae13991fb..ff6059ab6 100644 --- a/pkg/workspace/provider_versions.go +++ b/pkg/workspace/provider_versions.go @@ -15,11 +15,6 @@ var ErrVersionListUnsupported = errors.New("provider source does not support ver // ErrVersionListRateLimited indicates upstream rate-limiting hit the lister. var ErrVersionListRateLimited = errors.New("provider version list rate-limited") -// ErrInvalidProviderSourceForVersionSwap indicates a source string cannot be safely rewritten with a version tag. -var ErrInvalidProviderSourceForVersionSwap = errors.New( - "provider source cannot be safely rewritten with a version tag", -) - // githubAPIBaseURL is the base URL for GitHub API calls; overridden in tests. var githubAPIBaseURL = "https://api.github.com" @@ -128,15 +123,11 @@ type ListVersionsOptions struct { } // rewriteSourceTag returns the source with its version tag replaced by the given one. -// Errors if the tag is empty or the source contains multiple @ signs in non-version positions. func rewriteSourceTag(source, tag string) (string, error) { if tag == "" { return "", fmt.Errorf("version tag must not be empty") } base, _ := splitSourceAndTag(source) - if strings.Contains(base, "@") { - return "", ErrInvalidProviderSourceForVersionSwap - } return base + "@" + tag, nil } diff --git a/pkg/workspace/provider_versions_test.go b/pkg/workspace/provider_versions_test.go index ecd644bdd..79cd5bd76 100644 --- a/pkg/workspace/provider_versions_test.go +++ b/pkg/workspace/provider_versions_test.go @@ -97,17 +97,6 @@ func TestRewriteSourceTag(t *testing.T) { } } -func TestRewriteSourceTag_RejectsAmbiguousAt(t *testing.T) { - // If splitSourceAndTag yields a base that itself contains @, we cannot safely swap. - // Construct such a case: source with two @ signs. - // splitSourceAndTag splits on the FIRST @, so for "a@b@c", base="a", tag="b@c". - // base="a" does NOT contain @ — so this case passes. To get a base WITH @ we'd need a - // source like "a@@b" — base="a", tag="@b". Still base has no @. - // The check is defensive; without splitSourceAndTag changing semantics, this branch - // is hard to trigger naturally. Skip if not triggerable. - t.Skip("base-with-@ branch is defensive; not triggerable via splitSourceAndTag") -} - func TestListVersionsForSource_CachesResults(t *testing.T) { dir := t.TempDir() t.Setenv("DEVSY_HOME", dir)