Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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-<os>-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).
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/lib/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> 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 <path> 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
Expand Down
126 changes: 114 additions & 12 deletions src/lib/office.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { spawn } from "node:child_process";
import { execFileSync, spawn } from "node:child_process";
import {
chmodSync,
existsSync,
Expand Down Expand Up @@ -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 <path>] [--download-only] [--skip-verify] [--force-skills]";
"Usage: codevhub skill office [--platform ubuntu|macos|windows] [--arch arm64|x86_64] [--dir <path>] [--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<string, OfficeArch> = {
arm64: "arm64",
aarch64: "arm64",
x86_64: "x86_64",
x64: "x86_64",
intel: "x86_64",
};

export function detectPlatform(
p: NodeJS.Platform = process.platform,
): OfficePlatform | null {
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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<OfficePlatform, number> = {
const APPROX_BUNDLE_MB: Record<OfficeTarget, number> = {
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
Expand Down Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -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.",
Expand All @@ -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 },
},
);

Expand Down
67 changes: 65 additions & 2 deletions tests/lib/download.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { downloadFile } from "@/lib/download.js";
import {
detectArch,
ensureStagingDir,
installerArgs,
migrateLegacyOfficeDir,
Expand Down Expand Up @@ -324,10 +325,14 @@

// 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");
Expand All @@ -348,7 +353,7 @@
return 0;
},
);
expect(code).toBe(0);

Check failure on line 356 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > --download-only stages both files and never spawns

AssertionError: expected 1 to be +0 // Object.is equality - Expected + Received - 0 + 1 ❯ tests/lib/download.test.ts:356:16
expect(spawns).toEqual([]);
expect(readFileSync(join(dir, bundleName)).equals(BUNDLE)).toBe(true);
expect(readFileSync(join(dir, scriptName)).equals(SCRIPT)).toBe(true);
Expand All @@ -365,7 +370,7 @@
return 0;
},
);
expect(code).toBe(0);

Check failure on line 373 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > runs the installer via bash with translated flags

AssertionError: expected 1 to be +0 // Object.is equality - Expected + Received - 0 + 1 ❯ tests/lib/download.test.ts:373:16
expect(spawned).toEqual({
command: "bash",
args: [join(dir, scriptName), "--skip-verify"],
Expand All @@ -376,9 +381,67 @@
test("propagates the installer's exit code", async () => {
const dir = join(tempDir, "office");
const code = await runSkillOffice(["--dir", dir], baseUrl, async () => 7);
expect(code).toBe(7);

Check failure on line 384 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > propagates the installer's exit code

AssertionError: expected 1 to be 7 // Object.is equality - Expected + Received - 7 + 1 ❯ tests/lib/download.test.ts:384:16
});

// 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);
Expand Down Expand Up @@ -448,7 +511,7 @@
const fallback = join(tempDir, "fallback-office");
try {
const dir = ensureStagingDir(preferred, fallback);
expect(dir).toBe(fallback);

Check failure on line 514 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > ensureStagingDir falls back when the preferred dir is unwritable

AssertionError: expected 'C:\Users\RUNNER~1\AppData\Local\Temp\…' to be 'C:\Users\RUNNER~1\AppData\Local\Temp\…' // Object.is equality Expected: "C:\Users\RUNNER~1\AppData\Local\Temp\codev-download-vavwFD\fallback-office" Received: "C:\Users\RUNNER~1\AppData\Local\Temp\codev-download-vavwFD\locked\codev-office" ❯ tests/lib/download.test.ts:514:16
expect(existsSync(fallback)).toBe(true);
} finally {
chmodSync(locked, 0o755);
Expand Down Expand Up @@ -485,7 +548,7 @@
["--download-only", "--dir", dir],
baseUrl,
);
expect(code).toBe(0);

Check failure on line 551 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > always refetches the setup script, but reuses a finished bundle

AssertionError: expected 1 to be +0 // Object.is equality - Expected + Received - 0 + 1 ❯ tests/lib/download.test.ts:551:16
expect(readFileSync(join(dir, scriptName)).equals(SCRIPT)).toBe(true);
// No checksum to disagree with, so the existing bundle is trusted as-is.
expect(readFileSync(join(dir, bundleName), "utf8")).toBe("stale-bundle");
Expand All @@ -502,7 +565,7 @@
["--download-only", "--dir", dir],
baseUrl,
);
expect(code).toBe(0);

Check failure on line 568 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > drops a stale .partial for the script instead of resuming onto it

AssertionError: expected 1 to be +0 // Object.is equality - Expected + Received - 0 + 1 ❯ tests/lib/download.test.ts:568:16
expect(readFileSync(join(dir, scriptName)).equals(SCRIPT)).toBe(true);
// Both requests went out without a Range header.
expect(rangeLog).toEqual([undefined, undefined]);
Expand Down Expand Up @@ -565,7 +628,7 @@
return 0;
},
);
expect(code).toBe(0);

Check failure on line 631 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > --uninstall fetches only the uninstall script and runs it with passthroughs

AssertionError: expected 1 to be +0 // Object.is equality - Expected + Received - 0 + 1 ❯ tests/lib/download.test.ts:631:16
expect(spawned).toEqual({
command: "bash",
args: [join(dir, uninstallName), "--yes", "--skills-only"],
Expand Down
Loading
Loading