mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* feat(desktop): support macOS cross-platform packaging * fix(desktop): use releaseType instead of publishingType in electron-builder publish config publishingType is not a valid electron-builder key; the correct GitHub provider option is releaseType. The previous value was silently ignored, causing uploads to be skipped and breaking auto-update. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(release): standardize artifact naming across desktop and CLI Unified scheme: `multica-<kind>-<version>-<platform>-<arch>.<ext>` so a filename alone reveals kind, version, platform, and CPU arch. Desktop (apps/desktop/electron-builder.yml): mac → multica-desktop-<v>-mac-<arch>.{dmg,zip} linux → multica-desktop-<v>-linux-<arch>.{deb,AppImage} (fixes `\${name}` expanding the scoped `@multica/desktop` into a broken `@multica/desktop-*` filename path) windows → multica-desktop-<v>-windows-<arch>.exe CLI (.goreleaser.yml): multica_<os>_<arch>.tar.gz → multica-cli-<v>-<os>-<arch>.tar.gz (adds `-cli` marker + version; switches `_` to `-` for consistency) Matrix update in apps/desktop/scripts/package.mjs `--all-platforms`: - drop mac x64 (Intel not a target yet) - add linux arm64 Final: mac arm64, win x64/arm64, linux x64/arm64. Downstream updates so install paths match the new CLI names: - scripts/install.sh - scripts/install.ps1 (URL + checksum regex) - CLI_INSTALL.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(release): use multica_{os}_{arch} CLI archive naming Standardize on the GoReleaser default 'multica_{os}_{arch}.{tar.gz|zip}' asset names. Install scripts and the desktop CLI bootstrap now resolve assets via checksums.txt so they work without hardcoding versions. The Go self-update path queries the GitHub release API and accepts either the new or legacy 'multica-cli-<version>-...' names so existing releases keep updating cleanly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(release): ship both legacy and versioned CLI archive names GoReleaser now produces both 'multica_{os}_{arch}.{ext}' (legacy) and 'multica-cli-{version}-{os}-{arch}.{ext}' (versioned) archives in every release. The legacy name keeps already-released CLIs self-updating; the versioned name is what new clients should use going forward. Self-update / install paths flipped to prefer the versioned name and fall back to legacy: - server/internal/cli/update.go (multica update) - apps/desktop/src/main/cli-release-asset.ts (desktop CLI bootstrap) - scripts/install.sh, scripts/install.ps1 (fresh install) Homebrew formula is pinned to the versioned archive via 'ids: [versioned]'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(desktop): also build Linux .rpm packages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(release): build Linux/Windows Desktop installers in CI; detect Windows ARM64 in install.ps1 Address review feedback on PR #1262: - .github/workflows/release.yml: add a 'desktop' job that runs after the CLI 'release' job and packages the Desktop installers for Linux (AppImage/deb/rpm) and Windows (NSIS) on x64 and arm64, then publishes them to the same GitHub Release via electron-builder. macOS Desktop continues to ship through the manual release-desktop skill so it can be signed and notarized with Apple Developer credentials. - scripts/install.ps1: detect Windows ARM64 hosts via RuntimeInformation::OSArchitecture so the new windows-arm64 CLI archive is downloaded on ARM64 machines instead of always falling back to amd64. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(release): split Windows arm64 auto-update channel to avoid latest.yml collision electron-builder's update metadata file is hardcoded to `latest.yml` for Windows regardless of arch (only Linux gets an arch-suffixed name; see app-builder-lib's getArchPrefixForUpdateFile). With two separate electron-builder invocations for Windows x64 and arm64, both publish `latest.yml` to the same GitHub Release and the second upload silently overwrites the first — leaving one of the two architectures with auto- update metadata pointing at the other arch's installer. Route Windows arm64 to its own `latest-arm64` channel: * scripts/package.mjs appends `-c.publish.channel=latest-arm64` only for the Windows arm64 invocation, so x64 keeps producing `latest.yml` and arm64 produces `latest-arm64.yml` alongside it. * updater.ts pins `autoUpdater.channel = 'latest-arm64'` on Windows arm64 clients so they fetch the matching metadata file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Devv <devv@Devvs-Mac-mini.local> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
158 lines
5.6 KiB
TypeScript
158 lines
5.6 KiB
TypeScript
import { app } from "electron";
|
|
import { execFile } from "child_process";
|
|
import { createHash } from "crypto";
|
|
import { createReadStream, createWriteStream, existsSync } from "fs";
|
|
import { chmod, mkdir, rename, rm } from "fs/promises";
|
|
import { join, dirname } from "path";
|
|
import { pipeline } from "stream/promises";
|
|
import { tmpdir } from "os";
|
|
import { Readable } from "stream";
|
|
|
|
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 binaryName(): string {
|
|
return process.platform === "win32" ? "multica.exe" : "multica";
|
|
}
|
|
|
|
export function managedCliPath(): string {
|
|
return join(app.getPath("userData"), "bin", binaryName());
|
|
}
|
|
|
|
function run(cmd: string, args: string[], cwd?: string): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
execFile(cmd, args, { cwd }, (err) => (err ? reject(err) : resolve()));
|
|
});
|
|
}
|
|
|
|
async function downloadToFile(url: string, dest: string): Promise<void> {
|
|
const res = await fetch(url, { redirect: "follow" });
|
|
if (!res.ok || !res.body) {
|
|
throw new Error(`download failed: ${res.status} ${res.statusText}`);
|
|
}
|
|
await mkdir(dirname(dest), { recursive: true });
|
|
// Node's fetch returns a web ReadableStream; adapt to a Node stream for pipeline.
|
|
const nodeStream = Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]);
|
|
await pipeline(nodeStream, createWriteStream(dest));
|
|
}
|
|
|
|
// Fetch goreleaser's published checksums.txt and parse it into a
|
|
// filename → sha256 lookup. Format is `<hex> <filename>` per line.
|
|
async function fetchChecksums(): Promise<Map<string, string>> {
|
|
const url = `${GITHUB_LATEST_BASE}/checksums.txt`;
|
|
const res = await fetch(url, { redirect: "follow" });
|
|
if (!res.ok) {
|
|
throw new Error(
|
|
`checksums.txt fetch failed: ${res.status} ${res.statusText}`,
|
|
);
|
|
}
|
|
const text = await res.text();
|
|
const map = new Map<string, string>();
|
|
for (const rawLine of text.split("\n")) {
|
|
const line = rawLine.trim();
|
|
if (!line) continue;
|
|
const match = line.match(/^([a-f0-9]{64})\s+\*?(\S+)$/i);
|
|
if (match) map.set(match[2], match[1].toLowerCase());
|
|
}
|
|
return map;
|
|
}
|
|
|
|
async function sha256OfFile(path: string): Promise<string> {
|
|
const hash = createHash("sha256");
|
|
await pipeline(createReadStream(path), hash);
|
|
return hash.digest("hex");
|
|
}
|
|
|
|
async function verifyChecksum(
|
|
archivePath: string,
|
|
assetName: string,
|
|
expected: string,
|
|
): Promise<void> {
|
|
const actual = await sha256OfFile(archivePath);
|
|
if (actual.toLowerCase() !== expected) {
|
|
throw new Error(
|
|
`checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function extractArchive(archive: string, dest: string): Promise<void> {
|
|
await mkdir(dest, { recursive: true });
|
|
// Modern OSes all ship a `tar` that auto-detects tar.gz and zip:
|
|
// - macOS/Linux: GNU tar or bsdtar
|
|
// - Windows 10+: bsdtar is bundled as `tar.exe` since build 17063
|
|
await run("tar", ["-xf", archive, "-C", dest]);
|
|
}
|
|
|
|
async function installFresh(): Promise<string> {
|
|
const target = managedCliPath();
|
|
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()}`);
|
|
await mkdir(workDir, { recursive: true });
|
|
|
|
try {
|
|
const archivePath = join(workDir, assetName);
|
|
console.log(`[cli-bootstrap] downloading ${url}`);
|
|
await downloadToFile(url, archivePath);
|
|
|
|
console.log(`[cli-bootstrap] verifying ${assetName} against checksums.txt`);
|
|
await verifyChecksum(archivePath, assetName, expectedChecksum);
|
|
|
|
console.log(`[cli-bootstrap] extracting ${assetName}`);
|
|
await extractArchive(archivePath, workDir);
|
|
|
|
const extractedBin = join(workDir, binaryName());
|
|
if (!existsSync(extractedBin)) {
|
|
throw new Error(
|
|
`archive ${assetName} did not contain ${binaryName()} at its root`,
|
|
);
|
|
}
|
|
|
|
await mkdir(dirname(target), { recursive: true });
|
|
await rm(target, { force: true }).catch(() => {});
|
|
await rename(extractedBin, target);
|
|
await chmod(target, 0o755);
|
|
|
|
// macOS: ad-hoc sign so spawning the child never hits a gatekeeper quirk.
|
|
// Non-fatal: unsigned binaries still execute when the parent app is trusted.
|
|
if (process.platform === "darwin") {
|
|
await run("codesign", ["-s", "-", "--force", target]).catch((err) => {
|
|
console.warn("[cli-bootstrap] ad-hoc codesign failed:", err);
|
|
});
|
|
}
|
|
|
|
console.log(`[cli-bootstrap] installed CLI at ${target}`);
|
|
return target;
|
|
} finally {
|
|
await rm(workDir, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the path to a usable `multica` binary. If one is already present at
|
|
* the managed userData location, returns it immediately. Otherwise downloads
|
|
* the latest release asset for the current platform and installs it.
|
|
*/
|
|
export async function ensureManagedCli(
|
|
options: { forceInstall?: boolean } = {},
|
|
): Promise<string> {
|
|
const target = managedCliPath();
|
|
if (existsSync(target) && !options.forceInstall) return target;
|
|
return installFresh();
|
|
}
|