diff --git a/cmd/internal/check_provider_update.go b/cmd/internal/check_provider_update.go index 2d7a7421fe..aa0eaf5c60 100644 --- a/cmd/internal/check_provider_update.go +++ b/cmd/internal/check_provider_update.go @@ -99,6 +99,10 @@ func loadCurrentProvider( return "", "", errProviderNotFound } + // This probe checks latest; ordinary source reloads retain the installed pin. + if strings.HasPrefix(source, "github.com/") { + source, _ = provider.SplitSourceAndTag(source) + } return source, currentProvider.Config.Version, nil } diff --git a/cmd/internal/check_provider_update_test.go b/cmd/internal/check_provider_update_test.go new file mode 100644 index 0000000000..5c86546ef4 --- /dev/null +++ b/cmd/internal/check_provider_update_test.go @@ -0,0 +1,52 @@ +package cmdinternal + +import ( + "path/filepath" + "testing" + + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/workspace" + "github.com/stretchr/testify/require" +) + +func TestLoadCurrentProviderUsesLatestGitHubSourceWithoutChangingPin(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv(config.EnvHome, filepath.Join(home, ".devsy")) + config.ResetPathManager() + t.Cleanup(config.ResetPathManager) + + const name = "microsandbox" + cfg := &config.Config{ + DefaultContext: config.DefaultContext, + Contexts: map[string]*config.ContextConfig{ + config.DefaultContext: { + Providers: map[string]*config.ProviderConfig{name: {Initialized: true}}, + }, + }, + } + require.NoError(t, config.SaveConfig(cfg)) + const repo = "github.com/devsy-org/devsy-provider-microsandbox" + const pinned = repo + "@v0.1.8" + stored := &provider.ProviderConfig{ + Name: name, Version: "v0.1.8", + Exec: provider.ProviderCommands{Command: []string{"true"}}, + Source: provider.ProviderSource{ + Github: "devsy-org/devsy-provider-microsandbox", Raw: pinned, + }, + } + require.NoError(t, provider.SaveProviderConfig(config.DefaultContext, stored)) + reloadSource, err := workspace.ResolveProviderSource(cfg, name) + require.NoError(t, err) + require.Equal(t, pinned, reloadSource, "a reload must retain the installed pin") + + source, version, err := loadCurrentProvider(cfg, name) + require.NoError(t, err) + require.Equal(t, repo, source, "the update probe must fetch latest, not the installed pin") + require.Equal(t, stored.Version, version) + loaded, err := provider.LoadProviderConfig(config.DefaultContext, name) + require.NoError(t, err) + require.Equal(t, stored.Source, loaded.Source, "checking for updates must not mutate the pin") +} diff --git a/desktop/src/main/__tests__/ipc-provider-jobs.test.ts b/desktop/src/main/__tests__/ipc-provider-jobs.test.ts index 8a2cffd621..aee88e154d 100644 --- a/desktop/src/main/__tests__/ipc-provider-jobs.test.ts +++ b/desktop/src/main/__tests__/ipc-provider-jobs.test.ts @@ -235,6 +235,22 @@ describe("provider job lifecycle over IPC", () => { expect(providerJobs.get("docker")?.activity).toBe("installing") }) + it("adds and initializes the external Microsandbox preset without selecting a default", async () => { + const { cli, providerJobs } = setup() + const source = "github.com/devsy-org/devsy-provider-microsandbox@v0.1.8" + + await invoke("provider_add", { name: "microsandbox", source }) + await invoke("provider_init", { name: "microsandbox" }) + + expect(cli.runStreaming.mock.calls.map(([args]) => args)).toEqual([ + ["provider", "add", source, "--use=false", "--name", "microsandbox"], + ["provider", "init", "microsandbox"], + ]) + expect(cli.run).not.toHaveBeenCalled() + expect(cli.runRaw).not.toHaveBeenCalled() + expect(providerJobs.get("microsandbox")).toBeUndefined() + }) + it("releases an abandoned job so the card stops spinning", async () => { const { providerJobs } = setup(() => ({ lines: [], code: 0 })) @@ -268,6 +284,105 @@ describe("provider job lifecycle over IPC", () => { expect(providerJobs.get("docker")).toBeUndefined() }) + it.each(["provider_update", "provider_update_streaming"])( + "%s installs the confirmed version before initializing", + async (command) => { + const seen: string[][] = [] + const { providerJobs } = setup((cliArgs) => { + seen.push(cliArgs) + return { lines: [], code: 0 } + }) + + await invoke(command, { name: "microsandbox", tag: "v0.2.0" }) + + await vi.waitFor(() => + expect(providerJobs.get("microsandbox")).toBeUndefined(), + ) + expect(seen).toEqual([ + [ + "provider", + "set-source", + "microsandbox", + "--use=false", + "--version", + "v0.2.0", + ], + ["provider", "init", "microsandbox"], + ]) + }, + ) + + it.each(["provider_update", "provider_update_streaming"])( + "%s rejects a non-string version before opening a job", + async (command) => { + const { providerJobs, cli } = setup() + + await expect( + invoke(command, { name: "docker", tag: { version: "v1" } }), + ).rejects.toThrow("Provider version tag must be a string.") + + expect(providerJobs.get("docker")).toBeUndefined() + expect(cli.runRaw).not.toHaveBeenCalled() + expect(cli.runStreaming).not.toHaveBeenCalled() + }, + ) + + it.each(["provider_update", "provider_update_streaming"])( + "%s treats an empty optional version as source reload", + async (command) => { + const seen: string[][] = [] + const { providerJobs } = setup((cliArgs) => { + seen.push(cliArgs) + return { lines: [], code: 0 } + }) + + await invoke(command, { name: "docker", tag: "" }) + await vi.waitFor(() => expect(providerJobs.get("docker")).toBeUndefined()) + + expect(seen).toEqual([ + ["provider", "set-source", "docker", "--use=false"], + ["provider", "init", "docker"], + ]) + }, + ) + + it.each([undefined, "", 42])( + "provider_set_version rejects invalid required tag %s before opening a job", + async (tag) => { + const { providerJobs, cli } = setup() + + await expect( + invoke("provider_set_version", { name: "docker", tag }), + ).rejects.toThrow( + tag === undefined || tag === "" + ? "A provider version tag is required." + : "Provider version tag must be a string.", + ) + + expect(providerJobs.get("docker")).toBeUndefined() + expect(cli.runStreaming).not.toHaveBeenCalled() + }, + ) + + it("passes valid Git ref syntax as a separate set-version argument", async () => { + const seen: string[][] = [] + const { providerJobs } = setup((cliArgs) => { + seen.push(cliArgs) + return { lines: [], code: 0 } + }) + + await invoke("provider_set_version", { + name: "docker", + tag: "release/candidate+1", + }) + await vi.waitFor(() => expect(providerJobs.get("docker")).toBeUndefined()) + + expect(seen).toEqual([ + ["provider", "set-source", "docker", "--version", "release/candidate+1"], + ["provider", "init", "docker"], + ]) + }) + it("records the failure when the update's chained init fails", async () => { const { providerJobs } = setup((cliArgs) => ({ lines: [], diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index ce3ec0d65d..b18b6ed6b3 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -80,6 +80,16 @@ function validateSecretUnlockInput(args: unknown): SecretUnlockInput { return { ok: true, requestId: input.requestId, passphrase, remember } } +function providerVersionFlags(tag: unknown, required = false): string[] { + if (tag === undefined || tag === "") { + if (!required) return [] + throw new Error("A provider version tag is required.") + } + if (typeof tag !== "string") + throw new Error("Provider version tag must be a string.") + return ["--version", tag] +} + interface SecretEntry { name: string context: string @@ -1295,21 +1305,27 @@ export function registerIpcHandlers(deps: IpcDependencies): { // set-source installs replacement binaries and clears Initialized, since // the new binary has not run its init. Chain init so the provider ends up // usable rather than sitting in a needs-re-init state the user must notice. - ipcMain.handle("provider_update", async (_event, args: { name: string }) => { - await withProviderJob(args.name, "updating", async () => { - await runProviderWithStatus(args.name, [ - "provider", - "set-source", - args.name, - "--use=false", - ]) - await runProviderWithStatus(args.name, ["provider", "init", args.name]) - }) - }) + ipcMain.handle( + "provider_update", + async (_event, args: { name: string; tag?: unknown }) => { + const versionFlags = providerVersionFlags(args.tag) + await withProviderJob(args.name, "updating", async () => { + await runProviderWithStatus(args.name, [ + "provider", + "set-source", + args.name, + "--use=false", + ...versionFlags, + ]) + await runProviderWithStatus(args.name, ["provider", "init", args.name]) + }) + }, + ) ipcMain.handle( "provider_update_streaming", - async (_event, args: { name: string }) => { + async (_event, args: { name: string; tag?: unknown }) => { + const versionFlags = providerVersionFlags(args.tag) const cmdId = crypto.randomUUID() const win = deps.getMainWindow() providerJobs.start(args.name, "updating") @@ -1366,7 +1382,13 @@ export function registerIpcHandlers(deps: IpcDependencies): { let failure: CLIError | undefined try { sendProgress("Downloading provider update", "info") - await runStep(["provider", "set-source", args.name, "--use=false"]) + await runStep([ + "provider", + "set-source", + args.name, + "--use=false", + ...versionFlags, + ]) sendProgress("Initializing updated provider", "info") await runStep(["provider", "init", args.name]) } catch (error) { @@ -1476,7 +1498,8 @@ export function registerIpcHandlers(deps: IpcDependencies): { ipcMain.handle( "provider_set_version", - async (_event, args: { name: string; tag: string }) => { + async (_event, args: { name: string; tag?: unknown }) => { + const versionFlags = providerVersionFlags(args.tag, true) // Pinning a version swaps binaries via the same update path, so it // clears Initialized and needs the same re-init as a source change. await withProviderJob(args.name, "updating", async () => { @@ -1484,8 +1507,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { "provider", "set-source", args.name, - "--version", - args.tag, + ...versionFlags, ]) await runProviderWithStatus(args.name, ["provider", "init", args.name]) }) diff --git a/desktop/src/renderer/src/lib/components/provider/ProviderSheet.svelte b/desktop/src/renderer/src/lib/components/provider/ProviderSheet.svelte index 1d8d73939e..64426982f3 100644 --- a/desktop/src/renderer/src/lib/components/provider/ProviderSheet.svelte +++ b/desktop/src/renderer/src/lib/components/provider/ProviderSheet.svelte @@ -277,7 +277,16 @@ async function runUpdate() { updateLines = [] confirmUpdateOpen = false try { - updateCommandId = await providerUpdateStreaming(name) + const latestVersion = $providerVersions.updates[name]?.latest + const source = provider.source + const isPinnedGithub = + !!source?.github && + (source.github.includes("@") || source.raw?.includes("@")) + if (isPinnedGithub && latestVersion) { + updateCommandId = await providerUpdateStreaming(name, latestVersion) + } else { + updateCommandId = await providerUpdateStreaming(name) + } const matching = pendingUpdateProgress.filter( (progress) => progress.commandId === updateCommandId, ) 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 b8a728ac22..85294a4d60 100644 --- a/desktop/src/renderer/src/lib/components/provider/ProviderSheet.test.ts +++ b/desktop/src/renderer/src/lib/components/provider/ProviderSheet.test.ts @@ -1,12 +1,17 @@ import { render } from "@testing-library/svelte" import { tick } from "svelte" +import type { Readable, Writable } from "svelte/store" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import type { providerVersions as providerVersionsStore } from "$lib/stores/providerVersions.js" import type { CommandProgress, Provider, ProviderJob, } from "$lib/types/index.js" +type ProviderVersionsState = + typeof providerVersionsStore extends Readable ? State : never + const providerOptions = vi.fn() const providerUse = vi.fn() const providerUpdateStreaming = vi.fn() @@ -24,6 +29,9 @@ const providerJobsBox = vi.hoisted(() => ({ Record >, })) +const providerVersionsBox = vi.hoisted(() => ({ + store: undefined as unknown as Writable, +})) let progressCallback: ((progress: CommandProgress) => void) | null = null vi.mock("$lib/ipc/commands.js", () => ({ @@ -62,22 +70,30 @@ vi.mock("$lib/stores/providers.js", async () => { vi.mock("$lib/stores/providerVersions.js", async () => { const { writable } = await import("svelte/store") - return { - providerVersions: writable({ - byProvider: { - ssh: { - versions: [ - { tag: "0.1.0", current: true }, - { tag: "0.2.0", current: false }, - ], - unsupported: false, - }, + providerVersionsBox.store = writable({ + byProvider: { + ssh: { + versions: [ + { tag: "0.1.0", current: true, publishedAt: "", prerelease: false }, + { tag: "0.2.0", current: false, publishedAt: "", prerelease: false }, + ], + unsupported: false, }, - updates: { - ssh: { updateAvailable: true, current: "0.1.0", latest: "0.2.0" }, + }, + updates: { + ssh: { + updateAvailable: true, + current: "0.1.0", + latest: "0.2.0", + unsupported: false, }, - lastCheckedAt: null, - }), + }, + lastCheckedAt: null, + refreshing: false, + refreshError: null, + }) + return { + providerVersions: providerVersionsBox.store, loadVersionsFor: (...args: unknown[]) => loadVersionsFor(...args), refreshUpdates: (...args: unknown[]) => refreshUpdates(...args), } @@ -116,6 +132,17 @@ describe("ProviderSheet", () => { beforeEach(() => { progressCallback = null providerJobsBox.store.set({}) + providerVersionsBox.store.update((state) => ({ + ...state, + updates: { + ssh: { + updateAvailable: true, + current: "0.1.0", + latest: "0.2.0", + unsupported: false, + }, + }, + })) providerOptions.mockReset() providerOptions.mockImplementation(async (name: string) => { await new Promise((r) => setTimeout(r, MOCK_IPC_DELAY_MS)) @@ -312,7 +339,15 @@ describe("ProviderSheet", () => { it("confirming the update dialog starts one streaming provider update", async () => { const { unmount } = render(ProviderSheet, { - props: { provider: makeProvider("ssh"), open: true }, + props: { + provider: makeProvider("ssh", { + source: { + github: "github.com/example/ssh-provider", + raw: "github.com/example/ssh-provider@0.1.0", + }, + }), + open: true, + }, }) await flushAsync() @@ -333,6 +368,78 @@ describe("ProviderSheet", () => { confirmBtn?.click() await flushAsync() + expect(providerUpdateStreaming).toHaveBeenCalledTimes(1) + expect(providerUpdateStreaming).toHaveBeenCalledWith("ssh", "0.2.0") + unmount() + }) + + it("leaves an unpinned GitHub update on the latest-release path", async () => { + const { unmount } = render(ProviderSheet, { + props: { + provider: makeProvider("ssh", { + source: { github: "github.com/example/ssh-provider" }, + }), + open: true, + }, + }) + + await flushAsync() + Array.from(document.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Update") + ?.click() + await tick() + const confirmDialog = Array.from( + document.querySelectorAll("[role='dialog']"), + ).find((dialog) => dialog.textContent?.includes("Update 'ssh' to 0.2.0")) + Array.from(confirmDialog?.querySelectorAll("button") ?? []) + .find((button) => button.textContent?.trim() === "Update") + ?.click() + await flushAsync() + + expect(providerUpdateStreaming).toHaveBeenCalledTimes(1) + expect(providerUpdateStreaming).toHaveBeenCalledWith("ssh") + unmount() + }) + + it("reloads the configured source when the latest version is unknown", async () => { + providerVersionsBox.store.update((state) => ({ + ...state, + updates: { + ssh: { + current: "0.1.0", + latest: "", + updateAvailable: false, + unsupported: false, + }, + }, + })) + const { unmount } = render(ProviderSheet, { + props: { + provider: makeProvider("ssh", { + source: { + github: "github.com/example/ssh-provider", + raw: "github.com/example/ssh-provider@0.1.0", + }, + }), + open: true, + }, + }) + + await flushAsync() + Array.from(document.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Update") + ?.click() + await tick() + + const confirmDialog = Array.from( + document.querySelectorAll("[role='dialog']"), + ).find((dialog) => dialog.textContent?.includes("configured source")) + expect(confirmDialog).toBeDefined() + Array.from(confirmDialog?.querySelectorAll("button") ?? []) + .find((button) => button.textContent?.trim() === "Update") + ?.click() + await flushAsync() + expect(providerUpdateStreaming).toHaveBeenCalledTimes(1) expect(providerUpdateStreaming).toHaveBeenCalledWith("ssh") unmount() diff --git a/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte b/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte index b51969730b..67d6ca9971 100644 --- a/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte +++ b/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte @@ -31,11 +31,17 @@ import { isCommandSuccess } from "$lib/utils/log-parser.js" import type { ProviderOption } from "$lib/types/index.js" import type { UnlistenFn } from "$lib/ipc/types.js" -const PRESETS = [ +type ProviderPreset = { name: string; description: string; source?: string } + +const PRESETS: ProviderPreset[] = [ { name: "docker", description: "Local Docker containers" }, { name: "podman", description: "Local Podman containers" }, { name: "apple", description: "Apple containers (macOS 26+, Apple silicon)" }, - { name: "microsandbox", description: "Hardware-isolated microVMs" }, + { + name: "microsandbox", + source: "github.com/devsy-org/devsy-provider-microsandbox@v0.1.8", + description: "Hardware-isolated microVMs", + }, { name: "lima", description: "Linux VMs via Lima (macOS)" }, { name: "orbstack", @@ -49,6 +55,10 @@ const PRESETS = [ { name: "digitalocean", description: "DigitalOcean Droplets" }, ] +function presetSource(preset: ProviderPreset): string { + return preset.source ?? preset.name +} + type Step = "select" | "configure" | "initialize" | "complete" type StepState = "pending" | "active" | "complete" | "error" @@ -196,7 +206,8 @@ async function handleSelect() { error = "Provider source is required" return } - const name = customName.trim() || src + const preset = PRESETS.find((item) => presetSource(item) === src) + const name = customName.trim() || preset?.name || src if (nameExists(name)) { error = `Provider "${name}" already exists. Choose a different name.` return @@ -381,9 +392,9 @@ function handleDone() {