From d9574fea715d2a19d3ab83bb23357823c10bb741 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 08:09:36 -0500 Subject: [PATCH] Fix doctor summary after repairs --- cli/src/__tests__/doctor.test.ts | 99 ++++++++++++++++++++++++++++++++ cli/src/commands/doctor.ts | 83 ++++++++++++++++++-------- 2 files changed, 158 insertions(+), 24 deletions(-) create mode 100644 cli/src/__tests__/doctor.test.ts diff --git a/cli/src/__tests__/doctor.test.ts b/cli/src/__tests__/doctor.test.ts new file mode 100644 index 00000000..83a67831 --- /dev/null +++ b/cli/src/__tests__/doctor.test.ts @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { doctor } from "../commands/doctor.js"; +import { writeConfig } from "../config/store.js"; +import type { PaperclipConfig } from "../config/schema.js"; + +const ORIGINAL_ENV = { ...process.env }; + +function createTempConfig(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-doctor-")); + const configPath = path.join(root, ".paperclip", "config.json"); + const runtimeRoot = path.join(root, "runtime"); + + const config: PaperclipConfig = { + $meta: { + version: 1, + updatedAt: "2026-03-10T00:00:00.000Z", + source: "configure", + }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(runtimeRoot, "db"), + embeddedPostgresPort: 55432, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(runtimeRoot, "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(runtimeRoot, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3199, + allowedHostnames: [], + serveUi: true, + }, + auth: { + baseUrlMode: "auto", + disableSignUp: false, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(runtimeRoot, "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(runtimeRoot, "secrets", "master.key"), + }, + }, + }; + + writeConfig(config, configPath); + return configPath; +} + +describe("doctor", () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.PAPERCLIP_AGENT_JWT_SECRET; + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it("re-runs repairable checks so repaired failures do not remain blocking", async () => { + const configPath = createTempConfig(); + + const summary = await doctor({ + config: configPath, + repair: true, + yes: true, + }); + + expect(summary.failed).toBe(0); + expect(summary.warned).toBe(0); + expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBeTruthy(); + }); +}); diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index ab99b012..3ace070e 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -66,28 +66,40 @@ export async function doctor(opts: { printResult(deploymentAuthResult); // 3. Agent JWT check - const jwtResult = agentJwtSecretCheck(opts.config); - results.push(jwtResult); - printResult(jwtResult); - await maybeRepair(jwtResult, opts); + results.push( + await runRepairableCheck({ + run: () => agentJwtSecretCheck(opts.config), + configPath, + opts, + }), + ); // 4. Secrets adapter check - const secretsResult = secretsCheck(config, configPath); - results.push(secretsResult); - printResult(secretsResult); - await maybeRepair(secretsResult, opts); + results.push( + await runRepairableCheck({ + run: () => secretsCheck(config, configPath), + configPath, + opts, + }), + ); // 5. Storage check - const storageResult = storageCheck(config, configPath); - results.push(storageResult); - printResult(storageResult); - await maybeRepair(storageResult, opts); + results.push( + await runRepairableCheck({ + run: () => storageCheck(config, configPath), + configPath, + opts, + }), + ); // 6. Database check - const dbResult = await databaseCheck(config, configPath); - results.push(dbResult); - printResult(dbResult); - await maybeRepair(dbResult, opts); + results.push( + await runRepairableCheck({ + run: () => databaseCheck(config, configPath), + configPath, + opts, + }), + ); // 7. LLM check const llmResult = await llmCheck(config); @@ -95,10 +107,13 @@ export async function doctor(opts: { printResult(llmResult); // 8. Log directory check - const logResult = logCheck(config, configPath); - results.push(logResult); - printResult(logResult); - await maybeRepair(logResult, opts); + results.push( + await runRepairableCheck({ + run: () => logCheck(config, configPath), + configPath, + opts, + }), + ); // 9. Port check const portResult = await portCheck(config); @@ -120,9 +135,9 @@ function printResult(result: CheckResult): void { async function maybeRepair( result: CheckResult, opts: { repair?: boolean; yes?: boolean }, -): Promise { - if (result.status === "pass" || !result.canRepair || !result.repair) return; - if (!opts.repair) return; +): Promise { + if (result.status === "pass" || !result.canRepair || !result.repair) return false; + if (!opts.repair) return false; let shouldRepair = opts.yes; if (!shouldRepair) { @@ -130,7 +145,7 @@ async function maybeRepair( message: `Repair "${result.name}"?`, initialValue: true, }); - if (p.isCancel(answer)) return; + if (p.isCancel(answer)) return false; shouldRepair = answer; } @@ -138,10 +153,30 @@ async function maybeRepair( try { await result.repair(); p.log.success(`Repaired: ${result.name}`); + return true; } catch (err) { p.log.error(`Repair failed: ${err instanceof Error ? err.message : String(err)}`); } } + return false; +} + +async function runRepairableCheck(input: { + run: () => CheckResult | Promise; + configPath: string; + opts: { repair?: boolean; yes?: boolean }; +}): Promise { + let result = await input.run(); + printResult(result); + + const repaired = await maybeRepair(result, input.opts); + if (!repaired) return result; + + // Repairs may create/update the adjacent .env file or other local resources. + loadPaperclipEnvFile(input.configPath); + result = await input.run(); + printResult(result); + return result; } function printSummary(results: CheckResult[]): { passed: number; warned: number; failed: number } {