From f21f75e042f04db661c4e4311bdbe078c5619c83 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 19 May 2026 14:09:25 -0500 Subject: [PATCH 1/6] fix(ci): use GitHub App token in create-release to trigger build workflow GITHUB_TOKEN events don't trigger downstream workflows. Using the App token ensures the release:published event triggers release.yml to build CLI and desktop artifacts. --- .github/workflows/create-release.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index af20b0722..f12e508fe 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -13,6 +13,12 @@ jobs: name: Create GitHub Release runs-on: ubuntu-latest steps: + - uses: actions/create-github-app-token@v3 + id: app-token + with: + app-id: ${{ secrets.DEVSY_GITHUB_APP_ID }} + private-key: ${{ secrets.DEVSY_GITHUB_APP_PRIVATE_KEY }} + - uses: actions/checkout@v6 with: fetch-depth: 0 @@ -29,7 +35,7 @@ jobs: - name: Create release env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | gh release create "$GITHUB_REF_NAME" \ --generate-notes \ From c695adf34fb1a4cbb88d77ed5e620d40f470fa05 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 19 May 2026 14:45:25 -0500 Subject: [PATCH 2/6] fix(ci): skip electron update metadata deploy for pre-releases Electron-builder does not generate latest*.yml auto-update manifests for pre-release versions. The deploy-update-metadata job now only runs for stable releases, preventing failures when no metadata artifacts exist. --- .github/workflows/release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9332ff288..068556bfb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -222,6 +222,7 @@ jobs: deploy-update-metadata: name: Deploy Electron Update Metadata needs: [build-desktop] + if: ${{ !github.event.release.prerelease }} runs-on: ubuntu-latest permissions: contents: read @@ -293,7 +294,7 @@ jobs: prerelease: name: Publish Prerelease - needs: [build-desktop, build-flatpak, deploy-update-metadata] + needs: [build-desktop, build-flatpak] permissions: contents: write if: github.event.release.prerelease From e89c1dd99a0874f17eb48c2147dc1ceba9df1613 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 19 May 2026 16:19:31 -0500 Subject: [PATCH 3/6] feat: add beta release channel and split macOS into separate arch builds - Split macOS desktop builds from universal into separate arm64 and x64 builds for faster CI and smaller download sizes - Add release channel (stable/beta) support to desktop auto-updater with persistence and UI toggle in Settings - Add --channel flag to CLI self-update command for opting into pre-releases - Deploy electron update metadata for both stable and beta channels - Add merge script for combining macOS metadata from separate arch builds --- .github/workflows/release.yml | 66 ++++++++++++------- README.md | 9 ++- cmd/self_update.go | 11 +++- cmd/self_update_test.go | 7 ++ desktop/electron-builder.yml | 7 +- desktop/src/main/ipc.ts | 20 ++++++ desktop/src/main/updater.ts | 50 +++++++++++++- desktop/src/renderer/src/lib/ipc/commands.ts | 11 ++++ .../renderer/src/pages/SettingsPage.svelte | 38 +++++++++++ hack/merge-mac-metadata.py | 63 ++++++++++++++++++ pkg/selfupdate/selfupdate.go | 25 ++++--- 11 files changed, 266 insertions(+), 41 deletions(-) create mode 100755 hack/merge-mac-metadata.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 068556bfb..9f016b4ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,7 @@ jobs: path: dist/devsy-${{ steps.os.outputs.runner_os }}_*/devsy-${{ steps.os.outputs.runner_os }}-* build-desktop: - name: Build Desktop App on ${{ matrix.os }} + name: Build Desktop App (${{ matrix.name }}) needs: build-cli permissions: contents: write @@ -58,15 +58,23 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-22.04 + - name: linux-x64 + os: ubuntu-22.04 go-os: linux go-arch: amd64 builder-args: --linux - - os: macos-latest + - name: macos-arm64 + os: macos-latest go-os: darwin - go-arch: universal - builder-args: --mac - - os: windows-2022 + go-arch: arm64 + builder-args: --mac --arm64 + - name: macos-x64 + os: macos-latest + go-os: darwin + go-arch: amd64 + builder-args: --mac --x64 + - name: windows-x64 + os: windows-2022 go-os: windows go-arch: amd64 builder-args: --win @@ -114,15 +122,8 @@ jobs: run: | TEMP_DIR="${{ runner.temp }}" mkdir -p desktop/resources/bin - - if [ "${{ matrix.go-arch }}" = "universal" ]; then - # macOS: use both arch binaries for universal build - cp "$TEMP_DIR/devsy-bin/devsy-darwin-amd64" desktop/resources/bin/devsy - chmod +x desktop/resources/bin/devsy - else - cp "$TEMP_DIR/devsy-bin/devsy-${{ matrix.go-os }}-${{ matrix.go-arch }}" desktop/resources/bin/devsy - chmod +x desktop/resources/bin/devsy - fi + cp "$TEMP_DIR/devsy-bin/devsy-${{ matrix.go-os }}-${{ matrix.go-arch }}" desktop/resources/bin/devsy + chmod +x desktop/resources/bin/devsy # Also stage binaries for dist/ upload and provider generation mkdir -p dist/ @@ -136,7 +137,7 @@ jobs: Copy-Item "${{ runner.temp }}/devsy-bin/devsy-windows-amd64.exe" desktop/resources/bin/devsy.exe - name: generate pro provider - if: runner.os == 'Linux' + if: matrix.name == 'linux-x64' working-directory: ./hack/pro env: PARTIAL: "true" @@ -199,13 +200,15 @@ jobs: - name: upload update metadata artifact uses: actions/upload-artifact@v7 with: - name: update-metadata-${{ matrix.os }} - path: desktop/release/latest*.yml + name: update-metadata-${{ matrix.name }} + path: | + desktop/release/latest*.yml + desktop/release/beta*.yml if-no-files-found: ignore - name: upload cli assets uses: softprops/action-gh-release@v3 - if: runner.os == 'Linux' + if: matrix.name == 'linux-x64' with: tag_name: ${{ inputs.tag || github.ref_name }} draft: true @@ -213,7 +216,7 @@ jobs: ./dist/* - name: upload desktop linux artifact for flatpak - if: runner.os == 'Linux' + if: matrix.name == 'linux-x64' uses: actions/upload-artifact@v7 with: name: devsy-flatpak @@ -222,21 +225,36 @@ jobs: deploy-update-metadata: name: Deploy Electron Update Metadata needs: [build-desktop] - if: ${{ !github.event.release.prerelease }} runs-on: ubuntu-latest permissions: contents: read steps: + - uses: actions/checkout@v6 + - name: download update metadata artifacts uses: actions/download-artifact@v8 with: pattern: update-metadata-* path: metadata/ - - name: arrange files into publish directory + - name: fetch existing metadata from live site run: | mkdir -p publish-dir/desktop - find metadata/ -name 'latest*.yml' -exec cp {} publish-dir/desktop/ \; + for file in latest-mac.yml latest-linux.yml latest.yml beta-mac.yml beta-linux.yml beta.yml; do + curl -sSf "https://dl.devsy.sh/desktop/$file" -o "publish-dir/desktop/$file" 2>/dev/null || true + done + + - name: merge macOS metadata from separate arch builds + run: | + pip install pyyaml + python3 hack/merge-mac-metadata.py metadata/ publish-dir/desktop/ + + - name: overlay non-mac metadata files + run: | + find metadata/ -name 'latest-linux.yml' -exec cp {} publish-dir/desktop/ \; + find metadata/ -name 'latest.yml' -exec cp {} publish-dir/desktop/ \; + find metadata/ -name 'beta-linux.yml' -exec cp {} publish-dir/desktop/ \; + find metadata/ -name 'beta.yml' -exec cp {} publish-dir/desktop/ \; - name: deploy to Netlify (dl.devsy.sh) uses: nwtgck/actions-netlify@v3 @@ -294,7 +312,7 @@ jobs: prerelease: name: Publish Prerelease - needs: [build-desktop, build-flatpak] + needs: [build-desktop, build-flatpak, deploy-update-metadata] permissions: contents: write if: github.event.release.prerelease diff --git a/README.md b/README.md index afe1a170f..97d5235b8 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,13 @@ Take a look at the Devsy macOS -Universal (Apple Silicon + Intel) -macOS Universal +Apple Silicon (ARM64) +macOS ARM64 + + +macOS +Intel (x64) +macOS x64 Windows diff --git a/cmd/self_update.go b/cmd/self_update.go index 65a67d3ef..786881407 100644 --- a/cmd/self_update.go +++ b/cmd/self_update.go @@ -10,6 +10,7 @@ import ( // SelfUpdateCmd is a struct that defines a command call for "self-update". type SelfUpdateCmd struct { Version string + Channel string DryRun bool } @@ -22,7 +23,12 @@ func NewSelfUpdateCmd() *cobra.Command { Args: cobra.NoArgs, RunE: func(cobraCmd *cobra.Command, args []string) error { ctx := cobraCmd.Context() - if err := selfupdate.Upgrade(ctx, cmd.Version, cmd.DryRun); err != nil { + opts := selfupdate.Options{ + Version: cmd.Version, + DryRun: cmd.DryRun, + IncludePrerelease: cmd.Channel == "beta", + } + if err := selfupdate.Upgrade(ctx, opts); err != nil { return fmt.Errorf("unable to update: %w", err) } return nil @@ -32,6 +38,9 @@ func NewSelfUpdateCmd() *cobra.Command { selfUpdateCmd.Flags(). StringVar(&cmd.Version, "version", "", "The version to update to. Defaults to the latest stable version available") + selfUpdateCmd.Flags(). + StringVar(&cmd.Channel, "channel", "stable", + "Release channel: 'stable' for production releases, 'beta' for pre-release versions") selfUpdateCmd.Flags(). BoolVar(&cmd.DryRun, "dry-run", false, "Show which version would be downloaded without actually updating") return selfUpdateCmd diff --git a/cmd/self_update_test.go b/cmd/self_update_test.go index 093bef243..a286cd570 100644 --- a/cmd/self_update_test.go +++ b/cmd/self_update_test.go @@ -26,6 +26,13 @@ func TestNewSelfUpdateCmd_HasDryRunFlag(t *testing.T) { assert.Equal(t, "false", f.DefValue) } +func TestNewSelfUpdateCmd_HasChannelFlag(t *testing.T) { + cmd := NewSelfUpdateCmd() + f := cmd.Flags().Lookup("channel") + require.NotNil(t, f, "--channel flag must exist") + assert.Equal(t, "stable", f.DefValue) +} + func TestNewSelfUpdateCmd_AcceptsNoPositionalArgs(t *testing.T) { cmd := NewSelfUpdateCmd() err := cmd.Args(cmd, []string{"unexpected"}) diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml index 4a07f3f1c..fca862913 100644 --- a/desktop/electron-builder.yml +++ b/desktop/electron-builder.yml @@ -19,15 +19,16 @@ extraResources: mac: category: public.app-category.developer-tools icon: resources/icon.icns - x64ArchFiles: "Contents/Resources/bin/devsy" artifactName: Devsy_${os}_${arch}.${ext} target: - target: dmg arch: - - universal + - x64 + - arm64 - target: zip arch: - - universal + - x64 + - arm64 hardenedRuntime: true gatekeeperAssess: false entitlements: build/entitlements.mac.plist diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index e79b01db5..350ddf8ef 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -11,6 +11,12 @@ import type { CliRunner } from "./cli.js" import type { LogStore } from "./log-store.js" import type { PtyManager } from "./pty.js" import type { DaemonState } from "./state.js" +import { + type ReleaseChannel, + checkForUpdatesWithChannel, + getReleaseChannel, + setReleaseChannel, +} from "./updater.js" import { type ProviderEntry, parseProviderEntries } from "./watcher.js" const execFileAsync = promisify(execFile) @@ -656,6 +662,20 @@ export function registerIpcHandlers(deps: IpcDependencies): void { }, ) + // ── Release Channel ── + ipcMain.handle("get_release_channel", () => { + return getReleaseChannel() + }) + + ipcMain.handle( + "set_release_channel", + async (_event, args: { channel: string }) => { + const channel = args.channel as ReleaseChannel + setReleaseChannel(channel) + await checkForUpdatesWithChannel(channel) + }, + ) + // ── Analytics ── ipcMain.handle( "analytics_track", diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts index cd2297cc4..ba64a5a7e 100644 --- a/desktop/src/main/updater.ts +++ b/desktop/src/main/updater.ts @@ -1,13 +1,49 @@ -import { dialog, type BrowserWindow } from "electron" +import { readFileSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { app, dialog, type BrowserWindow } from "electron" import { trackEvent } from "./analytics.js" +export type ReleaseChannel = "stable" | "beta" + +function settingsPath(): string { + return join(app.getPath("userData"), "update-settings.json") +} + +function loadChannel(): ReleaseChannel { + try { + const data = JSON.parse(readFileSync(settingsPath(), "utf-8")) + if (data.channel === "beta") return "beta" + } catch { + // File doesn't exist or is corrupt + } + return "stable" +} + +function saveChannel(channel: ReleaseChannel): void { + writeFileSync(settingsPath(), JSON.stringify({ channel })) +} + +let currentChannel: ReleaseChannel = "stable" + +export function setReleaseChannel(channel: ReleaseChannel): void { + currentChannel = channel + saveChannel(channel) +} + +export function getReleaseChannel(): ReleaseChannel { + return currentChannel +} + export async function initAutoUpdater( getMainWindow: () => BrowserWindow | null, ): Promise { + currentChannel = loadChannel() const { autoUpdater } = await import("electron-updater") autoUpdater.autoDownload = true autoUpdater.autoInstallOnAppQuit = true + autoUpdater.allowPrerelease = currentChannel === "beta" + autoUpdater.channel = currentChannel === "beta" ? "beta" : "latest" autoUpdater.on("checking-for-update", () => { trackEvent("update_check") @@ -45,10 +81,20 @@ export async function initAutoUpdater( console.error("Auto-update error:", err.message) }) - // Check for updates after a short delay to avoid slowing down app launch setTimeout(() => { autoUpdater.checkForUpdates().catch((err: Error) => { console.error("Update check failed:", err.message) }) }, 10_000) } + +export async function checkForUpdatesWithChannel( + channel: ReleaseChannel, +): Promise { + const { autoUpdater } = await import("electron-updater") + currentChannel = channel + saveChannel(channel) + autoUpdater.allowPrerelease = channel === "beta" + autoUpdater.channel = channel === "beta" ? "beta" : "latest" + await autoUpdater.checkForUpdates() +} diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index e35ef2fe4..7bf782f63 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -250,6 +250,17 @@ export async function sshKeyGenerate(params: { return invoke("ssh_key_generate", params) } +// Release channel +export type ReleaseChannel = "stable" | "beta" + +export async function getReleaseChannel(): Promise { + return invoke("get_release_channel") +} + +export async function setReleaseChannel(channel: ReleaseChannel): Promise { + return invoke("set_release_channel", { channel }) +} + // Analytics export function analyticsTrack( name: string, diff --git a/desktop/src/renderer/src/pages/SettingsPage.svelte b/desktop/src/renderer/src/pages/SettingsPage.svelte index e7bfac586..dc8756e7e 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.svelte +++ b/desktop/src/renderer/src/pages/SettingsPage.svelte @@ -36,6 +36,9 @@ import { devsyVersion, devsyUpgrade, devsyUpgradeDryRun, + getReleaseChannel, + setReleaseChannel as setReleaseChannelIpc, + type ReleaseChannel, } from "$lib/ipc/commands.js" import { Skeleton } from "$lib/components/ui/skeleton/index.js" import { toasts } from "$lib/stores/toasts.js" @@ -137,10 +140,21 @@ let local = $state({ // ── Version management ────────────────────────────────────────────── +let releaseChannel = $state("stable") let targetVersion = $state("") let upgrading = $state(false) let upgradeResult = $state(null) +async function handleChannelChange(channel: ReleaseChannel) { + releaseChannel = channel + try { + await setReleaseChannelIpc(channel) + toasts.success(`Switched to ${channel} update channel`) + } catch (err) { + toasts.error(`Failed to switch channel: ${extractErrorMessage(err)}`) + } +} + async function handleUpgrade() { if (!targetVersion) return const version = targetVersion.startsWith("v") @@ -189,6 +203,11 @@ onMount(async () => { localOptionsStore.set(local) loading = false await loadVersion() + try { + releaseChannel = await getReleaseChannel() + } catch { + // Ignore — defaults to stable + } }) async function loadVersion() { @@ -325,6 +344,25 @@ function toggleLocal(key: keyof LocalOptions) { setAutoUpdate(v)} /> +
+ +

Choose which updates to receive. Beta includes pre-release versions with new features that may be less stable.

+
+ + +
+
+
{#if cliVersion} diff --git a/hack/merge-mac-metadata.py b/hack/merge-mac-metadata.py new file mode 100755 index 000000000..668f2bed4 --- /dev/null +++ b/hack/merge-mac-metadata.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Merge macOS electron-updater metadata from separate arch builds. + +When building macOS arm64 and x64 separately, each produces its own +latest-mac.yml (or beta-mac.yml) with only one architecture's files. +This script merges them into a single file with entries for both arches. + +Usage: python3 merge-mac-metadata.py +""" + +import glob +import sys +from pathlib import Path + +import yaml + + +def merge_mac_files(metadata_dir: str, output_dir: str) -> None: + metadata_path = Path(metadata_dir) + output_path = Path(output_dir) + + for prefix in ("latest-mac", "beta-mac"): + pattern = str(metadata_path / "**" / f"{prefix}.yml") + found = glob.glob(pattern, recursive=True) + if not found: + continue + + merged_files = [] + base_data = None + + for filepath in found: + with open(filepath) as f: + data = yaml.safe_load(f) + if data is None: + continue + if base_data is None: + base_data = data + if "files" in data: + merged_files.extend(data["files"]) + + if base_data is None: + continue + + base_data["files"] = merged_files + + # Set path to the first file entry (electron-updater uses this as default) + if merged_files: + base_data["path"] = merged_files[0]["url"] + base_data["sha512"] = merged_files[0]["sha512"] + base_data["size"] = merged_files[0].get("size") + + out_file = output_path / f"{prefix}.yml" + with open(out_file, "w") as f: + yaml.dump(base_data, f, default_flow_style=False, sort_keys=False) + + print(f"Merged {len(found)} files into {out_file}") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + merge_mac_files(sys.argv[1], sys.argv[2]) diff --git a/pkg/selfupdate/selfupdate.go b/pkg/selfupdate/selfupdate.go index 5b4defca3..c89d26a3d 100644 --- a/pkg/selfupdate/selfupdate.go +++ b/pkg/selfupdate/selfupdate.go @@ -12,10 +12,16 @@ import ( "github.com/devsy-org/devsy/pkg/version" ) +type Options struct { + Version string + DryRun bool + IncludePrerelease bool +} + // Upgrade downloads the latest release from github and replaces devsy if a new version is found. -// If dryRun is true, it only shows what would be downloaded without actually upgrading. -func Upgrade(ctx context.Context, targetVersion string, dryRun bool) error { - release, updater, err := detectRelease(ctx, targetVersion) +// If DryRun is true, it only shows what would be downloaded without actually upgrading. +func Upgrade(ctx context.Context, opts Options) error { + release, updater, err := detectRelease(ctx, opts) if err != nil { return err } @@ -31,7 +37,7 @@ func Upgrade(ctx context.Context, targetVersion string, dryRun bool) error { return nil } - if dryRun { + if opts.DryRun { dryRunOutput := fmt.Sprintf( "asset_name=%s\nversion=%s\nos=%s\narch=%s\nurl=%s\nsize=%d\n", release.AssetName, @@ -61,20 +67,21 @@ func Upgrade(ctx context.Context, targetVersion string, dryRun bool) error { return nil } -// detectRelease detects which release to use based on targetVersion. func detectRelease( ctx context.Context, - targetVersion string, + opts Options, ) (*selfupdate.Release, *selfupdate.Updater, error) { - updater, err := selfupdate.NewUpdater(selfupdate.Config{}) + updater, err := selfupdate.NewUpdater(selfupdate.Config{ + Prerelease: opts.IncludePrerelease, + }) if err != nil { return nil, nil, fmt.Errorf("initialize updater: %w", err) } repo := selfupdate.ParseSlug(config.RepoSlug) - if targetVersion != "" { - release, err := detectSpecificVersion(ctx, updater, repo, targetVersion) + if opts.Version != "" { + release, err := detectSpecificVersion(ctx, updater, repo, opts.Version) return release, updater, err } From c6a95d29ff6848e601fc6571975be09adae0dc6b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 19 May 2026 16:31:41 -0500 Subject: [PATCH 4/6] fix: address PR review feedback for beta channel implementation - Validate --channel flag in CLI (reject invalid values in PreRunE) - Validate IPC set_release_channel input before processing - Revert UI state on channel switch failure - Add persist-credentials: false to deploy-update-metadata checkout - Use argparse in merge-mac-metadata.py - Overhaul Updates section UI with card-based channel selector --- .github/workflows/release.yml | 2 + cmd/self_update.go | 8 ++ cmd/self_update_test.go | 19 +++ desktop/src/main/ipc.ts | 8 +- .../renderer/src/pages/SettingsPage.svelte | 122 +++++++++++------- hack/merge-mac-metadata.py | 26 ++-- 6 files changed, 124 insertions(+), 61 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f016b4ad..408d7e82a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -230,6 +230,8 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: download update metadata artifacts uses: actions/download-artifact@v8 diff --git a/cmd/self_update.go b/cmd/self_update.go index 786881407..b2ed38959 100644 --- a/cmd/self_update.go +++ b/cmd/self_update.go @@ -21,6 +21,14 @@ func NewSelfUpdateCmd() *cobra.Command { Use: "self-update", Short: "Update the Devsy CLI to the newest version", Args: cobra.NoArgs, + PreRunE: func(_ *cobra.Command, _ []string) error { + switch cmd.Channel { + case "stable", "beta": + return nil + default: + return fmt.Errorf("invalid channel %q: must be 'stable' or 'beta'", cmd.Channel) + } + }, RunE: func(cobraCmd *cobra.Command, args []string) error { ctx := cobraCmd.Context() opts := selfupdate.Options{ diff --git a/cmd/self_update_test.go b/cmd/self_update_test.go index a286cd570..56a924272 100644 --- a/cmd/self_update_test.go +++ b/cmd/self_update_test.go @@ -41,3 +41,22 @@ func TestNewSelfUpdateCmd_AcceptsNoPositionalArgs(t *testing.T) { err = cmd.Args(cmd, []string{}) assert.NoError(t, err, "should accept zero arguments") } + +func TestNewSelfUpdateCmd_RejectsInvalidChannel(t *testing.T) { + cmd := NewSelfUpdateCmd() + cmd.SetArgs([]string{"--channel", "nightly"}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid channel") +} + +func TestNewSelfUpdateCmd_AcceptsValidChannels(t *testing.T) { + for _, ch := range []string{"stable", "beta"} { + cmd := NewSelfUpdateCmd() + require.NoError(t, cmd.Flags().Set("channel", ch)) + // PreRunE should pass validation + preRunE := cmd.PreRunE + require.NotNil(t, preRunE) + assert.NoError(t, preRunE(cmd, nil)) + } +} diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 350ddf8ef..6e474b789 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -670,9 +670,11 @@ export function registerIpcHandlers(deps: IpcDependencies): void { ipcMain.handle( "set_release_channel", async (_event, args: { channel: string }) => { - const channel = args.channel as ReleaseChannel - setReleaseChannel(channel) - await checkForUpdatesWithChannel(channel) + if (args.channel !== "stable" && args.channel !== "beta") { + throw new Error(`Invalid release channel: ${args.channel}`) + } + setReleaseChannel(args.channel) + await checkForUpdatesWithChannel(args.channel) }, ) diff --git a/desktop/src/renderer/src/pages/SettingsPage.svelte b/desktop/src/renderer/src/pages/SettingsPage.svelte index dc8756e7e..c374799e4 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.svelte +++ b/desktop/src/renderer/src/pages/SettingsPage.svelte @@ -1,6 +1,6 @@