diff --git a/CHANGELOG.md b/CHANGELOG.md index 9025d81e9c..3695c4828a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,20 @@ file is the human-readable summary of what changed and why. longer needs any of them. ([#306](https://github.com/quickbeard/codev-code/pull/306)) +### Skills + +- **Manage SkillHub skills without the hub CLI** — `codev skill` ports the + whole `codevhub skill` workflow into the standalone CLI: `search` queries + the public SkillHub registry, `pull` installs a skill into the + `.claude/skills` + `.agents/skills` folders (current directory or global, + one real copy plus links, with a picker for which agents to serve — CoDev + Code always included), `push` publishes a skill directory or `.zip` with a + preview, confirmation, and an inline SSO sign-in, and `office` downloads + the CoDev Office offline bundle and runs its installer. Sessions and the + office download folder are shared with `codevhub`, so a hub login or an + already-downloaded bundle is reused as-is. + ([#330](https://github.com/quickbeard/codev-code/pull/330)) + ### Install - **Installing downloads about half as much** — the published platform packages diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 7a0622df16..0837e23353 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -91,7 +91,7 @@ from the squash message first, or use a plain merge. | File | Change | Why | On conflict | | --- | --- | --- | --- | | `packages/core/src/global.ts`, `packages/core/test/global.test.ts`, `packages/opencode/test/cli/mcp-add.test.ts` | `const app = "opencode"` → `"codev"`; test expectations `tmpdir()/opencode` → `tmpdir()/codev` and `.config/opencode/` → `.config/codev/` | Config/data/cache/state dirs under `~/.config/codev` etc.; upstream tests assert the literal dir name. | Keep ours (the one constant + expectations); take upstream's surrounding changes. | -| `packages/opencode/src/index.ts`, `packages/opencode/test/cli/error.test.ts`, `packages/opencode/test/cli/help/help-snapshots.test.ts`, `packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap` | `scriptName("opencode")` → `"codev"`; `show()` prefix check `"opencode "` → `"codev "`; registered the fork-only `ConfigureCommand` (cli/cmd/configure.ts, new file), `CodegraphCommand` (cli/cmd/codegraph.ts, new file), and `UploadCommand` (cli/cmd/upload.ts, new file — `codev upload`, the standalone `codevhub upload`: exports sessions and ships them to the analysis backend, `--force` to re-upload unchanged, `--logs` to print the last run's diagnostic trail) and added `configure` + `codegraph` + `upload` to the help-snapshot TOP_LEVEL list; test expectation `` `opencode models` `` → `` `codev models` ``; help snapshots regenerated so usage lines read `codev …` | Help/usage text shows the real user-facing command; `codev configure` is the standalone gateway-setup entry point, and `codev codegraph` is the standalone CodeGraph MCP wiring for installs not managed by CoDev Hub. The `show()` check must always match the scriptName; upstream's error-format test and help snapshots assert the literal command. | Keep our strings, command registration + expectation; take upstream's surrounding changes. Regenerate the snapshot file with `bun test test/cli/help --update-snapshots` instead of hand-merging. | +| `packages/opencode/src/index.ts`, `packages/opencode/test/cli/error.test.ts`, `packages/opencode/test/cli/help/help-snapshots.test.ts`, `packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap` | `scriptName("opencode")` → `"codev"`; `show()` prefix check `"opencode "` → `"codev "`; registered the fork-only `ConfigureCommand` (cli/cmd/configure.ts, new file), `CodegraphCommand` (cli/cmd/codegraph.ts, new file), `UploadCommand` (cli/cmd/upload.ts, new file — `codev upload`, the standalone `codevhub upload`: exports sessions and ships them to the analysis backend, `--force` to re-upload unchanged, `--logs` to print the last run's diagnostic trail; its inline SSO prompt now lives in cli/cmd/sso-prompt.ts, new file, shared with `codev skill push`), and `SkillHubCommand` (cli/cmd/skill.ts, new file — `codev skill search|pull|push|office`, the standalone `codevhub skill`: searches/installs/publishes SkillHub skills and fetches the CoDev Office offline bundle, backed by the fork-only `packages/codev-skillhub` workspace package with SSO via `@codev/gateway`) and added `configure` + `codegraph` + `upload` + `skill` to the help-snapshot TOP_LEVEL list (and the four `skill` subcommands to SUBCOMMANDS); test expectation `` `opencode models` `` → `` `codev models` ``; help snapshots regenerated so usage lines read `codev …` | Help/usage text shows the real user-facing command; `codev configure` is the standalone gateway-setup entry point, and `codev codegraph` is the standalone CodeGraph MCP wiring for installs not managed by CoDev Hub. The `show()` check must always match the scriptName; upstream's error-format test and help snapshots assert the literal command. | Keep our strings, command registration + expectation; take upstream's surrounding changes. Regenerate the snapshot file with `bun test test/cli/help --update-snapshots` instead of hand-merging. | | `package.json` (root) | `name` `opencode` → `codev-code`; `repository.url` → `github.com/quickbeard/codev-code`; added `release` script delegating to packages/opencode; added `bump` script pointing at the fork-only `script/bump-version.ts`; removed the `sso`, `dev:stats`, and `random` scripts; added the `lint-staged` devDependency and a `lint-staged` config block (`"*": "prettier --write --ignore-unknown"`) driving the fork-only `.husky/pre-commit` | The root manifest carries the fork's name (nothing reads it — root is private and unpublished; `packages/script` reads only `packageManager` — so this is cosmetic and also removes the name collision with the `packages/opencode` workspace member, whose name MUST stay `opencode`: build.ts derives bun compile targets and dist dir names from it). `bun run release` and `bun run bump` work from the repo root. `sso` logs into upstream's AWS org (`--sso-session=opencode`) and `dev:stats` runs inside an sst shell against upstream's production stage — neither works without anomalyco credentials; `random` is inert cruft (`echo 'Random script'`). `lint-staged` keeps staged files at Prettier's fixed point on commit, so the formatting drift PR #261 had to clean up cannot silently reaccumulate; `--ignore-unknown` skips files Prettier has no parser for (`bun.lock`, `.husky/*`, binaries) and `.prettierignore` still exempts `DIVERGENCES.md` and the docs merge base. | Keep our name + release/bump lines and the removals (drop any new upstream-infra-only scripts too); take upstream's other fields. | | `packages/opencode/package.json` | bin key `opencode` → `codev` (shim path `./bin/opencode` unchanged); added `release` script; added dependencies `"@codev/brand": "workspace:*"`, `"@codev/gateway": "workspace:*"` (the latter for the gateway-key migration's shared-auth-file read, see the config.ts migration row), and `"@codev/upload": "workspace:*"` (the session uploader — see the session-upload wiring row) | Installed command is `codev` — matches the CLI's `scriptName` and help text (the hub's command is `codevhub`; it relinquished the `codev` bin in codev-ai ≥ 0.4.0). `bun run release` publishes the version in this file (upstream-synced; script/release.ts is fork-only). | Keep our bin key, release line, and dependency lines; take upstream's other fields including `version`. | | Session-upload wiring: `packages/opencode/src/cli/cmd/tui.ts`, `packages/opencode/src/cli/cmd/run.ts`, `packages/opencode/src/cli/cmd/debug/index.ts` | tui.ts + run.ts: `spawnUploadDaemon()` (from the fork-only `@codev/upload` package) fire-and-forgotten at handler start — spawns a detached, unref'd, stdio-ignored `codev debug upload` child. debug/index.ts: registered the fork-only `UploadCommand` (debug/upload.ts, new file), the entry that child runs. | Standalone parity with the hub's launch-time upload daemon: every agent launch ships prior codev sessions to the analysis backend in the background. All gating (opt-out env `CODEV_DISABLE_AUTO_UPLOAD`, signed-in check, the hub-shared lockfile) lives inside `@codev/upload` — the wiring is one guarded call per entry point that can never block or fail the launch. | Keep the import + `spawnUploadDaemon()` line in both handlers and the debug registration; take upstream's surrounding changes. | diff --git a/bun.lock b/bun.lock index 31dccc4a9b..3804452cd8 100644 --- a/bun.lock +++ b/bun.lock @@ -178,6 +178,19 @@ "@types/bun": "catalog:", }, }, + "packages/codev-skillhub": { + "name": "@codev/skillhub", + "version": "0.0.1", + "dependencies": { + "@codev/gateway": "workspace:*", + "adm-zip": "0.5.16", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/adm-zip": "0.5.7", + "@types/bun": "catalog:", + }, + }, "packages/codev-upload": { "name": "@codev/upload", "version": "0.0.1", @@ -624,9 +637,11 @@ "@ai-sdk/vercel": "2.0.39", "@ai-sdk/xai": "3.0.102", "@aws-sdk/credential-providers": "3.1057.0", + "@clack/core": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1", "@codev/brand": "workspace:*", "@codev/gateway": "workspace:*", + "@codev/skillhub": "workspace:*", "@codev/upload": "workspace:*", "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", @@ -1457,6 +1472,8 @@ "@codev/sandbox": ["@codev/sandbox@workspace:packages/codev-sandbox"], + "@codev/skillhub": ["@codev/skillhub@workspace:packages/codev-skillhub"], + "@codev/upload": ["@codev/upload@workspace:packages/codev-upload"], "@corvu/dialog": ["@corvu/dialog@0.2.4", "", { "dependencies": { "@corvu/utils": "~0.4.2", "solid-dismissible": "~0.1.1", "solid-focus-trap": "~0.1.8", "solid-presence": "~0.2.0", "solid-prevent-scroll": "~0.1.10" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-n54vJq+fOy8GVrnYBdJpD6JXNuyx7LOeMrRxwzAvZnYGpW8+AA12tnb/P/2emJj/HjOO5otheGKb0breshdFlA=="], @@ -2755,6 +2772,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@types/adm-zip": ["@types/adm-zip@0.5.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -2967,6 +2986,8 @@ "acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="], + "adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], diff --git a/packages/codev-gateway/src/auth-file.ts b/packages/codev-gateway/src/auth-file.ts index 11fa17b904..9dc97f84dc 100644 --- a/packages/codev-gateway/src/auth-file.ts +++ b/packages/codev-gateway/src/auth-file.ts @@ -172,6 +172,12 @@ export function loadApiKey(): ApiKeyCreds | null { } } +// Hub-only credential (written by `codevhub login --admin`); read here so +// skill commands can ride an existing admin session, never written. +export function loadSkillhubCookie(): string | null { + return readAuthFile()?.skillhub_cookie ?? null +} + export function saveCodevConfig(config: CodevConfig): void { writeAuthFile({ ...(readAuthFile() ?? {}), diff --git a/packages/codev-gateway/src/index.ts b/packages/codev-gateway/src/index.ts index d20ac89195..fb5c9dd762 100644 --- a/packages/codev-gateway/src/index.ts +++ b/packages/codev-gateway/src/index.ts @@ -4,6 +4,7 @@ export { loadAuth, loadCodevConfig, loadGatewayUrl, + loadSkillhubCookie, saveApiKey, saveCodevConfig, type ApiKeyCreds, diff --git a/packages/codev-gateway/test/auth-file.test.ts b/packages/codev-gateway/test/auth-file.test.ts index 9ddb1e777e..8b29117b64 100644 --- a/packages/codev-gateway/test/auth-file.test.ts +++ b/packages/codev-gateway/test/auth-file.test.ts @@ -62,6 +62,19 @@ describe("auth-file", () => { expect(raw.api_key).toBe("sk-2") }) + test("loadSkillhubCookie reads the hub cookie and survives our own saves", async () => { + const { loadSkillhubCookie, saveApiKey, authFilePath } = await import("../src/auth-file") + const { mkdirSync, writeFileSync } = await import("node:fs") + expect(loadSkillhubCookie()).toBeNull() + + mkdirSync(join(tempDir, ".codev"), { recursive: true }) + writeFileSync(authFilePath(), JSON.stringify({ skillhub_cookie: "skill-hub-session=abc" })) + expect(loadSkillhubCookie()).toBe("skill-hub-session=abc") + + saveApiKey({ apiKey: "sk-1" }) + expect(loadSkillhubCookie()).toBe("skill-hub-session=abc") + }) + test.skipIf(process.platform === "win32")("writes the file 0600", async () => { const { saveApiKey, authFilePath } = await import("../src/auth-file") saveApiKey({ apiKey: "sk-3" }) diff --git a/packages/codev-skillhub/package.json b/packages/codev-skillhub/package.json new file mode 100644 index 0000000000..011b126293 --- /dev/null +++ b/packages/codev-skillhub/package.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/package", + "name": "@codev/skillhub", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "@codev/gateway": "workspace:*", + "adm-zip": "0.5.16" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/adm-zip": "0.5.7", + "@types/bun": "catalog:" + }, + "exports": { + ".": "./src/index.ts" + } +} diff --git a/packages/codev-skillhub/src/agents.ts b/packages/codev-skillhub/src/agents.ts new file mode 100644 index 0000000000..f7b68cbdf9 --- /dev/null +++ b/packages/codev-skillhub/src/agents.ts @@ -0,0 +1,48 @@ +import { existsSync } from "node:fs" +import { delimiter, join } from "node:path" +import { home } from "./paths" +import { SKILL_AGENTS, type SkillAgent } from "./skill-dirs" + +// The hub derives its default agent set from its own configure/backup markers +// (`detectCodevTools`), which only exist on machines the hub has configured. +// This standalone CLI instead treats "the agent is present on this machine" as +// the signal: its CLI resolves on PATH, or its config directory exists. +const AGENT_BINARIES: Record, string> = { + claude: "claude", + codex: "codex", + opencode: "opencode", +} + +function configDirs(agent: Exclude): string[] { + if (agent === "claude") return [join(home(), ".claude")] + if (agent === "codex") return [join(home(), ".codex")] + return [join(home(), ".config", "opencode")] +} + +// Object indirection so tests can stub detection without module mocks. +export const detectApi = { + which(binary: string): boolean { + const pathValue = process.env.PATH ?? "" + // Windows resolves executables through PATHEXT (claude.cmd, codex.exe, …). + const extensions = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") : [""] + return pathValue + .split(delimiter) + .filter((dir) => dir !== "") + .some((dir) => extensions.some((ext) => existsSync(join(dir, binary + ext.toLowerCase())))) + }, + dirExists(path: string): boolean { + return existsSync(path) + }, +} + +// The set installed for when the user names none: CoDev Code always (the +// flagship agent is never opted out of), plus every other agent present on +// this machine. Used as both the prompt's pre-check and the non-interactive +// default, so CI never has to spell out --agent. +export function defaultAgents(): SkillAgent[] { + return SKILL_AGENTS.filter((agent) => { + // Literal comparison (not ALWAYS_AGENT) so TypeScript narrows the union. + if (agent === "codev") return true + return detectApi.which(AGENT_BINARIES[agent]) || configDirs(agent).some((dir) => detectApi.dirExists(dir)) + }) +} diff --git a/packages/codev-skillhub/src/archive.ts b/packages/codev-skillhub/src/archive.ts new file mode 100644 index 0000000000..e7f759d213 --- /dev/null +++ b/packages/codev-skillhub/src/archive.ts @@ -0,0 +1,217 @@ +import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises" +import { dirname, isAbsolute, join, relative, resolve } from "node:path" +import AdmZip from "adm-zip" + +// Client-side caps for building a publish archive. The server rejects uploads +// over 100 MB anyway; enforcing here fails fast (no pointless upload) and guards +// against OOM when a user points `skill push` at a huge or mistargeted directory. +const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024 +const MAX_ARCHIVE_ENTRIES = 15_000 + +// Directories never bundled into a skill archive: VCS metadata, dependency +// trees, and local test/log output. Excluded wholesale (their contents are not +// walked). +const IGNORED_DIRS = new Set([".git", ".svn", ".hg", "node_modules", "tests", "logs"]) + +// Files never bundled: OS junk plus common secret-bearing files. Publishing a +// skill dir must not silently ship a `.env`, private key, or npm token to a hub +// entry that can later go PUBLIC. +function isIgnoredFile(name: string): boolean { + if (name === ".DS_Store" || name === "Thumbs.db" || name === ".npmrc") { + return true + } + if (name === ".env" || name.startsWith(".env.")) return true + if (/\.(pem|pfx|p12|key)$/i.test(name)) return true + if (/^id_(rsa|dsa|ecdsa|ed25519)$/.test(name)) return true + return false +} + +// Extract a ZIP buffer into targetDir with zip-slip protection: any entry that +// is absolute or resolves outside the target root is rejected rather than +// written. Directory entries are created; file entries are written verbatim. +// Entry-count and cumulative-size caps bound a hostile archive (e.g. one pulled +// from the hub) so it can't fill the disk. Note: adm-zip's getData() inflates a +// single entry fully into memory, so a crafted per-entry decompression bomb is +// still bounded only by the compressed input size (see downloadSkill's cap) — +// the cumulative check below is a backstop against many/large honest entries. +export async function extractZip(buffer: Buffer, targetDir: string): Promise { + await mkdir(targetDir, { recursive: true }) + const zip = new AdmZip(buffer) + const entries = zip.getEntries() + if (entries.length > MAX_ARCHIVE_ENTRIES) { + throw new Error(`Archive has too many files (limit ${MAX_ARCHIVE_ENTRIES}).`) + } + const root = resolve(targetDir) + let written = 0 + for (const entry of entries) { + const entryName = entry.entryName + if (isAbsolute(entryName)) { + throw new Error(`unsafe zip entry: ${entryName}`) + } + const dest = resolve(root, entryName) + const rel = relative(root, dest) + if (rel.startsWith("..") || isAbsolute(rel)) { + throw new Error(`unsafe zip entry: ${entryName}`) + } + if (entry.isDirectory) { + await mkdir(dest, { recursive: true }) + continue + } + const data = entry.getData() + written += data.length + if (written > MAX_ARCHIVE_BYTES) { + throw new Error(`Archive exceeds the ${MAX_ARCHIVE_BYTES / 1024 / 1024} MB limit.`) + } + await mkdir(dirname(dest), { recursive: true }) + await writeFile(dest, data) + } +} + +// Strip the top-level directory from zip entries when every entry shares the +// same single prefix. SkillHub server-side publishes ZIPs wrapped in a root +// folder (e.g. `pg-tuner/SKILL.md`); we want the contents extracted directly +// into `//` without that extra nesting layer. Returns the original +// buffer unchanged (stripped: null) when there is no single shared root. +export function detectAndStripRoot(zipBuffer: Buffer): { + buffer: Buffer + stripped: string | null +} { + const zip = new AdmZip(zipBuffer) + const entries = zip.getEntries() + if (entries.length === 0) return { buffer: zipBuffer, stripped: null } + + const firstSegments = new Set() + for (const e of entries) { + const seg = e.entryName.split("/")[0] + if (seg) firstSegments.add(seg) + } + if (firstSegments.size !== 1) return { buffer: zipBuffer, stripped: null } + + const root = [...firstSegments][0] as string + // Ensure at least one entry lives below the root (not just `root` as a lone + // file) — otherwise there's nothing to un-nest. + const hasNested = entries.some((e) => e.entryName.length > root.length + 1 && e.entryName.startsWith(`${root}/`)) + if (!hasNested) return { buffer: zipBuffer, stripped: null } + + const stripped = new AdmZip() + for (const e of entries) { + if (e.entryName === `${root}/`) continue + if (!e.entryName.startsWith(`${root}/`)) continue + const newName = e.entryName.slice(root.length + 1) + if (!newName) continue + if (e.isDirectory) { + stripped.addFile(newName, Buffer.alloc(0)) + } else { + stripped.addFile(newName, e.getData()) + } + } + return { buffer: stripped.toBuffer(), stripped: root } +} + +export interface ZipDirResult { + buffer: Buffer + // Included entries, relative to the zipped directory (e.g. "SKILL.md", + // "scripts/run.sh") — the top-level `rootName` prefix is not shown here. + files: string[] + // Uncompressed total of the included files. + totalBytes: number + // Names skipped by the ignore list (dirs shown with a trailing slash), so the + // caller can surface exactly what was left out. + skipped: string[] +} + +// Recursively zip a directory's contents into an archive wrapped in a single +// top-level folder named `rootName` — the layout the SkillHub validator expects. +// Junk/secret files and VCS/dependency dirs are dropped (see IGNORED_DIRS / +// isIgnoredFile), symlinks are ignored (a dirent that is neither a file nor a +// directory), and the size/entry caps are enforced as it walks so an oversized +// tree fails before it is read into memory. +export async function zipDirectory(dir: string, rootName: string): Promise { + const zip = new AdmZip() + const files: string[] = [] + const skipped: string[] = [] + const state = { totalBytes: 0 } + + const addDir = async (absDir: string, rel: string): Promise => { + const items = await readdir(absDir, { withFileTypes: true }) + // Add the directory entry first so empty dirs survive the round-trip. + zip.addFile(`${rootName}/${rel ? `${rel}/` : ""}`, Buffer.alloc(0)) + for (const item of items) { + const relPath = rel ? `${rel}/${item.name}` : item.name + const abs = join(absDir, item.name) + if (item.isDirectory()) { + if (IGNORED_DIRS.has(item.name)) { + skipped.push(`${relPath}/`) + continue + } + await addDir(abs, relPath) + continue + } + // Only real files are archived; symlinks (isFile()/isDirectory() both + // false) are silently and deliberately dropped. + if (!item.isFile()) continue + if (isIgnoredFile(item.name)) { + skipped.push(relPath) + continue + } + const st = await stat(abs) + if (state.totalBytes + st.size > MAX_ARCHIVE_BYTES) { + throw new Error(`Skill exceeds the ${MAX_ARCHIVE_BYTES / 1024 / 1024} MB upload limit.`) + } + if (files.length + 1 > MAX_ARCHIVE_ENTRIES) { + throw new Error(`Skill has too many files (limit ${MAX_ARCHIVE_ENTRIES}).`) + } + const data = await readFile(abs) + zip.addFile(`${rootName}/${relPath}`, data) + files.push(relPath) + state.totalBytes += data.length + } + } + + await addDir(dir, "") + return { + buffer: zip.toBuffer(), + files, + totalBytes: state.totalBytes, + skipped, + } +} + +// Enumerate a ZIP's file entries and uncompressed total, enforcing the archive +// caps. Used to preview + size-check a user-supplied `.zip` before uploading it +// (`skill push`) and to vet a hub download before extracting it (`skill pull`). +// Checks the compressed buffer, the declared uncompressed total, and the entry +// count — all cheap (central-directory reads, no inflation). Throws if any cap +// is exceeded. +export function inspectZip(zipBuffer: Buffer): { + files: string[] + totalBytes: number +} { + if (zipBuffer.length > MAX_ARCHIVE_BYTES) { + throw new Error(`Archive exceeds the ${MAX_ARCHIVE_BYTES / 1024 / 1024} MB limit.`) + } + const zip = new AdmZip(zipBuffer) + const files: string[] = [] + let totalBytes = 0 + for (const e of zip.getEntries()) { + if (e.isDirectory) continue + files.push(e.entryName) + totalBytes += e.header.size + } + if (totalBytes > MAX_ARCHIVE_BYTES) { + throw new Error(`Archive exceeds the ${MAX_ARCHIVE_BYTES / 1024 / 1024} MB limit.`) + } + if (files.length > MAX_ARCHIVE_ENTRIES) { + throw new Error(`Archive has too many files (limit ${MAX_ARCHIVE_ENTRIES}).`) + } + return { files, totalBytes } +} + +export async function pathExists(p: string): Promise { + try { + await stat(p) + return true + } catch { + return false + } +} diff --git a/packages/codev-skillhub/src/const.ts b/packages/codev-skillhub/src/const.ts new file mode 100644 index 0000000000..b7c33165b2 --- /dev/null +++ b/packages/codev-skillhub/src/const.ts @@ -0,0 +1,12 @@ +// Mirrors the hub's src/lib/const.ts (skill-relevant slice). The base host is +// base64-obfuscated to keep it out of trivial greps, matching the hub and the +// landing-page convention for netmind hosts. +const BASE_URL = atob("aHR0cHM6Ly9uZXRtaW5kLnZpZXR0ZWwudm4=") + +export const SKILLHUB_URL = `${BASE_URL}/netmindhub` + +// The codev-storage MinIO backend, reached through the shared reverse proxy's +// /codev-storage route. The codev-office bucket holds 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` diff --git a/packages/codev-skillhub/src/download.ts b/packages/codev-skillhub/src/download.ts new file mode 100644 index 0000000000..925a62dbd1 --- /dev/null +++ b/packages/codev-skillhub/src/download.ts @@ -0,0 +1,227 @@ +import { createHash } from "node:crypto" +import { + createReadStream, + createWriteStream, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs" +import { dirname } from "node:path" +import { Readable, Transform } from "node:stream" +import { pipeline } from "node:stream/promises" +import { httpApi } from "./http" + +// Streaming file download for GB-scale artifacts (the office bundles). Buffering +// whole responses in memory 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. +// +// Staleness without a manifest: the server's ETag is persisted next to the +// file (`${dest}.etag`). A finished file is revalidated with If-None-Match +// (304 = still the published object, anything else = republished, re-download) +// and a resume is guarded with If-Range so stale partial bytes are never +// spliced onto a republished object. The truth lives on the object itself, so +// nothing on the publish side can drift. + +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 naming the request in error paths, 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") +} + +function readEtag(etagFile: string): string | null { + try { + const etag = readFileSync(etagFile, "utf8").trim() + return etag.length > 0 ? etag : null + } catch { + return null + } +} + +export async function downloadFile(opts: DownloadOptions): Promise { + const { url, dest, endpoint, onProgress, signal } = opts + const partial = `${dest}.partial` + const etagFile = `${dest}.etag` + + // A finished file short-circuits the download. With an expected sha256 it + // must verify. Without one, a stored ETag lets us ask the server whether + // the published object changed; no ETag on record (a manually copied file, + // or a pre-ETag download) means the local file is trusted as-is. + if (existsSync(dest)) { + if (opts.sha256) { + if ((await fileSha256(dest)) === opts.sha256) return + rmSync(dest) + } else { + const etag = readEtag(etagFile) + if (!etag) return + let probe: Response + try { + probe = await httpApi.fetch(endpoint, url, { + headers: { "If-None-Match": etag }, + signal, + }) + } catch { + return // server unreachable — keep what we have + } + if (probe.status === 304 || !probe.ok) { + // Still the published object (304), or a server hiccup — either + // way the local file is the best copy available. + await probe.body?.cancel() + return + } + // Republished: drop the response (the fresh download below streams + // its own) and every local trace of the old object. + await probe.body?.cancel() + rmSync(dest, { force: true }) + rmSync(partial, { force: true }) + rmSync(etagFile, { force: true }) + } + } + + 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)) { + // Without an expected sha256 there is nothing downstream to catch a bad + // splice, so a resume is only trustworthy when the partial's ETag is on + // record to send as If-Range. A bare Range answered with 206 would + // append the republished object's bytes onto stale ones — scrap such a + // partial and download from scratch instead. + if (!opts.sha256 && !readEtag(etagFile)) { + rmSync(partial, { force: true }) + } else { + offset = statSync(partial).size + const h = hash + await pipeline(createReadStream(partial), async (chunks) => { + for await (const chunk of chunks) h.update(chunk as Buffer) + }) + } + } + + // If-Range makes a resume safe across republishes: when the entity no + // longer matches the partial's ETag, the server ignores Range and answers + // 200, and the start-over branch below discards the stale partial bytes. + const headers: Record = {} + if (offset > 0) { + headers.Range = `bytes=${offset}-` + const etag = readEtag(etagFile) + if (etag) headers["If-Range"] = etag + } + const res = await httpApi.fetch(endpoint, url, { + headers: offset > 0 ? headers : undefined, + signal, + }) + + let append = false + if (res.status === 206 && offset > 0) { + // Belt and braces on top of If-Range: a middlebox that mishandles it + // can still answer 206 for a republished entity (this corrupted real + // bundle downloads — "bad zipfile offset" from spliced halves). When + // the 206 carries an ETag that differs from the partial's, the range + // is against a different object: scrap the partial and refetch clean. + const storedEtag = readEtag(etagFile) + const gotEtag = res.headers.get("etag") + if (storedEtag && gotEtag && gotEtag !== storedEtag) { + await res.body?.cancel() + rmSync(partial, { force: true }) + rmSync(etagFile, { force: true }) + return downloadFile(opts) + } + 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}`) + + // Persist the entity tag of what we are about to write — before streaming, + // so an interrupted transfer leaves a partial+ETag pair the next resume can + // validate with If-Range. A server that sends no ETag clears the record. + const resEtag = res.headers.get("etag") + if (resEtag) writeFileSync(etagFile, resEtag) + else rmSync(etagFile, { force: true }) + + // A missing header must fall through to opts.size. `Number(null)` is 0, which + // is finite — reading it straight would make the fallback unreachable and + // report a total of `offset` (0 on a fresh download) for every chunked + // response. + const header = res.headers.get("content-length") + const contentLength = header === null ? Number.NaN : Number(header) + 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 unknown 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/packages/codev-skillhub/src/exec.ts b/packages/codev-skillhub/src/exec.ts new file mode 100644 index 0000000000..64f85adbe5 --- /dev/null +++ b/packages/codev-skillhub/src/exec.ts @@ -0,0 +1,33 @@ +import { execFile } from "node:child_process" + +export interface ExecResult { + stdout: string + stderr: string + error: NodeJS.ErrnoException | null +} + +// On Windows, agent CLIs are `.cmd` shims that execFile can't resolve without +// a shell. Composing the command string ourselves (rather than the deprecated +// (file, args, {shell: true}) signature, DEP0190) is safe here: callers pass +// fixed flags with no whitespace. +const USE_SHELL = process.platform === "win32" + +// Object indirection so tests can stub child processes without module mocks. +export const execApi = { + execAsync(file: string, args: string[]): Promise { + return new Promise((resolve) => { + const done = (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => + resolve({ + stdout: String(stdout ?? ""), + stderr: String(stderr ?? ""), + error: error as NodeJS.ErrnoException | null, + }) + if (USE_SHELL) { + const command = `${file.includes(" ") ? `"${file}"` : file} ${args.join(" ")}` + execFile(command, { shell: true }, done) + return + } + execFile(file, args, done) + }) + }, +} diff --git a/packages/codev-skillhub/src/http.ts b/packages/codev-skillhub/src/http.ts new file mode 100644 index 0000000000..c0b2c56eaa --- /dev/null +++ b/packages/codev-skillhub/src/http.ts @@ -0,0 +1,24 @@ +import { applySystemCaCertsOnce, isCertError } from "./tls" + +// All hub-bound HTTP goes through this seam. On a certificate failure the OS +// trust store is merged into the default CA set once (corporate TLS-intercepting +// proxies re-sign every chain with a root only the OS store has) and the +// request retried. The hub's diagnostic NDJSON logging is deliberately not +// ported — the endpoint label survives only so error messages can name what +// failed. +async function hubFetch(endpoint: string, input: string | URL, init?: RequestInit): Promise { + try { + return await fetch(input, init) + } catch (err) { + if (!isCertError(err)) throw err + const ca = applySystemCaCertsOnce() + if (ca?.status !== "merged") throw err + return await fetch(input, init) + } +} + +// Object indirection so tests can stub HTTP without module mocks (Bun spyOn on +// ESM namespaces is unreliable). +export const httpApi = { + fetch: hubFetch, +} diff --git a/packages/codev-skillhub/src/index.ts b/packages/codev-skillhub/src/index.ts new file mode 100644 index 0000000000..5d0e9e77ac --- /dev/null +++ b/packages/codev-skillhub/src/index.ts @@ -0,0 +1,28 @@ +export { defaultAgents } from "./agents" +export { pathExists } from "./archive" +export { describeNetworkError } from "./tls" +export { stripControlChars } from "./sanitize" +export { AGENT_LABELS, ALWAYS_AGENT, SKILL_AGENTS, isSkillAgent, type Scope, type SkillAgent } from "./skill-dirs" +export { + formatInstallResult, + installResolvedSkill, + installSkill, + parseAgentList, + runSkillInstall, + type InstallLocation, + type InstallResult, + type InstallTarget, +} from "./skill-install" +export { + formatPublishResult, + plannedSteps, + preparePublishArchive, + publishSkill, + runSkillPublish, + type PublishArchive, + type PublishResult, + type PublishStep, +} from "./skill-publish" +export { DEFAULT_SEARCH_LIMIT, runSkillSearch } from "./skill-search" +export { getSkillMeta, hasSkillhubAuth, SkillhubAuthError, type SkillMeta } from "./skillhub" +export { officeCrossFlagError, runSkillOffice, type OfficeArch, type OfficeArgs, type OfficePlatform } from "./office" diff --git a/packages/codev-skillhub/src/office.ts b/packages/codev-skillhub/src/office.ts new file mode 100644 index 0000000000..37ccd6e88c --- /dev/null +++ b/packages/codev-skillhub/src/office.ts @@ -0,0 +1,542 @@ +import { execFileSync, spawn } from "node:child_process" +import { chmodSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs" +import { join } from "node:path" +import { OFFICE_DOWNLOADS_URL } from "./const" +import { downloadFile } from "./download" +import { legacyOfficeDownloadsDir, officeDownloadsDir } from "./paths" + +// `codev 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 (DOCX, XLSX, PPTX and PDF) into the +// global ~/.claude/skills dir (read by Claude Code and CoDev Code). No +// skills-root is passed — the destination is the setup script's own default, +// which also detects the signed-in user under a different-account Windows +// elevation, so pinning a path here would defeat that. +// 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 a rendered UI would fight over. + +export const OFFICE_USAGE = + "Usage: codev skill office [--platform ubuntu|macos|windows] [--arch arm64|x86_64] [--dir ] [--target-user ] [--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 = { + arm64: "arm64", + aarch64: "arm64", + x86_64: "x86_64", + x64: "x86_64", + intel: "x86_64", +} + +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 +} + +// process.arch is the architecture of the RUNTIME BINARY, not of the machine: +// an x64 build 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.6 GB — the setup script is native bash, sees `uname -m` +// = arm64, finds no bundle it can use and downloads another 1.6 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 + forceSkills: boolean + // Windows only: the account that will USE the skills, when the installer is + // being run from an admin account instead of theirs. Forwarded to both the + // setup and uninstall scripts as -TargetUser. Without it there is no way to + // drive that case at all — the whole point of the flag is that a human is + // running the install manually. + targetUser?: string + // Deliberately unadvertised (hidden from --help): fetches and runs the + // published uninstall script instead of the installer. + uninstall: boolean + // Uninstall-only passthroughs, equally unadvertised. + yes: boolean + skillsOnly: boolean + purgeDownloads: boolean + error?: string +} + +// The two modes take disjoint flag sets — reject mixtures loudly rather than +// silently forwarding a flag the target script would choke on. Shared by +// parseOfficeArgs and the CLI's yargs handler. +export function officeCrossFlagError(parsed: OfficeArgs): string | undefined { + if (parsed.uninstall) { + if (parsed.skipVerify || parsed.forceSkills) { + return "--skip-verify/--force-skills do not apply with --uninstall" + } + return undefined + } + if (parsed.yes || parsed.skillsOnly || parsed.purgeDownloads) { + return "--yes/--skills-only/--purge-downloads require --uninstall" + } + return undefined +} + +export function parseOfficeArgs(argv: string[]): OfficeArgs { + const parsed: OfficeArgs = { + downloadOnly: false, + skipVerify: false, + forceSkills: false, + uninstall: false, + yes: false, + skillsOnly: false, + purgeDownloads: 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 "--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) { + parsed.error = "--dir requires a path" + return parsed + } + parsed.dir = value + break + } + case "--target-user": { + const value = takeValue() + if (!value) { + parsed.error = "--target-user requires an account name" + return parsed + } + parsed.targetUser = value + break + } + case "--download-only": + parsed.downloadOnly = true + break + case "--skip-verify": + parsed.skipVerify = true + break + case "--force-skills": + parsed.forceSkills = true + break + case "--uninstall": + parsed.uninstall = true + break + case "--yes": + parsed.yes = true + break + case "--skills-only": + parsed.skillsOnly = true + break + case "--purge-downloads": + parsed.purgeDownloads = true + break + default: + parsed.error = `unknown option: ${arg}` + return parsed + } + } + parsed.error = officeCrossFlagError(parsed) + return parsed +} + +// Bundle and script names are a naming contract with the codev-scripts repo +// (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 { + return platform === "windows" ? "codev-office-windows-setup.ps1" : `codev-office-${platform}-setup.sh` +} + +export function officeUninstallScriptName(platform: OfficePlatform): string { + return platform === "windows" ? "codev-office-windows-uninstall.ps1" : `codev-office-${platform}-uninstall.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: 1200, + windows: 1400, + "macos-arm64": 1600, + "macos-x86_64": 1600, +} + +// 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 +// 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) { + // `0` as well as `null`: a zero total is either an unknown length or an + // empty body, and dividing by it renders NaN%/Infinity%. + if (total === null || total === 0) { + if (tty) process.stderr.write(`\r${name} ${formatSize(received)}`) + return + } + const percent = Math.floor((received / total) * 100) + const line = `${name} ${formatSize(received)}/${formatSize(total)} (${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}` +} + +// The exact command the user types in an ELEVATED PowerShell — after field +// testing every alternative launcher, this is the only launch mode the +// endpoint protection tolerates: anything the CLI starts (directly or via a +// staged launcher) gets killed or quarantined, while the same command typed +// interactively runs to "Verification passed". Exported for tests. +export function officeManualWindowsCommand(script: string, args: string[]): string { + const argStr = args.map((a) => ` ${/\s/.test(a) ? `"${a}"` : a}`).join("") + return `powershell -ExecutionPolicy Bypass -File .\\${script}${argStr}` +} + +// Create the staging dir, falling back when the preferred location is not +// writable (e.g. hardened ACLs on C:\Users\Public). Returns the dir that +// actually exists. Exported for tests. +export function ensureStagingDir(preferred: string, fallback: string | null): string { + try { + mkdirSync(preferred, { recursive: true }) + return preferred + } catch (err) { + if (!fallback || fallback === preferred) throw err + console.error( + `Could not create ${preferred} (${err instanceof Error ? err.message : String(err)}) - using ${fallback} instead`, + ) + mkdirSync(fallback, { recursive: true }) + return fallback + } +} + +// One-time migration: move files staged under the old per-user dot-folder +// into the new Public dir, so multi-GB bundles are not re-downloaded just +// because the staging folder moved. Same-volume renames; anything locked or +// cross-volume is left behind (the downloader treats it as absent and +// re-fetches). Exported for tests. +export function migrateLegacyOfficeDir(fromDir: string, toDir: string): void { + if (fromDir === toDir || !existsSync(fromDir)) return + mkdirSync(toDir, { recursive: true }) + for (const name of readdirSync(fromDir)) { + const to = join(toDir, name) + if (existsSync(to)) continue + try { + renameSync(join(fromDir, name), to) + } catch { + // Locked or cross-volume — leave it; the download layer copes. + } + } +} + +export function installerArgs(parsed: OfficeArgs, platform: OfficePlatform): string[] { + // The bash scripts take GNU-style flags; the PowerShell script takes + // -SkipVerify / -ForceSkills switches. + if (platform === "windows") { + return [ + ...(parsed.targetUser ? ["-TargetUser", parsed.targetUser] : []), + ...(parsed.skipVerify ? ["-SkipVerify"] : []), + ...(parsed.forceSkills ? ["-ForceSkills"] : []), + ] + } + return [...(parsed.skipVerify ? ["--skip-verify"] : []), ...(parsed.forceSkills ? ["--force-skills"] : [])] +} + +export function uninstallerArgs(parsed: OfficeArgs, platform: OfficePlatform): string[] { + if (platform === "windows") { + return [ + ...(parsed.targetUser ? ["-TargetUser", parsed.targetUser] : []), + ...(parsed.yes ? ["-Yes"] : []), + ...(parsed.skillsOnly ? ["-SkillsOnly"] : []), + ...(parsed.purgeDownloads ? ["-PurgeDownloads"] : []), + ] + } + return [ + ...(parsed.yes ? ["--yes"] : []), + ...(parsed.skillsOnly ? ["--skills-only"] : []), + ...(parsed.purgeDownloads ? ["--purge-downloads"] : []), + ] +} + +// Exposed for tests (stubbed); 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( + parsed: OfficeArgs, + baseUrl: string = OFFICE_DOWNLOADS_URL, + spawner: OfficeSpawner = defaultSpawner, +): Promise { + 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 + } + + // 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 + } + + // -TargetUser exists only in the PowerShell scripts. On Ubuntu and macOS the + // invoking user is recoverable from SUDO_USER, so there is nothing to name — + // forwarding it would just make the bash script choke on an unknown flag. + if (parsed.targetUser !== undefined && platform !== "windows") { + console.error(`--target-user only applies to Windows (${platform} resolves the real user from SUDO_USER).`) + 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 + } + + // 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.6 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. + const dir = ensureStagingDir( + parsed.dir ?? officeDownloadsDir(), + parsed.dir === undefined && hostPlatform === "windows" ? legacyOfficeDownloadsDir() : null, + ) + // Windows moved its default staging from ~/.codev-hub/office to the + // profile-independent %PUBLIC%\Downloads\codev-office — pull already + // downloaded files across so nothing multi-GB is fetched twice. + if (hostPlatform === "windows" && parsed.dir === undefined) { + migrateLegacyOfficeDir(legacyOfficeDownloadsDir(), dir) + } + + 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 (${target})`) + console.error( + `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.", + ) + } + + // The scripts are tiny and must track the published version — always + // refetch them (belt and braces on top of the ETag revalidation). The + // `.partial` goes too: downloadFile resumes from it via Range, so a + // leftover from an interrupted run would splice stale bytes onto a script + // that has since been republished. + rmSync(join(dir, script), { force: true }) + rmSync(join(dir, `${script}.partial`), { force: true }) + + // Uninstall needs no bundle — only the script. + for (const name of parsed.uninstall ? [script] : [script, bundle]) { + const progress = makeProgressPrinter(name) + try { + await downloadFile({ + url: `${baseUrl}/${name}`, + dest: join(dir, name), + endpoint: name === bundle ? "office.bundle" : "office.script", + onProgress: (p) => progress.print(p.received, p.total), + }) + } catch (err) { + progress.done() + console.error(`Could not download ${baseUrl}/${name}: ${err instanceof Error ? err.message : String(err)}`) + return 1 + } + progress.done() + console.error(`✓ ${name} downloaded`) + } + if (process.platform !== "win32") { + chmodSync(join(dir, script), 0o755) + } + + const scriptArgs = parsed.uninstall ? uninstallerArgs(parsed, platform) : installerArgs(parsed, platform) + + // Windows: never launch the installer from the CLI. Endpoint protection + // (Kaspersky Endpoint Security in the field) kills or quarantines any + // launch the CLI initiates — the only mode it tolerates is the user + // typing the command in an elevated PowerShell themselves, which is + // field-proven to run to "Verification passed". Print that exact command, + // bare: the setup script itself defaults to profile-safe paths (shared + // %PUBLIC% dirs, console-user detection), so an elevation under a + // different admin account still installs to the real user's profile. + if (platform === "windows") { + const verb = parsed.uninstall ? "uninstaller" : "installer" + const commandLine = officeManualWindowsCommand(script, scriptArgs) + console.error(`\nFiles are in ${dir}.`) + console.error( + `codev does not auto-run the Windows ${verb}: endpoint protection ` + + "(e.g. Kaspersky) is known to kill installers it launches. Run it " + + "yourself in an ELEVATED PowerShell:", + ) + console.error(" 1. In this (or any) PowerShell window, run:") + console.error(" Start-Process powershell -Verb RunAs") + console.error( + " (in the UAC dialog: More choices -> Use a different account -> enter the ADMIN username and password)", + ) + console.error(" 2. Copy-paste these two lines into that window:") + console.error(` cd "${dir}"`) + console.error(` ${commandLine}`) + // Name the line the target script actually ends with. Uninstall mode used + // to quote the installer's "Verification passed", which the uninstaller + // never prints - so the user sat waiting for a message that was not + // coming. The installer's own closing line is unconditional; its + // "Verification passed" is not, being absent under --skip-verify. + console.error( + parsed.uninstall + ? ' 3. Wait for the green "codev-office uninstall finished" line' + : ' 3. Wait for the green "codev-office setup complete" closing line', + ) + return 0 + } + + if (downloadOnly) { + console.error(`\nFiles are in ${dir}. To ${parsed.uninstall ? "uninstall" : "install"}, run from that folder:`) + console.error(` ${manualRunCommand(platform, script)}`) + return 0 + } + console.error(`\nRunning the ${parsed.uninstall ? "uninstaller" : "installer"} (${script})...\n`) + // Windows returned above with the printed manual command — only the bash + // platforms reach the spawn. + const command = "bash" + const args = [join(dir, script), ...scriptArgs] + // cwd = download dir: the scripts locate their bundle zip next to + // themselves/CWD, and the staged files are there. + return spawner(command, args, dir) +} diff --git a/packages/codev-skillhub/src/paths.ts b/packages/codev-skillhub/src/paths.ts new file mode 100644 index 0000000000..d1792652e9 --- /dev/null +++ b/packages/codev-skillhub/src/paths.ts @@ -0,0 +1,34 @@ +import { homedir } from "node:os" +import { join } from "node:path" + +// CODEV_TEST_HOME rather than HOME (core Global convention): Bun's homedir() +// ignores runtime HOME changes, which subprocess tests rely on. +export function home(): string { + return process.env.CODEV_TEST_HOME ?? homedir() +} + +// Where `codev skill office` stages the offline bundle + setup script (up to +// ~3GB). Kept between runs so a re-run resumes an interrupted download or +// verifies the existing files instead of re-downloading. Shared with the hub's +// `codevhub skill office` — same paths, so bundles downloaded by either CLI +// are reused by the other. +// +// Windows uses a PROFILE-INDEPENDENT folder (%PUBLIC%\Downloads\codev-office): +// %USERPROFILE% diverges between a normal and an elevated shell when the UAC +// prompt is approved with a different admin account, and users kept looking +// for the files under the wrong profile. C:\Users\Public is the same path for +// every account, world-writable, not OneDrive-synced, and not targeted by +// Storage Sense cleanup. +export function officeDownloadsDir(): string { + if (process.platform === "win32") { + return join(process.env.PUBLIC ?? "C:\\Users\\Public", "Downloads", "codev-office") + } + return join(home(), ".codev-hub", "office") +} + +// The pre-Public staging dir on Windows — kept only so runSkillOffice can +// migrate already-downloaded files (a bundle is GBs; never re-download it +// just because the folder moved). +export function legacyOfficeDownloadsDir(): string { + return join(home(), ".codev-hub", "office") +} diff --git a/packages/codev-skillhub/src/sanitize.ts b/packages/codev-skillhub/src/sanitize.ts new file mode 100644 index 0000000000..dfbd0c69f0 --- /dev/null +++ b/packages/codev-skillhub/src/sanitize.ts @@ -0,0 +1,16 @@ +// Remove C0/C1 control characters (including ESC, which neutralises ANSI escape +// sequences) from a string before it's printed to a terminal. Fields that +// originate from the hub — skill name, description, provider, id — are +// attacker-influenced, so rendering them raw would allow terminal escape +// injection (cursor/clipboard manipulation, layout corruption). Printable text +// is left untouched. +export function stripControlChars(value: string): string { + let out = "" + for (const ch of value) { + const code = ch.codePointAt(0) ?? 0 + // Drop C0 (0x00–0x1F), DEL (0x7F), and C1 (0x80–0x9F) control ranges. + if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) continue + out += ch + } + return out +} diff --git a/packages/codev-skillhub/src/skill-dirs.ts b/packages/codev-skillhub/src/skill-dirs.ts new file mode 100644 index 0000000000..41b8a001fe --- /dev/null +++ b/packages/codev-skillhub/src/skill-dirs.ts @@ -0,0 +1,189 @@ +import { cp, mkdir, symlink } from "node:fs/promises" +import { isAbsolute, join, relative, resolve, sep } from "node:path" +import { execApi } from "./exec" +import { home } from "./paths" + +// Where each agent looks for skills, and how one extracted copy is made to serve +// several of them. +// +// Established by reading the agents themselves rather than their docs +// (`packages/opencode/src/skill/index.ts#discoverSkills`): +// +// externalDirs = [".claude", ".agents"] +// for (const dir of externalDirs) // GLOBAL: / +// scan(join(global.home, dir), "skills/**|SKILL.md", { dot: true }) +// for (const root of up({ targets: externalDirs, // PROJECT: cwd → worktree +// start: directory, stop: worktree })) +// scan(root, "skills/**|SKILL.md", { dot: true }) +// +// So CoDev Code and OpenCode read BOTH `.claude/skills` and `.agents/skills`, at +// BOTH scopes. Codex reads only `.agents/skills` (also cwd → repo root, plus +// `$HOME`); Claude Code reads only `.claude/skills`. Two directories therefore +// cover all four agents, and no agent needs a directory of its own. + +export const SKILL_AGENTS = ["claude", "codex", "opencode", "codev"] as const +export type SkillAgent = (typeof SKILL_AGENTS)[number] + +export function isSkillAgent(value: string): value is SkillAgent { + return (SKILL_AGENTS as readonly string[]).includes(value) +} + +// CoDev Code is the flagship agent and is always installed for — it is never +// absent from a target set, and the picker treats it as locked. +export const ALWAYS_AGENT: SkillAgent = "codev" + +export const AGENT_LABELS: Record = { + claude: "Claude Code", + codex: "Codex", + opencode: "OpenCode", + codev: "CoDev Code", +} + +export type Scope = "current" | "global" + +// The two directories, relative to a scope root. +const CLAUDE_DIR = join(".claude", "skills") +const AGENTS_DIR = join(".agents", "skills") + +// Which agents read which directory. Both scopes use the same rule. +const READS_AGENTS_DIR: Record = { + codex: true, + codev: true, + opencode: true, + claude: false, +} +const READS_CLAUDE_DIR: Record = { + codex: false, + codev: true, + opencode: true, + claude: true, +} + +export function scopeRoot(scope: Scope): string { + return scope === "current" ? process.cwd() : home() +} + +// How a link target was actually produced. `store` is the real extraction; the +// rest describe a second path pointing at it. Reported verbatim so a copy is +// never described as a link. +export type LinkMode = "store" | "symlink" | "junction" | "copy" + +export interface SkillTargets { + // Absolute path the archive is extracted into — the single real copy. + store: string + // Absolute paths that must end up pointing at `store`. + links: string[] +} + +// Pick the directory covering the most selected agents as the store, then link +// the other one only if some selected agent can't reach the store. +// +// The asymmetry is deliberate: when Codex isn't selected, `.claude/skills` alone +// serves every remaining agent, so only one directory is created and CoDev +// Code/OpenCode never see the skill twice. Adding Codex is what forces the +// second directory into existence. +export function resolveTargets(agents: readonly SkillAgent[], scope: Scope, name: string): SkillTargets { + const root = scopeRoot(scope) + const claudePath = join(root, CLAUDE_DIR, name) + const agentsPath = join(root, AGENTS_DIR, name) + + const needsAgentsDir = agents.some((a) => !READS_CLAUDE_DIR[a]) + if (!needsAgentsDir) return { store: claudePath, links: [] } + + const needsClaudeDir = agents.some((a) => !READS_AGENTS_DIR[a]) + return { + store: agentsPath, + links: needsClaudeDir ? [claudePath] : [], + } +} + +// Which selected agents a given path serves — so the result can say what each +// agent got rather than printing bare directories. +export function agentsServedBy(path: string, agents: readonly SkillAgent[], scope: Scope, name: string): SkillAgent[] { + const root = scopeRoot(scope) + const isClaudeDir = path === join(root, CLAUDE_DIR, name) + const table = isClaudeDir ? READS_CLAUDE_DIR : READS_AGENTS_DIR + return agents.filter((a) => table[a]) +} + +// Stubbable indirection, same idiom as tlsApi / httpApi: the fallback chain +// below is chosen by whether these throw, which no test could otherwise provoke +// on a machine where symlinks work. +export const linkApi = { + symlink, + cp, +} + +// Point `linkPath` at `store`, degrading rather than failing: +// +// 1. A RELATIVE symlink — relative is what survives `git clone`; an absolute +// link would break in every other checkout. +// 2. A Windows junction. Unlike a Windows symlink it needs neither Developer +// Mode nor administrator rights, which is what makes this viable for the +// Git Bash audience. Junctions resolve only absolute targets, so a +// project-scope junction isn't meaningfully committable — a Windows-only +// degradation, not a regression. +// 3. A recursive copy, reported as such. +export async function linkOrCopy(store: string, linkPath: string): Promise { + await mkdir(join(linkPath, ".."), { recursive: true }) + const target = relativeTarget(store, linkPath) + + try { + await linkApi.symlink(target, linkPath, "dir") + return "symlink" + } catch { + // fall through + } + if (process.platform === "win32") { + try { + await linkApi.symlink(resolve(store), linkPath, "junction") + return "junction" + } catch { + // fall through + } + } + await linkApi.cp(store, linkPath, { recursive: true }) + return "copy" +} + +// The link body: `store` expressed relative to the directory holding the link. +// Falls back to an absolute target only when the two share no common root +// (different Windows volumes), where a relative path cannot be formed at all. +function relativeTarget(store: string, linkPath: string): string { + const from = join(linkPath, "..") + const rel = relative(resolve(from), resolve(store)) + if (!rel || isAbsolute(rel)) return resolve(store) + return rel +} + +// Claude Code follows a symlinked skill directory only from v2.1.203 — earlier +// versions read the link as a plain file and find no SKILL.md. Below the floor +// the Claude link has to be a real copy, so probe before linking. +const CLAUDE_SYMLINK_FLOOR = [2, 1, 203] as const + +export async function claudeFollowsSymlinks(): Promise { + const r = await execApi.execAsync("claude", ["--version"]) + if (r.error) return false + const parsed = /(\d+)\.(\d+)\.(\d+)/.exec(r.stdout) + if (!parsed) return false + const version = [Number(parsed[1]), Number(parsed[2]), Number(parsed[3])] as const + for (let i = 0; i < CLAUDE_SYMLINK_FLOOR.length; i++) { + const have = version[i] ?? 0 + const need = CLAUDE_SYMLINK_FLOOR[i] ?? 0 + if (have !== need) return have > need + } + return true +} + +// True when `linkPath` is the Claude directory — the only link whose mechanism +// depends on the agent's version. +export function isClaudeLink(linkPath: string, scope: Scope, name: string): boolean { + return linkPath === join(scopeRoot(scope), CLAUDE_DIR, name) +} + +// Guard the skill name the same way skill-install does before it becomes a path +// segment under either directory. Exported so callers validate once and both +// target paths inherit it. +export function safeSegment(name: string): boolean { + return name !== "" && !name.startsWith(".") && !name.includes(sep) && !name.includes("/") && !isAbsolute(name) +} diff --git a/packages/codev-skillhub/src/skill-install.ts b/packages/codev-skillhub/src/skill-install.ts new file mode 100644 index 0000000000..086919665c --- /dev/null +++ b/packages/codev-skillhub/src/skill-install.ts @@ -0,0 +1,268 @@ +import { cp, mkdir, rm } from "node:fs/promises" +import { isAbsolute, relative, resolve, sep } from "node:path" +import { defaultAgents } from "./agents" +import { detectAndStripRoot, extractZip, inspectZip, pathExists } from "./archive" +import { stripControlChars } from "./sanitize" +import { + AGENT_LABELS, + agentsServedBy, + claudeFollowsSymlinks, + isClaudeLink, + isSkillAgent, + linkOrCopy, + resolveTargets, + safeSegment, + ALWAYS_AGENT, + SKILL_AGENTS, + type LinkMode, + type Scope, + type SkillAgent, +} from "./skill-dirs" +import { downloadSkill, getSkillMeta, type SkillMeta } from "./skillhub" + +// Registry lookups behind an object so tests can stub them without module +// mocks (Bun spyOn on ESM namespaces is unreliable). +export const skillhubApi = { + getSkillMeta, + downloadSkill, +} + +// The server's skill name becomes a directory under rootDir. Never trust it as +// a path: require it to resolve to a single direct child of rootDir, rejecting +// path separators, "..", and absolute paths. Without this, a hostile hub entry +// named e.g. "../../../tmp/x" could write outside the skills dir — or, combined +// with --force, delete an arbitrary directory via rm(). +function safeSkillDir(rootDir: string, name: string): string { + const root = resolve(rootDir) + const dir = resolve(root, name) + const rel = relative(root, dir) + if (rel === "" || rel.startsWith("..") || isAbsolute(rel) || rel.includes(sep)) { + throw new Error(`Refusing to install skill with unsafe name "${name}".`) + } + return dir +} + +// Scope is the only thing --here/--global choose; which agents get the skill is +// an orthogonal axis (see skill-dirs.ts). +export type InstallLocation = Scope + +// Parse a --agent value: comma-separated launch names. CoDev Code is folded in +// whether or not it was named — it is not opt-out. +export function parseAgentList(value: string): SkillAgent[] | string { + const names = value + .split(",") + .map((n) => n.trim()) + .filter((n) => n !== "") + if (names.length === 0) return "Missing value for --agent." + const unknown = names.filter((n) => !isSkillAgent(n)) + if (unknown.length > 0) { + return `Unknown agent: ${unknown.join(", ")}. Valid: ${SKILL_AGENTS.join(", ")}.` + } + const chosen = new Set(names.filter(isSkillAgent)) + chosen.add(ALWAYS_AGENT) + return SKILL_AGENTS.filter((agent) => chosen.has(agent)) +} + +// Where one extraction landed, and which of the selected agents it serves. +// `mode` is reported verbatim so a copy is never described as a link. +export interface InstallPlacement { + path: string + mode: LinkMode + agents: SkillAgent[] +} + +export interface InstallResult { + name: string + version?: string + id: string + // The single real extraction. Kept as `dir` so the --json shape stays + // compatible with callers written against the single-directory install. + dir: string + strippedRoot: string | null + placements: InstallPlacement[] +} + +// Where an install should go: either one exact directory (--dir) or the agent +// directories resolved from a scope. +export type InstallTarget = { kind: "dir"; rootDir: string } | { kind: "agents"; agents: SkillAgent[]; scope: Scope } + +// Resolve where this install writes, and check every destination is safe BEFORE +// any filesystem work — especially before the rm() below, so a hostile hub name +// can never delete a directory outside the intended root. +function planPlacements(target: InstallTarget, name: string): { store: string; links: string[]; agents: SkillAgent[] } { + if (target.kind === "dir") { + return { store: safeSkillDir(target.rootDir, name), links: [], agents: [] } + } + // resolveTargets joins the name into two fixed roots, so validating the name + // as a single path segment covers both destinations at once. + if (!safeSegment(name)) { + throw new Error(`Refusing to install skill with unsafe name "${name}".`) + } + const { store, links } = resolveTargets(target.agents, target.scope, name) + return { store, links, agents: target.agents } +} + +// Download + extract a skill whose metadata is already resolved. The install +// dir is named after the canonical skill name (meta.name), not the id or the +// ZIP's root folder — the root is only stripped so files aren't double-nested. +// +// The archive is extracted exactly once, into `store`; every other agent +// directory becomes a link to it (skill-dirs.ts#linkOrCopy). A hub skill can +// run to thousands of files, so fanning out copies per agent is not free. +export async function installResolvedSkill( + meta: SkillMeta, + opts: { target: InstallTarget; force: boolean }, +): Promise { + const plan = planPlacements(opts.target, meta.name) + + const zip = await skillhubApi.downloadSkill(meta.id) + // Vet the archive's size/entry counts before inflating or extracting it. + inspectZip(zip) + const { buffer, stripped } = detectAndStripRoot(zip) + + // Every destination is checked before anything is removed, so a refusal on + // the second one can't leave the first already deleted. + for (const dir of [plan.store, ...plan.links]) { + if (!(await pathExists(dir))) continue + if (!opts.force) { + throw new Error(`Already installed at ${dir}. Pass --force to overwrite.`) + } + } + for (const dir of [plan.store, ...plan.links]) { + await rm(dir, { recursive: true, force: true }) + } + + await mkdir(plan.store, { recursive: true }) + await extractZip(buffer, plan.store) + + const placements: InstallPlacement[] = [ + { + path: plan.store, + mode: "store", + agents: agentsFor(plan.store, opts.target, meta.name), + }, + ] + for (const link of plan.links) { + // Claude Code only follows a symlinked skill directory from v2.1.203; on an + // older build the link reads as a file with no SKILL.md inside, so it has + // to be a real copy. + const mustCopy = + opts.target.kind === "agents" && + isClaudeLink(link, opts.target.scope, meta.name) && + !(await claudeFollowsSymlinks()) + const mode = mustCopy ? await copyInto(plan.store, link) : await linkOrCopy(plan.store, link) + placements.push({ + path: link, + mode, + agents: agentsFor(link, opts.target, meta.name), + }) + } + + return { + name: meta.name, + version: meta.version, + id: meta.id, + dir: plan.store, + strippedRoot: stripped, + placements, + } +} + +function agentsFor(path: string, target: InstallTarget, name: string): SkillAgent[] { + if (target.kind === "dir") return [] + return agentsServedBy(path, target.agents, target.scope, name) +} + +async function copyInto(store: string, dest: string): Promise { + await cp(store, dest, { recursive: true }) + return "copy" +} + +// Resolve (id or name) to its canonical metadata, then download + +// extract. No console I/O — returns the result or throws. Used by the +// non-interactive runner; the interactive flow resolves metadata itself (to +// show the name in the prompt) and calls installResolvedSkill directly. +export async function installSkill( + target: string, + opts: { target: InstallTarget; force: boolean }, +): Promise { + const meta = await skillhubApi.getSkillMeta(target) + return installResolvedSkill(meta, opts) +} + +export function formatInstallResult(r: InstallResult, json: boolean): string { + if (json) { + return JSON.stringify({ + ok: true, + name: r.name, + version: r.version ?? null, + id: r.id, + dir: r.dir, + strippedRoot: r.strippedRoot, + placements: r.placements, + }) + } + // Sanitize hub-sourced name/version for terminal display (paths are local and + // already validated). JSON output above needs no scrubbing — stringify escapes + // control characters. + const name = stripControlChars(r.name) + const versionSuffix = r.version ? `@${stripControlChars(r.version)}` : "" + const served = r.placements.flatMap((p) => p.agents) + // --dir installs for no particular agent, so it keeps the original one-liner. + if (served.length === 0) { + return `Installed ${name}${versionSuffix} -> ${r.dir}` + } + const labels = SKILL_AGENTS.filter((a) => served.includes(a)).map((a) => AGENT_LABELS[a]) + const lines = [`Installed ${name}${versionSuffix} for ${labels.join(", ")}`] + for (const placement of r.placements) { + // Name the mechanism only when it isn't the real copy — and never call a + // fallback copy a link. + const suffix = placement.mode === "store" ? "" : ` (${placement.mode})` + lines.push(` ${placement.path}${suffix}`) + } + return lines.join("\n") +} + +// Non-interactive path (a location flag given, or piped/CI). The interactive +// prompt lives in the CLI command; here a location MUST be explicit, so no flag +// at all is an error rather than a silent default. The agent set does default +// though (defaultAgents()) — CI should not have to spell out --agent. Arg +// parsing lives in the CLI's yargs layer; this runner takes the parsed values +// and returns the exit code. +export async function runSkillInstall(opts: { + target: string + dir?: string + location?: InstallLocation + agents?: SkillAgent[] + force: boolean + json: boolean +}): Promise { + // --dir is the escape hatch and is used verbatim; --here/--global resolve to + // the selected agents' directories for that scope. + const target: InstallTarget | null = + opts.dir !== undefined + ? { kind: "dir", rootDir: resolve(opts.dir) } + : opts.location !== undefined + ? { + kind: "agents", + agents: opts.agents ?? defaultAgents(), + scope: opts.location, + } + : null + if (target === null) { + console.error("Not a terminal — pass --here, --global, or --dir to choose an install location.") + return 1 + } + + try { + const result = await installSkill(opts.target, { + target, + force: opts.force, + }) + console.log(formatInstallResult(result, opts.json)) + return 0 + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)) + return 1 + } +} diff --git a/packages/codev-skillhub/src/skill-publish.ts b/packages/codev-skillhub/src/skill-publish.ts new file mode 100644 index 0000000000..a22dcc49fb --- /dev/null +++ b/packages/codev-skillhub/src/skill-publish.ts @@ -0,0 +1,176 @@ +import { readFile, stat } from "node:fs/promises" +import { basename, join, resolve } from "node:path" +import { inspectZip, pathExists, zipDirectory } from "./archive" +import { adminReviewSkill, saveSkillMetadata, submitSkill, uploadSkill } from "./skillhub" + +export interface PublishArchive { + zipBuffer: Buffer + fileName: string + // Files included in the archive (relative to the skill root), for preview. + files: string[] + // Uncompressed total of the included files. + totalBytes: number + // Names excluded by the ignore list (secrets/junk/VCS), for transparency. + skipped: string[] + // "dir" (a folder we zipped) or "zip" (a pre-built archive read as-is). + source: "dir" | "zip" +} + +// Turn a user-supplied path into an uploadable archive. A directory must have a +// SKILL.md at its root and is zipped (junk/secrets dropped, caps enforced); a +// file must be a `.zip`, which is read as-is but still inspected for the preview +// and size cap. Throws with a plain message on any validation failure. +export async function preparePublishArchive(inputPath: string): Promise { + const absPath = resolve(inputPath) + if (!(await pathExists(absPath))) { + throw new Error(`Path not found: ${absPath}`) + } + const s = await stat(absPath) + + if (s.isDirectory()) { + if (!(await pathExists(join(absPath, "SKILL.md")))) { + throw new Error("Directory must contain a SKILL.md at its root.") + } + const rootName = basename(absPath) + const { buffer, files, totalBytes, skipped } = await zipDirectory(absPath, rootName) + return { + zipBuffer: buffer, + fileName: `${rootName}.zip`, + files, + totalBytes, + skipped, + source: "dir", + } + } + + if (s.isFile()) { + if (!absPath.endsWith(".zip")) { + throw new Error("File must be a .zip archive.") + } + const buffer = await readFile(absPath) + const { files, totalBytes } = inspectZip(buffer) + return { + zipBuffer: buffer, + fileName: basename(absPath), + files, + totalBytes, + skipped: [], + source: "zip", + } + } + + throw new Error("Path must be a directory or a .zip file.") +} + +export type PublishStep = "upload" | "metadata" | "submit" | "approve" +export type StepStatus = "start" | "done" + +export interface PublishOpts { + draftOnly: boolean + autoApprove: boolean +} + +export interface PublishResult { + skillId: string + status: "DRAFT" | "SUBMITTED" | "PUBLIC" + steps: string[] +} + +// Which steps a given set of options will run, in order. Drives the interactive +// progress display so it can render the rows before they start. +export function plannedSteps(opts: PublishOpts): PublishStep[] { + const steps: PublishStep[] = ["upload", "metadata"] + if (!opts.draftOnly) { + steps.push("submit") + if (opts.autoApprove) steps.push("approve") + } + return steps +} + +// Run the publish pipeline: upload -> save metadata (DRAFT) -> submit (unless +// --draft-only) -> admin approve (only with --auto-approve, admin-only). No +// console I/O — `onStep` lets the interactive UI animate each stage; the +// non-interactive runner passes nothing. +export async function publishSkill( + archive: { zipBuffer: Buffer; fileName: string }, + opts: PublishOpts, + onStep?: (step: PublishStep, status: StepStatus) => void, +): Promise { + onStep?.("upload", "start") + const uploaded = await uploadSkill(archive.zipBuffer, archive.fileName) + if (!uploaded.skill_id) { + throw new Error("Upload succeeded but no skill_id was returned.") + } + const skillId = uploaded.skill_id + onStep?.("upload", "done") + + onStep?.("metadata", "start") + await saveSkillMetadata(skillId, {}) + onStep?.("metadata", "done") + + const steps = ["uploaded", "metadata saved (DRAFT)"] + let status: PublishResult["status"] = opts.draftOnly ? "DRAFT" : "SUBMITTED" + + if (!opts.draftOnly) { + onStep?.("submit", "start") + await submitSkill(skillId) + onStep?.("submit", "done") + steps.push("submitted for review (SUBMITTED)") + + if (opts.autoApprove) { + onStep?.("approve", "start") + await adminReviewSkill(skillId, "APPROVE", "Auto-approved via CLI") + onStep?.("approve", "done") + steps.push("approved (PUBLIC)") + status = "PUBLIC" + } + } + + return { skillId, status, steps } +} + +export function formatPublishResult(r: PublishResult, json: boolean): string { + if (json) { + return JSON.stringify({ + ok: true, + skillId: r.skillId, + status: r.status, + steps: r.steps, + }) + } + const lines = [`Published skill ${r.skillId}`, ...r.steps.map((s) => ` ${s}`)] + if (r.status === "SUBMITTED") { + lines.push("", "Status: SUBMITTED — an admin must approve it before it appears on the public hub.") + } else if (r.status === "DRAFT") { + lines.push("", "Status: DRAFT — run `codev skill push` again (without --draft-only) to submit.") + } else if (r.status === "PUBLIC") { + lines.push("", "Status: PUBLIC — the skill is live on the hub.") + } + return lines.join("\n") +} + +// Non-interactive path (piped/CI, or --json). No confirmation prompt — the +// interactive preview/confirm lives in the CLI command. Excluded files are noted +// on stderr (never stdout, so --json stays clean). Returns the exit code. +export async function runSkillPublish(opts: { + path: string + draftOnly: boolean + autoApprove: boolean + json: boolean +}): Promise { + try { + const archive = await preparePublishArchive(opts.path) + if (!opts.json && archive.skipped.length > 0) { + console.error(`Excluded from upload: ${archive.skipped.join(", ")}`) + } + const result = await publishSkill(archive, { + draftOnly: opts.draftOnly, + autoApprove: opts.autoApprove, + }) + console.log(formatPublishResult(result, opts.json)) + return 0 + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)) + return 1 + } +} diff --git a/packages/codev-skillhub/src/skill-search.ts b/packages/codev-skillhub/src/skill-search.ts new file mode 100644 index 0000000000..1de10f3e4c --- /dev/null +++ b/packages/codev-skillhub/src/skill-search.ts @@ -0,0 +1,49 @@ +import { stripControlChars } from "./sanitize" +import { listHubSkills, type HubSkill } from "./skillhub" + +export const DEFAULT_SEARCH_LIMIT = 20 +const DESCRIPTION_MAX = 90 + +// `codev skill search [--json] [--limit ]`. Plain console output so +// results pipe cleanly and `--json` is script-friendly. Arg parsing lives in +// the CLI's yargs layer; this runner takes the parsed values and returns the +// exit code. +export async function runSkillSearch(opts: { query: string; limit?: number; json: boolean }): Promise { + try { + const { total, items } = await listHubSkills({ + search: opts.query, + limit: opts.limit ?? DEFAULT_SEARCH_LIMIT, + }) + + if (opts.json) { + console.log(JSON.stringify({ ok: true, total, items })) + return 0 + } + if (items.length === 0) { + console.log(`No skills match "${opts.query}".`) + return 0 + } + console.log(formatResults(total, items)) + return 0 + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)) + return 1 + } +} + +function formatResults(total: number, items: HubSkill[]): string { + const lines = [`Found ${total} skill(s) — showing ${items.length}:`, ""] + for (const s of items) { + // Hub-sourced fields are sanitized before printing (see stripControlChars). + const name = stripControlChars(s.name) + const version = stripControlChars(s.version) + const provider = stripControlChars(s.provider) + lines.push(` ${name}@${version} by ${provider}`) + const desc = stripControlChars(s.description ?? "") + const trimmed = desc.length > DESCRIPTION_MAX ? `${desc.slice(0, DESCRIPTION_MAX - 3)}...` : desc + if (trimmed) lines.push(` ${trimmed}`) + lines.push(` id: ${stripControlChars(s.id)}`) + lines.push("") + } + return lines.join("\n").trimEnd() +} diff --git a/packages/codev-skillhub/src/skillhub.ts b/packages/codev-skillhub/src/skillhub.ts new file mode 100644 index 0000000000..f2ed4afbfb --- /dev/null +++ b/packages/codev-skillhub/src/skillhub.ts @@ -0,0 +1,279 @@ +import { loadSkillhubCookie, silentSso } from "@codev/gateway" +import { SKILLHUB_URL } from "./const" +import { httpApi } from "./http" + +// SkillHub handshakes are quick JSON requests — cap them so a stalled registry +// surfaces as an error instead of hanging the CLI. +const SKILLHUB_TIMEOUT_MS = 15_000 +// A skill upload ships an entire ZIP — allow much longer than a handshake. +const SKILLHUB_UPLOAD_TIMEOUT_MS = 120_000 + +// Thrown when SkillHub can't authenticate the request at all (no local +// credential, or the SSO session/cookie is gone or expired). Carries an +// actionable "log in again" message. A 403 (authenticated but not permitted) +// is NOT this — it flows back to the caller as a normal Response to interpret. +export class SkillhubAuthError extends Error { + constructor(message: string) { + super(message) + this.name = "SkillhubAuthError" + } +} + +// Pick the credential for a SkillHub request. A stored admin cookie (written by +// `codevhub login --admin`; read-only here) wins; otherwise we ride the SSO +// session, auto-refreshing the access token via silentSso() so a background +// call never triggers an interactive login. When `optional` is set (public +// endpoints like the hub listing), missing credentials yield no auth header +// rather than an error. +async function skillhubAuthHeaders(optional: boolean): Promise> { + const cookie = loadSkillhubCookie() + if (cookie) return { Cookie: cookie } + + const auth = await silentSso() + if (auth) return { Authorization: `Bearer ${auth.access_token}` } + + if (optional) return {} + throw new SkillhubAuthError( + "Not logged in to SkillHub. Run `codev skill push` in a terminal to sign in (or `codevhub login`).", + ) +} + +// True when a SkillHub credential is available without prompting the user — a +// stored admin cookie, or an SSO session (refreshed silently if the access token +// expired). Mirrors the credential selection in skillhubFetch, so `skill push` +// can offer an interactive login only when the user is genuinely logged out. +// Never throws. +export async function hasSkillhubAuth(): Promise { + if (loadSkillhubCookie()) return true + try { + return (await silentSso()) !== null + } catch { + return false + } +} + +export interface SkillhubFetchOptions extends RequestInit { + // Short label naming the request in error paths. Defaults to the path. + label?: string + // Public endpoint: attach a credential if one is available, but don't require + // login. Default false — most endpoints need auth. + optionalAuth?: boolean +} + +// Authenticated fetch against the SkillHub registry. Prepends SKILLHUB_URL, +// attaches the right credential (cookie or Bearer), and routes through +// httpApi.fetch. A 401 is normalized into a SkillhubAuthError with a re-login +// hint; every other status (including 403) is returned for the caller to read. +export async function skillhubFetch(path: string, opts: SkillhubFetchOptions = {}): Promise { + const { label, headers, signal, optionalAuth, ...rest } = opts + const authHeaders = await skillhubAuthHeaders(optionalAuth ?? false) + const url = `${SKILLHUB_URL}${path.startsWith("/") ? path : `/${path}`}` + + const res = await httpApi.fetch(label ?? "skillhub.request", url, { + ...rest, + headers: { + Accept: "application/json", + ...authHeaders, + ...(headers as Record | undefined), + }, + signal: signal ?? AbortSignal.timeout(SKILLHUB_TIMEOUT_MS), + }) + + if (res.status === 401) { + throw new SkillhubAuthError("SkillHub session expired or invalid (401). Sign in again and re-run.") + } + return res +} + +// A public skill as returned by GET /api/v1/hub/skills. The endpoint carries +// more fields (metadata, author, counts, zipUrl); we type only what search +// renders and ignore the rest. +export interface HubSkill { + id: string + name: string + provider: string + description: string + version: string + publishedAt: string | null +} + +export interface HubSearchResult { + // Total matches server-side (may exceed items.length when capped by limit). + total: number + items: HubSkill[] +} + +// Browse/search the public hub. Authentication is optional — the listing is +// public — so a logged-out user can search; a stored session is attached when +// present (e.g. so private-namespace context could apply server-side later). +export async function listHubSkills(opts: { search?: string; limit?: number } = {}): Promise { + const params = new URLSearchParams() + if (opts.search) params.set("search", opts.search) + if (opts.limit) params.set("limit", String(opts.limit)) + const qs = params.toString() + + const res = await skillhubFetch(`/api/v1/hub/skills${qs ? `?${qs}` : ""}`, { + label: "skillhub.hub-skills", + optionalAuth: true, + }) + if (!res.ok) { + throw new Error(`Skill search failed (${res.status}).`) + } + + const body = (await res.json().catch(() => ({}))) as { + success?: boolean + data?: HubSkill[] + pagination?: { total?: number } + } + if (!body.success || !body.data) { + throw new Error("SkillHub returned an unexpected response.") + } + return { + total: body.pagination?.total ?? body.data.length, + items: body.data, + } +} + +export interface SkillMeta { + id: string + name: string + version?: string +} + +// Resolve a skill's canonical metadata by id OR name — the server's +// GET /api/v1/skills/ accepts either. Public for PUBLIC skills (optional +// auth). Used to show the real name (never a raw UUID) before download and to +// name the install directory. +export async function getSkillMeta(target: string): Promise { + const res = await skillhubFetch(`/api/v1/skills/${encodeURIComponent(target)}`, { + label: "skillhub.detail", + optionalAuth: true, + }) + if (res.status === 404) { + throw new Error(`Skill "${target}" not found or not public.`) + } + if (!res.ok) { + throw new Error(`Skill lookup failed (${res.status}).`) + } + const body = (await res.json().catch(() => ({}))) as { + success?: boolean + data?: { id?: string; name?: string; version?: string } + } + if (!body.success || !body.data?.id || !body.data.name) { + throw new Error("SkillHub returned an unexpected response.") + } + return { + id: body.data.id, + name: body.data.name, + version: body.data.version, + } +} + +// Server response to a skill upload. The skill lands in PENDING; `skill_id` +// identifies it for the metadata/submit steps that follow. +export interface UploadResponse { + success: boolean + skill_id?: string + status?: string + message?: string + errors?: { code: string; message: string; field?: string }[] +} + +// Upload a skill ZIP (multipart) — POST /api/v1/skills/upload. Auth is required. +// Content-Type is intentionally left unset so fetch adds the multipart boundary; +// a longer timeout than the JSON handshake accommodates a real archive. +export async function uploadSkill(zipBuffer: Buffer, fileName: string): Promise { + const blob = new Blob([new Uint8Array(zipBuffer)], { + type: "application/zip", + }) + const form = new FormData() + form.append("file", blob, fileName) + + const res = await skillhubFetch("/api/v1/skills/upload", { + label: "skillhub.upload", + method: "POST", + body: form, + signal: AbortSignal.timeout(SKILLHUB_UPLOAD_TIMEOUT_MS), + }) + const body = (await res.json().catch(() => ({}))) as UploadResponse + if (!res.ok || !body.success) { + throw new Error(body.message ?? `Upload failed (${res.status}).`) + } + return body +} + +// Save a skill's metadata — PATCH /api/v1/skills//metadata. An empty body is +// enough to flip the freshly-uploaded skill from PENDING to DRAFT; the server +// derives the real metadata from the ZIP's SKILL.md. +export async function saveSkillMetadata(skillId: string, payload: Record = {}): Promise { + const res = await skillhubFetch(`/api/v1/skills/${skillId}/metadata`, { + label: "skillhub.metadata", + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + const body = (await res.json().catch(() => ({}))) as { + success?: boolean + message?: string + } + if (!res.ok || !body.success) { + throw new Error(body.message ?? `Saving metadata failed (${res.status}).`) + } +} + +// Submit a DRAFT skill for review — PATCH /api/v1/skills//submit. Moves it +// to SUBMITTED and notifies admins. +export async function submitSkill(skillId: string): Promise { + const res = await skillhubFetch(`/api/v1/skills/${skillId}/submit`, { + label: "skillhub.submit", + method: "PATCH", + }) + const body = (await res.json().catch(() => ({}))) as { + success?: boolean + message?: string + } + if (!res.ok || !body.success) { + throw new Error(body.message ?? `Submit failed (${res.status}).`) + } +} + +// Admin-only review action — POST /api/v1/admin/review. Approving a SUBMITTED +// skill publishes it (PUBLIC). A non-admin caller gets a 403 here, surfaced as +// the server's message. +export async function adminReviewSkill( + skillId: string, + action: "APPROVE" | "REJECT", + feedback?: string, +): Promise { + const res = await skillhubFetch("/api/v1/admin/review", { + label: "skillhub.review", + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ skill_id: skillId, action, feedback }), + }) + const body = (await res.json().catch(() => ({}))) as { + success?: boolean + message?: string + } + if (!res.ok || !body.success) { + throw new Error(body.message ?? `Review failed (${res.status}).`) + } +} + +// Download a skill's ZIP by id. Public for PUBLIC skills (optional auth); a +// stored session is attached when present so a private-namespace skill the user +// can see also downloads. Returns the raw ZIP bytes; 404 → a clear +// not-found/not-public error. +export async function downloadSkill(id: string): Promise { + const res = await skillhubFetch(`/api/v1/skills/${id}/download`, { + label: "skillhub.download", + optionalAuth: true, + }) + if (res.status === 404) { + throw new Error(`Skill "${id}" not found or not public.`) + } + if (!res.ok) { + throw new Error(`Download failed (${res.status}).`) + } + return Buffer.from(await res.arrayBuffer()) +} diff --git a/packages/codev-skillhub/src/tls.ts b/packages/codev-skillhub/src/tls.ts new file mode 100644 index 0000000000..e43d5ccf53 --- /dev/null +++ b/packages/codev-skillhub/src/tls.ts @@ -0,0 +1,155 @@ +import tls from "node:tls" + +// Node verifies TLS against its own bundled Mozilla CA snapshot and never +// consults the OS trust store (tls.rootCertificates: "fixed at release time… +// identical on all supported platforms"). On a machine behind a TLS-intercepting +// proxy (Zscaler/Netskope/Fortinet) or HTTPS-scanning AV, every chain is +// re-signed by a corporate root that MDM/GPO installed into the *OS* store — so +// browsers work and we fail with `fetch failed (self-signed certificate in +// certificate chain)`. +// +// tls.getCACertificates("system") reads that OS store even without the +// --use-system-ca flag, so merging it into the default set fixes those users +// with no configuration on their side. +// +// Indirection so tests can simulate a runtime that predates these APIs. Not +// destructured at import: on Node < 22.19 these are `undefined`, and a named +// ESM import of a missing builtin export is a link-time error. +export const tlsApi = { + getCACertificates: (type: string): string[] => + ( + tls as unknown as { + getCACertificates?: (t: string) => string[] + } + ).getCACertificates?.(type) ?? [], + setDefaultCACertificates: (certs: string[]): void => { + ;( + tls as unknown as { + setDefaultCACertificates?: (c: string[]) => void + } + ).setDefaultCACertificates?.(certs) + }, + supported: (): boolean => + typeof (tls as unknown as Record).getCACertificates === "function" && + typeof (tls as unknown as Record).setDefaultCACertificates === "function", +} + +export type CaMergeStatus = "merged" | "unsupported" | "empty" | "failed" + +export interface CaMergeResult { + status: CaMergeStatus + /** How many certs the OS store contributed. */ + systemCount: number + error?: string +} + +let attempted = false + +// Merges the OS trust store into the runtime's default CA set. Runs at most +// once per process; every later call returns null so a caller can't retry +// forever. +// +// Deliberately called only *after* a certificate failure, never speculatively. +// The OS-store read is synchronous and costs ~20ms on macOS but ~300ms+ on +// Windows, where it blocks the event loop. Since the users who need this are +// the minority behind an intercepting proxy, and a cert failure is a precise +// signal that they're one of them, paying on failure keeps the happy path at +// exactly zero cost. +// +// Returns null when a previous call already attempted the merge — the caller +// must not retry the request again, or a permanently untrusted chain would loop. +// +// Best-effort by construction: TLS trust is not this CLI's job to have opinions +// about, and any failure here must leave the default behavior exactly as it +// was rather than break a user whose certs already work. +export function applySystemCaCertsOnce(): CaMergeResult | null { + if (attempted) return null + attempted = true + return mergeSystemCaCerts() +} + +function mergeSystemCaCerts(): CaMergeResult { + // Node < 22.19 / < 24.5. Those users need --use-system-ca (22.15+) or + // NODE_EXTRA_CA_CERTS; describeNetworkError tells them so. + if (!tlsApi.supported()) return { status: "unsupported", systemCount: 0 } + try { + const system = tlsApi.getCACertificates("system") + if (system.length === 0) return { status: "empty", systemCount: 0 } + // "default" already folds in NODE_EXTRA_CA_CERTS, so a user who fixed this + // the documented way keeps working — don't narrow this to the bundled set. + const defaults = tlsApi.getCACertificates("default") + tlsApi.setDefaultCACertificates([...defaults, ...system]) + return { status: "merged", systemCount: system.length } + } catch (err) { + return { + status: "failed", + systemCount: 0, + error: err instanceof Error ? err.message : String(err), + } + } +} + +// Test-only: forget that the merge ran so each case starts clean. +export function resetSystemCaCertsCache(): void { + attempted = false +} + +// OpenSSL verify failures that mean "I don't trust this chain", as opposed to +// DNS/connection/timeout errors. Node surfaces them on `err.cause.code`. +const CERT_ERROR_CODES = new Set([ + "SELF_SIGNED_CERT_IN_CHAIN", + "DEPTH_ZERO_SELF_SIGNED_CERT", + "UNABLE_TO_GET_ISSUER_CERT", + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "CERT_UNTRUSTED", + "SELF_SIGNED_CERT_IN_CHAIN_ERR", +]) + +// True when a thrown fetch error bottoms out in "I don't trust this chain". +// fetch hides the reason on `err.cause`, so unwrap before matching. +export function isCertError(err: unknown): boolean { + if (!(err instanceof Error)) return false + const cause = err.cause + if (!(cause instanceof Error)) return false + const code = (cause as NodeJS.ErrnoException).code + return code !== undefined && CERT_ERROR_CODES.has(code) +} + +// Pure: reading the OS store here would put a ~300ms Windows stall on the error +// path just to word a sentence. By the time this runs, hubFetch has already +// tried the merge and retried, so "not in your store either" is accurate. +function certHint(): string { + if (!tlsApi.supported()) { + return ( + `This looks like a TLS-intercepting proxy or antivirus. Node ${process.version} ` + + "can't read your system certificate store — upgrade to Node 22.15+ and re-run, " + + "or point NODE_EXTRA_CA_CERTS at your organization's root CA (PEM file)." + ) + } + // The merge ran and the chain still isn't trusted, so the intercepting root + // isn't in the OS store either — pointing at --use-system-ca would be a dead + // end. Only the explicit PEM can help. + return ( + "This looks like a TLS-intercepting proxy or antivirus, and its root CA isn't " + + "in your system certificate store. Ask IT for the root CA and point " + + "NODE_EXTRA_CA_CERTS at it (a PEM file), then re-run." + ) +} + +// Renders a network error for humans. +// +// fetch throws a bare `TypeError: fetch failed` and hides the real reason +// (DNS, TLS, proxy) on `err.cause` — so unwrap it. For certificate failures, +// append the remedy: Node only volunteers its own --use-system-ca hint for +// DEPTH_ZERO_SELF_SIGNED_CERT / UNABLE_TO_VERIFY_LEAF_SIGNATURE / +// UNABLE_TO_GET_ISSUER_CERT, which pointedly excludes SELF_SIGNED_CERT_IN_CHAIN +// — the corporate-proxy case this exists for. Those users get a dead-end string +// unless we say something. +export function describeNetworkError(err: unknown): string { + if (!(err instanceof Error)) return String(err) + const cause: unknown = err.cause + const causeMsg = cause instanceof Error ? cause.message : cause !== undefined ? String(cause) : "" + const base = causeMsg ? `${err.message} (${causeMsg})` : err.message + return isCertError(err) ? `${base}\n${certHint()}` : base +} diff --git a/packages/codev-skillhub/test/agents.test.ts b/packages/codev-skillhub/test/agents.test.ts new file mode 100644 index 0000000000..4fd4540f29 --- /dev/null +++ b/packages/codev-skillhub/test/agents.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { defaultAgents, detectApi } from "../src/agents" + +let tempHome: string +let originalTestHome: string | undefined + +beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), "codev-agents-home-")) + // CODEV_TEST_HOME rather than HOME: Bun's homedir() ignores runtime HOME + // changes, so overriding HOME would scan the real ~/.claude and friends. + originalTestHome = process.env.CODEV_TEST_HOME + process.env.CODEV_TEST_HOME = tempHome +}) +afterEach(() => { + mock.restore() + if (originalTestHome === undefined) delete process.env.CODEV_TEST_HOME + if (originalTestHome !== undefined) process.env.CODEV_TEST_HOME = originalTestHome + rmSync(tempHome, { recursive: true, force: true }) +}) + +describe("defaultAgents", () => { + test("CoDev Code is always present, even when nothing else is detected", () => { + spyOn(detectApi, "which").mockReturnValue(false) + spyOn(detectApi, "dirExists").mockReturnValue(false) + expect(defaultAgents()).toEqual(["codev"]) + }) + + test("an agent whose binary resolves on PATH is included", () => { + for (const binary of ["claude", "codex", "opencode"] as const) { + spyOn(detectApi, "which").mockImplementation((b) => b === binary) + spyOn(detectApi, "dirExists").mockReturnValue(false) + expect(defaultAgents()).toEqual([binary, "codev"]) + } + }) + + test("an agent whose config directory exists is included, binary or not", () => { + spyOn(detectApi, "which").mockReturnValue(false) + // Real directories under the isolated home, resolved by the real dirExists. + for (const [agent, dir] of [ + ["claude", join(tempHome, ".claude")], + ["codex", join(tempHome, ".codex")], + ["opencode", join(tempHome, ".config", "opencode")], + ] as const) { + mkdirSync(dir, { recursive: true }) + expect(defaultAgents()).toContain(agent) + rmSync(dir, { recursive: true, force: true }) + expect(defaultAgents()).not.toContain(agent) + } + }) + + test("all agents present yields the full set in canonical order", () => { + spyOn(detectApi, "which").mockReturnValue(true) + expect(defaultAgents()).toEqual(["claude", "codex", "opencode", "codev"]) + }) +}) diff --git a/packages/codev-skillhub/test/archive.test.ts b/packages/codev-skillhub/test/archive.test.ts new file mode 100644 index 0000000000..14a988f522 --- /dev/null +++ b/packages/codev-skillhub/test/archive.test.ts @@ -0,0 +1,105 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import AdmZip from "adm-zip" +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { detectAndStripRoot, extractZip, inspectZip } from "../src/archive" + +let tempDir: string + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codev-skillhub-archive-")) +}) +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) +}) + +function makeZip(files: Record): Buffer { + const zip = new AdmZip() + for (const [name, content] of Object.entries(files)) { + zip.addFile(name, Buffer.from(content)) + } + return zip.toBuffer() +} + +function entryNames(buffer: Buffer): string[] { + return new AdmZip(buffer) + .getEntries() + .map((e) => e.entryName) + .sort() +} + +describe("extractZip", () => { + test("writes nested files under the target dir", async () => { + const zip = makeZip({ + "SKILL.md": "# hi", + "scripts/run.sh": "echo hi", + }) + await extractZip(zip, tempDir) + expect(readFileSync(join(tempDir, "SKILL.md"), "utf-8")).toBe("# hi") + expect(readFileSync(join(tempDir, "scripts", "run.sh"), "utf-8")).toBe("echo hi") + }) + + test("rejects a zip-slip entry that escapes the target dir", async () => { + // AdmZip sanitizes `../` on addFile, so craft the malicious name by + // overriding entryName directly before serializing. + const zip = new AdmZip() + zip.addFile("evil.txt", Buffer.from("pwned")) + const entry = zip.getEntries()[0] + if (!entry) throw new Error("fixture setup failed") + entry.entryName = "../evil.txt" + const buf = zip.toBuffer() + + await expect(extractZip(buf, tempDir)).rejects.toThrow(/unsafe zip entry/) + expect(existsSync(join(tempDir, "..", "evil.txt"))).toBe(false) + }) + + test("rejects an archive that decompresses past the size cap", async () => { + // 101 MB of zeros compresses tiny but inflates past the 100 MB cap. + const zip = new AdmZip() + zip.addFile("big.bin", Buffer.alloc(101 * 1024 * 1024)) + await expect(extractZip(zip.toBuffer(), tempDir)).rejects.toThrow(/limit/i) + // Nothing was written before the cap tripped. + expect(existsSync(join(tempDir, "big.bin"))).toBe(false) + }) +}) + +describe("inspectZip", () => { + test("accepts a normal archive and reports its files/total", () => { + const buf = makeZip({ "SKILL.md": "hello", "run.sh": "echo hi" }) + const { files, totalBytes } = inspectZip(buf) + expect(files.sort()).toEqual(["SKILL.md", "run.sh"].sort()) + expect(totalBytes).toBe("hello".length + "echo hi".length) + }) + + test("rejects an archive over the uncompressed size cap", () => { + const zip = new AdmZip() + zip.addFile("big.bin", Buffer.alloc(101 * 1024 * 1024)) + expect(() => inspectZip(zip.toBuffer())).toThrow(/limit/i) + }) +}) + +describe("detectAndStripRoot", () => { + test("strips a single shared root folder", () => { + const zip = makeZip({ + "pg-tuner/SKILL.md": "a", + "pg-tuner/scripts/x.sh": "b", + }) + const { buffer, stripped } = detectAndStripRoot(zip) + expect(stripped).toBe("pg-tuner") + expect(entryNames(buffer)).toEqual(["SKILL.md", "scripts/x.sh"]) + }) + + test("leaves the buffer unchanged when there are multiple top-level entries", () => { + const zip = makeZip({ "SKILL.md": "a", "other.md": "b" }) + const { buffer, stripped } = detectAndStripRoot(zip) + expect(stripped).toBeNull() + expect(buffer).toBe(zip) + }) + + test("does not strip when the only shared name is a lone top-level file", () => { + const zip = makeZip({ "SKILL.md": "a" }) + const { stripped } = detectAndStripRoot(zip) + expect(stripped).toBeNull() + }) +}) diff --git a/packages/codev-skillhub/test/download.test.ts b/packages/codev-skillhub/test/download.test.ts new file mode 100644 index 0000000000..b3e20f69e5 --- /dev/null +++ b/packages/codev-skillhub/test/download.test.ts @@ -0,0 +1,289 @@ +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 "bun:test" +import { downloadFile } from "../src/download" + +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-skillhub-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 +// When true the server sends no Content-Length (chunked), as a proxy or a +// streaming origin may. +let omitContentLength = false +// Simulate a middlebox that honors Range but ignores If-Range semantics — +// answering 206 (with the current entity's ETag) even when If-Range mismatches. +let brokenIfRange = false +// Extra objects (path -> body) the server should serve. +let objects: Map + +beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), "codev-skillhub-download-")) + rangeLog = [] + ignoreRange = false + omitContentLength = false + brokenIfRange = false + objects = new Map([["/payload.bin", PAYLOAD]]) + server = createServer((req, res) => { + const body = objects.get(req.url ?? "") + if (!body) { + res.writeHead(404).end() + return + } + // MinIO-style conditional semantics: a per-body ETag, 304 on a matching + // If-None-Match, and Range honored only when If-Range (if sent) matches. + const etag = `"${sha256(body).slice(0, 16)}"` + const range = req.headers.range + rangeLog.push(range) + if (req.headers["if-none-match"] === etag) { + res.writeHead(304, { ETag: etag }).end() + return + } + const ifRange = req.headers["if-range"] + if (range && !ignoreRange && (brokenIfRange || ifRange === undefined || ifRange === etag)) { + 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}`, + ETag: etag, + }) + res.end(rest) + return + } + if (omitContentLength) { + // Bun's node:http computes Content-Length itself when end() receives the + // whole body, so stream in two writes to force chunked encoding (Node + // sends chunked either way). + res.writeHead(200, { ETag: etag }) + res.write(body.subarray(0, 1024)) + setImmediate(() => res.end(body.subarray(1024))) + return + } + res.writeHead(200, { "Content-Length": String(body.length), ETag: etag }) + 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) + }) + + // `Number(res.headers.get("content-length"))` is `Number(null)` === 0 when the + // header is absent, and 0 is finite — reading it straight makes the opts.size + // fallback unreachable and reports a total of `offset` (0 on a fresh + // download), which renders as Infinity%. + test("falls back to the declared size when the server omits content-length", async () => { + omitContentLength = true + const dest = join(tempDir, "payload.bin") + const totals: (number | null)[] = [] + await downloadFile({ + url: `${baseUrl}/payload.bin`, + dest, + size: PAYLOAD.length, + endpoint: "test.download", + onProgress: (p) => totals.push(p.total), + }) + expect(readFileSync(dest).equals(PAYLOAD)).toBe(true) + expect(totals.length).toBeGreaterThan(0) + expect(totals.every((t) => t === PAYLOAD.length)).toBe(true) + }) + + test("reports an unknown total when neither side gives a size", async () => { + omitContentLength = true + const dest = join(tempDir, "payload.bin") + const totals: (number | null)[] = [] + await downloadFile({ + url: `${baseUrl}/payload.bin`, + dest, + endpoint: "test.download", + onProgress: (p) => totals.push(p.total), + }) + expect(readFileSync(dest).equals(PAYLOAD)).toBe(true) + // null, not 0 — the progress printer branches on it to drop the percentage + // rather than dividing by zero. + expect(totals.every((t) => t === null)).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\)/) + }) +}) + +describe("downloadFile ETag revalidation", () => { + test("a finished file is kept when the server answers 304", async () => { + const dest = join(tempDir, "payload.bin") + await downloadFile({ url: `${baseUrl}/payload.bin`, dest, endpoint: "t" }) + const before = rangeLog.length + await downloadFile({ url: `${baseUrl}/payload.bin`, dest, endpoint: "t" }) + expect(readFileSync(dest).equals(PAYLOAD)).toBe(true) + // Exactly one request: the conditional probe, no re-download. + expect(rangeLog.length).toBe(before + 1) + }) + + test("a republished object is re-downloaded (ETag mismatch)", async () => { + const dest = join(tempDir, "payload.bin") + await downloadFile({ url: `${baseUrl}/payload.bin`, dest, endpoint: "t" }) + const NEW = Buffer.from("republished-bytes") + objects.set("/payload.bin", NEW) + await downloadFile({ url: `${baseUrl}/payload.bin`, dest, endpoint: "t" }) + expect(readFileSync(dest).equals(NEW)).toBe(true) + }) + + test("a file with no ETag on record is trusted without any request", async () => { + const dest = join(tempDir, "payload.bin") + writeFileSync(dest, "manually-placed") + await downloadFile({ url: `${baseUrl}/payload.bin`, dest, endpoint: "t" }) + expect(readFileSync(dest, "utf8")).toBe("manually-placed") + expect(rangeLog).toEqual([]) + }) + + test("a resume across a republish restarts instead of splicing (If-Range)", async () => { + const dest = join(tempDir, "payload.bin") + // A partial and ETag from an older publish of the object. + writeFileSync(`${dest}.partial`, Buffer.from("stale-old-bytes")) + writeFileSync(`${dest}.etag`, '"stale-etag"') + await downloadFile({ url: `${baseUrl}/payload.bin`, dest, endpoint: "t" }) + // If-Range mismatched → server sent 200 → clean restart, correct bytes. + expect(readFileSync(dest).equals(PAYLOAD)).toBe(true) + }) + + test("refuses a bare-Range resume of an ETag-less partial without a sha256", async () => { + const dest = join(tempDir, "payload.bin") + // A partial with no `.etag` on record (pre-ETag download, or a cleared + // record). Without an expected sha256 nothing downstream would catch a + // splice, so the partial must be scrapped, not resumed. + writeFileSync(`${dest}.partial`, Buffer.from("stale-old-bytes")) + await downloadFile({ url: `${baseUrl}/payload.bin`, dest, endpoint: "t" }) + // No Range header ever reached the server — a clean full download. + expect(rangeLog).toEqual([undefined]) + expect(readFileSync(dest).equals(PAYLOAD)).toBe(true) + expect(existsSync(`${dest}.partial`)).toBe(false) + }) + + test("scraps the partial when a broken hop answers 206 across a republish", async () => { + // A middlebox that honors Range while ignoring If-Range: it answers 206 + // for a republished entity, which used to splice the halves together + // ("bad zipfile offset" on real bundle downloads). + brokenIfRange = true + const dest = join(tempDir, "payload.bin") + writeFileSync(`${dest}.partial`, Buffer.from("stale-old-bytes")) + writeFileSync(`${dest}.etag`, '"stale-etag"') + await downloadFile({ url: `${baseUrl}/payload.bin`, dest, endpoint: "t" }) + // The lying 206 carried the new object's ETag → partial scrapped, then + // a clean full refetch (no Range on the second request). + expect(rangeLog).toEqual(["bytes=15-", undefined]) + expect(readFileSync(dest).equals(PAYLOAD)).toBe(true) + expect(existsSync(`${dest}.partial`)).toBe(false) + }) +}) diff --git a/packages/codev-skillhub/test/office.test.ts b/packages/codev-skillhub/test/office.test.ts new file mode 100644 index 0000000000..09b40dcbb1 --- /dev/null +++ b/packages/codev-skillhub/test/office.test.ts @@ -0,0 +1,684 @@ +import { createHash } from "node:crypto" +import { chmodSync, existsSync, mkdirSync, 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, mock, spyOn, test } from "bun:test" +import { + detectArch, + detectPlatform, + ensureStagingDir, + formatSize, + installerArgs, + migrateLegacyOfficeDir, + type OfficeArgs, + officeBundleName, + officeCrossFlagError, + officeManualWindowsCommand, + officeScriptName, + officeTarget, + officeUninstallScriptName, + parseOfficeArgs, + runSkillOffice, + uninstallerArgs, +} from "../src/office" + +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("detectArch", () => { + test("maps node arches to the uname -m names the bundles use", () => { + expect(detectArch("arm64", false)).toBe("arm64") + expect(detectArch("x64", false)).toBe("x86_64") + }) + + test("returns null for arches no bundle is built for", () => { + expect(detectArch("ia32", false)).toBeNull() + expect(detectArch("ppc64", false)).toBeNull() + }) + + // An x64 node under Rosetta reports "x64" on an Apple Silicon Mac. Believing + // it downloads the Intel bundle, which the native-bash setup script then + // refuses — and re-downloads the arm64 one. 3.2 GB to land 1.6. + test("Rosetta translation overrides the reported x64", () => { + expect(detectArch("x64", true)).toBe("arm64") + }) +}) + +describe("officeTarget", () => { + test("non-macOS platforms ignore the arch", () => { + expect(officeTarget("ubuntu", null)).toBe("ubuntu") + expect(officeTarget("windows", null)).toBe("windows") + expect(officeTarget("ubuntu", "arm64")).toBe("ubuntu") + }) + + test("macOS resolves to a per-chip target", () => { + expect(officeTarget("macos", "arm64")).toBe("macos-arm64") + expect(officeTarget("macos", "x86_64")).toBe("macos-x86_64") + }) + + test("macOS without an arch has no target — never guess a 1.6 GB download", () => { + expect(officeTarget("macos", null)).toBeNull() + }) +}) + +describe("parseOfficeArgs", () => { + test("defaults", () => { + expect(parseOfficeArgs([])).toEqual({ + downloadOnly: false, + skipVerify: false, + forceSkills: false, + uninstall: false, + yes: false, + skillsOnly: false, + purgeDownloads: false, + }) + }) + + test("all flags, space-separated values", () => { + const parsed = parseOfficeArgs([ + "--platform", + "windows", + "--dir", + "/tmp/x", + "--download-only", + "--skip-verify", + "--force-skills", + ]) + expect(parsed).toEqual({ + platform: "windows", + dir: "/tmp/x", + downloadOnly: true, + skipVerify: true, + forceSkills: true, + uninstall: false, + yes: false, + skillsOnly: false, + purgeDownloads: false, + }) + }) + + test("uninstall mode with its passthrough flags (unadvertised)", () => { + const parsed = parseOfficeArgs(["--uninstall", "--yes", "--skills-only", "--purge-downloads"]) + expect(parsed.error).toBeUndefined() + expect(parsed.uninstall).toBe(true) + expect(parsed.yes).toBe(true) + expect(parsed.skillsOnly).toBe(true) + expect(parsed.purgeDownloads).toBe(true) + }) + + test("rejects install flags combined with --uninstall", () => { + expect(parseOfficeArgs(["--uninstall", "--skip-verify"]).error).toMatch(/do not apply with --uninstall/) + }) + + test("rejects uninstall passthroughs without --uninstall", () => { + expect(parseOfficeArgs(["--yes"]).error).toMatch(/require --uninstall/) + }) + + 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("--arch, both spellings and the aliases people actually type", () => { + expect(parseOfficeArgs(["--arch", "arm64"]).arch).toBe("arm64") + expect(parseOfficeArgs(["--arch=x86_64"]).arch).toBe("x86_64") + expect(parseOfficeArgs(["--arch", "aarch64"]).arch).toBe("arm64") + expect(parseOfficeArgs(["--arch", "x64"]).arch).toBe("x86_64") + expect(parseOfficeArgs(["--arch", "Intel"]).arch).toBe("x86_64") + }) + + test("rejects an unknown or missing arch", () => { + expect(parseOfficeArgs(["--arch", "ppc64"]).error).toMatch(/--arch must be one of/) + expect(parseOfficeArgs(["--arch"]).error).toMatch(/--arch 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/) + }) + + test("--target-user, both spellings", () => { + expect(parseOfficeArgs(["--target-user", "minhnh49"]).targetUser).toBe("minhnh49") + expect(parseOfficeArgs(["--target-user=VTS\\minhnh49"]).targetUser).toBe("VTS\\minhnh49") + }) + + test("rejects --target-user without a value", () => { + expect(parseOfficeArgs(["--target-user"]).error).toMatch(/--target-user requires an account name/) + }) + + // It applies to BOTH modes: install puts the skills in the right profile, + // uninstall clears them out of it again. + test("--target-user is legal in install and uninstall mode alike", () => { + expect(parseOfficeArgs(["--target-user", "jdoe"]).error).toBeUndefined() + expect(parseOfficeArgs(["--uninstall", "--target-user", "jdoe"]).error).toBeUndefined() + }) +}) + +// The cross-flag validation is also exported on its own so the CLI's yargs +// handler can reuse it on already-parsed flags. +describe("officeCrossFlagError", () => { + const base: OfficeArgs = { + downloadOnly: false, + skipVerify: false, + forceSkills: false, + uninstall: false, + yes: false, + skillsOnly: false, + purgeDownloads: false, + } + + test("accepts each mode's own flags", () => { + expect(officeCrossFlagError({ ...base, skipVerify: true, forceSkills: true })).toBeUndefined() + expect(officeCrossFlagError({ ...base, uninstall: true, yes: true, skillsOnly: true })).toBeUndefined() + }) + + test("rejects install flags in uninstall mode and vice versa", () => { + expect(officeCrossFlagError({ ...base, uninstall: true, skipVerify: true })).toMatch( + /do not apply with --uninstall/, + ) + expect(officeCrossFlagError({ ...base, purgeDownloads: true })).toMatch(/require --uninstall/) + }) +}) + +// The scripts' own flag names — a drift here silently stops forwarding an +// option the user explicitly asked for. +describe("script argument forwarding", () => { + test("installer: -TargetUser is forwarded as a separate value token", () => { + expect(installerArgs(parseOfficeArgs(["--target-user", "jdoe"]), "windows")).toEqual(["-TargetUser", "jdoe"]) + }) + + test("installer: combines with the other switches", () => { + expect(installerArgs(parseOfficeArgs(["--target-user", "jdoe", "--skip-verify"]), "windows")).toEqual([ + "-TargetUser", + "jdoe", + "-SkipVerify", + ]) + }) + + test("uninstaller: -TargetUser is forwarded alongside its own switches", () => { + expect( + uninstallerArgs(parseOfficeArgs(["--uninstall", "--target-user", "jdoe", "--skills-only", "--yes"]), "windows"), + ).toEqual(["-TargetUser", "jdoe", "-Yes", "-SkillsOnly"]) + }) + + test("nothing is forwarded when the flag is absent", () => { + expect(installerArgs(parseOfficeArgs([]), "windows")).toEqual([]) + expect(uninstallerArgs(parseOfficeArgs(["--uninstall"]), "windows")).toEqual([]) + }) + + // A domain account (VTS\minhnh49) has no space, but a renamed local account + // can — the printed command has to stay copy-pasteable either way. + test("the printed Windows command quotes a value containing a space", () => { + expect( + officeManualWindowsCommand( + "codev-office-windows-setup.ps1", + installerArgs(parseOfficeArgs(["--target-user", "First Last"]), "windows"), + ), + ).toBe('powershell -ExecutionPolicy Bypass -File .\\codev-office-windows-setup.ps1 -TargetUser "First Last"') + }) + + test("a domain-qualified account survives unquoted", () => { + expect( + officeManualWindowsCommand( + "codev-office-windows-setup.ps1", + installerArgs(parseOfficeArgs(["--target-user", "VTS\\minhnh49"]), "windows"), + ), + ).toBe("powershell -ExecutionPolicy Bypass -File .\\codev-office-windows-setup.ps1 -TargetUser VTS\\minhnh49") + }) +}) + +// 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 — four bundles, macOS split per chip", () => { + expect(officeBundleName("ubuntu")).toBe("codev-office-ubuntu.zip") + expect(officeBundleName("windows")).toBe("codev-office-windows.zip") + expect(officeBundleName("macos-arm64")).toBe("codev-office-macos-arm64.zip") + expect(officeBundleName("macos-x86_64")).toBe("codev-office-macos-x86_64.zip") + }) + + // Three scripts, not four: the single macOS script resolves its own bundle + // from `uname -m`, so splitting the bundles added no script to pick between. + 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") + }) + + test("uninstall script names", () => { + expect(officeUninstallScriptName("ubuntu")).toBe("codev-office-ubuntu-uninstall.sh") + expect(officeUninstallScriptName("macos")).toBe("codev-office-macos-uninstall.sh") + expect(officeUninstallScriptName("windows")).toBe("codev-office-windows-uninstall.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") + }) +}) + +const sha256 = (data: Buffer | string) => createHash("sha256").update(data).digest("hex") + +// runSkillOffice reads process.platform to pick the bundle and to decide whether +// the installer may run here. Restores the original descriptor rather than +// re-defining a value, so nothing leaks into the tests that share this process. +async function withPlatform(value: NodeJS.Platform, fn: () => Promise): Promise { + const original = Object.getOwnPropertyDescriptor(process, "platform") + Object.defineProperty(process, "platform", { value, configurable: true }) + try { + return await fn() + } finally { + if (original) Object.defineProperty(process, "platform", original) + else delete (process as { platform?: unknown }).platform + } +} + +// End-to-end through runSkillOffice against a local server standing in for the +// codev-storage bucket (MinIO-style ETag/If-None-Match/If-Range semantics — +// downloadFile relies on them). The test host is linux/macos, so the detected +// platform maps to one of the bash bundles. 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 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") + + let tempDir: string + let server: Server + let baseUrl: string + // Range header seen per request, in arrival order. + let rangeLog: (string | undefined)[] + // Objects (path -> body) the server serves. + let objects: Map + + beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), "codev-office-")) + rangeLog = [] + objects = new Map([ + [`/${bundleName}`, BUNDLE], + [`/${scriptName}`, SCRIPT], + ]) + server = createServer((req, res) => { + const body = objects.get(req.url ?? "") + if (!body) { + res.writeHead(404).end() + return + } + const etag = `"${sha256(body).slice(0, 16)}"` + const range = req.headers.range + rangeLog.push(range) + if (req.headers["if-none-match"] === etag) { + res.writeHead(304, { ETag: etag }).end() + return + } + const ifRange = req.headers["if-range"] + if (range && (ifRange === undefined || ifRange === etag)) { + 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}`, + ETag: etag, + }) + res.end(rest) + return + } + res.writeHead(200, { "Content-Length": String(body.length), ETag: etag }) + res.end(body) + }) + await new Promise((resolve) => server.listen(0, resolve)) + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + // The runner narrates on stderr — keep the test output clean. + spyOn(console, "error").mockImplementation(() => {}) + }) + + afterEach(async () => { + mock.restore() + await new Promise((resolve) => { + server.close(() => resolve()) + }) + rmSync(tempDir, { recursive: true, force: true }) + }) + + test("--download-only stages both files and never spawns", async () => { + const dir = join(tempDir, "office") + const spawns: string[] = [] + const code = await runSkillOffice(parseOfficeArgs(["--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( + parseOfficeArgs(["--dir", dir, "--skip-verify"]), + baseUrl, + async (command, args, cwd) => { + spawned = { command, args, cwd } + return 0 + }, + ) + expect(code).toBe(0) + expect(spawned as unknown).toEqual({ + command: "bash", + args: [join(dir, scriptName), "--skip-verify"], + cwd: dir, + }) + }) + + test("propagates the installer's exit code", async () => { + const dir = join(tempDir, "office") + const code = await runSkillOffice(parseOfficeArgs(["--dir", dir]), baseUrl, async () => 7) + expect(code).toBe(7) + }) + + // 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.6 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(parseOfficeArgs(["--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(parseOfficeArgs(["--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( + parseOfficeArgs(["--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) + const dir = join(tempDir, "office") + const spawns: string[] = [] + const code = await runSkillOffice( + parseOfficeArgs(["--platform", "windows", "--dir", dir]), + baseUrl, + async (command) => { + spawns.push(command) + return 0 + }, + ) + expect(code).toBe(0) + expect(spawns).toEqual([]) + expect(existsSync(join(dir, "codev-office-windows.zip"))).toBe(true) + }) + + test("windows staging stages files only and never spawns", async () => { + // Endpoint protection kills or quarantines anything the CLI launches — + // the windows flow must only download and print the manual + // elevated-PowerShell command. + objects.set("/codev-office-windows.zip", BUNDLE) + objects.set("/codev-office-windows-setup.ps1", SCRIPT) + const dir = join(tempDir, "office") + const spawns: string[] = [] + const code = await runSkillOffice( + parseOfficeArgs(["--platform", "windows", "--dir", dir, "--skip-verify", "--force-skills"]), + baseUrl, + async (command) => { + spawns.push(command) + return 0 + }, + ) + expect(code).toBe(0) + expect(spawns).toEqual([]) + expect(existsSync(join(dir, "codev-office-windows.zip"))).toBe(true) + }) + + test("the manual command carries flags, quoting space-containing values", () => { + const line = officeManualWindowsCommand("codev-office-windows-setup.ps1", [ + "-SkipVerify", + "-SkillsRoot", + "C:\\Users\\Van Phong\\.claude\\skills", + ]) + expect(line).toContain("powershell -ExecutionPolicy Bypass -File .\\codev-office-windows-setup.ps1 -SkipVerify") + // Space-containing paths are quoted so copy-paste survives them. + expect(line).toContain('-SkillsRoot "C:\\Users\\Van Phong\\.claude\\skills"') + }) + + test("ensureStagingDir falls back when the preferred dir is unwritable", () => { + const locked = join(tempDir, "locked") + mkdirSync(locked, { recursive: true }) + chmodSync(locked, 0o555) + const preferred = join(locked, "codev-office") + const fallback = join(tempDir, "fallback-office") + try { + const dir = ensureStagingDir(preferred, fallback) + expect(dir).toBe(fallback) + expect(existsSync(fallback)).toBe(true) + } finally { + chmodSync(locked, 0o755) + } + // Writable preferred dir wins; no fallback means errors propagate. + const fine = join(tempDir, "fine") + expect(ensureStagingDir(fine, fallback)).toBe(fine) + expect(() => ensureStagingDir(preferred, null)).not.toThrow() // now writable again + }) + + test("migrateLegacyOfficeDir moves files without clobbering", () => { + const from = join(tempDir, "legacy-office") + const to = join(tempDir, "public-office") + mkdirSync(from, { recursive: true }) + mkdirSync(to, { recursive: true }) + writeFileSync(join(from, "bundle.zip"), "old-bundle") + writeFileSync(join(from, "kept.txt"), "from-legacy") + writeFileSync(join(to, "kept.txt"), "already-new") + migrateLegacyOfficeDir(from, to) + // Moved when absent at the destination, left alone when present. + expect(readFileSync(join(to, "bundle.zip"), "utf8")).toBe("old-bundle") + expect(readFileSync(join(to, "kept.txt"), "utf8")).toBe("already-new") + expect(existsSync(join(from, "bundle.zip"))).toBe(false) + // A missing source dir is a no-op, not an error. + migrateLegacyOfficeDir(join(tempDir, "nope"), to) + }) + + 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(parseOfficeArgs(["--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") + }) + + // The other half of "always refetch the script": deleting the finished file + // alone still leaves downloadFile resuming from a leftover .partial via Range, + // and no checksum is passed that could catch the splice. + test("drops a stale .partial for the script instead of resuming onto it", async () => { + const dir = join(tempDir, "office") + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, `${scriptName}.partial`), "##") + const code = await runSkillOffice(parseOfficeArgs(["--download-only", "--dir", dir]), baseUrl) + expect(code).toBe(0) + expect(readFileSync(join(dir, scriptName)).equals(SCRIPT)).toBe(true) + // Both requests went out without a Range header. + expect(rangeLog).toEqual([undefined, undefined]) + }) + + // The PowerShell branch is unreachable from a non-Windows host — a + // cross-platform --platform forces download-only — so the host is stubbed to + // keep the argv shape pinned on the Linux/macOS machines that run this suite. + test("a Windows host never spawns the installer", async () => { + objects.set("/codev-office-windows.zip", BUNDLE) + objects.set("/codev-office-windows-setup.ps1", SCRIPT) + const dir = join(tempDir, "office") + let spawned: { command: string; args: string[]; cwd: string } | null = null + const code = await withPlatform("win32", () => + runSkillOffice(parseOfficeArgs(["--dir", dir, "--skip-verify"]), baseUrl, async (command, args, cwd) => { + spawned = { command, args, cwd } + return 0 + }), + ) + expect(code).toBe(0) + expect(spawned).toBeNull() + }) + + test("exits 1 on an OS with no bundle and downloads nothing", async () => { + const code = await withPlatform("freebsd", () => + runSkillOffice(parseOfficeArgs(["--dir", join(tempDir, "office")]), baseUrl), + ) + expect(code).toBe(1) + expect(rangeLog).toEqual([]) + }) + + test("exits 1 on an unknown flag and downloads nothing", async () => { + expect(await runSkillOffice(parseOfficeArgs(["--wat"]), baseUrl)).toBe(1) + expect(rangeLog).toEqual([]) + }) + + test("fails cleanly when the bundle is not published", async () => { + objects.delete(`/${bundleName}`) + const code = await runSkillOffice(parseOfficeArgs(["--dir", join(tempDir, "office")]), baseUrl) + expect(code).toBe(1) + }) + + test("--uninstall fetches only the uninstall script and runs it with passthroughs", async () => { + const uninstallName = `codev-office-${hostPlatform}-uninstall.sh` + objects.set(`/${uninstallName}`, SCRIPT) + const dir = join(tempDir, "office") + let spawned: { command: string; args: string[] } | null = null + const code = await runSkillOffice( + parseOfficeArgs(["--uninstall", "--yes", "--skills-only", "--dir", dir]), + baseUrl, + async (command, args) => { + spawned = { command, args } + return 0 + }, + ) + expect(code).toBe(0) + expect(spawned as unknown).toEqual({ + command: "bash", + args: [join(dir, uninstallName), "--yes", "--skills-only"], + }) + // The bundle is never touched in uninstall mode. + expect(existsSync(join(dir, bundleName))).toBe(false) + }) +}) + +const NO_FLAGS: OfficeArgs = { + downloadOnly: false, + skipVerify: false, + forceSkills: false, + uninstall: false, + yes: false, + skillsOnly: false, + purgeDownloads: false, +} + +describe("installerArgs", () => { + const base = { + ...NO_FLAGS, + skipVerify: true, + forceSkills: true, + } + + test("bash platforms get GNU-style flags", () => { + expect(installerArgs(base, "ubuntu")).toEqual(["--skip-verify", "--force-skills"]) + }) + + test("windows gets PowerShell switches", () => { + expect(installerArgs(base, "windows")).toEqual(["-SkipVerify", "-ForceSkills"]) + }) + + test("no flags when none requested", () => { + expect(installerArgs(NO_FLAGS, "windows")).toEqual([]) + }) +}) + +describe("uninstallerArgs", () => { + const base = { + ...NO_FLAGS, + uninstall: true, + yes: true, + skillsOnly: true, + purgeDownloads: true, + } + + test("bash platforms get GNU-style flags", () => { + expect(uninstallerArgs(base, "ubuntu")).toEqual(["--yes", "--skills-only", "--purge-downloads"]) + }) + + test("windows gets PowerShell switches", () => { + expect(uninstallerArgs(base, "windows")).toEqual(["-Yes", "-SkillsOnly", "-PurgeDownloads"]) + }) +}) diff --git a/packages/codev-skillhub/test/skill-dirs.test.ts b/packages/codev-skillhub/test/skill-dirs.test.ts new file mode 100644 index 0000000000..d78c9fc079 --- /dev/null +++ b/packages/codev-skillhub/test/skill-dirs.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { isAbsolute, join } from "node:path" +import { execApi } from "../src/exec" +import { + agentsServedBy, + claudeFollowsSymlinks, + isClaudeLink, + linkApi, + linkOrCopy, + resolveTargets, + safeSegment, + type SkillAgent, +} from "../src/skill-dirs" + +let tempHome: string +let tempCwd: string +let originalTestHome: string | undefined + +const CLAUDE = join(".claude", "skills") +const AGENTS = join(".agents", "skills") + +beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), "codev-skill-dirs-home-")) + tempCwd = mkdtempSync(join(tmpdir(), "codev-skill-dirs-cwd-")) + // CODEV_TEST_HOME rather than HOME: Bun's homedir() ignores runtime HOME + // changes, so overriding HOME would silently hit the real home directory. + originalTestHome = process.env.CODEV_TEST_HOME + process.env.CODEV_TEST_HOME = tempHome + spyOn(process, "cwd").mockReturnValue(tempCwd) +}) +afterEach(() => { + mock.restore() + if (originalTestHome === undefined) delete process.env.CODEV_TEST_HOME + if (originalTestHome !== undefined) process.env.CODEV_TEST_HOME = originalTestHome + rmSync(tempHome, { recursive: true, force: true }) + rmSync(tempCwd, { recursive: true, force: true }) +}) + +// Which directory each agent reads was established from the agents' own source +// (see the header of src/skill-dirs.ts). These pin the consequence: whichever +// directory covers the most selected agents becomes the single real copy. +describe("resolveTargets", () => { + test("without Codex, .claude/skills alone serves every agent", () => { + for (const agents of [ + ["codev"], + ["codev", "claude"], + ["codev", "opencode"], + ["claude", "codev", "opencode"], + ] as SkillAgent[][]) { + const t = resolveTargets(agents, "global", "pg-tuner") + expect(t.store).toBe(join(tempHome, CLAUDE, "pg-tuner")) + // One directory, so CoDev Code and OpenCode never see the skill twice. + expect(t.links).toEqual([]) + } + }) + + test("Codex without Claude Code needs only .agents/skills", () => { + const t = resolveTargets(["codev", "codex"], "global", "pg-tuner") + expect(t.store).toBe(join(tempHome, AGENTS, "pg-tuner")) + expect(t.links).toEqual([]) + }) + + test("Codex plus Claude Code is the only case needing two directories", () => { + const t = resolveTargets(["claude", "codex", "codev"], "global", "pg-tuner") + expect(t.store).toBe(join(tempHome, AGENTS, "pg-tuner")) + expect(t.links).toEqual([join(tempHome, CLAUDE, "pg-tuner")]) + }) + + test("scope only changes the root — the rule is identical", () => { + const global = resolveTargets(["claude", "codex"], "global", "x") + const current = resolveTargets(["claude", "codex"], "current", "x") + expect(global.store).toBe(join(tempHome, AGENTS, "x")) + expect(current.store).toBe(join(tempCwd, AGENTS, "x")) + expect(current.links).toEqual([join(tempCwd, CLAUDE, "x")]) + }) +}) + +describe("agentsServedBy", () => { + test("reports every selected agent that reads a given directory", () => { + const agents: SkillAgent[] = ["claude", "codex", "codev"] + const store = join(tempHome, AGENTS, "x") + const link = join(tempHome, CLAUDE, "x") + // .agents/skills is read by Codex, CoDev Code and OpenCode — not Claude. + expect(agentsServedBy(store, agents, "global", "x")).toEqual(["codex", "codev"]) + // .claude/skills is read by Claude Code, CoDev Code and OpenCode. + expect(agentsServedBy(link, agents, "global", "x")).toEqual(["claude", "codev"]) + }) +}) + +describe("linkOrCopy", () => { + function makeStore(): string { + const store = join(tempHome, AGENTS, "pg-tuner") + mkdirSync(store, { recursive: true }) + writeFileSync(join(store, "SKILL.md"), "# pg-tuner") + return store + } + + test("creates a relative symlink that resolves to the store", async () => { + const store = makeStore() + const link = join(tempHome, CLAUDE, "pg-tuner") + + const mode = await linkOrCopy(store, link) + + expect(mode).toBe("symlink") + expect(lstatSync(link).isSymbolicLink()).toBe(true) + // Relative, not absolute — a relative link is the only kind that survives a + // git clone into another checkout. + const target = readlinkSync(link) + expect(isAbsolute(target)).toBe(false) + expect(target).toContain("..") + // And it actually resolves. + expect(readFileSync(join(link, "SKILL.md"), "utf-8")).toBe("# pg-tuner") + }) + + test("falls back to a copy when symlinking fails, and says so", async () => { + const store = makeStore() + const link = join(tempHome, CLAUDE, "pg-tuner") + spyOn(linkApi, "symlink").mockRejectedValue(Object.assign(new Error("operation not permitted"), { code: "EPERM" })) + + const mode = await linkOrCopy(store, link) + + // Reported as a copy — never described as a link it isn't. + expect(mode).toBe("copy") + expect(lstatSync(link).isSymbolicLink()).toBe(false) + expect(readFileSync(join(link, "SKILL.md"), "utf-8")).toBe("# pg-tuner") + }) + + test("creates the parent directory when the agent has never been used", async () => { + const store = makeStore() + const link = join(tempHome, CLAUDE, "pg-tuner") + expect(existsSync(join(tempHome, CLAUDE))).toBe(false) + + await linkOrCopy(store, link) + + expect(existsSync(link)).toBe(true) + }) +}) + +describe("claudeFollowsSymlinks", () => { + function version(stdout: string) { + return spyOn(execApi, "execAsync").mockResolvedValue({ error: null, stdout, stderr: "" }) + } + + test("true at and above the 2.1.203 floor", async () => { + version("2.1.203 (Claude Code)") + expect(await claudeFollowsSymlinks()).toBe(true) + version("2.1.220 (Claude Code)") + expect(await claudeFollowsSymlinks()).toBe(true) + version("3.0.0 (Claude Code)") + expect(await claudeFollowsSymlinks()).toBe(true) + }) + + test("false below it, so the Claude link becomes a real copy", async () => { + version("2.1.202 (Claude Code)") + expect(await claudeFollowsSymlinks()).toBe(false) + version("2.0.999 (Claude Code)") + expect(await claudeFollowsSymlinks()).toBe(false) + }) + + test("false when claude is absent or unparseable", async () => { + spyOn(execApi, "execAsync").mockResolvedValue({ + error: Object.assign(new Error("ENOENT"), { code: "ENOENT" }), + stdout: "", + stderr: "", + }) + expect(await claudeFollowsSymlinks()).toBe(false) + + version("some unexpected banner") + expect(await claudeFollowsSymlinks()).toBe(false) + }) +}) + +describe("isClaudeLink", () => { + test("identifies the one link whose mechanism depends on a version", () => { + expect(isClaudeLink(join(tempHome, CLAUDE, "x"), "global", "x")).toBe(true) + expect(isClaudeLink(join(tempHome, AGENTS, "x"), "global", "x")).toBe(false) + }) +}) + +describe("safeSegment", () => { + test("rejects anything that would escape a skills directory", () => { + expect(safeSegment("pg-tuner")).toBe(true) + expect(safeSegment("..")).toBe(false) + expect(safeSegment("../evil")).toBe(false) + expect(safeSegment("nested/name")).toBe(false) + expect(safeSegment("")).toBe(false) + // A leading dot would collide with the dot-directories the agents scan. + expect(safeSegment(".hidden")).toBe(false) + }) +}) diff --git a/packages/codev-skillhub/test/skill-install.test.ts b/packages/codev-skillhub/test/skill-install.test.ts new file mode 100644 index 0000000000..4d9a1cb52a --- /dev/null +++ b/packages/codev-skillhub/test/skill-install.test.ts @@ -0,0 +1,369 @@ +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import AdmZip from "adm-zip" +import { execApi } from "../src/exec" +import { parseAgentList, runSkillInstall, skillhubApi } from "../src/skill-install" +import type { SkillMeta } from "../src/skillhub" + +let tempDir: string +let tempHome: string +let originalTestHome: string | undefined + +const META: SkillMeta = { + id: "3f9a0000-0000-4000-8000-000000000000", + name: "pg-tuner", + version: "1.2.0", +} + +// A ZIP wrapped in a `pg-tuner/` root folder, like the server publishes. +function skillZip(root = "pg-tuner"): Buffer { + const zip = new AdmZip() + zip.addFile(`${root}/SKILL.md`, Buffer.from("# pg-tuner")) + zip.addFile(`${root}/scripts/run.sh`, Buffer.from("echo hi")) + return zip.toBuffer() +} + +function mockMeta(meta: SkillMeta = META) { + return spyOn(skillhubApi, "getSkillMeta").mockResolvedValue(meta) +} +function mockDownload(buf: Buffer = skillZip()) { + return spyOn(skillhubApi, "downloadSkill").mockResolvedValue(buf) +} + +function captureLog() { + const out: string[] = [] + spyOn(console, "log").mockImplementation((m?: unknown) => { + out.push(String(m)) + }) + return out +} +function captureErr() { + const out: string[] = [] + spyOn(console, "error").mockImplementation((m?: unknown) => { + out.push(String(m)) + }) + return out +} + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codev-skill-install-")) + tempHome = mkdtempSync(join(tmpdir(), "codev-skill-install-home-")) + // Isolate global-scope paths from the real home directory (CODEV_TEST_HOME + // rather than HOME: Bun's homedir() ignores runtime HOME changes). + originalTestHome = process.env.CODEV_TEST_HOME + process.env.CODEV_TEST_HOME = tempHome + // The Claude link's mechanism depends on `claude --version`. Pin it above the + // symlink floor so these tests don't spawn a real process and don't change + // behavior on a machine where Claude Code is absent or old. + spyOn(execApi, "execAsync").mockResolvedValue({ + error: null, + stdout: "2.1.220 (Claude Code)", + stderr: "", + }) +}) +afterEach(() => { + mock.restore() + if (originalTestHome === undefined) delete process.env.CODEV_TEST_HOME + if (originalTestHome !== undefined) process.env.CODEV_TEST_HOME = originalTestHome + rmSync(tempDir, { recursive: true, force: true }) + rmSync(tempHome, { recursive: true, force: true }) +}) + +describe("runSkillInstall", () => { + test("installs by name: resolves meta, downloads, strips root, extracts", async () => { + mockMeta() + const dl = mockDownload() + const out = captureLog() + + const code = await runSkillInstall({ target: "pg-tuner", dir: tempDir, force: false, json: false }) + + expect(code).toBe(0) + expect(dl).toHaveBeenCalledWith(META.id) + const skillDir = join(tempDir, "pg-tuner") + expect(readFileSync(join(skillDir, "SKILL.md"), "utf-8")).toBe("# pg-tuner") + expect(existsSync(join(skillDir, "scripts", "run.sh"))).toBe(true) + expect(out.join("\n")).toContain("Installed pg-tuner@1.2.0 ->") + }) + + test("installs by UUID: names the folder from the resolved name, not the id", async () => { + const getMeta = mockMeta() + mockDownload() + const out = captureLog() + + const code = await runSkillInstall({ target: META.id, dir: tempDir, force: false, json: false }) + + expect(code).toBe(0) + expect(getMeta).toHaveBeenCalledWith(META.id) + // Folder is the human name, not the UUID — and the version is shown too. + expect(existsSync(join(tempDir, "pg-tuner", "SKILL.md"))).toBe(true) + expect(existsSync(join(tempDir, META.id))).toBe(false) + expect(out.join("\n")).toContain("Installed pg-tuner@1.2.0 ->") + }) + + test("errors when the skill is not found", async () => { + spyOn(skillhubApi, "getSkillMeta").mockRejectedValue(new Error('Skill "nope" not found or not public.')) + const dl = mockDownload() + const errs = captureErr() + + const code = await runSkillInstall({ target: "nope", dir: tempDir, force: false, json: false }) + + expect(code).toBe(1) + expect(dl).not.toHaveBeenCalled() + expect(errs.join("\n")).toMatch(/not found or not public/i) + }) + + test("refuses to overwrite an existing install without --force", async () => { + mockMeta() + mockDownload() + mkdirSync(join(tempDir, "pg-tuner"), { recursive: true }) + const errs = captureErr() + + const code = await runSkillInstall({ target: "pg-tuner", dir: tempDir, force: false, json: false }) + + expect(code).toBe(1) + expect(errs.join("\n")).toMatch(/Already installed/) + }) + + test("--force overwrites an existing install", async () => { + mockMeta() + mockDownload() + const skillDir = join(tempDir, "pg-tuner") + mkdirSync(skillDir, { recursive: true }) + captureLog() + + const code = await runSkillInstall({ target: "pg-tuner", dir: tempDir, force: true, json: false }) + + expect(code).toBe(0) + expect(existsSync(join(skillDir, "SKILL.md"))).toBe(true) + }) + + test("--json emits a machine-readable summary", async () => { + mockMeta() + mockDownload() + const out = captureLog() + + const code = await runSkillInstall({ target: "pg-tuner", dir: tempDir, force: false, json: true }) + + expect(code).toBe(0) + expect(out).toHaveLength(1) + const parsed = JSON.parse(out[0] as string) + expect(parsed).toMatchObject({ + ok: true, + name: "pg-tuner", + version: "1.2.0", + id: META.id, + strippedRoot: "pg-tuner", + }) + expect(parsed.dir).toContain("pg-tuner") + }) + + test("requires a location when none is given (no prompt available)", async () => { + const getMeta = mockMeta() + const errs = captureErr() + + const code = await runSkillInstall({ target: "pg-tuner", force: false, json: false }) + + expect(code).toBe(1) + expect(getMeta).not.toHaveBeenCalled() + expect(errs.join("\n")).toMatch(/--here, --global, or --dir/i) + }) + + // dir is an exact path (`/`), while location "current" resolves + // the selected agents' directories under the cwd — the layout the agents + // actually read. + test("dir installs verbatim; location resolves agent directories", async () => { + mockMeta() + mockDownload() + captureLog() + spyOn(process, "cwd").mockReturnValue(tempDir) + + expect(await runSkillInstall({ target: "pg-tuner", dir: tempDir, force: false, json: false })).toBe(0) + expect(existsSync(join(tempDir, "pg-tuner", "SKILL.md"))).toBe(true) + + // CoDev Code alone reads .claude/skills, so that is the whole install. + expect( + await runSkillInstall({ target: "pg-tuner", location: "current", agents: ["codev"], force: false, json: false }), + ).toBe(0) + expect(existsSync(join(tempDir, ".claude", "skills", "pg-tuner", "SKILL.md"))).toBe(true) + }) + + // The one arrangement that needs two directories: Codex reads only + // .agents/skills, Claude Code only .claude/skills. + test("claude plus codex extracts once and links the second directory", async () => { + mockMeta() + const dl = mockDownload() + captureLog() + spyOn(process, "cwd").mockReturnValue(tempDir) + + expect( + await runSkillInstall({ + target: "pg-tuner", + location: "current", + agents: ["claude", "codex", "codev"], + force: false, + json: false, + }), + ).toBe(0) + + const store = join(tempDir, ".agents", "skills", "pg-tuner") + const link = join(tempDir, ".claude", "skills", "pg-tuner") + expect(readFileSync(join(store, "SKILL.md"), "utf-8")).toBe("# pg-tuner") + // Downloaded once, extracted once — the second path is a link to the first. + expect(dl).toHaveBeenCalledTimes(1) + expect(lstatSync(link).isSymbolicLink()).toBe(true) + expect(readFileSync(join(link, "SKILL.md"), "utf-8")).toBe("# pg-tuner") + }) + + // Claude Code only follows a symlinked skill dir from v2.1.203; below that the + // link has to be a real copy or the skill is simply invisible to it. + test("an older Claude Code gets a copy instead of a link", async () => { + mockMeta() + mockDownload() + spyOn(process, "cwd").mockReturnValue(tempDir) + spyOn(execApi, "execAsync").mockResolvedValue({ + error: null, + stdout: "2.1.202 (Claude Code)", + stderr: "", + }) + const out = captureLog() + + expect( + await runSkillInstall({ + target: "pg-tuner", + location: "current", + agents: ["claude", "codex", "codev"], + force: false, + json: false, + }), + ).toBe(0) + + const link = join(tempDir, ".claude", "skills", "pg-tuner") + expect(lstatSync(link).isSymbolicLink()).toBe(false) + expect(readFileSync(join(link, "SKILL.md"), "utf-8")).toBe("# pg-tuner") + // And it says so rather than claiming a link. + expect(out.join("\n")).toContain("(copy)") + }) + + test("the full agent set names every agent in the result", async () => { + mockMeta() + mockDownload() + spyOn(process, "cwd").mockReturnValue(tempDir) + const out = captureLog() + + expect( + await runSkillInstall({ + target: "pg-tuner", + location: "current", + agents: ["claude", "codex", "opencode", "codev"], + force: false, + json: false, + }), + ).toBe(0) + + const text = out.join("\n") + for (const label of ["Claude Code", "Codex", "OpenCode", "CoDev Code"]) { + expect(text).toContain(label) + } + }) + + // The upgrade path: a skill installed before agent support is a real directory + // in .claude/skills. Re-pulling with Codex selected has to convert it to a + // link rather than leave a stale second copy behind. + test("--force converts a pre-existing real directory into a link", async () => { + mockMeta() + mockDownload() + spyOn(process, "cwd").mockReturnValue(tempDir) + const stale = join(tempDir, ".claude", "skills", "pg-tuner") + mkdirSync(stale, { recursive: true }) + captureLog() + + expect( + await runSkillInstall({ + target: "pg-tuner", + location: "current", + agents: ["claude", "codex", "codev"], + force: true, + json: false, + }), + ).toBe(0) + + expect(lstatSync(stale).isSymbolicLink()).toBe(true) + expect(readFileSync(join(stale, "SKILL.md"), "utf-8")).toBe("# pg-tuner") + }) + + test("without --force an existing agent directory stops the install", async () => { + mockMeta() + mockDownload() + spyOn(process, "cwd").mockReturnValue(tempDir) + mkdirSync(join(tempDir, ".claude", "skills", "pg-tuner"), { + recursive: true, + }) + const errs = captureErr() + + const code = await runSkillInstall({ + target: "pg-tuner", + location: "current", + agents: ["claude", "codex", "codev"], + force: false, + json: false, + }) + + expect(code).toBe(1) + expect(errs.join("\n")).toMatch(/Already installed/) + // Refused before anything was removed — the store was never created. + expect(existsSync(join(tempDir, ".agents", "skills", "pg-tuner"))).toBe(false) + }) + + test("rejects a server name that escapes the target dir (no download, no write)", async () => { + spyOn(skillhubApi, "getSkillMeta").mockResolvedValue({ + id: META.id, + name: "../../evil", + version: "1.0.0", + }) + const dl = mockDownload() + const errs = captureErr() + + const code = await runSkillInstall({ target: "whatever", dir: tempDir, force: false, json: false }) + + expect(code).toBe(1) + expect(errs.join("\n")).toMatch(/unsafe name/i) + // The path is validated before any network or filesystem work. + expect(dl).not.toHaveBeenCalled() + expect(existsSync(join(tempDir, "..", "evil"))).toBe(false) + }) + + test("rejects a server name containing a path separator", async () => { + spyOn(skillhubApi, "getSkillMeta").mockResolvedValue({ + id: META.id, + name: "nested/name", + version: "1.0.0", + }) + const dl = mockDownload() + const errs = captureErr() + + const code = await runSkillInstall({ target: "whatever", dir: tempDir, force: false, json: false }) + + expect(code).toBe(1) + expect(errs.join("\n")).toMatch(/unsafe name/i) + expect(dl).not.toHaveBeenCalled() + }) +}) + +describe("parseAgentList", () => { + // CoDev Code is the flagship: naming other agents never drops it. + test("CoDev Code is always in the agent set", () => { + expect(parseAgentList("claude")).toEqual(["claude", "codev"]) + expect(parseAgentList("codex")).toEqual(["codex", "codev"]) + }) + + test("rejects an unknown agent name", () => { + expect(parseAgentList("cursor")).toContain("Unknown agent: cursor") + }) + + test("rejects an empty value", () => { + expect(parseAgentList("")).toBe("Missing value for --agent.") + expect(parseAgentList(" , ")).toBe("Missing value for --agent.") + }) +}) diff --git a/packages/codev-skillhub/test/skill-publish.test.ts b/packages/codev-skillhub/test/skill-publish.test.ts new file mode 100644 index 0000000000..8d972d3da5 --- /dev/null +++ b/packages/codev-skillhub/test/skill-publish.test.ts @@ -0,0 +1,312 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import AdmZip from "adm-zip" +import { httpApi } from "../src/http" +import { + formatPublishResult, + type PublishResult, + type PublishStep, + plannedSteps, + preparePublishArchive, + publishSkill, + runSkillPublish, + type StepStatus, +} from "../src/skill-publish" + +let tempDir: string +let savedHome: string | undefined + +// Create a skill directory with a SKILL.md plus the given extra files. +function makeSkillDir(name: string, extra: Record = {}): string { + const dir = join(tempDir, name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, "SKILL.md"), "# skill") + for (const [rel, contents] of Object.entries(extra)) { + const abs = join(dir, rel) + mkdirSync(join(abs, ".."), { recursive: true }) + writeFileSync(abs, contents) + } + return dir +} + +function captureLog() { + const out: string[] = [] + spyOn(console, "log").mockImplementation((m?: unknown) => { + out.push(String(m)) + }) + return out +} +function captureErr() { + const out: string[] = [] + spyOn(console, "error").mockImplementation((m?: unknown) => { + out.push(String(m)) + }) + return out +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }) +} + +// Stub the network seam so publishSkill/runSkillPublish never touch the wire: +// one httpApi.fetch fake routing the four SkillHub endpoints, recording each +// request so tests can assert which steps ran and with what payloads. +function mockNetwork(overrides: Partial> = {}) { + const calls: { url: string; init: RequestInit }[] = [] + spyOn(httpApi, "fetch").mockImplementation(async (_endpoint, input, init) => { + const url = String(input) + calls.push({ url, init: init ?? {} }) + if (url.endsWith("/api/v1/skills/upload")) { + return overrides.upload ?? jsonResponse(200, { success: true, skill_id: "sk-1", status: "PENDING" }) + } + if (url.endsWith("/metadata")) return overrides.metadata ?? jsonResponse(200, { success: true }) + if (url.endsWith("/submit")) return overrides.submit ?? jsonResponse(200, { success: true }) + if (url.endsWith("/api/v1/admin/review")) return overrides.review ?? jsonResponse(200, { success: true }) + return jsonResponse(404, { success: false }) + }) + return { + calls, + of: (suffix: string) => calls.filter((c) => c.url.endsWith(suffix)), + } +} + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codev-skill-publish-")) + savedHome = process.env.CODEV_TEST_HOME + process.env.CODEV_TEST_HOME = tempDir + // The publish endpoints require auth — seed the real auth file the gateway + // reads so skillhubFetch attaches the cookie without any module mock. + const dir = join(tempDir, ".codev") + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, "auth.json"), JSON.stringify({ skillhub_cookie: "skill-hub-session=abc" })) +}) + +afterEach(() => { + mock.restore() + if (savedHome === undefined) delete process.env.CODEV_TEST_HOME + else process.env.CODEV_TEST_HOME = savedHome + rmSync(tempDir, { recursive: true, force: true }) +}) + +describe("plannedSteps", () => { + test("default runs upload, metadata, submit", () => { + expect(plannedSteps({ draftOnly: false, autoApprove: false })).toEqual(["upload", "metadata", "submit"]) + }) + test("--draft-only stops after metadata", () => { + expect(plannedSteps({ draftOnly: true, autoApprove: false })).toEqual(["upload", "metadata"]) + }) + test("--auto-approve appends approve", () => { + expect(plannedSteps({ draftOnly: false, autoApprove: true })).toEqual(["upload", "metadata", "submit", "approve"]) + }) + test("--auto-approve is ignored under --draft-only", () => { + expect(plannedSteps({ draftOnly: true, autoApprove: true })).toEqual(["upload", "metadata"]) + }) +}) + +describe("preparePublishArchive", () => { + test("zips a directory, includes real files, and excludes secrets/junk", async () => { + const dir = makeSkillDir("pg-tuner", { + "scripts/run.sh": "echo hi", + ".env": "SECRET=1", + ".DS_Store": "junk", + "tests/run.test.sh": "echo test", + "logs/run.log": "log line", + }) + mkdirSync(join(dir, ".git"), { recursive: true }) + writeFileSync(join(dir, ".git", "config"), "[core]") + + const a = await preparePublishArchive(dir) + + expect(a.source).toBe("dir") + expect(a.fileName).toBe("pg-tuner.zip") + expect(a.files.sort()).toEqual(["SKILL.md", "scripts/run.sh"]) + expect(a.skipped).toContain(".env") + expect(a.skipped).toContain(".DS_Store") + expect(a.skipped).toContain(".git/") + expect(a.skipped).toContain("tests/") + expect(a.skipped).toContain("logs/") + + // The excluded files really are absent from the produced archive. + const names = new AdmZip(a.zipBuffer).getEntries().map((e) => e.entryName) + expect(names).toContain("pg-tuner/SKILL.md") + expect(names).toContain("pg-tuner/scripts/run.sh") + expect(names.some((n) => n.includes(".env"))).toBe(false) + expect(names.some((n) => n.includes(".git"))).toBe(false) + expect(names.some((n) => n.includes("tests/"))).toBe(false) + expect(names.some((n) => n.includes("logs/"))).toBe(false) + }) + + test("rejects a directory with no SKILL.md", async () => { + const dir = join(tempDir, "no-skill") + mkdirSync(dir, { recursive: true }) + await expect(preparePublishArchive(dir)).rejects.toThrow(/SKILL\.md/) + }) + + test("reads a .zip file as-is and inspects it", async () => { + const zip = new AdmZip() + zip.addFile("SKILL.md", Buffer.from("# s")) + const zipPath = join(tempDir, "bundle.zip") + writeFileSync(zipPath, zip.toBuffer()) + + const a = await preparePublishArchive(zipPath) + expect(a.source).toBe("zip") + expect(a.fileName).toBe("bundle.zip") + expect(a.files).toEqual(["SKILL.md"]) + expect(a.skipped).toEqual([]) + }) + + test("rejects a non-zip file", async () => { + const file = join(tempDir, "notes.txt") + writeFileSync(file, "hi") + await expect(preparePublishArchive(file)).rejects.toThrow(/\.zip/) + }) + + test("rejects a missing path", async () => { + await expect(preparePublishArchive(join(tempDir, "nope"))).rejects.toThrow(/not found/i) + }) +}) + +describe("publishSkill", () => { + const archive = { zipBuffer: Buffer.from("z"), fileName: "s.zip" } + + test("default: upload -> metadata -> submit, status SUBMITTED", async () => { + const net = mockNetwork() + const events: [PublishStep, StepStatus][] = [] + + const r = await publishSkill(archive, { draftOnly: false, autoApprove: false }, (step, status) => + events.push([step, status]), + ) + + expect(net.of("/api/v1/skills/upload")).toHaveLength(1) + const metaCall = net.of("/api/v1/skills/sk-1/metadata") + expect(metaCall).toHaveLength(1) + expect(metaCall[0]?.init.body).toBe("{}") + expect(net.of("/api/v1/skills/sk-1/submit")).toHaveLength(1) + expect(net.of("/api/v1/admin/review")).toHaveLength(0) + expect(r.status).toBe("SUBMITTED") + expect(r.skillId).toBe("sk-1") + expect(events).toEqual([ + ["upload", "start"], + ["upload", "done"], + ["metadata", "start"], + ["metadata", "done"], + ["submit", "start"], + ["submit", "done"], + ]) + }) + + test("--draft-only stops after metadata, status DRAFT", async () => { + const net = mockNetwork() + const r = await publishSkill(archive, { draftOnly: true, autoApprove: false }) + expect(net.of("/submit")).toHaveLength(0) + expect(net.of("/api/v1/admin/review")).toHaveLength(0) + expect(r.status).toBe("DRAFT") + }) + + test("--auto-approve approves after submit, status PUBLIC", async () => { + const net = mockNetwork() + const r = await publishSkill(archive, { draftOnly: false, autoApprove: true }) + expect(net.of("/api/v1/skills/sk-1/submit")).toHaveLength(1) + const review = net.of("/api/v1/admin/review") + expect(review).toHaveLength(1) + expect(JSON.parse(String(review[0]?.init.body))).toEqual({ + skill_id: "sk-1", + action: "APPROVE", + feedback: "Auto-approved via CLI", + }) + expect(r.status).toBe("PUBLIC") + }) + + test("throws when upload returns no skill_id", async () => { + const net = mockNetwork({ upload: jsonResponse(200, { success: true }) }) + await expect(publishSkill(archive, { draftOnly: false, autoApprove: false })).rejects.toThrow(/no skill_id/i) + expect(net.of("/metadata")).toHaveLength(0) + }) +}) + +describe("formatPublishResult", () => { + const base: PublishResult = { + skillId: "sk-1", + status: "SUBMITTED", + steps: ["uploaded", "metadata saved (DRAFT)", "submitted for review (SUBMITTED)"], + } + + test("human output shows the id, steps, and SUBMITTED hint", () => { + const out = formatPublishResult(base, false) + expect(out).toContain("Published skill sk-1") + expect(out).toContain("submitted for review (SUBMITTED)") + expect(out).toMatch(/Status: SUBMITTED/) + }) + + test("json output is a single machine-readable line", () => { + expect(JSON.parse(formatPublishResult(base, true))).toEqual({ + ok: true, + skillId: "sk-1", + status: "SUBMITTED", + steps: base.steps, + }) + }) + + test("DRAFT and PUBLIC carry their own hints", () => { + const draft = formatPublishResult({ ...base, status: "DRAFT" }, false) + expect(draft).toMatch(/Status: DRAFT/) + expect(draft).toContain("run `codev skill push` again (without --draft-only) to submit.") + expect(formatPublishResult({ ...base, status: "PUBLIC" }, false)).toMatch(/Status: PUBLIC/) + }) +}) + +describe("runSkillPublish", () => { + test("publishes a directory and prints the result (exit 0)", async () => { + mockNetwork() + const dir = makeSkillDir("pg-tuner") + const out = captureLog() + + const code = await runSkillPublish({ path: dir, draftOnly: false, autoApprove: false, json: false }) + + expect(code).toBe(0) + expect(out.join("\n")).toContain("Published skill sk-1") + }) + + test("--json emits a machine-readable summary", async () => { + mockNetwork() + const dir = makeSkillDir("pg-tuner") + const out = captureLog() + + const code = await runSkillPublish({ path: dir, draftOnly: false, autoApprove: false, json: true }) + + expect(code).toBe(0) + expect(JSON.parse(out[0] as string)).toMatchObject({ + ok: true, + skillId: "sk-1", + status: "SUBMITTED", + }) + }) + + test("notes excluded files on stderr (not stdout)", async () => { + mockNetwork() + const dir = makeSkillDir("pg-tuner", { ".env": "SECRET=1" }) + const out = captureLog() + const errs = captureErr() + + await runSkillPublish({ path: dir, draftOnly: false, autoApprove: false, json: false }) + + expect(errs.join("\n")).toMatch(/Excluded from upload:.*\.env/) + expect(out.join("\n")).not.toContain("Excluded from upload") + }) + + test("returns exit 1 with the message on failure", async () => { + mockNetwork({ upload: jsonResponse(400, { success: false, message: "name already taken" }) }) + const dir = makeSkillDir("pg-tuner") + const errs = captureErr() + + const code = await runSkillPublish({ path: dir, draftOnly: false, autoApprove: false, json: false }) + + expect(code).toBe(1) + expect(errs.join("\n")).toContain("name already taken") + }) +}) diff --git a/packages/codev-skillhub/test/skill-search.test.ts b/packages/codev-skillhub/test/skill-search.test.ts new file mode 100644 index 0000000000..fc50abf09d --- /dev/null +++ b/packages/codev-skillhub/test/skill-search.test.ts @@ -0,0 +1,153 @@ +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import { httpApi } from "../src/http" +import { runSkillSearch } from "../src/skill-search" +import type { HubSkill } from "../src/skillhub" + +let tempDir: string +let savedHome: string | undefined + +// runSkillSearch reaches the hub through listHubSkills → skillhubFetch → +// httpApi.fetch, so stubbing the HTTP seam is enough — no module mocks. The +// listing endpoint is optional-auth, so an empty CODEV_TEST_HOME (no auth +// file) exercises the logged-out path with no network. +function mockSearch(items: HubSkill[], total = items.length) { + return spyOn(httpApi, "fetch").mockResolvedValue( + new Response(JSON.stringify({ success: true, data: items, pagination: { total } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ) +} + +function captureLog() { + const out: string[] = [] + spyOn(console, "log").mockImplementation((m?: unknown) => { + out.push(String(m)) + }) + return out +} + +function captureErr() { + const out: string[] = [] + spyOn(console, "error").mockImplementation((m?: unknown) => { + out.push(String(m)) + }) + return out +} + +const SKILL: HubSkill = { + id: "id-1", + name: "pg-tuner", + provider: "viettel", + description: "Tune Postgres configs", + version: "1.2.0", + publishedAt: "2026-06-01", +} + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codev-skill-search-")) + savedHome = process.env.CODEV_TEST_HOME + process.env.CODEV_TEST_HOME = tempDir +}) + +afterEach(() => { + mock.restore() + if (savedHome === undefined) delete process.env.CODEV_TEST_HOME + else process.env.CODEV_TEST_HOME = savedHome + rmSync(tempDir, { recursive: true, force: true }) +}) + +describe("runSkillSearch", () => { + test("passes the query and the default limit to the hub request", async () => { + const spy = mockSearch([SKILL]) + captureLog() + + const code = await runSkillSearch({ query: "postgres tuning", json: false }) + + expect(code).toBe(0) + expect(spy).toHaveBeenCalledTimes(1) + const url = new URL(String(spy.mock.calls[0]?.[1])) + expect(url.searchParams.get("search")).toBe("postgres tuning") + expect(url.searchParams.get("limit")).toBe("20") + }) + + test("an explicit limit rides along", async () => { + const spy = mockSearch([SKILL]) + captureLog() + + await runSkillSearch({ query: "pg", limit: 5, json: false }) + + const url = new URL(String(spy.mock.calls[0]?.[1])) + expect(url.searchParams.get("limit")).toBe("5") + }) + + test("renders a human-readable list", async () => { + mockSearch([SKILL], 7) + const out = captureLog() + + await runSkillSearch({ query: "pg", json: false }) + + const text = out.join("\n") + expect(text).toContain("Found 7 skill(s) — showing 1:") + expect(text).toContain("pg-tuner@1.2.0 by viettel") + expect(text).toContain("Tune Postgres configs") + expect(text).toContain("id: id-1") + }) + + test("strips control/ANSI characters from hub fields in the human list", async () => { + const esc = String.fromCharCode(0x1b) + const bel = String.fromCharCode(0x07) + mockSearch([ + { + ...SKILL, + name: `pg${esc}[31m-tuner`, + provider: `vie${bel}ttel`, + description: `Tune${esc}[2J Postgres`, + }, + ]) + const out = captureLog() + + await runSkillSearch({ query: "pg", json: false }) + + const text = out.join("\n") + // The escape/control bytes are gone; the surrounding text remains. + expect(text).not.toContain(esc) + expect(text).not.toContain(bel) + expect(text).toContain("pg[31m-tuner@1.2.0 by viettel") + expect(text).toContain("Tune[2J Postgres") + }) + + test("emits JSON with --json (no human text)", async () => { + mockSearch([SKILL], 7) + const out = captureLog() + + const code = await runSkillSearch({ query: "pg", json: true }) + + expect(code).toBe(0) + expect(out).toHaveLength(1) + const parsed = JSON.parse(out[0] as string) + expect(parsed).toEqual({ ok: true, total: 7, items: [SKILL] }) + }) + + test("shows a query-specific message when there are no matches", async () => { + mockSearch([]) + const out = captureLog() + + await runSkillSearch({ query: "nope", json: false }) + + expect(out.join("\n")).toBe('No skills match "nope".') + }) + + test("returns exit code 1 and prints the error when the API fails", async () => { + spyOn(httpApi, "fetch").mockResolvedValue(new Response(JSON.stringify({ success: false }), { status: 500 })) + const errs = captureErr() + + const code = await runSkillSearch({ query: "pg", json: false }) + + expect(code).toBe(1) + expect(errs.join("\n")).toContain("Skill search failed (500).") + }) +}) diff --git a/packages/codev-skillhub/test/skillhub.test.ts b/packages/codev-skillhub/test/skillhub.test.ts new file mode 100644 index 0000000000..2d573effad --- /dev/null +++ b/packages/codev-skillhub/test/skillhub.test.ts @@ -0,0 +1,332 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import { SKILLHUB_URL } from "../src/const" +import { httpApi } from "../src/http" +import { + adminReviewSkill, + downloadSkill, + hasSkillhubAuth, + listHubSkills, + SkillhubAuthError, + saveSkillMetadata, + skillhubFetch, + submitSkill, + uploadSkill, +} from "../src/skillhub" + +let tempDir: string +let savedHome: string | undefined + +// Seed $CODEV_TEST_HOME/.codev/auth.json for the test — the real file the +// gateway's loadSkillhubCookie/silentSso read (no module mocks). A valid +// (non-expired) SSO session lets silentSso() return it without any network +// call, so the Bearer path is exercisable offline. Never seed an expired +// session with a refresh_token — silentSso() would attempt a network refresh. +function seedAuth(contents: Record): void { + const dir = join(tempDir, ".codev") + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, "auth.json"), JSON.stringify(contents)) +} + +const VALID_SSO = { + access_token: "sso-access-token", + id_token: "sso-id-token", + expires_at: Date.now() + 3_600_000, + user: { sub: "emp-1", email: "a@b.c", displayName: "A" }, +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }) +} + +type FetchSpy = ReturnType + +function stubFetch(response: Response) { + return spyOn(httpApi, "fetch").mockResolvedValue(response) +} + +function lastUrl(spy: FetchSpy): string { + const call = spy.mock.calls.at(-1) + if (!call) throw new Error("httpApi.fetch was not called") + return String(call[1]) +} + +function lastInit(spy: FetchSpy): RequestInit { + const call = spy.mock.calls.at(-1) + if (!call) throw new Error("httpApi.fetch was not called") + return call[2] ?? {} +} + +function headersOf(init: RequestInit): Record { + return (init.headers ?? {}) as Record +} + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codev-skillhub-")) + savedHome = process.env.CODEV_TEST_HOME + process.env.CODEV_TEST_HOME = tempDir +}) + +afterEach(() => { + mock.restore() + if (savedHome === undefined) delete process.env.CODEV_TEST_HOME + else process.env.CODEV_TEST_HOME = savedHome + rmSync(tempDir, { recursive: true, force: true }) +}) + +describe("skillhubFetch auth selection", () => { + test("sends the stored admin cookie when one exists (no Bearer)", async () => { + seedAuth({ skillhub_cookie: "skill-hub-session=abc" }) + const spy = stubFetch(jsonResponse(200, { success: true })) + + await skillhubFetch("/api/v1/me") + + expect(spy).toHaveBeenCalledTimes(1) + expect(lastUrl(spy)).toBe(`${SKILLHUB_URL}/api/v1/me`) + const h = headersOf(lastInit(spy)) + expect(h.Cookie).toBe("skill-hub-session=abc") + expect(h.Authorization).toBeUndefined() + }) + + test("falls back to the SSO access token as Bearer when no cookie", async () => { + seedAuth(VALID_SSO) + const spy = stubFetch(jsonResponse(200, { success: true })) + + await skillhubFetch("/api/v1/me") + + const h = headersOf(lastInit(spy)) + expect(h.Authorization).toBe("Bearer sso-access-token") + expect(h.Cookie).toBeUndefined() + }) + + test("throws SkillhubAuthError when neither cookie nor SSO session exists", async () => { + // No auth file at all. + const spy = stubFetch(jsonResponse(200, { success: true })) + + await expect(skillhubFetch("/api/v1/me")).rejects.toMatchObject({ + name: "SkillhubAuthError", + message: "Not logged in to SkillHub. Run `codev skill push` in a terminal to sign in (or `codevhub login`).", + }) + expect(spy).not.toHaveBeenCalled() + }) + + test("normalizes a 401 into a SkillhubAuthError with a re-login hint", async () => { + seedAuth(VALID_SSO) + stubFetch(jsonResponse(401, { success: false, error: "Unauthorized" })) + + await expect(skillhubFetch("/api/v1/me")).rejects.toMatchObject({ + name: "SkillhubAuthError", + message: "SkillHub session expired or invalid (401). Sign in again and re-run.", + }) + }) + + test("returns a 403 response for the caller (not an auth error)", async () => { + seedAuth(VALID_SSO) + stubFetch(jsonResponse(403, { success: false, error: "forbidden" })) + + const res = await skillhubFetch("/api/v1/admin/review") + expect(res.status).toBe(403) + }) +}) + +describe("listHubSkills", () => { + const HUB = { + success: true, + data: [ + { + id: "id-1", + name: "pg-tuner", + provider: "viettel", + description: "Tune Postgres", + version: "1.2.0", + publishedAt: "2026-06-01", + }, + ], + pagination: { total: 7 }, + } + + test("works logged out (optional auth) and passes search + limit", async () => { + // No cookie, no SSO session. + const spy = stubFetch(jsonResponse(200, HUB)) + + const result = await listHubSkills({ search: "postgres", limit: 5 }) + + expect(spy).toHaveBeenCalledTimes(1) + const url = new URL(lastUrl(spy)) + expect(url.pathname).toBe("/netmindhub/api/v1/hub/skills") + expect(url.searchParams.get("search")).toBe("postgres") + expect(url.searchParams.get("limit")).toBe("5") + // No credential available → no auth header, but no throw either. + const h = headersOf(lastInit(spy)) + expect(h.Authorization).toBeUndefined() + expect(h.Cookie).toBeUndefined() + + expect(result.total).toBe(7) + expect(result.items).toHaveLength(1) + expect(result.items[0]?.name).toBe("pg-tuner") + }) + + test("attaches a stored session when one exists", async () => { + seedAuth({ skillhub_cookie: "skill-hub-session=abc" }) + const spy = stubFetch(jsonResponse(200, HUB)) + + await listHubSkills() + + expect(headersOf(lastInit(spy)).Cookie).toBe("skill-hub-session=abc") + }) + + test("falls back to items.length when pagination.total is absent", async () => { + stubFetch(jsonResponse(200, { success: true, data: HUB.data })) + expect((await listHubSkills()).total).toBe(1) + }) + + test("throws on a non-2xx response", async () => { + stubFetch(jsonResponse(500, { success: false })) + await expect(listHubSkills()).rejects.toThrow(/failed \(500\)/) + }) + + test("throws on an unexpected body shape", async () => { + stubFetch(jsonResponse(200, { success: false })) + await expect(listHubSkills()).rejects.toThrow(/unexpected response/i) + }) +}) + +describe("downloadSkill", () => { + test("returns the raw ZIP bytes on 200 (public, logged out)", async () => { + const bytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]) // "PK\x03\x04" + const spy = stubFetch(new Response(bytes, { status: 200 })) + + const buf = await downloadSkill("id-1") + + expect(lastUrl(spy)).toBe(`${SKILLHUB_URL}/api/v1/skills/id-1/download`) + expect(Buffer.isBuffer(buf)).toBe(true) + expect([...buf]).toEqual([0x50, 0x4b, 0x03, 0x04]) + expect(headersOf(lastInit(spy)).Authorization).toBeUndefined() + }) + + test("gives a not-found/not-public message on 404", async () => { + stubFetch(new Response("", { status: 404 })) + await expect(downloadSkill("missing")).rejects.toThrow(/not found or not public/i) + }) + + test("throws on other non-2xx", async () => { + stubFetch(new Response("", { status: 500 })) + await expect(downloadSkill("x")).rejects.toThrow(/failed \(500\)/) + }) +}) + +describe("uploadSkill", () => { + test("POSTs multipart form-data and returns the skill_id", async () => { + seedAuth({ skillhub_cookie: "skill-hub-session=abc" }) + const spy = stubFetch(jsonResponse(200, { success: true, skill_id: "sk-1", status: "PENDING" })) + + const res = await uploadSkill(Buffer.from("PK\x03\x04zip"), "pg-tuner.zip") + + expect(res.skill_id).toBe("sk-1") + expect(lastUrl(spy)).toBe(`${SKILLHUB_URL}/api/v1/skills/upload`) + const init = lastInit(spy) + expect(init.method).toBe("POST") + expect(init.body).toBeInstanceOf(FormData) + // Content-Type must NOT be set by us — fetch adds the multipart boundary. + expect(headersOf(init)["Content-Type"]).toBeUndefined() + // Cookie still rides along via skillhubFetch. + expect(headersOf(init).Cookie).toBe("skill-hub-session=abc") + }) + + test("throws the server message when success is false", async () => { + seedAuth({ skillhub_cookie: "skill-hub-session=abc" }) + stubFetch(jsonResponse(400, { success: false, message: "name already taken" })) + await expect(uploadSkill(Buffer.from("z"), "x.zip")).rejects.toThrow("name already taken") + }) +}) + +describe("saveSkillMetadata", () => { + test("PATCHes the metadata endpoint with the JSON body", async () => { + seedAuth({ skillhub_cookie: "skill-hub-session=abc" }) + const spy = stubFetch(jsonResponse(200, { success: true })) + + await saveSkillMetadata("sk-1") + + expect(lastUrl(spy)).toBe(`${SKILLHUB_URL}/api/v1/skills/sk-1/metadata`) + const init = lastInit(spy) + expect(init.method).toBe("PATCH") + expect(init.body).toBe("{}") + expect(headersOf(init)["Content-Type"]).toBe("application/json") + }) + + test("throws on a failure response", async () => { + seedAuth({ skillhub_cookie: "skill-hub-session=abc" }) + stubFetch(jsonResponse(500, { success: false, message: "boom" })) + await expect(saveSkillMetadata("sk-1")).rejects.toThrow("boom") + }) +}) + +describe("submitSkill", () => { + test("PATCHes the submit endpoint", async () => { + seedAuth({ skillhub_cookie: "skill-hub-session=abc" }) + const spy = stubFetch(jsonResponse(200, { success: true })) + + await submitSkill("sk-1") + + expect(lastUrl(spy)).toBe(`${SKILLHUB_URL}/api/v1/skills/sk-1/submit`) + expect(lastInit(spy).method).toBe("PATCH") + }) + + test("throws on a failure response", async () => { + seedAuth({ skillhub_cookie: "skill-hub-session=abc" }) + stubFetch(jsonResponse(409, { success: false, message: "wrong status" })) + await expect(submitSkill("sk-1")).rejects.toThrow("wrong status") + }) +}) + +describe("adminReviewSkill", () => { + test("POSTs the review action with skill_id and feedback", async () => { + seedAuth({ skillhub_cookie: "skill-hub-session=abc" }) + const spy = stubFetch(jsonResponse(200, { success: true })) + + await adminReviewSkill("sk-1", "APPROVE", "ok") + + expect(lastUrl(spy)).toBe(`${SKILLHUB_URL}/api/v1/admin/review`) + const init = lastInit(spy) + expect(init.method).toBe("POST") + expect(JSON.parse(String(init.body))).toEqual({ + skill_id: "sk-1", + action: "APPROVE", + feedback: "ok", + }) + }) + + test("throws the server message when a non-admin is forbidden (403)", async () => { + seedAuth({ skillhub_cookie: "skill-hub-session=abc" }) + stubFetch(jsonResponse(403, { success: false, message: "admin only" })) + await expect(adminReviewSkill("sk-1", "APPROVE")).rejects.toThrow("admin only") + }) +}) + +describe("hasSkillhubAuth", () => { + test("true when an admin cookie is stored (no network)", async () => { + seedAuth({ skillhub_cookie: "skill-hub-session=abc" }) + const spy = stubFetch(jsonResponse(200, { success: true })) + expect(await hasSkillhubAuth()).toBe(true) + expect(spy).not.toHaveBeenCalled() + }) + + test("true when a valid SSO session exists", async () => { + seedAuth(VALID_SSO) + expect(await hasSkillhubAuth()).toBe(true) + }) + + test("false when logged out (no cookie, no SSO session)", async () => { + seedAuth({}) + expect(await hasSkillhubAuth()).toBe(false) + }) +}) + +test("SkillhubAuthError carries its name", () => { + expect(new SkillhubAuthError("x").name).toBe("SkillhubAuthError") +}) diff --git a/packages/codev-skillhub/test/tls.test.ts b/packages/codev-skillhub/test/tls.test.ts new file mode 100644 index 0000000000..a4dbcc3c9b --- /dev/null +++ b/packages/codev-skillhub/test/tls.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { applySystemCaCertsOnce, describeNetworkError, isCertError, resetSystemCaCertsCache, tlsApi } from "../src/tls" + +afterEach(() => { + resetSystemCaCertsCache() + mock.restore() +}) + +// Builds the error Node's fetch actually throws: a bare `fetch failed` with the +// real reason hidden on `cause`. +function fetchError(code: string, message: string): Error { + const err = new TypeError("fetch failed") + const cause: NodeJS.ErrnoException = new Error(message) + cause.code = code + err.cause = cause + return err +} + +describe("applySystemCaCertsOnce", () => { + // Bun's node:tls does not ship getCACertificates/setDefaultCACertificates, + // so `supported()` is genuinely false under `bun test` (the hub ran these + // under Node where it is true). Tests exercising the supported path stub it + // through the same tlsApi seam the runtime check reads. + test("merges the OS store into the default set, keeping the defaults", () => { + spyOn(tlsApi, "supported").mockReturnValue(true) + const set = spyOn(tlsApi, "setDefaultCACertificates").mockImplementation(() => {}) + spyOn(tlsApi, "getCACertificates").mockImplementation((type) => + type === "system" ? ["sys-a", "sys-b"] : ["def-a"], + ) + + const result = applySystemCaCertsOnce() + + expect(result?.status).toBe("merged") + expect(result?.systemCount).toBe(2) + // "default" already folds in NODE_EXTRA_CA_CERTS — dropping it would break + // users who fixed their proxy the documented way. + expect(set).toHaveBeenCalledWith(["def-a", "sys-a", "sys-b"]) + }) + + // The null is what bounds hubFetch's retry: a chain that stays untrusted + // after the merge must surface its error, not re-request forever. + test("runs at most once per process, returning null afterwards", () => { + spyOn(tlsApi, "supported").mockReturnValue(true) + const get = spyOn(tlsApi, "getCACertificates").mockImplementation((type) => (type === "system" ? ["sys"] : ["def"])) + spyOn(tlsApi, "setDefaultCACertificates").mockImplementation(() => {}) + + expect(applySystemCaCertsOnce()?.status).toBe("merged") + expect(applySystemCaCertsOnce()).toBeNull() + expect(applySystemCaCertsOnce()).toBeNull() + // 2 = one "system" + one "default" from the single real run. + expect(get).toHaveBeenCalledTimes(2) + }) + + test("reports unsupported on a Node without the CA APIs, without touching TLS", () => { + spyOn(tlsApi, "supported").mockReturnValue(false) + const set = spyOn(tlsApi, "setDefaultCACertificates") + + expect(applySystemCaCertsOnce()?.status).toBe("unsupported") + expect(set).not.toHaveBeenCalled() + }) + + test("leaves the default set alone when the OS store is empty", () => { + spyOn(tlsApi, "supported").mockReturnValue(true) + spyOn(tlsApi, "getCACertificates").mockReturnValue([]) + const set = spyOn(tlsApi, "setDefaultCACertificates") + + expect(applySystemCaCertsOnce()?.status).toBe("empty") + // Merging an empty system store would replace the defaults with themselves + // for no reason; skipping keeps Node's behavior byte-identical. + expect(set).not.toHaveBeenCalled() + }) + + test("swallows a throwing OS store read rather than breaking the command", () => { + spyOn(tlsApi, "supported").mockReturnValue(true) + spyOn(tlsApi, "getCACertificates").mockImplementation(() => { + throw new Error("store unreadable") + }) + + const result = applySystemCaCertsOnce() + + expect(result?.status).toBe("failed") + expect(result?.error).toBe("store unreadable") + }) +}) + +describe("isCertError", () => { + test("recognizes the corporate-proxy chain error", () => { + expect(isCertError(fetchError("SELF_SIGNED_CERT_IN_CHAIN", "self-signed certificate in certificate chain"))).toBe( + true, + ) + }) + + test("does not fire for DNS/connection failures or bare errors", () => { + expect(isCertError(fetchError("ENOTFOUND", "getaddrinfo ENOTFOUND"))).toBe(false) + expect(isCertError(new Error("fetch failed"))).toBe(false) + expect(isCertError("nope")).toBe(false) + }) +}) + +describe("describeNetworkError", () => { + test("unwraps Node's bare `fetch failed` to the real reason", () => { + const msg = describeNetworkError(fetchError("ENOTFOUND", "getaddrinfo ENOTFOUND netmind.viettel.vn")) + expect(msg).toContain("fetch failed") + expect(msg).toContain("getaddrinfo ENOTFOUND netmind.viettel.vn") + }) + + test("appends a remedy to the corporate-proxy cert error", () => { + // The exact error users report. Node itself does NOT hint for this code — + // its --use-system-ca suggestion covers only DEPTH_ZERO/UNABLE_TO_VERIFY_ + // LEAF/UNABLE_TO_GET_ISSUER — so without us it's a dead end. + spyOn(tlsApi, "supported").mockReturnValue(true) + const get = spyOn(tlsApi, "getCACertificates") + + const msg = describeNetworkError( + fetchError("SELF_SIGNED_CERT_IN_CHAIN", "self-signed certificate in certificate chain"), + ) + + expect(msg).toContain("self-signed certificate in certificate chain") + expect(msg).toContain("NODE_EXTRA_CA_CERTS") + // Rendering an error must not touch the OS store: that read is a ~300ms + // event-loop stall on Windows, and it already ran on the retry path. + expect(get).not.toHaveBeenCalled() + }) + + test("tells old-Node users to upgrade instead of pointing at the OS store", () => { + spyOn(tlsApi, "supported").mockReturnValue(false) + + const msg = describeNetworkError( + fetchError("SELF_SIGNED_CERT_IN_CHAIN", "self-signed certificate in certificate chain"), + ) + + expect(msg).toContain("Node 22.15+") + expect(msg).toContain("NODE_EXTRA_CA_CERTS") + }) + + test("does not blame certificates for a plain connection failure", () => { + const msg = describeNetworkError(fetchError("ECONNREFUSED", "connect ECONNREFUSED 10.0.0.1:443")) + expect(msg).not.toContain("NODE_EXTRA_CA_CERTS") + }) + + test("handles an error with no cause, and a non-Error throw", () => { + expect(describeNetworkError(new Error("boom"))).toBe("boom") + expect(describeNetworkError("just a string")).toBe("just a string") + }) +}) diff --git a/packages/codev-skillhub/tsconfig.json b/packages/codev-skillhub/tsconfig.json new file mode 100644 index 0000000000..00ef125468 --- /dev/null +++ b/packages/codev-skillhub/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index d39ebaa8c6..2cb42b253d 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -76,9 +76,11 @@ "@ai-sdk/vercel": "2.0.39", "@ai-sdk/xai": "3.0.102", "@aws-sdk/credential-providers": "3.1057.0", + "@clack/core": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1", "@codev/brand": "workspace:*", "@codev/gateway": "workspace:*", + "@codev/skillhub": "workspace:*", "@codev/upload": "workspace:*", "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", diff --git a/packages/opencode/src/cli/cmd/skill.ts b/packages/opencode/src/cli/cmd/skill.ts new file mode 100644 index 0000000000..01e2a10e10 --- /dev/null +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -0,0 +1,529 @@ +// Fork-only command: `codev skill` — the SkillHub workflow (search, pull, +// push, office) ported from `codevhub skill` so a standalone codev install +// can manage skills without the hub CLI. The heavy lifting lives in +// @codev/skillhub; this file owns yargs parsing and the clack interactive +// flows (the hub used Ink). Sessions ride the same ~/.codev-hub/auth.json the +// hub writes (via @codev/gateway), so a `codevhub login` also signs this in. +import type { Writable } from "node:stream" +import { MultiSelectPrompt } from "@clack/core" +import { + S_BAR, + S_BAR_END, + S_CHECKBOX_ACTIVE, + S_CHECKBOX_INACTIVE, + S_CHECKBOX_SELECTED, + confirm, + intro, + isCancel, + limitOptions, + log, + note, + outro, + select, + spinner, + symbol, +} from "@clack/prompts" +import { + AGENT_LABELS, + SKILL_AGENTS, + formatInstallResult, + formatPublishResult, + getSkillMeta, + hasSkillhubAuth, + installResolvedSkill, + officeCrossFlagError, + parseAgentList, + plannedSteps, + preparePublishArchive, + publishSkill, + runSkillInstall, + runSkillOffice, + runSkillPublish, + runSkillSearch, + stripControlChars, + type OfficeArgs, + type OfficePlatform, + type PublishArchive, + type PublishStep, + type Scope, + type SkillAgent, +} from "@codev/skillhub" +import { UI } from "../ui" +import { cmd } from "./cmd" +import { interactiveAuth } from "./sso-prompt" + +// `SkillHubCommand`, not `SkillCommand` — that name belongs to `codev debug +// skill` (cli/cmd/debug/skill.ts). +export const SkillHubCommand = cmd({ + command: "skill", + describe: "search, install, and publish skills from the CoDev SkillHub", + builder: (yargs) => + yargs + .command(SkillSearchCommand) + .command(SkillPullCommand) + .command(SkillPushCommand) + .command(SkillOfficeCommand) + .demandCommand(), + async handler() {}, +}) + +const SkillSearchCommand = cmd({ + command: "search ", + describe: "search the public SkillHub registry", + builder: (yargs) => + yargs + .positional("query", { + describe: "search terms", + type: "string", + array: true, + demandOption: true, + }) + .option("limit", { + type: "number", + default: 20, + describe: "maximum number of results", + }) + .option("json", { + type: "boolean", + default: false, + describe: "print the results as one JSON line", + }), + handler: async (args) => { + if (!Number.isInteger(args.limit) || args.limit <= 0) { + console.error("Invalid --limit: expected a positive integer.") + process.exitCode = 1 + return + } + process.exitCode = await runSkillSearch({ + query: args.query.join(" ").trim(), + limit: args.limit, + json: args.json, + }) + }, +}) + +const SkillPullCommand = cmd({ + command: "pull ", + describe: "install a skill from the SkillHub", + builder: (yargs) => + yargs + .positional("target", { + describe: "skill name or id", + type: "string", + demandOption: true, + }) + .option("here", { + type: "boolean", + default: false, + describe: "install under the current directory (.claude/.agents skills folders)", + }) + .option("global", { + type: "boolean", + default: false, + describe: "install under the home directory", + }) + .option("dir", { + type: "string", + describe: "install into this exact directory — no agent folders, no links", + }) + .option("agent", { + type: "string", + describe: `comma-separated agents to install for (${SKILL_AGENTS.join(", ")})`, + }) + .option("all-agents", { + type: "boolean", + default: false, + describe: "install for every supported agent", + }) + // Deliberately no -f alias: elsewhere -f forces a cheap re-login, while + // here the identical keystroke is an rm -rf of a skill directory that + // may hold local edits. The reflex must not reach it. + .option("force", { + type: "boolean", + default: false, + describe: "overwrite an existing install", + }) + .option("json", { + type: "boolean", + default: false, + describe: "print the result as one JSON line", + }), + handler: async (args) => { + // Cross-flag rules match the hub verbatim. Checked by hand rather than + // yargs .conflicts() so the messages stay identical to the hub's. + if (args.here && args.global) { + console.error("Pass either --here or --global, not both.") + process.exitCode = 1 + return + } + const location: Scope | undefined = args.here ? "current" : args.global ? "global" : undefined + if (args.dir !== undefined && location !== undefined) { + console.error("Pass either --dir or --here/--global, not both.") + process.exitCode = 1 + return + } + if (args.dir !== undefined && (args.agent !== undefined || args.allAgents)) { + console.error("--dir installs to an exact path; it can't be combined with --agent.") + process.exitCode = 1 + return + } + const agents = args.allAgents + ? [...SKILL_AGENTS] + : args.agent !== undefined + ? parseAgentList(args.agent) + : undefined + if (typeof agents === "string") { + console.error(agents) + process.exitCode = 1 + return + } + + // Interactive + no explicit location: prompt for one. Otherwise + // (--here/--global/--dir given, or piped/CI) go the plain path, where a + // location must be explicit. + const located = args.dir !== undefined || location !== undefined + const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true + if (located || !interactive) { + process.exitCode = await runSkillInstall({ + target: args.target, + dir: args.dir, + location, + agents, + force: args.force, + json: args.json, + }) + return + } + process.exitCode = await interactivePull(args.target, { + agents, + force: args.force, + json: args.json, + }) + }, +}) + +const SkillPushCommand = cmd({ + command: "push ", + describe: "publish a skill to the SkillHub", + builder: (yargs) => + yargs + .positional("path", { + describe: "skill directory (with SKILL.md at its root) or a .zip archive", + type: "string", + demandOption: true, + }) + .option("draft-only", { + type: "boolean", + default: false, + describe: "upload as a DRAFT without submitting for review", + }) + .option("auto-approve", { + type: "boolean", + default: false, + describe: "approve immediately after submitting (admin only)", + }) + .option("json", { + type: "boolean", + default: false, + describe: "print the result as one JSON line", + }), + handler: async (args) => { + // Interactive TTY (and not --json): preview + confirm before uploading. + // Otherwise (piped/CI, or --json) the plain runner, which never confirms. + const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true && !args.json + if (!interactive) { + process.exitCode = await runSkillPublish({ + path: args.path, + draftOnly: args.draftOnly, + autoApprove: args.autoApprove, + json: args.json, + }) + return + } + process.exitCode = await interactivePush({ + path: args.path, + draftOnly: args.draftOnly, + autoApprove: args.autoApprove, + }) + }, +}) + +const OFFICE_PLATFORMS = ["ubuntu", "macos", "windows"] as const +// Spellings people actually type, mapped to the `uname -m` names the bundles +// are published under (mirrors the hub's alias table). +const OFFICE_ARCHES = ["arm64", "aarch64", "x86_64", "x64", "intel"] as const + +const SkillOfficeCommand = cmd({ + command: "office", + describe: "download and install the CoDev Office offline skill bundle", + builder: (yargs) => + yargs + .option("platform", { + choices: OFFICE_PLATFORMS, + describe: "download another OS's bundle (implies --download-only when it differs from this machine)", + }) + .option("arch", { + choices: OFFICE_ARCHES, + describe: "which Mac chip's bundle to fetch (macOS only)", + }) + .option("dir", { + type: "string", + describe: "download folder (default: the shared codev-office staging dir)", + }) + .option("target-user", { + type: "string", + describe: "Windows only: the account that will use the skills", + }) + .option("download-only", { + type: "boolean", + default: false, + describe: "fetch the script + bundle and print the run command, without installing", + }) + .option("skip-verify", { + type: "boolean", + default: false, + describe: "passed to the setup script: skip bundle verification", + }) + .option("force-skills", { + type: "boolean", + default: false, + describe: "passed to the setup script: replace already-installed skills", + }) + // Unadvertised maintenance flags, same as the hub. + .option("uninstall", { type: "boolean", default: false, hidden: true }) + .option("yes", { type: "boolean", default: false, hidden: true }) + .option("skills-only", { type: "boolean", default: false, hidden: true }) + .option("purge-downloads", { type: "boolean", default: false, hidden: true }), + handler: async (args) => { + const parsed: OfficeArgs = { + platform: args.platform as OfficePlatform | undefined, + arch: args.arch === undefined ? undefined : args.arch === "arm64" || args.arch === "aarch64" ? "arm64" : "x86_64", + dir: args.dir, + targetUser: args.targetUser, + downloadOnly: args.downloadOnly, + skipVerify: args.skipVerify, + forceSkills: args.forceSkills, + uninstall: args.uninstall, + yes: args.yes, + skillsOnly: args.skillsOnly, + purgeDownloads: args.purgeDownloads, + } + parsed.error = officeCrossFlagError(parsed) + process.exitCode = await runSkillOffice(parsed) + }, +}) + +// The clack replacement for the hub's SkillPullApp: resolve metadata first (so +// the prompt shows the real name, never a UUID), pick a scope, pick agents +// (unless --agent/--all-agents preselected them), install. With --json the +// prompts render on stderr so stdout carries only the JSON result line. +async function interactivePull( + target: string, + opts: { agents?: SkillAgent[]; force: boolean; json: boolean }, +): Promise { + const output: Writable | undefined = opts.json ? process.stderr : undefined + if (!opts.json) { + UI.empty() + intro("Install skill") + } + + const resolving = spinner({ output }) + resolving.start("Resolving skill...") + const meta = await getSkillMeta(target).catch((err: unknown) => { + resolving.stop(err instanceof Error ? err.message : String(err), 1) + return null + }) + if (meta === null) return 1 + const shownName = stripControlChars(meta.name) + resolving.stop(`Found ${shownName}${meta.version ? `@${stripControlChars(meta.version)}` : ""}`) + + const location = await select({ + message: `Install ${shownName} to:`, + options: [ + { label: "Current directory (recommended)", value: "current" as Scope }, + { label: "Global", value: "global" as Scope }, + ], + output, + }) + if (isCancel(location)) return 1 + + const agents = opts.agents ?? (await pickAgents(output)) + if (agents === null) return 1 + + const installing = spinner({ output }) + installing.start("Installing...") + try { + const result = await installResolvedSkill(meta, { + target: { kind: "agents", agents, scope: location }, + force: opts.force, + }) + installing.stop(`Installed ${shownName}`) + console.log(formatInstallResult(result, opts.json)) + if (!opts.json) outro("Done") + return 0 + } catch (err) { + installing.stop(err instanceof Error ? err.message : String(err), 1) + return 1 + } +} + +// The agent checklist with CoDev Code as a LOCKED row: always shown selected +// and impossible to unselect, like the hub's Ink picker. clack's multiselect +// has no locked-option support, so this drops to @clack/core's +// MultiSelectPrompt with clack's own default render (colors/symbols copied +// from @clack/prompts' multiselect) plus one twist: a "key" subscriber +// re-adds codev whenever a toggle removed it. Prompt internals emit "cursor" +// (which toggles) and "key" before rendering the frame, and subscribers run +// in registration order — the constructor's toggle handlers first, ours after +// — so the row can never even be *seen* unchecked. The other agents start +// unselected. +async function pickAgents(output: Writable | undefined): Promise { + const message = "Install for which agents?" + // CoDev Code (the locked row) leads the list; SKILL_AGENTS order for the rest. + const listed: SkillAgent[] = ["codev", ...SKILL_AGENTS.filter((agent) => agent !== "codev")] + const options = listed.map((agent) => ({ + value: agent, + label: AGENT_LABELS[agent], + hint: agent === "codev" ? "always included" : undefined, + })) + const opt = (option: (typeof options)[number], state: string) => { + const hint = option.hint ? ` ${dim(`(${option.hint})`)}` : "" + if (state === "active") return `${cyan(S_CHECKBOX_ACTIVE)} ${option.label}${hint}` + if (state === "selected") return `${green(S_CHECKBOX_SELECTED)} ${dim(option.label)}${hint}` + if (state === "active-selected") return `${green(S_CHECKBOX_SELECTED)} ${option.label}${hint}` + if (state === "cancelled") return strikethrough(dim(option.label)) + if (state === "submitted") return dim(option.label) + return `${dim(S_CHECKBOX_INACTIVE)} ${dim(option.label)}` + } + const prompt = new MultiSelectPrompt({ + options, + initialValues: ["codev" as SkillAgent], + required: false, + output, + render() { + const title = `${gray(S_BAR)}\n${symbol(this.state)} ${message}\n` + const selected = this.value ?? [] + const style = (option: (typeof options)[number], active: boolean) => { + const isSelected = selected.includes(option.value) + if (active && isSelected) return opt(option, "active-selected") + if (isSelected) return opt(option, "selected") + return opt(option, active ? "active" : "inactive") + } + if (this.state === "submit") { + const picked = this.options.filter((option) => selected.includes(option.value)) + return `${title}${gray(S_BAR)} ${picked.map((option) => opt(option, "submitted")).join(dim(", "))}` + } + if (this.state === "cancel") { + const picked = this.options + .filter((option) => selected.includes(option.value)) + .map((option) => opt(option, "cancelled")) + .join(dim(", ")) + return `${title}${gray(S_BAR)} ${picked.trim() ? `${picked}\n${gray(S_BAR)}` : ""}` + } + const rows = limitOptions({ output, options: this.options, cursor: this.cursor, maxItems: undefined, style }) + const hint = dim("Space to choose, Enter to continue") + return `${title}${cyan(S_BAR)} ${rows.join(`\n${cyan(S_BAR)} `)}\n${cyan(S_BAR)} ${hint}\n${cyan(S_BAR_END)}\n` + }, + }) + prompt.on("key", () => { + if (!prompt.value?.includes("codev")) prompt.value = [...(prompt.value ?? []), "codev"] + }) + const chosen = await prompt.prompt() + if (isCancel(chosen) || !Array.isArray(chosen)) return null + return SKILL_AGENTS.filter((agent) => chosen.includes(agent)) +} + +const cyan = (text: string) => `\x1b[36m${text}\x1b[39m` +const green = (text: string) => `\x1b[32m${text}\x1b[39m` +const gray = (text: string) => `\x1b[90m${text}\x1b[39m` +const dim = (text: string) => `\x1b[2m${text}\x1b[22m` +const strikethrough = (text: string) => `\x1b[9m${text}\x1b[29m` + +const PUSH_STEP_LABELS: Record = { + upload: "Uploading", + metadata: "Saving metadata", + submit: "Submitting for review", + approve: "Approving (admin)", +} + +const PREVIEW_FILE_LIMIT = 12 + +// The clack replacement for the hub's SkillPushApp: archive preview, an +// explicit default-No confirm, and only AFTER confirming an auth check with +// inline SSO — cancelling must never trigger a browser login. +async function interactivePush(args: { path: string; draftOnly: boolean; autoApprove: boolean }): Promise { + UI.empty() + intro("Publish skill") + + const preparing = spinner() + preparing.start("Preparing archive...") + let archive: PublishArchive + try { + archive = await preparePublishArchive(args.path) + } catch (err) { + preparing.stop(err instanceof Error ? err.message : String(err), 1) + outro("Not published") + return 1 + } + preparing.stop(`Archive ready: ${archive.fileName}`) + + note(previewLines(archive, args).join("\n"), "Upload preview") + + const ok = await confirm({ message: "Publish this skill?", initialValue: false }) + if (isCancel(ok) || !ok) { + outro("Cancelled.") + return 1 + } + + if (!(await hasSkillhubAuth())) { + const signin = spinner() + signin.start("Checking sign-in...") + const auth = await interactiveAuth(signin).catch(() => null) + if (!auth) { + signin.stop("Sign-in failed", 1) + outro("Not published") + return 1 + } + signin.stop(`Signed in as ${auth.user.email}`) + } + + const publishing = spinner() + publishing.start(`${PUSH_STEP_LABELS[plannedSteps(args)[0] ?? "upload"]}...`) + try { + const result = await publishSkill(archive, args, (step, status) => { + if (status === "start") publishing.message(`${PUSH_STEP_LABELS[step]}...`) + }) + publishing.stop("Published") + console.log(formatPublishResult(result, false)) + outro("Done") + return 0 + } catch (err) { + publishing.stop(err instanceof Error ? err.message : String(err), 1) + outro("Not published") + return 1 + } +} + +function previewLines(archive: PublishArchive, args: { draftOnly: boolean; autoApprove: boolean }): string[] { + const lines = [`${archive.fileName} — ${archive.files.length} file(s), ${formatBytes(archive.totalBytes)}`] + for (const file of archive.files.slice(0, PREVIEW_FILE_LIMIT)) lines.push(` ${file}`) + if (archive.files.length > PREVIEW_FILE_LIMIT) { + lines.push(` … and ${archive.files.length - PREVIEW_FILE_LIMIT} more`) + } + if (archive.skipped.length > 0) { + lines.push(`Excluded: ${archive.skipped.join(", ")}`) + } + lines.push( + args.draftOnly + ? "Will upload as a DRAFT (not submitted for review)." + : args.autoApprove + ? "Will upload, submit, and auto-approve (admin)." + : "Will upload and submit for review.", + ) + return lines +} + +function formatBytes(bytes: number): string { + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} diff --git a/packages/opencode/src/cli/cmd/sso-prompt.ts b/packages/opencode/src/cli/cmd/sso-prompt.ts new file mode 100644 index 0000000000..294da48a66 --- /dev/null +++ b/packages/opencode/src/cli/cmd/sso-prompt.ts @@ -0,0 +1,108 @@ +import readline from "node:readline" +import { loadAuth, login, silentSso, type AuthData } from "@codev/gateway" +import * as prompts from "@clack/prompts" +import open from "open" +import { EOL } from "os" + +// Fork-only: the clack-flavored interactive SSO sign-in shared by `codev +// upload` and `codev skill push`. Browser + loopback, with a paste-back +// fallback for machines whose browser can't reach back. + +// Silent session first (cached or refreshable); otherwise the full browser +// login. `afterLogin` runs best-effort on a fresh login only — upload uses it +// to refresh the cached CoDev config so the first attempt doesn't have to +// fail-and-retry just to learn the backend coordinates (hub ensureAuth parity). +export async function interactiveAuth( + spinner: ReturnType, + options: { afterLogin?: (auth: AuthData) => Promise } = {}, +): Promise { + const cached = loadAuth() ?? (await silentSso()) + if (cached) return cached + + spinner.message("Signing in...") + let rl: readline.Interface | undefined + try { + const auth = await login({ + onLog: (msg) => spinner.message(msg), + onReady: (openBrowser, url, submitManualCode) => { + // The copy hint is only offered when the paste prompt below actually + // runs — without a TTY there is no keystroke to press, and advertising + // one would be a dead end. The URL itself still works either way: the + // browser redirects to the loopback callback and sign-in completes. + const copyHint = process.stdin.isTTY ? ' (press "c" then Enter to copy)' : "" + prompts.log.info(`If your browser didn't open, sign in here${copyHint}:${EOL}${url}`) + openBrowser() + rl = startPasteBack(submitManualCode, url) + }, + openUrl: (url) => open(url), + }) + try { + await options.afterLogin?.(auth) + } catch { + // Best-effort — callers cope with the follow-up work not having run. + } + return auth + } finally { + rl?.close() + } +} + +// Paste-back fallback: when the loopback callback can't reach us (no browser +// on this machine, or it opened elsewhere), the SSO success page shows a code +// the user can paste here instead. Runs alongside the loopback wait — whichever +// completes first wins, and login()'s promise resolving closes the prompt. +// +// A lone "c" copies the sign-in URL instead of submitting (hub parity — its +// install wizard offers the same shortcut). Safe to overload: a real pasted +// callback code arrives as one long string and never settles on a bare "c". +// Unlike the hub's raw-mode TUI this prompt is line-buffered, so the copy +// needs an Enter — which is what the caller's hint says. +function startPasteBack(submit: (pasted: string) => string | null, url: string): readline.Interface | undefined { + if (!process.stdin.isTTY) return undefined + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }) + const ask = () => { + rl.question("After signing in, paste the code shown here: ", (answer) => { + const action = pasteBackAction(answer) + if (action === "ignore") return ask() + if (action === "copy") { + copyToClipboard(url) + process.stderr.write(`Copied the sign-in URL to your clipboard.${EOL}`) + return ask() + } + const error = submit(answer.trim()) + if (error) { + process.stderr.write(error + EOL) + ask() + } + }) + } + ask() + return rl +} + +// What a line typed at the paste-back prompt means. Overloading "c" onto the +// field the code is pasted into is safe because a real callback code is one +// long string that never settles on a bare "c" — the same reasoning the hub's +// install wizard uses for its copy shortcut. Case-insensitive: the hint says +// "c" but users type what the hub prints, which is "C". +export type PasteBackAction = "ignore" | "copy" | "submit" + +export function pasteBackAction(answer: string): PasteBackAction { + const trimmed = answer.trim() + if (!trimmed) return "ignore" + if (trimmed.toLowerCase() === "c") return "copy" + return "submit" +} + +// Best-effort clipboard write via the OSC 52 terminal escape (hub +// lib/clipboard.ts parity). Widely supported — iTerm2, kitty, recent xterm, +// Windows Terminal, tmux with `set-clipboard on` — and terminals that don't +// understand it silently drop the sequence, leaving the printed URL as the +// manual fallback. Write-only: we never read the clipboard, so nothing the +// user already copied is captured. Deliberately not @opencode-ai/tui's +// clipboard helper: that module is not in the package's export map, and +// reaching it would mean editing an upstream manifest for a one-line escape. +function copyToClipboard(text: string): void { + // OSC (ESC ]) 52 ; c (clipboard selection) ; ; BEL. + process.stderr.write(`\x1b]52;c;${Buffer.from(text, "utf-8").toString("base64")}\x07`) +} diff --git a/packages/opencode/src/cli/cmd/upload.ts b/packages/opencode/src/cli/cmd/upload.ts index 7408cc6f75..5bce769db5 100644 --- a/packages/opencode/src/cli/cmd/upload.ts +++ b/packages/opencode/src/cli/cmd/upload.ts @@ -1,11 +1,10 @@ -import readline from "node:readline" -import { fetchCodevConfig, loadAuth, login, saveCodevConfig, silentSso, type AuthData } from "@codev/gateway" +import { fetchCodevConfig, saveCodevConfig } from "@codev/gateway" import { renderLastRun, runUpload } from "@codev/upload" import * as prompts from "@clack/prompts" -import open from "open" import { EOL } from "os" import { UI } from "../ui" import { cmd } from "./cmd" +import { interactiveAuth } from "./sso-prompt" // Fork-only: `codev upload` — the standalone twin of `codevhub upload`. // Exports every codev session to the shared agent-logs layout and ships @@ -43,7 +42,13 @@ export const UploadCommand = cmd({ const summary = await runUpload({ force: args.force, onStatus: (message) => spinner.message(message), - getAuth: () => interactiveAuth(spinner), + // A fresh login also refreshes the cached CoDev config so the first + // upload attempt doesn't have to fail-and-retry just to learn the + // backend coordinates (hub ensureAuth parity). + getAuth: () => + interactiveAuth(spinner, { + afterLogin: async (auth) => saveCodevConfig(await fetchCodevConfig(auth.access_token)), + }), }) if (summary.found === 0) { spinner.stop("No sessions found") @@ -67,99 +72,3 @@ export const UploadCommand = cmd({ } }, }) - -// Silent session first (cached or refreshable); otherwise the full browser -// login. A fresh login also refreshes the cached CoDev config so the first -// upload attempt doesn't have to fail-and-retry just to learn the backend -// coordinates (hub ensureAuth parity). -async function interactiveAuth(spinner: ReturnType): Promise { - const cached = loadAuth() ?? (await silentSso()) - if (cached) return cached - - spinner.message("Signing in...") - let rl: readline.Interface | undefined - try { - const auth = await login({ - onLog: (msg) => spinner.message(msg), - onReady: (openBrowser, url, submitManualCode) => { - // The copy hint is only offered when the paste prompt below actually - // runs — without a TTY there is no keystroke to press, and advertising - // one would be a dead end. The URL itself still works either way: the - // browser redirects to the loopback callback and sign-in completes. - const copyHint = process.stdin.isTTY ? ' (press "c" then Enter to copy)' : "" - prompts.log.info(`If your browser didn't open, sign in here${copyHint}:${EOL}${url}`) - openBrowser() - rl = startPasteBack(submitManualCode, url) - }, - openUrl: (url) => open(url), - }) - try { - saveCodevConfig(await fetchCodevConfig(auth.access_token)) - } catch { - // Best-effort — runUpload's refresh-retry covers a missing config. - } - return auth - } finally { - rl?.close() - } -} - -// Paste-back fallback: when the loopback callback can't reach us (no browser -// on this machine, or it opened elsewhere), the SSO success page shows a code -// the user can paste here instead. Runs alongside the loopback wait — whichever -// completes first wins, and login()'s promise resolving closes the prompt. -// -// A lone "c" copies the sign-in URL instead of submitting (hub parity — its -// install wizard offers the same shortcut). Safe to overload: a real pasted -// callback code arrives as one long string and never settles on a bare "c". -// Unlike the hub's raw-mode TUI this prompt is line-buffered, so the copy -// needs an Enter — which is what the caller's hint says. -function startPasteBack(submit: (pasted: string) => string | null, url: string): readline.Interface | undefined { - if (!process.stdin.isTTY) return undefined - const rl = readline.createInterface({ input: process.stdin, output: process.stderr }) - const ask = () => { - rl.question("After signing in, paste the code shown here: ", (answer) => { - const action = pasteBackAction(answer) - if (action === "ignore") return ask() - if (action === "copy") { - copyToClipboard(url) - process.stderr.write(`Copied the sign-in URL to your clipboard.${EOL}`) - return ask() - } - const error = submit(answer.trim()) - if (error) { - process.stderr.write(error + EOL) - ask() - } - }) - } - ask() - return rl -} - -// What a line typed at the paste-back prompt means. Overloading "c" onto the -// field the code is pasted into is safe because a real callback code is one -// long string that never settles on a bare "c" — the same reasoning the hub's -// install wizard uses for its copy shortcut. Case-insensitive: the hint says -// "c" but users type what the hub prints, which is "C". -export type PasteBackAction = "ignore" | "copy" | "submit" - -export function pasteBackAction(answer: string): PasteBackAction { - const trimmed = answer.trim() - if (!trimmed) return "ignore" - if (trimmed.toLowerCase() === "c") return "copy" - return "submit" -} - -// Best-effort clipboard write via the OSC 52 terminal escape (hub -// lib/clipboard.ts parity). Widely supported — iTerm2, kitty, recent xterm, -// Windows Terminal, tmux with `set-clipboard on` — and terminals that don't -// understand it silently drop the sequence, leaving the printed URL as the -// manual fallback. Write-only: we never read the clipboard, so nothing the -// user already copied is captured. Deliberately not @opencode-ai/tui's -// clipboard helper: that module is not in the package's export map, and -// reaching it would mean editing an upstream manifest for a one-line escape. -function copyToClipboard(text: string): void { - // OSC (ESC ]) 52 ; c (clipboard selection) ; ; BEL. - process.stderr.write(`\x1b]52;c;${Buffer.from(text, "utf-8").toString("base64")}\x07`) -} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 58ed43780a..1bda193f86 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -29,6 +29,7 @@ import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" import { DbCommand } from "./cli/cmd/db" import { UploadCommand } from "./cli/cmd/upload" +import { SkillHubCommand } from "./cli/cmd/skill" import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" @@ -107,6 +108,7 @@ const cli = yargs(args) .command(PluginCommand) .command(DbCommand) .command(UploadCommand) + .command(SkillHubCommand) .fail((msg, err) => { if ( msg?.startsWith("Unknown argument") || diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index 06471543db..f7a1e65dcd 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -449,6 +449,25 @@ Options: [boolean] [default: false]" `; +exports[`codev CLI help-text snapshots every documented command emits stable help text: codev skill --help 1`] = ` +"codev skill + +search, install, and publish skills from the CoDev SkillHub + +Commands: + codev skill search search the public SkillHub registry + codev skill pull install a skill from the SkillHub + codev skill push publish a skill to the SkillHub + codev skill office download and install the CoDev Office offline skill bundle + +Options: + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean]" +`; + exports[`codev CLI help-text snapshots every documented command emits stable help text: codev mcp list --help 1`] = ` "codev mcp list @@ -667,3 +686,89 @@ Options: --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean]" `; + +exports[`codev CLI help-text snapshots every documented command emits stable help text: codev skill search --help 1`] = ` +"codev skill search + +search the public SkillHub registry + +Positionals: + query search terms [array] [required] [default: []] + +Options: + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --limit maximum number of results [number] [default: 20] + --json print the results as one JSON line [boolean] [default: false]" +`; + +exports[`codev CLI help-text snapshots every documented command emits stable help text: codev skill pull --help 1`] = ` +"codev skill pull + +install a skill from the SkillHub + +Positionals: + target skill name or id [string] [required] + +Options: + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --here install under the current directory (.claude/.agents skills folders) + [boolean] [default: false] + --global install under the home directory [boolean] [default: false] + --dir install into this exact directory — no agent folders, no links [string] + --agent comma-separated agents to install for (claude, codex, opencode, codev) [string] + --all-agents install for every supported agent [boolean] [default: false] + --force overwrite an existing install [boolean] [default: false] + --json print the result as one JSON line [boolean] [default: false]" +`; + +exports[`codev CLI help-text snapshots every documented command emits stable help text: codev skill push --help 1`] = ` +"codev skill push + +publish a skill to the SkillHub + +Positionals: + path skill directory (with SKILL.md at its root) or a .zip archive [string] [required] + +Options: + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --draft-only upload as a DRAFT without submitting for review [boolean] [default: false] + --auto-approve approve immediately after submitting (admin only) [boolean] [default: false] + --json print the result as one JSON line [boolean] [default: false]" +`; + +exports[`codev CLI help-text snapshots every documented command emits stable help text: codev skill office --help 1`] = ` +"codev skill office + +download and install the CoDev Office offline skill bundle + +Options: + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --platform download another OS's bundle (implies --download-only when it differs from + this machine) [choices: "ubuntu", "macos", "windows"] + --arch which Mac chip's bundle to fetch (macOS only) + [choices: "arm64", "aarch64", "x86_64", "x64", "intel"] + --dir download folder (default: the shared codev-office staging dir) [string] + --target-user Windows only: the account that will use the skills [string] + --download-only fetch the script + bundle and print the run command, without installing + [boolean] [default: false] + --skip-verify passed to the setup script: skip bundle verification + [boolean] [default: false] + --force-skills passed to the setup script: replace already-installed skills + [boolean] [default: false]" +`; diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts index 36ff1f2269..d2e57797ee 100644 --- a/packages/opencode/test/cli/help/help-snapshots.test.ts +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -66,6 +66,7 @@ const TOP_LEVEL = [ "plugin", "db", "upload", + "skill", ] as const // Subcommands worth pinning. Not exhaustive — the goal is one snapshot per @@ -86,6 +87,10 @@ const SUBCOMMANDS = [ ["github", "install"], ["github", "run"], ["db", "path"], + ["skill", "search"], + ["skill", "pull"], + ["skill", "push"], + ["skill", "office"], ] as const // Fixed wrap width so a developer's terminal doesn't affect snapshots. diff --git a/packages/opencode/test/cli/skill.test.ts b/packages/opencode/test/cli/skill.test.ts new file mode 100644 index 0000000000..1a0f6f4941 --- /dev/null +++ b/packages/opencode/test/cli/skill.test.ts @@ -0,0 +1,139 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { cliIt } from "../lib/cli-process" + +// Fork-only `codev skill` (cli/cmd/skill.ts). Everything here is offline: +// flag validation and TTY-gating fail before any network or filesystem work, +// which is also what the assertions prove (the hub's own tests cover the +// install/publish engines in @codev/skillhub). + +describe("codev skill (subprocess)", () => { + cliIt.concurrent( + "demands a subcommand", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["skill"]) + expect(result.exitCode).not.toBe(0) + }), + 60_000, + ) + + cliIt.concurrent( + "pull without a location on a non-TTY names the flags that fix it", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["skill", "pull", "some-skill"]) + opencode.expectExit(result, 1) + expect(result.stderr).toContain("pass --here, --global, or --dir ") + }), + 60_000, + ) + + cliIt.concurrent( + "pull rejects --here with --global", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["skill", "pull", "some-skill", "--here", "--global"]) + opencode.expectExit(result, 1) + expect(result.stderr).toContain("Pass either --here or --global, not both.") + }), + 60_000, + ) + + cliIt.concurrent( + "pull rejects --dir combined with a location flag", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["skill", "pull", "some-skill", "--dir", "x", "--here"]) + opencode.expectExit(result, 1) + expect(result.stderr).toContain("Pass either --dir or --here/--global, not both.") + }), + 60_000, + ) + + cliIt.concurrent( + "pull rejects --dir combined with --agent", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["skill", "pull", "some-skill", "--dir", "x", "--agent", "claude"]) + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--dir installs to an exact path; it can't be combined with --agent.") + }), + 60_000, + ) + + cliIt.concurrent( + "pull rejects an unknown agent name", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["skill", "pull", "some-skill", "--here", "--agent", "bogus"]) + opencode.expectExit(result, 1) + expect(result.stderr).toContain("Unknown agent: bogus. Valid: claude, codex, opencode, codev.") + }), + 60_000, + ) + + cliIt.concurrent( + "search rejects a non-positive --limit", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["skill", "search", "pdf", "--limit", "0"]) + opencode.expectExit(result, 1) + expect(result.stderr).toContain("Invalid --limit: expected a positive integer.") + }), + 60_000, + ) + + cliIt.concurrent( + "search without a query fails with usage", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["skill", "search"]) + expect(result.exitCode).not.toBe(0) + }), + 60_000, + ) + + cliIt.concurrent( + "push without a path fails with usage", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["skill", "push"]) + expect(result.exitCode).not.toBe(0) + }), + 60_000, + ) + + cliIt.concurrent( + "office rejects uninstall-only passthroughs without --uninstall", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["skill", "office", "--yes"]) + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--yes/--skills-only/--purge-downloads require --uninstall") + }), + 60_000, + ) + + cliIt.concurrent( + "office rejects install-only flags with --uninstall", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["skill", "office", "--uninstall", "--skip-verify"]) + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--skip-verify/--force-skills do not apply with --uninstall") + }), + 60_000, + ) + + cliIt.concurrent( + "office rejects --arch on a non-macOS platform", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["skill", "office", "--platform", "ubuntu", "--arch", "arm64"]) + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--arch only applies to macOS bundles") + }), + 60_000, + ) +}) diff --git a/packages/opencode/test/cli/upload.test.ts b/packages/opencode/test/cli/upload.test.ts index 69a18c7dc3..a636ff9bfd 100644 --- a/packages/opencode/test/cli/upload.test.ts +++ b/packages/opencode/test/cli/upload.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { pasteBackAction } from "../../src/cli/cmd/upload" +import { pasteBackAction } from "../../src/cli/cmd/sso-prompt" // The paste-back prompt overloads one field: it takes the SSO callback code // AND the "c" copy shortcut. These pin that the overload can never swallow a