diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6445bafd3..04dafedb9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,3 +44,60 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + + # Build the Desktop installers for Linux and Windows and upload them to + # the GitHub Release that the `release` job above just published. macOS + # Desktop continues to ship via the manual `release-desktop` skill so it + # can be signed + notarized with Apple Developer credentials that are + # not (yet) wired into CI. + desktop: + needs: release + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: linux + - os: windows-latest + target: win + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install rpmbuild (Linux) + if: matrix.target == 'linux' + run: sudo apt-get update && sudo apt-get install -y rpm + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: server/go.mod + cache-dependency-path: server/go.sum + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Package Desktop installers (${{ matrix.target }}) + working-directory: apps/desktop + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # electron-builder's GitHub publisher reads this: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Disable code signing on Linux/Windows for now — the public + # release is unsigned for these platforms, the CLI carries the + # trust boundary. Set CSC_LINK in repo secrets to enable + # Windows signing later. + CSC_IDENTITY_AUTO_DISCOVERY: "false" + run: node scripts/package.mjs --${{ matrix.target }} --x64 --arm64 --publish always diff --git a/.goreleaser.yml b/.goreleaser.yml index 5737ae059..a3555f561 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -21,12 +21,12 @@ builds: goarch: - amd64 - arm64 - ignore: - - goos: windows - goarch: arm64 archives: - - id: default + # Legacy archive name kept so already-released CLIs (whose `multica update` + # looks for `multica_{os}_{arch}.{ext}`) can keep self-updating. Remove + # once those versions are no longer in use. + - id: legacy formats: - tar.gz format_overrides: @@ -34,6 +34,16 @@ archives: formats: - zip name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + # Versioned archive name used by current CLI / install scripts / + # desktop bootstrap going forward. + - id: versioned + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + name_template: "{{ .ProjectName }}-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}" checksum: name_template: "checksums.txt" @@ -48,6 +58,8 @@ changelog: brews: - name: multica + ids: + - versioned repository: owner: multica-ai name: homebrew-tap diff --git a/CLI_INSTALL.md b/CLI_INSTALL.md index 1d1a22113..192eb648d 100644 --- a/CLI_INSTALL.md +++ b/CLI_INSTALL.md @@ -76,7 +76,8 @@ fi LATEST=$(curl -sI https://github.com/multica-ai/multica/releases/latest | grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r\n') # Download and extract -curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica_${OS}_${ARCH}.tar.gz" -o /tmp/multica.tar.gz +VERSION="${LATEST#v}" +curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica-cli-${VERSION}-${OS}-${ARCH}.tar.gz" -o /tmp/multica.tar.gz tar -xzf /tmp/multica.tar.gz -C /tmp multica sudo mv /tmp/multica /usr/local/bin/multica rm /tmp/multica.tar.gz diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index bc4cc223a..01354a3da 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -21,23 +21,26 @@ mac: - zip # Hardcoded name avoids the `@multica/desktop-*` subdirectory that # `${name}` produces for scoped package names. - artifactName: multica-desktop-${version}-${arch}.${ext} + # Naming scheme: multica-desktop---. + # so the filename alone surfaces kind, version, platform, and CPU arch. + artifactName: multica-desktop-${version}-mac-${arch}.${ext} # Notarize via notarytool. Requires APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD # + APPLE_TEAM_ID env vars at package time. Non-mac contributors are # unaffected because `pnpm package` already requires the Developer ID # signing cert — notarization is a strict superset. notarize: true dmg: - artifactName: multica-desktop-${version}-${arch}.${ext} + artifactName: multica-desktop-${version}-mac-${arch}.${ext} linux: target: - AppImage - deb - artifactName: ${name}-${version}-${arch}.${ext} + - rpm + artifactName: multica-desktop-${version}-linux-${arch}.${ext} win: target: - nsis - artifactName: ${name}-${version}-setup.${ext} + artifactName: multica-desktop-${version}-windows-${arch}.${ext} publish: provider: github owner: multica-ai diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2d6474d78..502ce2c8b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -13,6 +13,7 @@ "typecheck": "pnpm run typecheck:node && pnpm run typecheck:web", "preview": "electron-vite preview", "package": "node scripts/package.mjs", + "package:all": "node scripts/package.mjs --all-platforms --publish never", "lint": "eslint .", "test": "vitest run", "postinstall": "electron-builder install-app-deps" diff --git a/apps/desktop/scripts/bundle-cli.mjs b/apps/desktop/scripts/bundle-cli.mjs index 03a8b7504..44a3ba1ba 100644 --- a/apps/desktop/scripts/bundle-cli.mjs +++ b/apps/desktop/scripts/bundle-cli.mjs @@ -13,7 +13,7 @@ // skip the build and fall through to auto-install at runtime. A genuine // Go compile error is fatal — you want that to block dev, not hide. -import { access, chmod, copyFile, mkdir } from "node:fs/promises"; +import { access, chmod, copyFile, mkdir, rm } from "node:fs/promises"; import { constants } from "node:fs"; import { execFileSync, execSync } from "node:child_process"; import { dirname, join, resolve } from "node:path"; @@ -23,8 +23,54 @@ const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(here, "..", "..", ".."); const serverDir = join(repoRoot, "server"); -const binName = process.platform === "win32" ? "multica.exe" : "multica"; -const srcBinary = join(serverDir, "bin", binName); +const PLATFORM_TO_GOOS = { + darwin: "darwin", + linux: "linux", + win32: "windows", +}; + +const SUPPORTED_ARCHS = new Set(["x64", "arm64"]); + +function runtimePlatformFromArgs(argv) { + const flagIndex = argv.indexOf("--target-platform"); + if (flagIndex === -1) return process.platform; + return argv[flagIndex + 1] ?? ""; +} + +function runtimeArchFromArgs(argv) { + const flagIndex = argv.indexOf("--target-arch"); + if (flagIndex === -1) return process.arch; + return argv[flagIndex + 1] ?? ""; +} + +function normalizeRuntimePlatform(platform) { + if (platform in PLATFORM_TO_GOOS) return platform; + throw new Error( + `[bundle-cli] unsupported target platform: ${platform}. ` + + "Use darwin, linux, or win32.", + ); +} + +function normalizeRuntimeArch(arch) { + if (SUPPORTED_ARCHS.has(arch)) return arch; + throw new Error( + `[bundle-cli] unsupported target architecture: ${arch}. ` + + "Use x64 or arm64.", + ); +} + +function binaryNameForPlatform(platform) { + return platform === "win32" ? "multica.exe" : "multica"; +} + +const targetPlatform = normalizeRuntimePlatform( + runtimePlatformFromArgs(process.argv.slice(2)), +); +const targetArch = normalizeRuntimeArch(runtimeArchFromArgs(process.argv.slice(2))); +const goos = PLATFORM_TO_GOOS[targetPlatform]; +const goarch = targetArch === "x64" ? "amd64" : targetArch; +const binName = binaryNameForPlatform(targetPlatform); +const srcBinary = join(serverDir, "bin", `${goos}-${goarch}`, binName); const destDir = join(repoRoot, "apps", "desktop", "resources", "bin"); const destBinary = join(destDir, binName); @@ -61,8 +107,9 @@ if (hasGo()) { const ldflags = `-X main.version=${version} -X main.commit=${commit} -X main.date=${date}`; console.log( - `[bundle-cli] go build → ${srcBinary} (version=${version} commit=${commit})`, + `[bundle-cli] go build → ${srcBinary} (${goos}/${goarch}, version=${version} commit=${commit})`, ); + await mkdir(join(serverDir, "bin", `${goos}-${goarch}`), { recursive: true }); execFileSync( "go", [ @@ -70,10 +117,19 @@ if (hasGo()) { "-ldflags", ldflags, "-o", - join("bin", binName), + srcBinary, "./cmd/multica", ], - { cwd: serverDir, stdio: "inherit" }, + { + cwd: serverDir, + stdio: "inherit", + env: { + ...process.env, + CGO_ENABLED: "0", + GOOS: goos, + GOARCH: goarch, + }, + }, ); } else { console.warn( @@ -88,9 +144,11 @@ if (!(await exists(srcBinary))) { `[bundle-cli] ${srcBinary} not present — Desktop will fall back to ` + `auto-installing the latest release at runtime.`, ); + await rm(destDir, { recursive: true, force: true }); process.exit(0); } +await rm(destDir, { recursive: true, force: true }); await mkdir(destDir, { recursive: true }); await copyFile(srcBinary, destBinary); await chmod(destBinary, 0o755); diff --git a/apps/desktop/scripts/package.mjs b/apps/desktop/scripts/package.mjs index f7a6e401b..e96acbe53 100644 --- a/apps/desktop/scripts/package.mjs +++ b/apps/desktop/scripts/package.mjs @@ -5,11 +5,11 @@ // binary via the `main.version` ldflag — so a single `vX.Y.Z` tag push // produces matching CLI and Desktop versions. // -// Runs bundle-cli.mjs first (so the Go binary is compiled and copied -// into resources/bin/), then `electron-vite build` to produce the -// main/preload/renderer bundles under out/, then invokes electron-builder -// with `-c.extraMetadata.version=` so the override applies at -// build time without mutating the tracked package.json. +// Builds the Electron bundles once, then for each requested target +// (platform + arch) compiles the matching Go CLI into resources/bin/ and +// invokes electron-builder with `-c.extraMetadata.version=` so +// the override applies at build time without mutating the tracked +// package.json. // // The electron-vite step is important: electron-builder only packages // whatever is already in out/, so skipping it (or relying on stale @@ -30,6 +30,45 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const desktopRoot = resolve(here, ".."); +const bundleCliScript = resolve(here, "bundle-cli.mjs"); + +const PLATFORM_CONFIG = { + mac: { + aliases: new Set(["--mac", "--macos", "-m"]), + builderFlag: "--mac", + runtimePlatform: "darwin", + label: "macOS", + }, + win: { + aliases: new Set(["--win", "--windows", "-w"]), + builderFlag: "--win", + runtimePlatform: "win32", + label: "Windows", + }, + linux: { + aliases: new Set(["--linux", "-l"]), + builderFlag: "--linux", + runtimePlatform: "linux", + label: "Linux", + }, +}; + +const ARCH_FLAGS = new Map([ + ["--x64", "x64"], + ["--arm64", "arm64"], + ["--ia32", "ia32"], + ["--armv7l", "armv7l"], + ["--universal", "universal"], +]); + +const SUPPORTED_CLI_ARCHS = new Set(["x64", "arm64"]); +const MAC_ALL_PLATFORM_TARGETS = [ + { platform: "mac", arch: "arm64" }, + { platform: "win", arch: "x64" }, + { platform: "win", arch: "arm64" }, + { platform: "linux", arch: "x64" }, + { platform: "linux", arch: "arm64" }, +]; function sh(cmd) { try { @@ -77,14 +116,196 @@ function deriveVersion() { return normalizeGitVersion(sh("git describe --tags --always --dirty")); } -function main() { - // Step 1: build + bundle the Go CLI via the existing script. - execFileSync("node", [resolve(here, "bundle-cli.mjs")], { - stdio: "inherit", - cwd: desktopRoot, - }); +function uniqueOrdered(values) { + return [...new Set(values)]; +} - // Step 2: build the Electron main/preload/renderer bundles. Without +function hostPlatformKey(platform = process.platform) { + if (platform === "darwin") return "mac"; + if (platform === "win32") return "win"; + if (platform === "linux") return "linux"; + throw new Error(`[package] unsupported host platform: ${platform}`); +} + +function hostArchKey(arch = process.arch) { + if (SUPPORTED_CLI_ARCHS.has(arch)) return arch; + throw new Error( + `[package] unsupported host architecture for Desktop CLI bundling: ${arch}`, + ); +} + +function expandPlatformShorthand(token) { + if (!/^-[mwl]{2,}$/.test(token)) return null; + const expanded = []; + for (const char of token.slice(1)) { + if (char === "m") expanded.push("mac"); + if (char === "w") expanded.push("win"); + if (char === "l") expanded.push("linux"); + } + return uniqueOrdered(expanded); +} + +function platformKeyForToken(token) { + for (const [platform, config] of Object.entries(PLATFORM_CONFIG)) { + if (config.aliases.has(token)) return platform; + } + return null; +} + +function platformTargetsTemplate() { + return { mac: [], win: [], linux: [] }; +} + +export function parsePackageArgs(argv) { + const sharedArgs = []; + const platformTargets = platformTargetsTemplate(); + const requestedPlatforms = []; + const requestedArchs = []; + let allPlatforms = false; + + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === "--all-platforms") { + allPlatforms = true; + continue; + } + + const expandedPlatforms = expandPlatformShorthand(token); + if (expandedPlatforms) { + requestedPlatforms.push(...expandedPlatforms); + continue; + } + + const platform = platformKeyForToken(token); + if (platform) { + requestedPlatforms.push(platform); + while (i + 1 < argv.length && !argv[i + 1].startsWith("-")) { + platformTargets[platform].push(argv[i + 1]); + i += 1; + } + continue; + } + + const arch = ARCH_FLAGS.get(token); + if (arch) { + requestedArchs.push(arch); + continue; + } + + sharedArgs.push(token); + } + + return { + allPlatforms, + sharedArgs, + platformTargets, + requestedPlatforms: uniqueOrdered(requestedPlatforms), + requestedArchs: uniqueOrdered(requestedArchs), + }; +} + +export function resolveBuildMatrix(parsed, platform = process.platform, arch = process.arch) { + if (parsed.allPlatforms) { + if (parsed.requestedPlatforms.length > 0 || parsed.requestedArchs.length > 0) { + throw new Error( + "[package] --all-platforms cannot be combined with explicit platform or arch flags", + ); + } + if (platform !== "darwin") { + throw new Error( + `[package] --all-platforms is only supported on macOS hosts (current: ${platform})`, + ); + } + return MAC_ALL_PLATFORM_TARGETS.map((target) => ({ ...target })); + } + + const platforms = + parsed.requestedPlatforms.length > 0 + ? parsed.requestedPlatforms + : [hostPlatformKey(platform)]; + const archs = + parsed.requestedArchs.length > 0 + ? parsed.requestedArchs + : [hostArchKey(arch)]; + + const unsupported = archs.filter((value) => !SUPPORTED_CLI_ARCHS.has(value)); + if (unsupported.length > 0) { + throw new Error( + `[package] unsupported Desktop CLI architecture(s): ${unsupported.join(", ")}. ` + + "Use --x64 or --arm64.", + ); + } + + return platforms.flatMap((targetPlatform) => + archs.map((targetArch) => ({ + platform: targetPlatform, + arch: targetArch, + })), + ); +} + +function formatTarget(target) { + return `${PLATFORM_CONFIG[target.platform].label} ${target.arch}`; +} + +export function builderArgsForTarget( + target, + parsed, + version, + { + disableMacNotarize = false, + hostPlatform = process.platform, + useScopedOutputDir = false, + } = {}, +) { + const builderArgs = []; + if (version) builderArgs.push(`-c.extraMetadata.version=${version}`); + if (disableMacNotarize) builderArgs.push("-c.mac.notarize=false"); + builderArgs.push(PLATFORM_CONFIG[target.platform].builderFlag); + const requestedTargets = parsed.platformTargets[target.platform]; + if ( + target.platform === "linux" && + hostPlatform !== "linux" && + requestedTargets.length === 0 + ) { + // electron-builder only guarantees AppImage/Snap when cross-building + // Linux from macOS/Windows. Keep `package:all` portable by defaulting + // to AppImage unless the caller explicitly requests Linux targets. + builderArgs.push("AppImage"); + } else { + builderArgs.push(...requestedTargets); + } + builderArgs.push(`--${target.arch}`); + builderArgs.push(...parsed.sharedArgs); + if (useScopedOutputDir) { + builderArgs.push( + `-c.directories.output=dist/${target.platform}-${target.arch}`, + ); + } + // electron-builder's update metadata file is `latest.yml` for Windows + // regardless of arch (only Linux gets an arch suffix automatically — see + // app-builder-lib's getArchPrefixForUpdateFile). Without an explicit + // channel override, building Windows x64 and arm64 in two invocations + // makes both publish `latest.yml` to the same GitHub Release, so the + // second upload overwrites the first and one of the two architectures + // ends up with no auto-update metadata. Route Windows arm64 to its own + // channel so x64 keeps `latest.yml` and arm64 ships `latest-arm64.yml`; + // the renderer-side updater pins the matching channel per arch. + if (target.platform === "win" && target.arch === "arm64") { + builderArgs.push("-c.publish.channel=latest-arm64"); + } + return builderArgs; +} + +function main() { + const passthrough = stripLeadingSeparator(process.argv.slice(2)); + const parsed = parsePackageArgs(passthrough); + const buildMatrix = resolveBuildMatrix(parsed); + console.log( + `[package] build matrix → ${buildMatrix.map(formatTarget).join(", ")}`, + ); + + // Step 1: build the Electron main/preload/renderer bundles. Without // this step electron-builder silently packages whatever is already in // out/, which on a fresh checkout (or after a partial build) ships an // app that white-screens because the renderer bundle is missing. @@ -103,7 +324,7 @@ function main() { process.exit(viteResult.status ?? 1); } - // Step 3: derive the version that should be written into the app. + // Step 2: derive the version that should be written into the app. const version = deriveVersion(); if (version) { console.log(`[package] Desktop version → ${version} (from git describe)`); @@ -113,43 +334,58 @@ function main() { ); } - // Step 4: assemble electron-builder args. - const passthrough = stripLeadingSeparator(process.argv.slice(2)); - const builderArgs = []; - if (version) builderArgs.push(`-c.extraMetadata.version=${version}`); - - // Step 5: gracefully degrade for local dev builds. electron-builder.yml - // sets `notarize: true` so real releases notarize in-build (keeping the - // stapled .app consistent with latest-mac.yml's SHA512). But a mac dev - // who just wants to smoke-test a local package doesn't have Apple - // credentials, and would otherwise hit a hard failure at the notarize - // step. Detect the missing env and flip notarize off for this run only. - if (!process.env.APPLE_TEAM_ID) { + const disableMacNotarize = !process.env.APPLE_TEAM_ID; + if (disableMacNotarize) { console.warn( "[package] APPLE_TEAM_ID not set — skipping notarization (local dev build). " + "Set APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID for a release build.", ); - builderArgs.push("-c.mac.notarize=false"); } - builderArgs.push(...passthrough); + const useScopedOutputDir = buildMatrix.length > 1; - // Step 6: invoke electron-builder. pnpm puts node_modules/.bin on PATH - // for the script run, so spawnSync finds the binary without needing a - // shell wrapper (avoids any risk of argv interpolation). - const result = spawnSync("electron-builder", builderArgs, { - stdio: "inherit", - cwd: desktopRoot, - }); - - if (result.error) { - console.error( - "[package] failed to spawn electron-builder:", - result.error.message, + // Step 3: for each requested target, build the matching CLI into + // resources/bin/ and package that target in isolation. + for (const target of buildMatrix) { + console.log(`[package] bundling CLI → ${formatTarget(target)}`); + execFileSync( + "node", + [ + bundleCliScript, + "--target-platform", + PLATFORM_CONFIG[target.platform].runtimePlatform, + "--target-arch", + target.arch, + ], + { + stdio: "inherit", + cwd: desktopRoot, + }, ); - process.exit(1); + + const builderArgs = builderArgsForTarget(target, parsed, version, { + disableMacNotarize, + hostPlatform: process.platform, + useScopedOutputDir, + }); + + // Step 4: invoke electron-builder for the current target only. + const result = spawnSync("electron-builder", builderArgs, { + stdio: "inherit", + cwd: desktopRoot, + }); + + if (result.error) { + console.error( + "[package] failed to spawn electron-builder:", + result.error.message, + ); + process.exit(1); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } } - process.exit(result.status ?? 1); } // Only run when invoked as a CLI, not when imported by a test file. diff --git a/apps/desktop/scripts/package.test.mjs b/apps/desktop/scripts/package.test.mjs index 763b9663d..c705a7bbd 100644 --- a/apps/desktop/scripts/package.test.mjs +++ b/apps/desktop/scripts/package.test.mjs @@ -1,5 +1,11 @@ import { describe, it, expect } from "vitest"; -import { normalizeGitVersion, stripLeadingSeparator } from "./package.mjs"; +import { + builderArgsForTarget, + normalizeGitVersion, + parsePackageArgs, + resolveBuildMatrix, + stripLeadingSeparator, +} from "./package.mjs"; describe("normalizeGitVersion", () => { it("returns null for empty / nullish input", () => { @@ -59,3 +65,175 @@ describe("stripLeadingSeparator", () => { expect(stripLeadingSeparator([])).toEqual([]); }); }); + +describe("parsePackageArgs", () => { + it("collects per-platform targets and shared args", () => { + expect( + parsePackageArgs([ + "--win", "nsis", + "--mac", "dmg", "zip", + "--arm64", + "--publish", "never", + ]), + ).toEqual({ + allPlatforms: false, + sharedArgs: ["--publish", "never"], + platformTargets: { + mac: ["dmg", "zip"], + win: ["nsis"], + linux: [], + }, + requestedPlatforms: ["win", "mac"], + requestedArchs: ["arm64"], + }); + }); + + it("expands combined short flags", () => { + expect(parsePackageArgs(["-mw", "--x64"]).requestedPlatforms).toEqual([ + "mac", + "win", + ]); + }); + + it("tracks the all-platforms shortcut", () => { + expect(parsePackageArgs(["--all-platforms", "--publish", "never"]).allPlatforms).toBe(true); + }); +}); + +describe("resolveBuildMatrix", () => { + it("defaults to the current host platform and arch", () => { + expect( + resolveBuildMatrix( + { + allPlatforms: false, + sharedArgs: [], + platformTargets: { mac: [], win: [], linux: [] }, + requestedPlatforms: [], + requestedArchs: [], + }, + "darwin", + "arm64", + ), + ).toEqual([{ platform: "mac", arch: "arm64" }]); + }); + + it("expands all-platforms on macOS", () => { + expect( + resolveBuildMatrix( + { + allPlatforms: true, + sharedArgs: [], + platformTargets: { mac: [], win: [], linux: [] }, + requestedPlatforms: [], + requestedArchs: [], + }, + "darwin", + "arm64", + ), + ).toEqual([ + { platform: "mac", arch: "arm64" }, + { platform: "win", arch: "x64" }, + { platform: "win", arch: "arm64" }, + { platform: "linux", arch: "x64" }, + { platform: "linux", arch: "arm64" }, + ]); + }); + + it("rejects unsupported architectures", () => { + expect(() => + resolveBuildMatrix( + { + allPlatforms: false, + sharedArgs: [], + platformTargets: { mac: [], win: [], linux: [] }, + requestedPlatforms: ["win"], + requestedArchs: ["universal"], + }, + "darwin", + "arm64", + ), + ).toThrow(/unsupported Desktop CLI architecture/); + }); +}); + +describe("builderArgsForTarget", () => { + it("adds scoped output directories for multi-target builds", () => { + expect( + builderArgsForTarget( + { platform: "win", arch: "arm64" }, + { + allPlatforms: false, + sharedArgs: ["--publish", "never"], + platformTargets: { mac: [], win: ["nsis"], linux: [] }, + requestedPlatforms: ["win"], + requestedArchs: ["arm64"], + }, + "1.2.3", + { + disableMacNotarize: true, + hostPlatform: "darwin", + useScopedOutputDir: true, + }, + ), + ).toEqual([ + "-c.extraMetadata.version=1.2.3", + "-c.mac.notarize=false", + "--win", + "nsis", + "--arm64", + "--publish", + "never", + "-c.directories.output=dist/win-arm64", + "-c.publish.channel=latest-arm64", + ]); + }); + + it("does not override the publish channel for Windows x64 (default latest.yml)", () => { + expect( + builderArgsForTarget( + { platform: "win", arch: "x64" }, + { + allPlatforms: false, + sharedArgs: ["--publish", "always"], + platformTargets: { mac: [], win: ["nsis"], linux: [] }, + requestedPlatforms: ["win"], + requestedArchs: ["x64"], + }, + "1.2.3", + { hostPlatform: "win32", useScopedOutputDir: true }, + ), + ).toEqual([ + "-c.extraMetadata.version=1.2.3", + "--win", + "nsis", + "--x64", + "--publish", + "always", + "-c.directories.output=dist/win-x64", + ]); + }); + + it("defaults linux cross-builds to AppImage on non-Linux hosts", () => { + expect( + builderArgsForTarget( + { platform: "linux", arch: "x64" }, + { + allPlatforms: false, + sharedArgs: ["--publish", "never"], + platformTargets: { mac: [], win: [], linux: [] }, + requestedPlatforms: ["linux"], + requestedArchs: ["x64"], + }, + "1.2.3", + { hostPlatform: "darwin" }, + ), + ).toEqual([ + "-c.extraMetadata.version=1.2.3", + "--linux", + "AppImage", + "--x64", + "--publish", + "never", + ]); + }); +}); diff --git a/apps/desktop/src/main/cli-bootstrap.ts b/apps/desktop/src/main/cli-bootstrap.ts index 4a3a8130a..8ed2dcda1 100644 --- a/apps/desktop/src/main/cli-bootstrap.ts +++ b/apps/desktop/src/main/cli-bootstrap.ts @@ -8,35 +8,15 @@ import { pipeline } from "stream/promises"; import { tmpdir } from "os"; import { Readable } from "stream"; -// Desktop bootstraps its own copy of the `multica` CLI into userData on first -// launch, so users never have to brew-install anything. Build-time decoupled: -// we don't bundle the binary into the .app, we download whatever the upstream -// release is at first run. +import { selectPlatformReleaseAssetName } from "./cli-release-asset"; + +// Desktop prefers the bundled `multica` CLI shipped inside the app for +// same-repo builds, but it can also repair or bootstrap a managed copy in +// userData on first launch when the bundled binary is missing or unusable. const GITHUB_LATEST_BASE = "https://github.com/multica-ai/multica/releases/latest/download"; -function platformAssetName(): string { - const osMap: Record = { - darwin: "darwin", - linux: "linux", - win32: "windows", - }; - const archMap: Record = { - x64: "amd64", - arm64: "arm64", - }; - const os = osMap[process.platform]; - const arch = archMap[process.arch]; - if (!os || !arch) { - throw new Error( - `unsupported platform for CLI auto-install: ${process.platform}/${process.arch}`, - ); - } - const ext = process.platform === "win32" ? "zip" : "tar.gz"; - return `multica_${os}_${arch}.${ext}`; -} - function binaryName(): string { return process.platform === "win32" ? "multica.exe" : "multica"; } @@ -92,14 +72,8 @@ async function sha256OfFile(path: string): Promise { async function verifyChecksum( archivePath: string, assetName: string, + expected: string, ): Promise { - const checksums = await fetchChecksums(); - const expected = checksums.get(assetName); - if (!expected) { - throw new Error( - `no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`, - ); - } const actual = await sha256OfFile(archivePath); if (actual.toLowerCase() !== expected) { throw new Error( @@ -118,7 +92,14 @@ async function extractArchive(archive: string, dest: string): Promise { async function installFresh(): Promise { const target = managedCliPath(); - const assetName = platformAssetName(); + const checksums = await fetchChecksums(); + const assetName = selectPlatformReleaseAssetName(checksums.keys()); + const expectedChecksum = checksums.get(assetName); + if (!expectedChecksum) { + throw new Error( + `no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`, + ); + } const url = `${GITHUB_LATEST_BASE}/${assetName}`; const workDir = join(tmpdir(), `multica-cli-${Date.now()}`); @@ -130,7 +111,7 @@ async function installFresh(): Promise { await downloadToFile(url, archivePath); console.log(`[cli-bootstrap] verifying ${assetName} against checksums.txt`); - await verifyChecksum(archivePath, assetName); + await verifyChecksum(archivePath, assetName, expectedChecksum); console.log(`[cli-bootstrap] extracting ${assetName}`); await extractArchive(archivePath, workDir); @@ -143,6 +124,7 @@ async function installFresh(): Promise { } await mkdir(dirname(target), { recursive: true }); + await rm(target, { force: true }).catch(() => {}); await rename(extractedBin, target); await chmod(target, 0o755); @@ -166,8 +148,10 @@ async function installFresh(): Promise { * the managed userData location, returns it immediately. Otherwise downloads * the latest release asset for the current platform and installs it. */ -export async function ensureManagedCli(): Promise { +export async function ensureManagedCli( + options: { forceInstall?: boolean } = {}, +): Promise { const target = managedCliPath(); - if (existsSync(target)) return target; + if (existsSync(target) && !options.forceInstall) return target; return installFresh(); } diff --git a/apps/desktop/src/main/cli-release-asset.test.ts b/apps/desktop/src/main/cli-release-asset.test.ts new file mode 100644 index 000000000..253880731 --- /dev/null +++ b/apps/desktop/src/main/cli-release-asset.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { selectPlatformReleaseAssetName } from "./cli-release-asset"; + +describe("selectPlatformReleaseAssetName", () => { + it("prefers the versioned archive name when both exist", () => { + const assetNames = [ + "checksums.txt", + "multica_darwin_amd64.tar.gz", + "multica-cli-1.2.3-darwin-amd64.tar.gz", + ]; + + expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe( + "multica-cli-1.2.3-darwin-amd64.tar.gz", + ); + }); + + it("falls back to the legacy archive name when only legacy is present", () => { + const assetNames = ["checksums.txt", "multica_darwin_amd64.tar.gz"]; + + expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe( + "multica_darwin_amd64.tar.gz", + ); + }); + + it("matches the renamed darwin archive from release assets", () => { + const assetNames = [ + "checksums.txt", + "multica-cli-1.2.3-darwin-amd64.tar.gz", + "multica-cli-1.2.3-darwin-arm64.tar.gz", + "multica-cli-1.2.3-linux-amd64.tar.gz", + ]; + + expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe( + "multica-cli-1.2.3-darwin-amd64.tar.gz", + ); + }); + + it("matches the renamed windows zip archive", () => { + const assetNames = [ + "multica-cli-1.2.3-windows-amd64.zip", + "multica-cli-1.2.3-linux-amd64.tar.gz", + ]; + + expect(selectPlatformReleaseAssetName(assetNames, "win32", "x64")).toBe( + "multica-cli-1.2.3-windows-amd64.zip", + ); + }); + + it("fails when the current platform asset is missing", () => { + expect(() => + selectPlatformReleaseAssetName( + ["multica-cli-1.2.3-linux-amd64.tar.gz", "multica_linux_amd64.tar.gz"], + "darwin", + "arm64", + ), + ).toThrow(/no release asset found/); + }); +}); diff --git a/apps/desktop/src/main/cli-release-asset.ts b/apps/desktop/src/main/cli-release-asset.ts new file mode 100644 index 000000000..a8ad94bb5 --- /dev/null +++ b/apps/desktop/src/main/cli-release-asset.ts @@ -0,0 +1,62 @@ +const RELEASE_ARCHIVE_PREFIX = "multica-cli-"; + +function platformArchiveDescriptor( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, +): { os: string; arch: string; ext: string } { + const osMap: Record = { + darwin: "darwin", + linux: "linux", + win32: "windows", + }; + const archMap: Record = { + x64: "amd64", + arm64: "arm64", + }; + const os = osMap[platform]; + const mappedArch = archMap[arch]; + if (!os || !mappedArch) { + throw new Error( + `unsupported platform for CLI auto-install: ${platform}/${arch}`, + ); + } + const ext = platform === "win32" ? "zip" : "tar.gz"; + return { os, arch: mappedArch, ext }; +} + +export function selectPlatformReleaseAssetName( + assetNames: Iterable, + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, +): string { + const { os, arch: mappedArch, ext } = platformArchiveDescriptor( + platform, + arch, + ); + const names = [...assetNames]; + + // Prefer the versioned `multica-cli---.` name; fall + // back to the legacy `multica__.` so older releases that + // only ship the legacy archive keep working. + const suffix = `-${os}-${mappedArch}.${ext}`; + const matches = names.filter( + (name) => + name.startsWith(RELEASE_ARCHIVE_PREFIX) && name.endsWith(suffix), + ); + + if (matches.length === 1) { + return matches[0]; + } + if (matches.length > 1) { + throw new Error( + `multiple release assets matched current platform ${suffix}: ${matches.join(", ")}`, + ); + } + + const legacyName = `multica_${os}_${mappedArch}.${ext}`; + if (names.includes(legacyName)) { + return legacyName; + } + + throw new Error(`no release asset found for current platform: ${suffix}`); +} diff --git a/apps/desktop/src/main/daemon-manager.ts b/apps/desktop/src/main/daemon-manager.ts index cc450f1cc..0e53f2b48 100644 --- a/apps/desktop/src/main/daemon-manager.ts +++ b/apps/desktop/src/main/daemon-manager.ts @@ -316,6 +316,36 @@ function bundledCliPath(): string { ); } +async function probeCliBinary( + bin: string, + source: "bundled" | "managed" | "path", +): Promise { + try { + const stdout = await new Promise((resolve, reject) => { + execFile( + bin, + ["version", "--output", "json"], + { timeout: 5_000 }, + (err, out) => { + if (err) reject(err); + else resolve(out); + }, + ); + }); + const parsed = JSON.parse(stdout) as { version?: string }; + if (typeof parsed.version === "string" && parsed.version.length > 0) { + return parsed.version; + } + console.warn( + `[daemon] ignoring ${source} CLI at ${bin}: version output was missing or invalid`, + ); + return null; + } catch (err) { + console.warn(`[daemon] ignoring ${source} CLI at ${bin}:`, err); + return null; + } +} + /** * Returns a usable `multica` binary path. Priority: * 1. Cached result from a previous successful resolve. @@ -339,27 +369,55 @@ async function resolveCliBinary(): Promise { cliResolvePromise = (async () => { const bundled = bundledCliPath(); if (existsSync(bundled)) { - console.log(`[daemon] using bundled CLI at ${bundled}`); - cachedCliBinary = bundled; - return bundled; + const version = await probeCliBinary(bundled, "bundled"); + if (version) { + console.log(`[daemon] using bundled CLI at ${bundled}`); + cachedCliBinary = bundled; + cachedCliBinaryVersion = version; + return bundled; + } } const managed = managedCliPath(); if (existsSync(managed)) { - cachedCliBinary = managed; - return managed; + const version = await probeCliBinary(managed, "managed"); + if (version) { + cachedCliBinary = managed; + cachedCliBinaryVersion = version; + return managed; + } } try { - const installed = await ensureManagedCli(); - cachedCliBinary = installed; - return installed; + const installed = await ensureManagedCli({ + forceInstall: existsSync(managed), + }); + const version = await probeCliBinary(installed, "managed"); + if (version) { + cachedCliBinary = installed; + cachedCliBinaryVersion = version; + return installed; + } + console.warn( + `[daemon] managed CLI at ${installed} failed validation after install`, + ); } catch (err) { console.warn("[daemon] CLI auto-install failed, falling back to PATH:", err); - const onPath = findCliOnPath(); - cachedCliBinary = onPath; - return onPath; } + + const onPath = findCliOnPath(); + if (onPath) { + const version = await probeCliBinary(onPath, "path"); + if (version) { + cachedCliBinary = onPath; + cachedCliBinaryVersion = version; + return onPath; + } + } + + cachedCliBinary = null; + cachedCliBinaryVersion = null; + return null; })(); try { @@ -370,11 +428,10 @@ async function resolveCliBinary(): Promise { } /** - * Reads the version of the currently resolved CLI binary by invoking - * `multica version --output json`. Cached for the process lifetime — the - * bundled binary doesn't change after `bundle-cli.mjs` runs at dev/build time. + * Reads the version of the currently resolved CLI binary. Cached for the + * process lifetime — the bundled binary doesn't change after bundle time. * Returns null on any failure (unknown `go` at bundle time, broken binary, - * etc.) so callers can fail open. + * wrong-arch bundled binary, etc.) so callers can fail open. */ async function getCliBinaryVersion(): Promise { if (cachedCliBinaryVersion !== undefined) return cachedCliBinaryVersion; @@ -383,24 +440,7 @@ async function getCliBinaryVersion(): Promise { cachedCliBinaryVersion = null; return null; } - try { - const stdout = await new Promise((resolve, reject) => { - execFile( - bin, - ["version", "--output", "json"], - { timeout: 5_000 }, - (err, out) => { - if (err) reject(err); - else resolve(out); - }, - ); - }); - const parsed = JSON.parse(stdout) as { version?: string }; - cachedCliBinaryVersion = parsed.version ?? null; - } catch (err) { - console.warn("[daemon] failed to read CLI binary version:", err); - cachedCliBinaryVersion = null; - } + cachedCliBinaryVersion = await probeCliBinary(bin, "path"); return cachedCliBinaryVersion; } diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index fa0cec192..a97b47a12 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -4,6 +4,16 @@ import { app, BrowserWindow, ipcMain } from "electron"; autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = true; +// Windows arm64 ships its own update metadata channel because +// electron-builder's `latest.yml` is not arch-suffixed on Windows — both +// arches would otherwise collide on the same file in the GitHub Release. +// See scripts/package.mjs (builderArgsForTarget) for the publish-side half +// of this pact. Pin the channel here so arm64 clients fetch +// `latest-arm64.yml` instead of the x64 metadata. +if (process.platform === "win32" && process.arch === "arm64") { + autoUpdater.channel = "latest-arm64"; +} + const STARTUP_CHECK_DELAY_MS = 5_000; const PERIODIC_CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d46669c6d..e3ea2044b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -48,14 +48,22 @@ function Install-CliBinary { if (-not [Environment]::Is64BitOperatingSystem) { Write-Fail "Multica requires a 64-bit Windows installation." } - $arch = "amd64" + + # Distinguish amd64 vs arm64 — Is64BitOperatingSystem is true for both. + $osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture + switch ($osArch) { + 'X64' { $arch = "amd64" } + 'Arm64' { $arch = "arm64" } + default { Write-Fail "Unsupported Windows architecture: $osArch (only X64 and Arm64 are supported)." } + } $latest = Get-LatestVersion if (-not $latest) { Write-Fail "Could not determine latest release. Check your network connection." } - $url = "https://github.com/multica-ai/multica/releases/download/$latest/multica_windows_$arch.zip" + $version = $latest.TrimStart('v') + $url = "https://github.com/multica-ai/multica/releases/download/$latest/multica-cli-$version-windows-$arch.zip" $tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "multica-install" if (Test-Path $tmpDir) { Remove-Item $tmpDir -Recurse -Force } @@ -75,7 +83,7 @@ function Install-CliBinary { $checksums = Invoke-WebRequest -Uri $checksumUrl -UseBasicParsing -ErrorAction Stop $zipFile = Join-Path $tmpDir "multica.zip" $actualHash = (Get-FileHash -Path $zipFile -Algorithm SHA256).Hash.ToLower() - $expectedLine = ($checksums.Content -split "`n") | Where-Object { $_ -match "multica_windows_$arch\.zip" } | Select-Object -First 1 + $expectedLine = ($checksums.Content -split "`n") | Where-Object { $_ -match "multica-cli-$version-windows-$arch\.zip" } | Select-Object -First 1 if ($expectedLine) { $expectedHash = ($expectedLine -split "\s+")[0].ToLower() if ($actualHash -ne $expectedHash) { diff --git a/scripts/install.sh b/scripts/install.sh index afae6a223..0c4a45e3d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -90,7 +90,8 @@ install_cli_binary() { fail "Could not determine latest release. Check your network connection." fi - local url="https://github.com/multica-ai/multica/releases/download/${latest}/multica_${OS}_${ARCH}.tar.gz" + local version="${latest#v}" + local url="https://github.com/multica-ai/multica/releases/download/${latest}/multica-cli-${version}-${OS}-${ARCH}.tar.gz" local tmp_dir tmp_dir=$(mktemp -d) diff --git a/server/internal/cli/update.go b/server/internal/cli/update.go index 8bea4acc8..e51755e9f 100644 --- a/server/internal/cli/update.go +++ b/server/internal/cli/update.go @@ -19,8 +19,79 @@ import ( // GitHubRelease is the subset of the GitHub releases API response we need. type GitHubRelease struct { - TagName string `json:"tag_name"` - HTMLURL string `json:"html_url"` + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Assets []GitHubReleaseAsset `json:"assets"` +} + +type GitHubReleaseAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +func releaseArchiveExtension(goos string) string { + if goos == "windows" { + return "zip" + } + return "tar.gz" +} + +func normalizeReleaseTag(targetVersion string) string { + tag := strings.TrimSpace(targetVersion) + if !strings.HasPrefix(tag, "v") { + tag = "v" + tag + } + return tag +} + +func releaseAssetCandidates(targetVersion, goos, goarch string) []string { + tag := normalizeReleaseTag(targetVersion) + version := strings.TrimPrefix(tag, "v") + ext := releaseArchiveExtension(goos) + // Prefer the versioned name (current scheme); fall back to the legacy + // `multica_{os}_{arch}` name for releases that still ship it. + return []string{ + fmt.Sprintf("multica-cli-%s-%s-%s.%s", version, goos, goarch, ext), + fmt.Sprintf("multica_%s_%s.%s", goos, goarch, ext), + } +} + +func findReleaseAsset(assets []GitHubReleaseAsset, targetVersion, goos, goarch string) (*GitHubReleaseAsset, error) { + for _, candidate := range releaseAssetCandidates(targetVersion, goos, goarch) { + for i := range assets { + if assets[i].Name == candidate { + return &assets[i], nil + } + } + } + + candidates := strings.Join(releaseAssetCandidates(targetVersion, goos, goarch), ", ") + return nil, fmt.Errorf("no matching release asset for %s/%s (tried: %s)", goos, goarch, candidates) +} + +func fetchReleaseByTag(tag string) (*GitHubRelease, error) { + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/multica-ai/multica/releases/tags/"+tag, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode) + } + + var release GitHubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, err + } + return &release, nil } // FetchLatestRelease fetches the latest release tag from the multica GitHub repo. @@ -106,18 +177,17 @@ func UpdateViaDownload(targetVersion string) (string, error) { return "", fmt.Errorf("resolve symlink: %w", err) } - // Build download URL: multica_{os}_{arch}.{tar.gz|zip} - // GoReleaser produces .zip for Windows and .tar.gz for everything else. - tag := targetVersion - if !strings.HasPrefix(tag, "v") { - tag = "v" + tag + tag := normalizeReleaseTag(targetVersion) + release, err := fetchReleaseByTag(tag) + if err != nil { + return "", fmt.Errorf("fetch release metadata: %w", err) } - ext := "tar.gz" - if runtime.GOOS == "windows" { - ext = "zip" + asset, err := findReleaseAsset(release.Assets, tag, runtime.GOOS, runtime.GOARCH) + if err != nil { + return "", err } - assetName := fmt.Sprintf("multica_%s_%s.%s", runtime.GOOS, runtime.GOARCH, ext) - downloadURL := fmt.Sprintf("https://github.com/multica-ai/multica/releases/download/%s/%s", tag, assetName) + downloadURL := asset.BrowserDownloadURL + assetName := asset.Name // Download the archive. client := &http.Client{Timeout: 120 * time.Second} @@ -242,4 +312,3 @@ func extractBinaryFromZip(r io.Reader, name string) ([]byte, error) { } return nil, fmt.Errorf("binary %q not found in archive", name) } - diff --git a/server/internal/cli/update_test.go b/server/internal/cli/update_test.go new file mode 100644 index 000000000..237cb1a55 --- /dev/null +++ b/server/internal/cli/update_test.go @@ -0,0 +1,96 @@ +package cli + +import "testing" + +func TestReleaseAssetCandidates(t *testing.T) { + tests := []struct { + name string + targetVersion string + goos string + goarch string + wantAssets []string + }{ + { + name: "darwin prefers versioned then legacy candidate", + targetVersion: "v1.2.3", + goos: "darwin", + goarch: "arm64", + wantAssets: []string{ + "multica-cli-1.2.3-darwin-arm64.tar.gz", + "multica_darwin_arm64.tar.gz", + }, + }, + { + name: "linux normalizes missing v in versioned candidate", + targetVersion: "1.2.3", + goos: "linux", + goarch: "amd64", + wantAssets: []string{ + "multica-cli-1.2.3-linux-amd64.tar.gz", + "multica_linux_amd64.tar.gz", + }, + }, + { + name: "windows uses zip assets", + targetVersion: "1.2.3", + goos: "windows", + goarch: "amd64", + wantAssets: []string{ + "multica-cli-1.2.3-windows-amd64.zip", + "multica_windows_amd64.zip", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := releaseAssetCandidates(tt.targetVersion, tt.goos, tt.goarch) + if len(got) != len(tt.wantAssets) { + t.Fatalf("candidate count mismatch: got %d, want %d", len(got), len(tt.wantAssets)) + } + for i := range got { + if got[i] != tt.wantAssets[i] { + t.Fatalf("candidate[%d] mismatch: got %q, want %q", i, got[i], tt.wantAssets[i]) + } + } + }) + } +} + +func TestFindReleaseAsset(t *testing.T) { + t.Run("prefers versioned asset when both names exist", func(t *testing.T) { + assets := []GitHubReleaseAsset{ + {Name: "multica_darwin_amd64.tar.gz", BrowserDownloadURL: "old"}, + {Name: "multica-cli-1.2.3-darwin-amd64.tar.gz", BrowserDownloadURL: "new"}, + } + + got, err := findReleaseAsset(assets, "v1.2.3", "darwin", "amd64") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Name != "multica-cli-1.2.3-darwin-amd64.tar.gz" { + t.Fatalf("asset mismatch: got %q", got.Name) + } + }) + + t.Run("falls back to legacy asset when versioned is absent", func(t *testing.T) { + assets := []GitHubReleaseAsset{ + {Name: "multica_linux_amd64.tar.gz", BrowserDownloadURL: "old"}, + } + + got, err := findReleaseAsset(assets, "1.2.3", "linux", "amd64") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Name != "multica_linux_amd64.tar.gz" { + t.Fatalf("asset mismatch: got %q", got.Name) + } + }) + + t.Run("returns error when no candidate matches", func(t *testing.T) { + _, err := findReleaseAsset([]GitHubReleaseAsset{{Name: "checksums.txt"}}, "1.2.3", "linux", "amd64") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +}