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.4",
"version": "0.5.5",
"description": "CoDev — AI Coding Agent Hub. Install, configure, and manage multiple AI coding agents.",
"keywords": [
"ai",
Expand Down
12 changes: 9 additions & 3 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -387,12 +388,17 @@ switch (command) {
// `skill <subcommand>`: 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) {
Expand Down Expand Up @@ -465,8 +471,8 @@ switch (command) {
}
console.error(
sub === undefined
? "Usage: codevhub skill <search|pull|push> ..."
: `Unknown skill subcommand: ${sub}. Valid: search, pull, push.`,
? "Usage: codevhub skill <search|pull|push|office> ..."
: `Unknown skill subcommand: ${sub}. Valid: search, pull, push, office.`,
);
process.exit(1);
break;
Expand Down
5 changes: 5 additions & 0 deletions src/lib/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
140 changes: 140 additions & 0 deletions src/lib/download.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<void> {
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);
}
6 changes: 6 additions & 0 deletions src/lib/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,11 @@ 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
(--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,
--minimal / --skip-verify passed through to the installer)
`);
}
Loading
Loading