From 030a1b15d5b1f0ce49245ef78dd285eacc975820 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Tue, 4 Aug 2026 16:06:53 +0700 Subject: [PATCH 1/3] Fix skill office for codev-office bundles; drop the manifest requirement The command fetched manifest.json before anything else and hard-failed without it, and the published manifest still listed the old minimax-docx-*.zip names after the bundles were renamed. - Derive file names deterministically per platform (codev-office-.zip, codev-office--setup.sh|ps1) instead of reading them from a manifest; pin the full name set in tests as the contract with codev-scripts and the bucket layout - Download without outer checksums: resume via Range is kept, a finished bundle is reused as-is, and the setup script is always refetched so it tracks the published version - Catch download errors for a clean exit-1 message (previously only the manifest fetch had that handling) - Update copy: MiniMax-DOCX bundle -> CoDev Office skills bundle Co-Authored-By: Claude Fable 5 --- src/index.tsx | 4 +- src/lib/const.ts | 4 +- src/lib/help.ts | 5 +- src/lib/office.ts | 119 ++++++++++++++----------------------- tests/lib/download.test.ts | 71 +++++++--------------- tests/lib/office.test.ts | 66 +++++--------------- 6 files changed, 88 insertions(+), 181 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index ff6da93..06d840a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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") { diff --git a/src/lib/const.ts b/src/lib/const.ts index 6cb50fb..eb750d6 100644 --- a/src/lib/const.ts +++ b/src/lib/const.ts @@ -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"); diff --git a/src/lib/help.ts b/src/lib/help.ts index e81d317..22f41ae 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -49,8 +49,9 @@ Skill hub: skill push 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 for the download folder, --download-only to skip running the installer, diff --git a/src/lib/office.ts b/src/lib/office.ts index 0b97759..319d4fd 100644 --- a/src/lib/office.ts +++ b/src/lib/office.ts @@ -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 ] [--download-only] [--minimal] [--skip-verify]"; @@ -90,40 +92,27 @@ export function parseOfficeArgs(argv: string[]): OfficeArgs { return parsed; } -export interface OfficeManifest { - schema: number; - version: string; - platforms: Record; - files: Record; +// 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; - 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`; } +// Rough bundle sizes for the pre-download heads-up only; progress totals come +// from the server's content-length. +const APPROX_BUNDLE_MB: Record = { + ubuntu: 610, + windows: 820, + macos: 1400, +}; + function formatMb(bytes: number): string { return (bytes / (1024 * 1024)).toFixed(1); } @@ -231,60 +220,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); diff --git a/tests/lib/download.test.ts b/tests/lib/download.test.ts index 6d2e1e5..1e5b426 100644 --- a/tests/lib/download.test.ts +++ b/tests/lib/download.test.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { existsSync, + mkdirSync, mkdtempSync, readFileSync, rmSync, @@ -168,38 +169,15 @@ describe("downloadFile", () => { // 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); }); @@ -247,28 +225,8 @@ describe("runSkillOffice", () => { }); 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( @@ -281,11 +239,26 @@ describe("runSkillOffice", () => { ); expect(code).toBe(0); expect(spawns).toEqual([]); - 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); + 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, diff --git a/tests/lib/office.test.ts b/tests/lib/office.test.ts index ab6b971..1958f7f 100644 --- a/tests/lib/office.test.ts +++ b/tests/lib/office.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "vitest"; import { detectPlatform, - type OfficeManifest, + officeBundleName, + officeScriptName, parseOfficeArgs, - parseOfficeManifest, } from "@/lib/office.js"; describe("detectPlatform", () => { @@ -66,57 +66,19 @@ describe("parseOfficeArgs", () => { }); }); -const VALID_MANIFEST: OfficeManifest = { - schema: 1, - version: "2026-08-03", - 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: { - "minimax-docx-ubuntu.zip": { size: 123, sha256: "ab".repeat(32) }, - }, -}; - -describe("parseOfficeManifest", () => { - test("accepts a valid manifest", () => { - expect(parseOfficeManifest(VALID_MANIFEST)).toEqual(VALID_MANIFEST); - }); - - test("rejects a wrong schema", () => { - expect(() => parseOfficeManifest({ ...VALID_MANIFEST, schema: 2 })).toThrow( - /manifest.json shape/, - ); - }); - - test("rejects a missing platform entry", () => { - const { windows: _, ...platforms } = VALID_MANIFEST.platforms; - expect(() => parseOfficeManifest({ ...VALID_MANIFEST, platforms })).toThrow( - /platform windows/, - ); - }); - - test("rejects malformed file entries", () => { - expect(() => - parseOfficeManifest({ - ...VALID_MANIFEST, - files: { "x.zip": { size: "big", sha256: "ab" } }, - }), - ).toThrow(/file x.zip/); +// The names are a contract with the codev-scripts repo (codev-office/*) and +// 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", () => { + expect(officeBundleName("ubuntu")).toBe("codev-office-ubuntu.zip"); + expect(officeBundleName("macos")).toBe("codev-office-macos.zip"); + expect(officeBundleName("windows")).toBe("codev-office-windows.zip"); }); - test("rejects non-objects", () => { - expect(() => parseOfficeManifest(null)).toThrow(/not an object/); - expect(() => parseOfficeManifest("[]")).toThrow(/manifest.json shape/); + test("script names", () => { + expect(officeScriptName("ubuntu")).toBe("codev-office-ubuntu-setup.sh"); + expect(officeScriptName("macos")).toBe("codev-office-macos-setup.sh"); + expect(officeScriptName("windows")).toBe("codev-office-windows-setup.ps1"); }); }); From fd656378489239e91a75e582d3cb71bb2cb4909a Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Tue, 4 Aug 2026 16:11:43 +0700 Subject: [PATCH 2/3] chore/minor-changes --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0ec1966..2b73480 100644 --- a/package.json +++ b/package.json @@ -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", From 2bd295cec89710588fc167221736b6de9890c327 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Tue, 4 Aug 2026 16:18:03 +0700 Subject: [PATCH 3/3] Use adaptive KB/MB units in office download progress The ~13 KB setup script rendered as "0.0/0.0 MB (100%)" under the fixed-MB format. formatSize switches to KB below 1 MB. Co-Authored-By: Claude Fable 5 --- src/lib/office.ts | 11 +++++++---- tests/lib/office.test.ts | 13 +++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/lib/office.ts b/src/lib/office.ts index 319d4fd..ca29da7 100644 --- a/src/lib/office.ts +++ b/src/lib/office.ts @@ -113,8 +113,11 @@ const APPROX_BUNDLE_MB: Record = { macos: 1400, }; -function formatMb(bytes: number): string { - return (bytes / (1024 * 1024)).toFixed(1); +// 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 @@ -128,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) { diff --git a/tests/lib/office.test.ts b/tests/lib/office.test.ts index 1958f7f..f6096a9 100644 --- a/tests/lib/office.test.ts +++ b/tests/lib/office.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { detectPlatform, + formatSize, officeBundleName, officeScriptName, parseOfficeArgs, @@ -82,3 +83,15 @@ describe("office file names", () => { expect(officeScriptName("windows")).toBe("codev-office-windows-setup.ps1"); }); }); + +describe("formatSize", () => { + test("KB below 1 MB — a 13 KB script must not render as 0.0 MB", () => { + expect(formatSize(13 * 1024)).toBe("13.0 KB"); + expect(formatSize(0)).toBe("0.0 KB"); + }); + + test("MB from 1 MB up", () => { + expect(formatSize(1024 * 1024)).toBe("1.0 MB"); + expect(formatSize(637_252_608)).toBe("607.7 MB"); + }); +});