diff --git a/src/commands/signal-install.ts b/src/commands/signal-install.ts index e5b492a36..1f168cd9f 100644 --- a/src/commands/signal-install.ts +++ b/src/commands/signal-install.ts @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import { pipeline } from "node:stream/promises"; import type { RuntimeEnv } from "../runtime.js"; +import { resolveBrewExecutable } from "../infra/brew.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { CONFIG_DIR } from "../utils.js"; @@ -34,35 +35,49 @@ function looksLikeArchive(name: string): boolean { return name.endsWith(".tar.gz") || name.endsWith(".tgz") || name.endsWith(".zip"); } -function pickAsset(assets: ReleaseAsset[], platform: NodeJS.Platform) { +/** + * Pick a native release asset from the official GitHub releases. + * + * The official signal-cli releases only publish native (GraalVM) binaries for + * x86-64 Linux. On architectures where no native asset is available this + * returns `undefined` so the caller can fall back to a different install + * strategy (e.g. Homebrew). + */ +function pickAsset( + assets: ReleaseAsset[], + platform: NodeJS.Platform, + arch: string, +): NamedAsset | undefined { const withName = assets.filter((asset): asset is NamedAsset => Boolean(asset.name && asset.browser_download_url), ); + + // Archives only, excluding signature files (.asc) + const archives = withName.filter((a) => looksLikeArchive(a.name.toLowerCase())); + const byName = (pattern: RegExp) => - withName.find((asset) => pattern.test(asset.name.toLowerCase())); + archives.find((asset) => pattern.test(asset.name.toLowerCase())); if (platform === "linux") { - return ( - byName(/linux-native/) || - byName(/linux/) || - withName.find((asset) => looksLikeArchive(asset.name.toLowerCase())) - ); + // The official "Linux-native" asset is an x86-64 GraalVM binary. + // On non-x64 architectures it will fail with "Exec format error", + // so only select it when the host architecture matches. + if (arch === "x64") { + return byName(/linux-native/) || byName(/linux/) || archives[0]; + } + // No native release for this arch — caller should fall back. + return undefined; } if (platform === "darwin") { - return ( - byName(/macos|osx|darwin/) || - withName.find((asset) => looksLikeArchive(asset.name.toLowerCase())) - ); + return byName(/macos|osx|darwin/) || archives[0]; } if (platform === "win32") { - return ( - byName(/windows|win/) || withName.find((asset) => looksLikeArchive(asset.name.toLowerCase())) - ); + return byName(/windows|win/) || archives[0]; } - return withName.find((asset) => looksLikeArchive(asset.name.toLowerCase())); + return archives[0]; } async function downloadToFile(url: string, dest: string, maxRedirects = 5): Promise { @@ -110,14 +125,84 @@ async function findSignalCliBinary(root: string): Promise { return candidates[0] ?? null; } -export async function installSignalCli(runtime: RuntimeEnv): Promise { - if (process.platform === "win32") { +// --------------------------------------------------------------------------- +// Brew-based install (used on architectures without an official native build) +// --------------------------------------------------------------------------- + +async function resolveBrewSignalCliPath(brewExe: string): Promise { + try { + const result = await runCommandWithTimeout([brewExe, "--prefix", "signal-cli"], { + timeoutMs: 10_000, + }); + if (result.code === 0 && result.stdout.trim()) { + const prefix = result.stdout.trim(); + // Homebrew installs the wrapper script at /bin/signal-cli + const candidate = path.join(prefix, "bin", "signal-cli"); + try { + await fs.access(candidate); + return candidate; + } catch { + // Fall back to searching the prefix + return findSignalCliBinary(prefix); + } + } + } catch { + // ignore + } + return null; +} + +async function installSignalCliViaBrew(runtime: RuntimeEnv): Promise { + const brewExe = resolveBrewExecutable(); + if (!brewExe) { return { ok: false, - error: "Signal CLI auto-install is not supported on Windows yet.", + error: + `No native signal-cli build is available for ${process.arch}. ` + + "Install Homebrew (https://brew.sh) and try again, or install signal-cli manually.", }; } + runtime.log(`Installing signal-cli via Homebrew (${brewExe})…`); + const result = await runCommandWithTimeout([brewExe, "install", "signal-cli"], { + timeoutMs: 15 * 60_000, // brew builds from source; can take a while + }); + + if (result.code !== 0) { + return { + ok: false, + error: `brew install signal-cli failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`, + }; + } + + const cliPath = await resolveBrewSignalCliPath(brewExe); + if (!cliPath) { + return { + ok: false, + error: "brew install succeeded but signal-cli binary was not found.", + }; + } + + // Extract version from the installed binary. + let version: string | undefined; + try { + const vResult = await runCommandWithTimeout([cliPath, "--version"], { + timeoutMs: 10_000, + }); + // Output is typically "signal-cli 0.13.24" + version = vResult.stdout.trim().replace(/^signal-cli\s+/, "") || undefined; + } catch { + // non-critical; leave version undefined + } + + return { ok: true, cliPath, version }; +} + +// --------------------------------------------------------------------------- +// Direct download install (used when an official native asset is available) +// --------------------------------------------------------------------------- + +async function installSignalCliFromRelease(runtime: RuntimeEnv): Promise { const apiUrl = "https://api.github.com/repos/AsamK/signal-cli/releases/latest"; const response = await fetch(apiUrl, { headers: { @@ -136,11 +221,9 @@ export async function installSignalCli(runtime: RuntimeEnv): Promise { + if (process.platform === "win32") { + return { + ok: false, + error: "Signal CLI auto-install is not supported on Windows yet.", + }; + } + + // The official signal-cli GitHub releases only ship a native binary for + // x86-64 Linux. On other architectures (arm64, armv7, etc.) we delegate + // to Homebrew which builds from source and bundles the JRE automatically. + const hasNativeRelease = process.platform !== "linux" || process.arch === "x64"; + + if (hasNativeRelease) { + return installSignalCliFromRelease(runtime); + } + + return installSignalCliViaBrew(runtime); +}