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
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.5",
"version": "0.5.6",
"description": "CoDev — AI Coding Agent Hub. Install, configure, and manage multiple AI coding agents.",
"keywords": [
"ai",
Expand Down
4 changes: 2 additions & 2 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -389,8 +389,8 @@ switch (command) {
// so it doesn't collide with `codevhub install` (which installs agents).
// `pull` downloads/installs a skill (not `install`, to avoid that confusion);
// `push` publishes one; whoami migrates here next. `office` fetches the
// MiniMax-DOCX offline bundle from codev-storage (anonymous — unlike the
// other skill subcommands it must never force a login).
// CoDev Office offline skills bundle from codev-storage (anonymous — unlike
// the other skill subcommands it must never force a login).
case "skill": {
const [sub, ...rest] = args;
if (sub === "search") {
Expand Down
4 changes: 2 additions & 2 deletions src/lib/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ export const SKILLHUB_URL = `${BASE_URL}/netmindhub`;
export const CODE_DOWNLOADS_URL = `${BASE_URL}/codev/docs/code/downloads`;
// The codev-storage MinIO backend (codev-storage repo), reached through the
// shared reverse proxy's /codev-storage route. The codev-office bucket holds
// the MiniMax-DOCX offline bundles + setup scripts + manifest.json, all
// anonymous read-only.
// the CoDev Office offline skill bundles + setup scripts (deterministic names,
// see office.ts), all anonymous read-only.
export const OFFICE_DOWNLOADS_URL = `${BASE_URL}/codev-storage/codev-office`;

export const FALLBACK_MODEL = atob("TWluaU1heC9NaW5pTWF4LU0z");
Expand Down
5 changes: 3 additions & 2 deletions src/lib/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ Skill hub:
skill push <path> Publish a skill (a directory with SKILL.md, or a .zip)
(previews and confirms before upload; --draft-only to stop
at DRAFT, --auto-approve for admins, --json for output)
skill office Download the MiniMax-DOCX offline bundle for this OS,
verify its checksum, and run its setup script
skill office Download the CoDev Office offline skills bundle
(minimax-docx, minimax-xlsx) 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
folder, --download-only to skip running the installer,
Expand Down
130 changes: 52 additions & 78 deletions src/lib/office.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { spawn } from "node:child_process";
import { chmodSync, mkdirSync } from "node:fs";
import { chmodSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { OFFICE_DOWNLOADS_URL } from "@/lib/const.js";
import { downloadFile } from "@/lib/download.js";
import { loggedFetch, logInfo } from "@/lib/log.js";
import { logInfo } from "@/lib/log.js";
import { officeDownloadsDir } from "@/lib/paths.js";

// `codevhub skill office`: fetch the MiniMax-DOCX offline bundle (published by
// the codev-storage MinIO backend) for this OS, verify it against the bucket's
// manifest, and run the bundled setup script. Non-interactive on purpose — the
// second half hands the terminal to an installer that prompts for sudo/UAC,
// which an Ink render would fight over.
// `codevhub skill office`: fetch the CoDev Office offline bundle (published by
// the codev-storage MinIO backend) for this OS and run the bundled setup
// script, which installs the Office skills (minimax-docx, minimax-xlsx, …).
// File names are deterministic per platform — no manifest fetch — and each
// bundle carries its own SHA256SUMS.txt that the setup flow can verify.
// Non-interactive on purpose — the second half hands the terminal to an
// 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] [--minimal] [--skip-verify]";
Expand Down Expand Up @@ -90,42 +92,32 @@ export function parseOfficeArgs(argv: string[]): OfficeArgs {
return parsed;
}

export interface OfficeManifest {
schema: number;
version: string;
platforms: Record<OfficePlatform, { bundle: string; script: string }>;
files: Record<string, { size: number; sha256: string }>;
// 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`;
}

export function parseOfficeManifest(json: unknown): OfficeManifest {
const bad = (why: string): never => {
throw new Error(
`unexpected manifest.json shape (${why}) — the published bundle layout may have changed; update codevhub`,
);
};
if (typeof json !== "object" || json === null) bad("not an object");
const m = json as Record<string, unknown>;
if (m.schema !== 1) bad(`schema ${String(m.schema)}`);
if (typeof m.version !== "string") bad("missing version");
const platforms = m.platforms as OfficeManifest["platforms"];
if (typeof platforms !== "object" || platforms === null)
bad("missing platforms");
for (const p of OFFICE_PLATFORMS) {
const entry = platforms[p];
if (typeof entry?.bundle !== "string" || typeof entry?.script !== "string")
bad(`platform ${p}`);
}
const files = m.files as OfficeManifest["files"];
if (typeof files !== "object" || files === null) bad("missing files");
for (const [name, meta] of Object.entries(files)) {
if (typeof meta?.size !== "number" || typeof meta?.sha256 !== "string")
bad(`file ${name}`);
}
return m as unknown as OfficeManifest;
export function officeScriptName(platform: OfficePlatform): string {
return platform === "windows"
? "codev-office-windows-setup.ps1"
: `codev-office-${platform}-setup.sh`;
}

function formatMb(bytes: number): string {
return (bytes / (1024 * 1024)).toFixed(1);
// 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> = {
ubuntu: 610,
windows: 820,
macos: 1400,
};

// Adaptive size for progress lines: the setup script is ~13 KB and rendered
// "0.0/0.0 MB (100%)" under a fixed-MB format. Exported for tests.
export function formatSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

// Single rewriting progress line on a TTY; on pipes/CI, a line roughly every
Expand All @@ -139,11 +131,11 @@ function makeProgressPrinter(name: string): {
return {
print(received, total) {
if (total === null) {
if (tty) process.stderr.write(`\r${name} ${formatMb(received)} MB`);
if (tty) process.stderr.write(`\r${name} ${formatSize(received)}`);
return;
}
const percent = Math.floor((received / total) * 100);
const line = `${name} ${formatMb(received)}/${formatMb(total)} MB (${percent}%)`;
const line = `${name} ${formatSize(received)}/${formatSize(total)} (${percent}%)`;
if (tty) {
process.stderr.write(`\r${line}`);
} else if (percent >= lastPercent + 5) {
Expand Down Expand Up @@ -231,60 +223,42 @@ export async function runSkillOffice(
const dir = parsed.dir ?? officeDownloadsDir();
mkdirSync(dir, { recursive: true });

let manifest: OfficeManifest;
try {
const res = await loggedFetch(
"office.manifest",
`${baseUrl}/manifest.json`,
{
signal: AbortSignal.timeout(30_000),
},
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
manifest = parseOfficeManifest(await res.json());
} catch (err) {
console.error(
`Could not fetch the bundle manifest from ${baseUrl}/manifest.json: ${err instanceof Error ? err.message : String(err)}`,
);
return 1;
}
const bundle = officeBundleName(platform);
const script = officeScriptName(platform);
console.error(`CoDev Office offline skills bundle (${platform})`);
console.error(
`MiniMax-DOCX offline bundle, version ${manifest.version} (${platform})`,
);
const { bundle, script } = manifest.platforms[platform];
const bundleSize = manifest.files[bundle]?.size;
console.error(
`Heads-up: the bundle is ${
bundleSize ? `~${formatMb(bundleSize)} MB` : "large (up to ~1.1 GB)"
} — downloading might take a while. An interrupted run picks up where it left off.`,
`Heads-up: the bundle is ~${APPROX_BUNDLE_MB[platform]} MB — downloading might ` +
"take a while. An interrupted run picks up where it left off; an " +
"already-downloaded file is reused (delete it to force a fresh download).",
);
logInfo("office bundle download starting", {
action: "office.install",
extra: { platform, version: manifest.version, dir, downloadOnly },
extra: { platform, dir, downloadOnly },
});

// Without per-file checksums an existing file is reused as-is. That's the
// point for the GB-scale bundle, but the setup script is tiny and must
// track the published version — always refetch it.
rmSync(join(dir, script), { force: true });

for (const name of [script, bundle]) {
const meta = manifest.files[name];
if (!meta) {
console.error(
`manifest.json does not list ${name} — re-publish the bundle`,
);
return 1;
}
const progress = makeProgressPrinter(name);
try {
await downloadFile({
url: `${baseUrl}/${name}`,
dest: join(dir, name),
sha256: meta.sha256,
size: meta.size,
endpoint: name === bundle ? "office.bundle" : "office.script",
onProgress: (p) => progress.print(p.received, p.total),
});
} finally {
} catch (err) {
progress.done();
console.error(
`Could not download ${baseUrl}/${name}: ${err instanceof Error ? err.message : String(err)}`,
);
return 1;
}
console.error(`✓ ${name} verified (SHA-256)`);
progress.done();
console.error(`✓ ${name} downloaded`);
}
if (process.platform !== "win32") {
chmodSync(join(dir, script), 0o755);
Expand Down
71 changes: 22 additions & 49 deletions tests/lib/download.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
Expand Down Expand Up @@ -168,38 +169,15 @@

// 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.
describe("runSkillOffice", () => {
const hostPlatform = process.platform === "darwin" ? "macos" : "ubuntu";
const bundleName = `minimax-docx-${hostPlatform}.zip`;
const bundleName = `codev-office-${hostPlatform}.zip`;
const scriptName = `codev-office-${hostPlatform}-setup.sh`;
const BUNDLE = Buffer.from("fake-bundle-bytes");
const SCRIPT = Buffer.from("#!/bin/sh\nexit 0\n");

const manifest = () => ({
schema: 1,
version: "test-1",
platforms: {
ubuntu: {
bundle: "minimax-docx-ubuntu.zip",
script: "codev-office-ubuntu-setup.sh",
},
macos: {
bundle: "minimax-docx-macos.zip",
script: "codev-office-macos-setup.sh",
},
windows: {
bundle: "minimax-docx-windows.zip",
script: "codev-office-windows-setup.ps1",
},
},
files: {
[bundleName]: { size: BUNDLE.length, sha256: sha256(BUNDLE) },
[scriptName]: { size: SCRIPT.length, sha256: sha256(SCRIPT) },
},
});

beforeEach(() => {
objects.set("/manifest.json", Buffer.from(JSON.stringify(manifest())));
objects.set(`/${bundleName}`, BUNDLE);
objects.set(`/${scriptName}`, SCRIPT);
});
Expand All @@ -215,7 +193,7 @@
return 0;
},
);
expect(code).toBe(0);

Check failure on line 196 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:196:16
expect(spawns).toEqual([]);
expect(readFileSync(join(dir, bundleName)).equals(BUNDLE)).toBe(true);
expect(readFileSync(join(dir, scriptName)).equals(SCRIPT)).toBe(true);
Expand All @@ -232,7 +210,7 @@
return 0;
},
);
expect(code).toBe(0);

Check failure on line 213 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:213:16
expect(spawned).toEqual({
command: "bash",
args: [join(dir, scriptName), "--minimal", "--skip-verify"],
Expand All @@ -243,32 +221,12 @@
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 224 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:224:16
});

test("a cross-platform --platform forces download-only", async () => {
// The windows files are not even published on the test server: proving
// they were requested-and-downloaded but the installer never ran.
objects.set("/minimax-docx-windows.zip", BUNDLE);
objects.set("/codev-office-windows.zip", BUNDLE);
objects.set("/codev-office-windows-setup.ps1", SCRIPT);
objects.set(
"/manifest.json",
Buffer.from(
JSON.stringify({
...manifest(),
files: {
"minimax-docx-windows.zip": {
size: BUNDLE.length,
sha256: sha256(BUNDLE),
},
"codev-office-windows-setup.ps1": {
size: SCRIPT.length,
sha256: sha256(SCRIPT),
},
},
}),
),
);
const dir = join(tempDir, "office");
const spawns: string[] = [];
const code = await runSkillOffice(
Expand All @@ -280,12 +238,27 @@
},
);
expect(code).toBe(0);
expect(spawns).toEqual([]);

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

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > a cross-platform --platform forces download-only

AssertionError: expected [ 'powershell.exe' ] to deeply equal [] - Expected + Received - [] + [ + "powershell.exe", + ] ❯ tests/lib/download.test.ts:241:18
expect(existsSync(join(dir, "minimax-docx-windows.zip"))).toBe(true);
expect(existsSync(join(dir, "codev-office-windows.zip"))).toBe(true);
});

test("always refetches the setup script, but reuses a finished bundle", async () => {
const dir = join(tempDir, "office");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, scriptName), "stale-script");
writeFileSync(join(dir, bundleName), "stale-bundle");
const code = await runSkillOffice(
["--download-only", "--dir", dir],
baseUrl,
);
expect(code).toBe(0);

Check failure on line 254 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:254: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");
});

test("fails cleanly when the manifest is missing", async () => {
objects.delete("/manifest.json");
test("fails cleanly when the bundle is not published", async () => {
objects.delete(`/${bundleName}`);
const code = await runSkillOffice(
["--dir", join(tempDir, "office")],
baseUrl,
Expand Down
Loading
Loading