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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/internal/check_provider_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
52 changes: 52 additions & 0 deletions cmd/internal/check_provider_update_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
skevetter marked this conversation as resolved.
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")
}
115 changes: 115 additions & 0 deletions desktop/src/main/__tests__/ipc-provider-jobs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))

Expand Down Expand Up @@ -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: [],
Expand Down
54 changes: 38 additions & 16 deletions desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@
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
Expand Down Expand Up @@ -668,122 +678,122 @@

ipcMain.handle(
"secret_protection_action",
async (
event,
args: {
action: string
newPassphrase?: string
currentPassphrase?: string
},
) => {
const win = getMainWindow()
if (!win || !isTrustedSecretIpcSender(event, win, secretDocumentURL))
return secretRequestDenied
const allowed = [
"set-passphrase",
"change-passphrase",
"remove-passphrase",
"remember",
"forget",
]
if (!args || typeof args !== "object" || !allowed.includes(args.action))
return { ok: false, message: "Unknown protection action." }
const action = args.action
const { newPassphrase, currentPassphrase } = args
if (
currentPassphrase !== undefined &&
typeof currentPassphrase !== "string"
)
return { ok: false, message: "Enter a valid current passphrase." }
if (newPassphrase !== undefined && typeof newPassphrase !== "string")
return { ok: false, message: "Enter a non-empty passphrase." }
const options: CliInvocationOptions = {
allowUnlockPrompt: false,
...(currentPassphrase !== undefined
? { env: { DEVSY_SECRETS_PASSPHRASE: currentPassphrase } }
: {}),
}
const cacheGeneration = secretCacheGeneration
const changesPassphrase = [
"set-passphrase",
"change-passphrase",
].includes(action)
try {
const command = ["secret", "protection", action]
if (changesPassphrase) {
if (typeof newPassphrase !== "string" || !newPassphrase.trim())
return { ok: false, message: "Enter a non-empty passphrase." }
}
const descriptions: Record<string, string> = {
"set-passphrase": "Set a passphrase for all file-backed secrets?",
"change-passphrase":
"Change the passphrase for all file-backed secrets?",
"remove-passphrase":
"Remove passphrase protection from all file-backed secrets?",
remember: "Remember the secrets passphrase in your OS keychain?",
forget: "Forget the secrets passphrase stored in your OS keychain?",
}
const confirmation = await dialog.showMessageBox(win, {
type: "warning",
title: "Secret protection",
message: descriptions[action],
detail: "This changes the shared secret store across all contexts.",
buttons: ["Cancel", "Continue"],
defaultId: 0,
cancelId: 0,
noLink: true,
})
const approvalFailure = protectionApprovalFailure(
event,
cacheGeneration,
confirmation.response,
)
if (approvalFailure) return approvalFailure
if (changesPassphrase && typeof newPassphrase === "string") {
await cli.runRawStdin([...command, "--stdin"], newPassphrase, options)
updateProtectedSessionCredential(
event,
cacheGeneration,
newPassphrase,
)
} else {
await cli.runRaw(command, options)
if (action === "remove-passphrase")
updateProtectedSessionCredential(event, cacheGeneration, undefined)
}
return { ok: true }
} catch (err) {
// Preserve structured recovery information without sending credential
// material (or arbitrary error properties) back across the IPC boundary.
const redact = (value: string) =>
[currentPassphrase, newPassphrase].reduce<string>(
(text, credential) =>
credential ? text.replaceAll(credential, "***") : text,
value,
)
const error = (err as { cliError?: CLIError } | null)?.cliError
const cliError = error
? {
code: redact(error.code),
message: redact(error.message),
hint: error.hint ? redact(error.hint) : undefined,
context: error.context
? Object.fromEntries(
Object.entries(error.context).map(([key, value]) => [
redact(key),
redact(value),
]),
)
: undefined,
}
: undefined
return {
ok: false,
message: cliError?.message ?? "Secret protection operation failed.",
cliError,
}
}
},

Check notice on line 796 in desktop/src/main/ipc.ts

View check run for this annotation

codefactor.io / CodeFactor

desktop/src/main/ipc.ts#L681-L796

Complex Method
)
const tunnelProcesses = new Map<
string,
Expand Down Expand Up @@ -1295,21 +1305,27 @@
// 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])
})
},
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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")
Expand Down Expand Up @@ -1366,7 +1382,13 @@
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) {
Expand Down Expand Up @@ -1476,16 +1498,16 @@

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 () => {
await runProviderWithStatus(args.name, [
"provider",
"set-source",
args.name,
"--version",
args.tag,
...versionFlags,
])
await runProviderWithStatus(args.name, ["provider", "init", args.name])
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
Loading
Loading