diff --git a/package.json b/package.json index e21f6910..93236743 100644 --- a/package.json +++ b/package.json @@ -224,6 +224,7 @@ "test:reviewer-access": "tsx tests/reviewer-access.test.ts", "test:command-router": "tsx tests/command-router.test.ts", "test:temp-runtime-cleanup": "tsx tests/temp-runtime-cleanup.test.ts", + "test:doctor-archive-inspection": "tsx tests/doctor-archive-inspection.test.ts", "test:command-agent-run": "tsx tests/command-agent-run.test.ts", "smoke:cli-version": "tsx scripts/cli-version-smoke.ts", "test:host-command-executor": "tsx tests/host-command-executor.test.ts", diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 3f455a8b..f0e5dba9 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -216,18 +216,22 @@ async function archiveCheck(options: DoctorOptions): Promise { const roots = unique([...options.archiveRoots, ...defaultArchiveRoots()].map((root) => resolve(root))) const existingRoots = roots.filter((root) => existsSync(root)) let checked = 0 + let skipped = 0 const invalid: Array<{ path: string; size: number; reason: string; deleted: boolean; error?: string }> = [] for (const root of existingRoots) { for await (const archivePath of walkArchiveFiles(root)) { checked++ - const archiveStat = await stat(archivePath) - const reason = await invalidZipReason(archivePath, archiveStat.size) - if (!reason) { + const inspection = await inspectArchivePath(archivePath) + if (!inspection) { + skipped++ + continue + } + if (!inspection.reason) { continue } - const row = { path: archivePath, size: archiveStat.size, reason, deleted: false } + const row = { path: archivePath, size: inspection.size, reason: inspection.reason, deleted: false } if (options.cleanup) { try { await unlink(archivePath) @@ -242,16 +246,16 @@ async function archiveCheck(options: DoctorOptions): Promise { } if (existingRoots.length === 0) { - return { id: "wp-codebox.archives", status: "ok", message: "no known WP Codebox/Playground archive roots found", details: { roots, existingRoots, checked: 0, invalid: [] } } + return { id: "wp-codebox.archives", status: "ok", message: "no known WP Codebox/Playground archive roots found", details: { roots, existingRoots, checked: 0, skipped: 0, invalid: [] } } } if (invalid.length === 0) { - return { id: "wp-codebox.archives", status: "ok", message: `checked ${checked} archive(s); no invalid archives found`, details: { roots, existingRoots, checked, invalid } } + return { id: "wp-codebox.archives", status: "ok", message: `checked ${checked} archive(s); no invalid archives found`, details: { roots, existingRoots, checked, skipped, invalid } } } return { id: "wp-codebox.archives", status: options.cleanup && invalid.every((row) => row.deleted) ? "ok" : "warning", message: options.cleanup ? `removed ${invalid.filter((row) => row.deleted).length}/${invalid.length} invalid archive(s)` : `${invalid.length} invalid archive(s) found`, - details: { roots, existingRoots, checked, invalid }, + details: { roots, existingRoots, checked, skipped, invalid }, } } @@ -303,10 +307,7 @@ function isRecipeRunCommand(command: string): boolean { } async function* walkArchiveFiles(root: string): AsyncGenerator { - const entries = await opendir(root).catch(() => undefined) - if (!entries) { - return - } + const entries = await opendir(root) for await (const entry of entries) { const path = join(root, entry.name) if (entry.isDirectory()) { @@ -317,6 +318,16 @@ async function* walkArchiveFiles(root: string): AsyncGenerator { } } +export async function inspectArchivePath(path: string): Promise<{ size: number; reason?: string } | undefined> { + try { + const archiveStat = await stat(path) + return { size: archiveStat.size, reason: await invalidZipReason(path, archiveStat.size) } + } catch (error) { + if (isMissingFileError(error)) return undefined + throw error + } +} + async function invalidZipReason(path: string, size: number): Promise { if (size < 22) { return "too small to be a zip archive" @@ -339,6 +350,10 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } +function isMissingFileError(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT" +} + function execFile(command: string, args: string[], options: { cwd?: string } = {}): Promise<{ stdout: string; stderr: string }> { return new Promise((resolveExec, rejectExec) => { const child = spawn(command, args, { cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"] }) diff --git a/scripts/smoke-manifest.ts b/scripts/smoke-manifest.ts index 9b109593..e150f860 100644 --- a/scripts/smoke-manifest.ts +++ b/scripts/smoke-manifest.ts @@ -68,6 +68,7 @@ export const smokeGroups = { npmScript("test:runtime-preset-registry"), npmScript("test:provider-runtime-contracts"), tsxSmoke("discovery-command-smoke"), + npmScript("test:doctor-archive-inspection"), tsxSmoke("doctor-command-smoke"), tsxSmoke("cli-json-failure-smoke"), tsxSmoke("source-checkout-entrypoint-smoke"), diff --git a/tests/doctor-archive-inspection.test.ts b/tests/doctor-archive-inspection.test.ts new file mode 100644 index 00000000..66f4a2dd --- /dev/null +++ b/tests/doctor-archive-inspection.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict" +import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { inspectArchivePath } from "../packages/cli/src/commands/doctor.js" + +const root = await mkdtemp(join(tmpdir(), "wp-codebox-doctor-archive-test-")) + +try { + const vanishedArchive = join(root, "vanished.zip") + await writeFile(vanishedArchive, Buffer.alloc(22)) + const enumeratedArchives = [vanishedArchive] + await rm(vanishedArchive) + + assert.equal(await inspectArchivePath(enumeratedArchives[0]!), undefined, "an archive removed after enumeration is skipped") + + const erroringArchive = join(root, "still-present.zip") + await mkdir(erroringArchive) + for (let index = 0; index < 4; index++) { + await writeFile(join(erroringArchive, `entry-${index}`), "content") + } + + await assert.rejects( + inspectArchivePath(erroringArchive), + (error: NodeJS.ErrnoException) => error.code === "EISDIR", + "a still-present entry inspection error must remain surfaced", + ) + assert.equal((await stat(erroringArchive)).isDirectory(), true) + + console.log("Doctor archive inspection tests passed") +} finally { + await rm(root, { recursive: true, force: true }) +}