diff --git a/README.md b/README.md index d7b2862..bcfbdd3 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ codevhub init # initialize + index the current project (one time) codevhub skill office ``` -It picks the bundle for your OS and downloads it into `~/.codev-hub/office` (on Windows: `%PUBLIC%\Downloads\codev-office`). On macOS/Linux it then runs the bundled setup script, which may prompt for `sudo`; on Windows it prints the exact command to paste into an elevated PowerShell instead of launching the installer itself. +It picks the bundle for your OS — on macOS, for your chip: Apple Silicon and Intel get separate bundles — and downloads it into `~/.codev-hub/office` (on Windows: `%PUBLIC%\Downloads\codev-office`). On macOS/Linux it then runs the bundled setup script, which may prompt for `sudo`; on Windows it prints the exact command to paste into an elevated PowerShell instead of launching the installer itself. **The bundle is large**, and the command prints its approximate size before downloading. The download folder is kept between runs, so an interrupted transfer resumes where it left off and an already-downloaded bundle is reused. To force a completely fresh download, delete that folder. @@ -109,9 +109,12 @@ To fetch the bundle now and install later — or to stage it for a machine that ```bash codevhub skill office --download-only # download for this OS, don't install codevhub skill office --platform windows # download another OS's bundle (implies --download-only) +codevhub skill office --platform macos --arch arm64 # macOS needs a chip: arm64 or x86_64 codevhub skill office --dir /media/usb/codev-office # download somewhere else ``` +Because macOS has one bundle per chip, `--platform macos` from a non-Mac also needs `--arch arm64` or `--arch x86_64` — nothing on a Linux or Windows host implies which Mac the bundle is for, and guessing costs a 1.7 GB download. On a Mac the chip is detected for you (including through Rosetta), and `--arch` is only needed to stage a bundle for the *other* chip. There is still a single macOS setup script: it resolves its own bundle from `uname -m`. + Both files land side by side, and the command prints the exact line to run from that folder (`bash codev-office--setup.sh`, or `powershell -ExecutionPolicy Bypass -File .\codev-office-windows-setup.ps1`). A bundle downloaded for another OS is never executed on this machine. Two flags are passed straight through to the setup script: `--skip-verify` (skip the bundle's own SHA-256 check) and `--force-skills` (replace already-installed skills with the bundled versions). diff --git a/package.json b/package.json index d05e272..8f1f4f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codev-ai", - "version": "0.5.13", + "version": "0.5.14", "description": "CoDev — AI Coding Agent Hub. Install, configure, and manage multiple AI coding agents.", "keywords": [ "ai", diff --git a/src/lib/help.ts b/src/lib/help.ts index 7470e38..625f3c0 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -53,7 +53,9 @@ Skill hub: (create, read and edit DOCX, XLSX, PPTX and PDF) for this OS and run its setup script (--platform ubuntu|macos|windows to fetch for another OS - [implies --download-only], --dir for the download + [implies --download-only], --arch arm64|x86_64 to pick + the macOS chip's bundle [required when fetching a macOS + bundle from another OS], --dir for the download folder, --download-only to skip running the installer, --skip-verify passed through to the installer, --force-skills to replace already-installed skills diff --git a/src/lib/office.ts b/src/lib/office.ts index e6bce7d..6337ffe 100644 --- a/src/lib/office.ts +++ b/src/lib/office.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { chmodSync, existsSync, @@ -26,12 +26,33 @@ import { legacyOfficeDownloadsDir, officeDownloadsDir } from "@/lib/paths.js"; // installer that prompts for sudo/UAC, which an Ink render would fight over. export const OFFICE_USAGE = - "Usage: codevhub skill office [--platform ubuntu|macos|windows] [--dir ] [--download-only] [--skip-verify] [--force-skills]"; + "Usage: codevhub skill office [--platform ubuntu|macos|windows] [--arch arm64|x86_64] [--dir ] [--download-only] [--skip-verify] [--force-skills]"; export type OfficePlatform = "ubuntu" | "macos" | "windows"; +export type OfficeArch = "arm64" | "x86_64"; + +// The bundle identity in the codev-storage bucket. macOS publishes one bundle +// per chip; ubuntu and windows are x64-only, so their target is just the +// platform. There is still ONE setup script per OS — the macOS script picks its +// bundle from `uname -m`, so nothing below needs an arch to name a script. +export type OfficeTarget = + | "ubuntu" + | "windows" + | "macos-arm64" + | "macos-x86_64"; const OFFICE_PLATFORMS: OfficePlatform[] = ["ubuntu", "macos", "windows"]; +// Spellings people actually type, mapped to the `uname -m` names the bundles +// are published under. +const OFFICE_ARCH_ALIASES: Record = { + arm64: "arm64", + aarch64: "arm64", + x86_64: "x86_64", + x64: "x86_64", + intel: "x86_64", +}; + export function detectPlatform( p: NodeJS.Platform = process.platform, ): OfficePlatform | null { @@ -41,8 +62,50 @@ export function detectPlatform( return null; } +// process.arch is the architecture of the NODE BINARY, not of the machine: an +// x64 node running under Rosetta on Apple Silicon reports "x64". Trusting it +// there would fetch the Intel bundle onto an M-series Mac, and the cost is not +// just the wrong 1.7 GB — the setup script is native bash, sees `uname -m` = +// arm64, finds no bundle it can use and downloads another 1.7 GB. The +// sysctl.proc_translated flag is the supported way to detect the translation. +function isRosettaTranslated(): boolean { + if (process.platform !== "darwin" || process.arch !== "x64") return false; + try { + const out = execFileSync("sysctl", ["-in", "sysctl.proc_translated"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + return out.trim() === "1"; + } catch { + // sysctl missing, or the key does not exist — a genuine Intel Mac. + return false; + } +} + +export function detectArch( + arch: NodeJS.Architecture = process.arch, + translated: boolean = isRosettaTranslated(), +): OfficeArch | null { + if (translated) return "arm64"; + if (arch === "arm64") return "arm64"; + if (arch === "x64") return "x86_64"; + return null; +} + +// The bundle a (platform, arch) pair resolves to. Only macOS consumes the arch. +export function officeTarget( + platform: OfficePlatform, + arch: OfficeArch | null, +): OfficeTarget | null { + if (platform !== "macos") return platform; + return arch === null ? null : `macos-${arch}`; +} + export interface OfficeArgs { platform?: OfficePlatform; + // macOS only — which chip's bundle to fetch. Required when downloading a + // macOS bundle from a non-Mac, where nothing on the host can imply it. + arch?: OfficeArch; dir?: string; downloadOnly: boolean; skipVerify: boolean; @@ -88,6 +151,18 @@ export function parseOfficeArgs(argv: string[]): OfficeArgs { parsed.platform = value as OfficePlatform; break; } + case "--arch": { + const value = takeValue(); + const resolved = value + ? OFFICE_ARCH_ALIASES[value.toLowerCase()] + : undefined; + if (!resolved) { + parsed.error = "--arch must be one of: arm64, x86_64"; + return parsed; + } + parsed.arch = resolved; + break; + } case "--dir": { const value = takeValue(); if (!value) { @@ -137,10 +212,11 @@ export function parseOfficeArgs(argv: string[]): OfficeArgs { } // Bundle and script names are a naming contract with the codev-scripts repo -// (codev-office/*) — deterministic per platform, so no manifest round-trip is -// needed before downloading. -export function officeBundleName(platform: OfficePlatform): string { - return `codev-office-${platform}.zip`; +// (codev-office/*) — deterministic per target, so no manifest round-trip is +// needed before downloading. Bundles are named per TARGET (macOS is per chip), +// scripts per PLATFORM (one macOS script, which picks its own bundle). +export function officeBundleName(target: OfficeTarget): string { + return `codev-office-${target}.zip`; } export function officeScriptName(platform: OfficePlatform): string { @@ -157,10 +233,11 @@ export function officeUninstallScriptName(platform: OfficePlatform): string { // Rough bundle sizes for the pre-download heads-up only; progress totals come // from the server's content-length. -const APPROX_BUNDLE_MB: Record = { +const APPROX_BUNDLE_MB: Record = { ubuntu: 1200, windows: 1400, - macos: 3100, + "macos-arm64": 1700, + "macos-x86_64": 1700, }; // Adaptive size for progress lines: the setup script is ~13 KB and rendered @@ -330,6 +407,15 @@ export async function runSkillOffice( return 1; } + // Only macOS has per-chip bundles; --arch anywhere else is a misunderstanding + // worth naming before anything is downloaded. + if (parsed.arch !== undefined && platform !== "macos") { + console.error( + `--arch only applies to macOS bundles (${platform} is x86_64 only).`, + ); + return 1; + } + // Never execute a script built for another OS. The override stays useful // for fetching a bundle to carry to a different machine. let downloadOnly = parsed.downloadOnly; @@ -342,6 +428,22 @@ export async function runSkillOffice( downloadOnly = true; } + // macOS publishes one bundle per chip, so a macOS download needs an arch. + // The host's own arch may only stand in for it when the host IS the target: + // on a Linux/Windows x64 box, detectArch() would confidently answer x86_64 + // for a `--platform macos` download and hand the user the wrong 1.7 GB. + // --arch always wins, including on a Mac staging a bundle for the other chip. + const arch = parsed.arch ?? (crossPlatform ? null : detectArch()); + const target = officeTarget(platform, arch); + if (target === null) { + console.error( + crossPlatform + ? "Downloading a macOS bundle from another OS needs --arch arm64 or --arch x86_64 — there is one bundle per chip and nothing here implies which." + : `Could not determine this Mac's architecture (node reports ${process.arch}) — pass --arch arm64 or --arch x86_64.`, + ); + return 1; + } + // Windows staging prefers the profile-independent %PUBLIC% folder, but a // hardened image can deny non-admin writes under C:\Users\Public — fall // back to the old per-user folder rather than crashing. @@ -358,16 +460,16 @@ export async function runSkillOffice( migrateLegacyOfficeDir(legacyOfficeDownloadsDir(), dir); } - const bundle = officeBundleName(platform); + const bundle = officeBundleName(target); const script = parsed.uninstall ? officeUninstallScriptName(platform) : officeScriptName(platform); if (parsed.uninstall) { console.error(`CoDev Office offline skills uninstaller (${platform})`); } else { - console.error(`CoDev Office offline skills bundle (${platform})`); + console.error(`CoDev Office offline skills bundle (${target})`); console.error( - `Heads-up: the bundle is ~${APPROX_BUNDLE_MB[platform]} MB — downloading might ` + + `Heads-up: the bundle is ~${APPROX_BUNDLE_MB[target]} MB — downloading might ` + "take a while. An interrupted run picks up where it left off; an " + "already-downloaded bundle is reused after checking with the server " + "that it is still the published version.", @@ -379,7 +481,7 @@ export async function runSkillOffice( : "office bundle download starting", { action: parsed.uninstall ? "office.uninstall" : "office.install", - extra: { platform, dir, downloadOnly }, + extra: { platform, target, dir, downloadOnly }, }, ); diff --git a/tests/lib/download.test.ts b/tests/lib/download.test.ts index 11c7da5..589df1b 100644 --- a/tests/lib/download.test.ts +++ b/tests/lib/download.test.ts @@ -15,6 +15,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { downloadFile } from "@/lib/download.js"; import { + detectArch, ensureStagingDir, installerArgs, migrateLegacyOfficeDir, @@ -324,10 +325,14 @@ describe("downloadFile ETag revalidation", () => { // End-to-end through runSkillOffice against the local server. The test host is // linux/macos in CI, so the detected platform maps to one of the bash bundles. -// File names are the deterministic per-platform contract — no manifest. +// File names are the deterministic contract — no manifest. Note the asymmetry +// the macOS bundle split introduced: the BUNDLE is named per chip, the SCRIPT +// is not, because the one macOS script resolves its own bundle from `uname -m`. describe("runSkillOffice", () => { const hostPlatform = process.platform === "darwin" ? "macos" : "ubuntu"; - const bundleName = `codev-office-${hostPlatform}.zip`; + const hostTarget = + hostPlatform === "macos" ? `macos-${detectArch() ?? "arm64"}` : "ubuntu"; + const bundleName = `codev-office-${hostTarget}.zip`; const scriptName = `codev-office-${hostPlatform}-setup.sh`; const BUNDLE = Buffer.from("fake-bundle-bytes"); const SCRIPT = Buffer.from("#!/bin/sh\nexit 0\n"); @@ -379,6 +384,64 @@ describe("runSkillOffice", () => { expect(code).toBe(7); }); + // macOS publishes one bundle per chip. From another OS nothing implies + // which, and the host's own x64 would be a confident wrong answer costing + // 1.7 GB — so this must fail loudly instead of picking a default. + // Pinned to linux so the macOS bundle is always the cross-platform case, + // whichever machine runs the suite. + test("a macOS bundle from another OS refuses without --arch", async () => { + const dir = join(tempDir, "office"); + const spawns: string[] = []; + const code = await withPlatform("linux", () => + runSkillOffice( + ["--platform", "macos", "--dir", dir], + baseUrl, + async (c) => { + spawns.push(c); + return 0; + }, + ), + ); + expect(code).toBe(1); + expect(spawns).toEqual([]); + expect(existsSync(join(dir, "codev-office-macos-x86_64.zip"))).toBe(false); + expect(existsSync(join(dir, "codev-office-macos-arm64.zip"))).toBe(false); + }); + + test("--platform macos --arch stages that chip's bundle", async () => { + objects.set("/codev-office-macos-arm64.zip", BUNDLE); + objects.set("/codev-office-macos-setup.sh", SCRIPT); + const dir = join(tempDir, "office"); + const spawns: string[] = []; + const code = await withPlatform("linux", () => + runSkillOffice( + ["--platform", "macos", "--arch", "arm64", "--dir", dir], + baseUrl, + async (c) => { + spawns.push(c); + return 0; + }, + ), + ); + expect(code).toBe(0); + expect(spawns).toEqual([]); + expect(existsSync(join(dir, "codev-office-macos-arm64.zip"))).toBe(true); + // One script for both chips — not codev-office-macos-arm64-setup.sh. + expect(existsSync(join(dir, "codev-office-macos-setup.sh"))).toBe(true); + }); + + test("--arch is refused for the x64-only bundles", async () => { + const dir = join(tempDir, "office"); + const code = await withPlatform("linux", () => + runSkillOffice( + ["--platform", "windows", "--arch", "arm64", "--dir", dir], + baseUrl, + async () => 0, + ), + ); + expect(code).toBe(1); + }); + test("a cross-platform --platform forces download-only", async () => { objects.set("/codev-office-windows.zip", BUNDLE); objects.set("/codev-office-windows-setup.ps1", SCRIPT); diff --git a/tests/lib/office.test.ts b/tests/lib/office.test.ts index aed79bf..ec917c2 100644 --- a/tests/lib/office.test.ts +++ b/tests/lib/office.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from "vitest"; import { + detectArch, detectPlatform, formatSize, officeBundleName, officeScriptName, + officeTarget, officeUninstallScriptName, parseOfficeArgs, } from "@/lib/office.js"; @@ -21,6 +23,42 @@ describe("detectPlatform", () => { }); }); +describe("detectArch", () => { + test("maps node arches to the uname -m names the bundles use", () => { + expect(detectArch("arm64", false)).toBe("arm64"); + expect(detectArch("x64", false)).toBe("x86_64"); + }); + + test("returns null for arches no bundle is built for", () => { + expect(detectArch("ia32", false)).toBeNull(); + expect(detectArch("ppc64", false)).toBeNull(); + }); + + // An x64 node under Rosetta reports "x64" on an Apple Silicon Mac. Believing + // it downloads the Intel bundle, which the native-bash setup script then + // refuses — and re-downloads the arm64 one. 3.4 GB to land 1.7. + test("Rosetta translation overrides the reported x64", () => { + expect(detectArch("x64", true)).toBe("arm64"); + }); +}); + +describe("officeTarget", () => { + test("non-macOS platforms ignore the arch", () => { + expect(officeTarget("ubuntu", null)).toBe("ubuntu"); + expect(officeTarget("windows", null)).toBe("windows"); + expect(officeTarget("ubuntu", "arm64")).toBe("ubuntu"); + }); + + test("macOS resolves to a per-chip target", () => { + expect(officeTarget("macos", "arm64")).toBe("macos-arm64"); + expect(officeTarget("macos", "x86_64")).toBe("macos-x86_64"); + }); + + test("macOS without an arch has no target — never guess a 1.7 GB download", () => { + expect(officeTarget("macos", null)).toBeNull(); + }); +}); + describe("parseOfficeArgs", () => { test("defaults", () => { expect(parseOfficeArgs([])).toEqual({ @@ -91,6 +129,21 @@ describe("parseOfficeArgs", () => { ); }); + test("--arch, both spellings and the aliases people actually type", () => { + expect(parseOfficeArgs(["--arch", "arm64"]).arch).toBe("arm64"); + expect(parseOfficeArgs(["--arch=x86_64"]).arch).toBe("x86_64"); + expect(parseOfficeArgs(["--arch", "aarch64"]).arch).toBe("arm64"); + expect(parseOfficeArgs(["--arch", "x64"]).arch).toBe("x86_64"); + expect(parseOfficeArgs(["--arch", "Intel"]).arch).toBe("x86_64"); + }); + + test("rejects an unknown or missing arch", () => { + expect(parseOfficeArgs(["--arch", "ppc64"]).error).toMatch( + /--arch must be one of/, + ); + expect(parseOfficeArgs(["--arch"]).error).toMatch(/--arch must be one of/); + }); + test("rejects --dir without a value", () => { expect(parseOfficeArgs(["--dir"]).error).toMatch(/--dir requires a path/); }); @@ -104,12 +157,19 @@ describe("parseOfficeArgs", () => { // the codev-storage bucket layout — a drift here is a broken download for // every user, so the full set is spelled out. describe("office file names", () => { - test("bundle names", () => { + test("bundle names — four bundles, macOS split per chip", () => { expect(officeBundleName("ubuntu")).toBe("codev-office-ubuntu.zip"); - expect(officeBundleName("macos")).toBe("codev-office-macos.zip"); expect(officeBundleName("windows")).toBe("codev-office-windows.zip"); + expect(officeBundleName("macos-arm64")).toBe( + "codev-office-macos-arm64.zip", + ); + expect(officeBundleName("macos-x86_64")).toBe( + "codev-office-macos-x86_64.zip", + ); }); + // Three scripts, not four: the single macOS script resolves its own bundle + // from `uname -m`, so splitting the bundles added no script to pick between. test("script names", () => { expect(officeScriptName("ubuntu")).toBe("codev-office-ubuntu-setup.sh"); expect(officeScriptName("macos")).toBe("codev-office-macos-setup.sh");