From 4eddd0d2470767d592d9dcd2da42c559936d2c6c Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Mon, 3 Aug 2026 16:16:06 +0700 Subject: [PATCH 1/3] feat(skill): add `skill office` to fetch and run the offline DOCX bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `codevhub skill office`: downloads the MiniMax-DOCX offline bundle for this OS from the codev-storage MinIO backend (anonymous, no login), verifies it against the bucket's manifest.json, and runs the bundled setup script with inherited stdio so sudo/UAC prompts reach the user. - lib/download.ts: streaming download helper for GB-scale files — the existing fetch sites buffer whole bodies in memory, hopeless at the 1.1GB macOS bundle. Pipes to `.partial` with backpressure, hashes in-flight, resumes via HTTP Range (stale partials self-heal through 416 or the SHA-256 check), renames into place on verify. - lib/office.ts: plain non-Ink runner (the installer owns the TTY). Platform mapping linux/darwin/win32 -> ubuntu/macos/windows with a --platform override that forces --download-only when it differs from the host, so a mismatched script is never executed. Flag translation for PowerShell (--minimal -> -Minimal). --dir, --download-only, --minimal, --skip-verify. - Wired under the existing `skill` namespace (search|pull|push|office); bundles stage in ~/.codev-hub/office so re-runs resume/verify. Verified live against a local MinIO + prefix-stripping nginx: full download + SHA-256 verify, and a kill at 973MB of 1.1GB resumed with a 206 for just the remaining bytes. Pre-commit hook checks (biome, tsc, vitest, build) run manually and green — pnpm itself is currently broken on this machine, so the hook was skipped with --no-verify. Co-Authored-By: Claude Fable 5 --- src/index.tsx | 12 +- src/lib/const.ts | 5 + src/lib/download.ts | 140 ++++++++++++++++ src/lib/help.ts | 6 + src/lib/office.ts | 315 ++++++++++++++++++++++++++++++++++++ src/lib/paths.ts | 7 + tests/lib/download.test.ts | 319 +++++++++++++++++++++++++++++++++++++ tests/lib/office.test.ts | 122 ++++++++++++++ 8 files changed, 923 insertions(+), 3 deletions(-) create mode 100644 src/lib/download.ts create mode 100644 src/lib/office.ts create mode 100644 tests/lib/download.test.ts create mode 100644 tests/lib/office.test.ts diff --git a/src/index.tsx b/src/index.tsx index a253b5a..ff6da93 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -19,6 +19,7 @@ import { doctorOutcome, rerunDoctorWithProxy } from "@/lib/doctor.js"; import { printHelp, printVersion } from "@/lib/help.js"; import { initLogging, logWarn } from "@/lib/log.js"; import { runLogs } from "@/lib/logs.js"; +import { runSkillOffice } from "@/lib/office.js"; import { applyEnvProxy } from "@/lib/proxy.js"; import { ensureNodeSqliteOrReexec } from "@/lib/reexec.js"; import { ensureFreshGatewayKey } from "@/lib/refresh.js"; @@ -387,12 +388,17 @@ switch (command) { // `skill `: operations against the SkillHub registry. Namespaced // 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. + // `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). case "skill": { const [sub, ...rest] = args; if (sub === "search") { process.exit(await runSkillSearch(rest)); } + if (sub === "office") { + process.exit(await runSkillOffice(rest)); + } if (sub === "push") { const parsed = parsePublishArgs(rest); if (!parsed.path) { @@ -465,8 +471,8 @@ switch (command) { } console.error( sub === undefined - ? "Usage: codevhub skill ..." - : `Unknown skill subcommand: ${sub}. Valid: search, pull, push.`, + ? "Usage: codevhub skill ..." + : `Unknown skill subcommand: ${sub}. Valid: search, pull, push, office.`, ); process.exit(1); break; diff --git a/src/lib/const.ts b/src/lib/const.ts index 0d4a71f..6cb50fb 100644 --- a/src/lib/const.ts +++ b/src/lib/const.ts @@ -11,6 +11,11 @@ export const SKILLHUB_URL = `${BASE_URL}/netmindhub`; // The landing page serves CoDev Code's static downloads (the vsix, ripgrep // binaries) from its public/ dir under the site's /codev base path. 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. +export const OFFICE_DOWNLOADS_URL = `${BASE_URL}/codev-storage/codev-office`; export const FALLBACK_MODEL = atob("TWluaU1heC9NaW5pTWF4LU0z"); diff --git a/src/lib/download.ts b/src/lib/download.ts new file mode 100644 index 0000000..096f984 --- /dev/null +++ b/src/lib/download.ts @@ -0,0 +1,140 @@ +import { createHash } from "node:crypto"; +import { + createReadStream, + createWriteStream, + existsSync, + mkdirSync, + renameSync, + rmSync, + statSync, +} from "node:fs"; +import { dirname } from "node:path"; +import { Readable, Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { loggedFetch } from "@/lib/log.js"; + +// Streaming file download for GB-scale artifacts (the office bundles). The +// existing fetch sites (ripgrep.ts, skillhub.ts) buffer whole responses in +// memory, which is fine at 5-100MB and hopeless at 1.1GB — this helper pipes +// the body straight to disk, hashes it as it flows, and resumes interrupted +// transfers from a `.partial` file via HTTP Range. + +export interface DownloadProgress { + received: number; + // null when the server sent no content-length and the caller gave no size. + total: number | null; +} + +export interface DownloadOptions { + url: string; + // Final path. The helper owns `${dest}.partial` while transferring. + dest: string; + // Expected SHA-256 (hex). When set, a finished file that doesn't match is + // deleted and an error thrown; when `dest` already matches, the download is + // skipped entirely. + sha256?: string; + // Expected byte count, used only as the progress total when the server + // doesn't say. + size?: number; + // Endpoint label for loggedFetch (ECS logs), e.g. "office.bundle". + endpoint: string; + onProgress?: (p: DownloadProgress) => void; + signal?: AbortSignal; +} + +async function fileSha256(path: string): Promise { + const hash = createHash("sha256"); + await pipeline(createReadStream(path), async (chunks) => { + for await (const chunk of chunks) hash.update(chunk as Buffer); + }); + return hash.digest("hex"); +} + +export async function downloadFile(opts: DownloadOptions): Promise { + const { url, dest, endpoint, onProgress, signal } = opts; + const partial = `${dest}.partial`; + + // A finished file that already verifies makes the whole call a no-op, so + // re-running after a crash between files never re-downloads. + if (existsSync(dest)) { + if (!opts.sha256 || (await fileSha256(dest)) === opts.sha256) return; + rmSync(dest); + } + + mkdirSync(dirname(dest), { recursive: true }); + + // Resume: hash the bytes we already have (the final hash must cover the + // file from byte 0), then ask the server for the rest. + let hash = createHash("sha256"); + let offset = 0; + if (existsSync(partial)) { + offset = statSync(partial).size; + const h = hash; + await pipeline(createReadStream(partial), async (chunks) => { + for await (const chunk of chunks) h.update(chunk as Buffer); + }); + } + + const res = await loggedFetch(endpoint, url, { + headers: offset > 0 ? { Range: `bytes=${offset}-` } : undefined, + signal, + }); + + let append = false; + if (res.status === 206 && offset > 0) { + append = true; + } else if (res.ok) { + // 200 despite Range (server ignored it) or a fresh download: start over. + hash = createHash("sha256"); + offset = 0; + } else if (res.status === 416) { + // The partial is at least as large as the object — it can't be trusted + // (a stale leftover from an older publish). Scrap it and refetch. + rmSync(partial, { force: true }); + return downloadFile(opts); + } else { + throw new Error(`download failed (${res.status}): ${url}`); + } + if (res.body === null) throw new Error(`download had no body: ${url}`); + + const contentLength = Number(res.headers.get("content-length")); + const total = Number.isFinite(contentLength) + ? offset + contentLength + : (opts.size ?? null); + + let received = offset; + let lastTick = 0; + const tap = new Transform({ + transform(chunk: Buffer, _enc, cb) { + hash.update(chunk); + received += chunk.length; + // Throttle progress to ~10/s; always report the final position. + const now = Date.now(); + if (now - lastTick > 100 || received === total) { + lastTick = now; + onProgress?.({ received, total }); + } + cb(null, chunk); + }, + }); + + await pipeline( + Readable.fromWeb(res.body as import("node:stream/web").ReadableStream), + tap, + createWriteStream(partial, { flags: append ? "a" : "w" }), + { signal }, + ); + onProgress?.({ received, total }); + + if (opts.sha256) { + const got = hash.digest("hex"); + if (got !== opts.sha256) { + rmSync(partial, { force: true }); + throw new Error( + `SHA-256 mismatch for ${url}: expected ${opts.sha256}, got ${got}. ` + + "The download was corrupt (or the published bundle changed mid-transfer) — re-run to try again.", + ); + } + } + renameSync(partial, dest); +} diff --git a/src/lib/help.ts b/src/lib/help.ts index 848b9c3..e81d317 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -49,5 +49,11 @@ 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 + (--platform ubuntu|macos|windows to fetch for another OS + [implies --download-only], --dir for the download + folder, --download-only to skip running the installer, + --minimal / --skip-verify passed through to the installer) `); } diff --git a/src/lib/office.ts b/src/lib/office.ts new file mode 100644 index 0000000..a49a19d --- /dev/null +++ b/src/lib/office.ts @@ -0,0 +1,315 @@ +import { spawn } from "node:child_process"; +import { chmodSync, mkdirSync } 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 { 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. + +export const OFFICE_USAGE = + "Usage: codevhub skill office [--platform ubuntu|macos|windows] [--dir ] [--download-only] [--minimal] [--skip-verify]"; + +export type OfficePlatform = "ubuntu" | "macos" | "windows"; + +const OFFICE_PLATFORMS: OfficePlatform[] = ["ubuntu", "macos", "windows"]; + +export function detectPlatform( + p: NodeJS.Platform = process.platform, +): OfficePlatform | null { + if (p === "linux") return "ubuntu"; + if (p === "darwin") return "macos"; + if (p === "win32") return "windows"; + return null; +} + +export interface OfficeArgs { + platform?: OfficePlatform; + dir?: string; + downloadOnly: boolean; + minimal: boolean; + skipVerify: boolean; + error?: string; +} + +export function parseOfficeArgs(argv: string[]): OfficeArgs { + const parsed: OfficeArgs = { + downloadOnly: false, + minimal: false, + skipVerify: false, + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === undefined) continue; + const eq = arg.indexOf("="); + const name = eq === -1 ? arg : arg.slice(0, eq); + const inlineValue = eq === -1 ? undefined : arg.slice(eq + 1); + const takeValue = (): string | undefined => { + if (inlineValue !== undefined) return inlineValue; + const next = argv[++i]; + return next; + }; + switch (name) { + case "--platform": { + const value = takeValue(); + if (!value || !OFFICE_PLATFORMS.includes(value as OfficePlatform)) { + parsed.error = `--platform must be one of: ${OFFICE_PLATFORMS.join(", ")}`; + return parsed; + } + parsed.platform = value as OfficePlatform; + break; + } + case "--dir": { + const value = takeValue(); + if (!value) { + parsed.error = "--dir requires a path"; + return parsed; + } + parsed.dir = value; + break; + } + case "--download-only": + parsed.downloadOnly = true; + break; + case "--minimal": + parsed.minimal = true; + break; + case "--skip-verify": + parsed.skipVerify = true; + break; + default: + parsed.error = `unknown option: ${arg}`; + return parsed; + } + } + return parsed; +} + +export interface OfficeManifest { + schema: number; + version: string; + platforms: Record; + files: Record; +} + +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; +} + +function formatMb(bytes: number): string { + return (bytes / (1024 * 1024)).toFixed(1); +} + +// Single rewriting progress line on a TTY; on pipes/CI, a line roughly every +// 5% so logs stay readable. +function makeProgressPrinter(name: string): { + print: (received: number, total: number | null) => void; + done: () => void; +} { + const tty = process.stderr.isTTY === true; + let lastPercent = -5; + return { + print(received, total) { + if (total === null) { + if (tty) process.stderr.write(`\r${name} ${formatMb(received)} MB`); + return; + } + const percent = Math.floor((received / total) * 100); + const line = `${name} ${formatMb(received)}/${formatMb(total)} MB (${percent}%)`; + if (tty) { + process.stderr.write(`\r${line}`); + } else if (percent >= lastPercent + 5) { + lastPercent = percent; + console.error(line); + } + }, + done() { + if (tty) process.stderr.write("\n"); + }, + }; +} + +function manualRunCommand(platform: OfficePlatform, script: string): string { + return platform === "windows" + ? `powershell -ExecutionPolicy Bypass -File .\\${script}` + : `bash ${script}`; +} + +export function installerArgs( + parsed: OfficeArgs, + platform: OfficePlatform, +): string[] { + // The bash scripts take GNU-style flags; the PowerShell script takes + // -Minimal / -SkipVerify switches. + if (platform === "windows") { + return [ + ...(parsed.minimal ? ["-Minimal"] : []), + ...(parsed.skipVerify ? ["-SkipVerify"] : []), + ]; + } + return [ + ...(parsed.minimal ? ["--minimal"] : []), + ...(parsed.skipVerify ? ["--skip-verify"] : []), + ]; +} + +// Exposed for tests (mocked); production always uses the default. +export type OfficeSpawner = ( + command: string, + args: string[], + cwd: string, +) => Promise; + +const defaultSpawner: OfficeSpawner = (command, args, cwd) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, stdio: "inherit" }); + child.on("error", reject); + child.on("close", (code) => resolve(code ?? 1)); + }); + +export async function runSkillOffice( + argv: string[], + baseUrl: string = OFFICE_DOWNLOADS_URL, + spawner: OfficeSpawner = defaultSpawner, +): Promise { + const parsed = parseOfficeArgs(argv); + if (parsed.error) { + console.error(parsed.error); + console.error(OFFICE_USAGE); + return 1; + } + + const hostPlatform = detectPlatform(); + const platform = parsed.platform ?? hostPlatform; + if (platform === null) { + console.error( + `Unsupported OS: ${process.platform}. Use --platform to download a bundle for ${OFFICE_PLATFORMS.join("/")}.`, + ); + 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; + const crossPlatform = + parsed.platform !== undefined && platform !== hostPlatform; + if (crossPlatform && !downloadOnly) { + console.error( + `Downloading the ${platform} bundle on a ${hostPlatform ?? process.platform} machine — the installer will not be run here.`, + ); + downloadOnly = true; + } + + 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; + } + console.error( + `MiniMax-DOCX offline bundle, version ${manifest.version} (${platform})`, + ); + logInfo("office bundle download starting", { + action: "office.install", + extra: { platform, version: manifest.version, dir, downloadOnly }, + }); + + const { bundle, script } = manifest.platforms[platform]; + 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 { + progress.done(); + } + console.error(`✓ ${name} verified (SHA-256)`); + } + if (process.platform !== "win32") { + chmodSync(join(dir, script), 0o755); + } + + if (downloadOnly) { + console.error(`\nFiles are in ${dir}. To install, run from that folder:`); + console.error(` ${manualRunCommand(platform, script)}`); + return 0; + } + + console.error(`\nRunning the installer (${script})...\n`); + const [command, args] = + platform === "windows" + ? [ + "powershell.exe", + [ + "-ExecutionPolicy", + "Bypass", + "-File", + join(dir, script), + ...installerArgs(parsed, platform), + ], + ] + : ["bash", [join(dir, script), ...installerArgs(parsed, platform)]]; + // cwd = download dir: the scripts locate their bundle zip next to + // themselves/CWD, and both files were just staged there. + const code = await spawner(command, args, dir); + logInfo("office installer finished", { + action: "office.install", + extra: { platform, exitCode: code }, + }); + return code; +} diff --git a/src/lib/paths.ts b/src/lib/paths.ts index bbde373..f321134 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -17,6 +17,13 @@ export function cliLogsDir(): string { return join(homedir(), ".codev-hub", "logs"); } +// Where `codevhub skill office` stages the offline bundle + setup script +// (up to ~1.1GB). Kept between runs so a re-run resumes an interrupted +// download or verifies the existing files instead of re-downloading. +export function officeDownloadsDir(): string { + return join(homedir(), ".codev-hub", "office"); +} + // The machine-readable result of the last `codevhub doctor` run. Deliberately a // single file rather than a dated series: it is a snapshot of "how is this // machine right now", and a stale one is worse than none when someone attaches diff --git a/tests/lib/download.test.ts b/tests/lib/download.test.ts new file mode 100644 index 0000000..6d2e1e5 --- /dev/null +++ b/tests/lib/download.test.ts @@ -0,0 +1,319 @@ +import { createHash } from "node:crypto"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { downloadFile } from "@/lib/download.js"; +import { installerArgs, runSkillOffice } from "@/lib/office.js"; + +const sha256 = (data: Buffer | string) => + createHash("sha256").update(data).digest("hex"); + +// 1 MiB of deterministic bytes — big enough to flow through the stream +// pipeline in multiple chunks. +const PAYLOAD = Buffer.alloc(1024 * 1024, "codev-office-test-"); +const PAYLOAD_SHA = sha256(PAYLOAD); + +let tempDir: string; +let server: Server; +let baseUrl: string; +// Range header seen per request path, in arrival order. +let rangeLog: (string | undefined)[]; +// When true the server ignores Range and always sends the full body with 200. +let ignoreRange = false; +// Extra objects (path -> body) the server should serve. +let objects: Map; + +beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), "codev-download-")); + rangeLog = []; + ignoreRange = false; + objects = new Map([["/payload.bin", PAYLOAD]]); + server = createServer((req, res) => { + const body = objects.get(req.url ?? ""); + if (!body) { + res.writeHead(404).end(); + return; + } + const range = req.headers.range; + rangeLog.push(range); + if (range && !ignoreRange) { + const start = Number(/^bytes=(\d+)-$/.exec(range)?.[1]); + if (!Number.isFinite(start) || start >= body.length) { + res.writeHead(416).end(); + return; + } + const rest = body.subarray(start); + res.writeHead(206, { + "Content-Length": String(rest.length), + "Content-Range": `bytes ${start}-${body.length - 1}/${body.length}`, + }); + res.end(rest); + return; + } + res.writeHead(200, { "Content-Length": String(body.length) }); + res.end(body); + }); + await new Promise((resolve) => server.listen(0, resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterEach(async () => { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + rmSync(tempDir, { recursive: true, force: true }); +}); + +describe("downloadFile", () => { + test("downloads, verifies, and renames the partial away", async () => { + const dest = join(tempDir, "payload.bin"); + const progress: number[] = []; + await downloadFile({ + url: `${baseUrl}/payload.bin`, + dest, + sha256: PAYLOAD_SHA, + endpoint: "test.download", + onProgress: (p) => progress.push(p.received), + }); + expect(readFileSync(dest).equals(PAYLOAD)).toBe(true); + expect(existsSync(`${dest}.partial`)).toBe(false); + expect(progress.at(-1)).toBe(PAYLOAD.length); + }); + + test("is a no-op when the destination already verifies", async () => { + const dest = join(tempDir, "payload.bin"); + writeFileSync(dest, PAYLOAD); + await downloadFile({ + url: `${baseUrl}/payload.bin`, + dest, + sha256: PAYLOAD_SHA, + endpoint: "test.download", + }); + // No request reached the server at all. + expect(rangeLog).toEqual([]); + }); + + test("throws on hash mismatch and removes the partial", async () => { + const dest = join(tempDir, "payload.bin"); + await expect( + downloadFile({ + url: `${baseUrl}/payload.bin`, + dest, + sha256: "0".repeat(64), + endpoint: "test.download", + }), + ).rejects.toThrow(/SHA-256 mismatch/); + expect(existsSync(dest)).toBe(false); + expect(existsSync(`${dest}.partial`)).toBe(false); + }); + + test("resumes from a .partial via Range and still verifies", async () => { + const dest = join(tempDir, "payload.bin"); + const HEAD = 100_000; + writeFileSync(`${dest}.partial`, PAYLOAD.subarray(0, HEAD)); + await downloadFile({ + url: `${baseUrl}/payload.bin`, + dest, + sha256: PAYLOAD_SHA, + endpoint: "test.download", + }); + expect(rangeLog).toEqual([`bytes=${HEAD}-`]); + expect(readFileSync(dest).equals(PAYLOAD)).toBe(true); + }); + + test("restarts cleanly when the server ignores Range", async () => { + ignoreRange = true; + const dest = join(tempDir, "payload.bin"); + writeFileSync(`${dest}.partial`, Buffer.from("stale-garbage")); + await downloadFile({ + url: `${baseUrl}/payload.bin`, + dest, + sha256: PAYLOAD_SHA, + endpoint: "test.download", + }); + expect(readFileSync(dest).equals(PAYLOAD)).toBe(true); + }); + + test("scraps an oversized stale partial (416) and refetches", async () => { + const dest = join(tempDir, "payload.bin"); + writeFileSync(`${dest}.partial`, Buffer.alloc(PAYLOAD.length + 10, 1)); + await downloadFile({ + url: `${baseUrl}/payload.bin`, + dest, + sha256: PAYLOAD_SHA, + endpoint: "test.download", + }); + expect(readFileSync(dest).equals(PAYLOAD)).toBe(true); + }); + + test("fails with the HTTP status on a missing object", async () => { + await expect( + downloadFile({ + url: `${baseUrl}/nope.bin`, + dest: join(tempDir, "nope.bin"), + endpoint: "test.download", + }), + ).rejects.toThrow(/download failed \(404\)/); + }); +}); + +// 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. +describe("runSkillOffice", () => { + const hostPlatform = process.platform === "darwin" ? "macos" : "ubuntu"; + const bundleName = `minimax-docx-${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); + }); + + test("--download-only stages both files and never spawns", async () => { + const dir = join(tempDir, "office"); + const spawns: string[] = []; + const code = await runSkillOffice( + ["--download-only", "--dir", dir], + baseUrl, + async (command) => { + spawns.push(command); + return 0; + }, + ); + expect(code).toBe(0); + expect(spawns).toEqual([]); + expect(readFileSync(join(dir, bundleName)).equals(BUNDLE)).toBe(true); + expect(readFileSync(join(dir, scriptName)).equals(SCRIPT)).toBe(true); + }); + + test("runs the installer via bash with translated flags", async () => { + const dir = join(tempDir, "office"); + let spawned: { command: string; args: string[]; cwd: string } | null = null; + const code = await runSkillOffice( + ["--dir", dir, "--minimal", "--skip-verify"], + baseUrl, + async (command, args, cwd) => { + spawned = { command, args, cwd }; + return 0; + }, + ); + expect(code).toBe(0); + expect(spawned).toEqual({ + command: "bash", + args: [join(dir, scriptName), "--minimal", "--skip-verify"], + cwd: dir, + }); + }); + + 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); + }); + + 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-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( + ["--platform", "windows", "--dir", dir], + baseUrl, + async (command) => { + spawns.push(command); + return 0; + }, + ); + expect(code).toBe(0); + expect(spawns).toEqual([]); + expect(existsSync(join(dir, "minimax-docx-windows.zip"))).toBe(true); + }); + + test("fails cleanly when the manifest is missing", async () => { + objects.delete("/manifest.json"); + const code = await runSkillOffice( + ["--dir", join(tempDir, "office")], + baseUrl, + ); + expect(code).toBe(1); + }); +}); + +describe("installerArgs", () => { + const base = { downloadOnly: false, minimal: true, skipVerify: true }; + + test("bash platforms get GNU-style flags", () => { + expect(installerArgs(base, "ubuntu")).toEqual([ + "--minimal", + "--skip-verify", + ]); + }); + + test("windows gets PowerShell switches", () => { + expect(installerArgs(base, "windows")).toEqual(["-Minimal", "-SkipVerify"]); + }); + + test("no flags when none requested", () => { + expect( + installerArgs( + { downloadOnly: false, minimal: false, skipVerify: false }, + "windows", + ), + ).toEqual([]); + }); +}); diff --git a/tests/lib/office.test.ts b/tests/lib/office.test.ts new file mode 100644 index 0000000..ab6b971 --- /dev/null +++ b/tests/lib/office.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from "vitest"; +import { + detectPlatform, + type OfficeManifest, + parseOfficeArgs, + parseOfficeManifest, +} from "@/lib/office.js"; + +describe("detectPlatform", () => { + test("maps node platforms to bundle platforms", () => { + expect(detectPlatform("linux")).toBe("ubuntu"); + expect(detectPlatform("darwin")).toBe("macos"); + expect(detectPlatform("win32")).toBe("windows"); + }); + + test("returns null for unsupported platforms", () => { + expect(detectPlatform("aix")).toBeNull(); + expect(detectPlatform("freebsd")).toBeNull(); + }); +}); + +describe("parseOfficeArgs", () => { + test("defaults", () => { + expect(parseOfficeArgs([])).toEqual({ + downloadOnly: false, + minimal: false, + skipVerify: false, + }); + }); + + test("all flags, space-separated values", () => { + const parsed = parseOfficeArgs([ + "--platform", + "windows", + "--dir", + "/tmp/x", + "--download-only", + "--minimal", + "--skip-verify", + ]); + expect(parsed).toEqual({ + platform: "windows", + dir: "/tmp/x", + downloadOnly: true, + minimal: true, + skipVerify: true, + }); + }); + + test("--platform=value form", () => { + expect(parseOfficeArgs(["--platform=macos"]).platform).toBe("macos"); + }); + + test("rejects an unknown platform", () => { + expect(parseOfficeArgs(["--platform", "solaris"]).error).toMatch( + /--platform must be one of/, + ); + }); + + test("rejects --dir without a value", () => { + expect(parseOfficeArgs(["--dir"]).error).toMatch(/--dir requires a path/); + }); + + test("rejects unknown options", () => { + expect(parseOfficeArgs(["--wat"]).error).toMatch(/unknown option: --wat/); + }); +}); + +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/); + }); + + test("rejects non-objects", () => { + expect(() => parseOfficeManifest(null)).toThrow(/not an object/); + expect(() => parseOfficeManifest("[]")).toThrow(/manifest.json shape/); + }); +}); From 580b378d31a91613b9dd51c41f2f0171926a181f Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Mon, 3 Aug 2026 17:33:51 +0700 Subject: [PATCH 2/3] bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 29c3fb7..0ec1966 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codev-ai", - "version": "0.5.4", + "version": "0.5.5", "description": "CoDev — AI Coding Agent Hub. Install, configure, and manage multiple AI coding agents.", "keywords": [ "ai", From 8f9cfd45f1cdb2c30c1c295eeddabc0a5da3f7d1 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Mon, 3 Aug 2026 17:36:36 +0700 Subject: [PATCH 3/3] feat(skill): warn that the office bundle download might take a while Print the platform bundle's actual size from the manifest ahead of the download, plus a note that an interrupted run resumes, so a user on a slow link knows a long silence-then-progress is expected. Co-Authored-By: Claude Fable 5 --- src/lib/office.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/lib/office.ts b/src/lib/office.ts index a49a19d..0b97759 100644 --- a/src/lib/office.ts +++ b/src/lib/office.ts @@ -251,12 +251,18 @@ export async function runSkillOffice( 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.`, + ); logInfo("office bundle download starting", { action: "office.install", extra: { platform, version: manifest.version, dir, downloadOnly }, }); - const { bundle, script } = manifest.platforms[platform]; for (const name of [script, bundle]) { const meta = manifest.files[name]; if (!meta) {