diff --git a/cli/src/__tests__/company-export-force.test.ts b/cli/src/__tests__/company-export-force.test.ts new file mode 100644 index 0000000000..78ef22977e --- /dev/null +++ b/cli/src/__tests__/company-export-force.test.ts @@ -0,0 +1,51 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { confirmOverwriteExportDirectory } from "../commands/client/company.js"; + +// These tests run under vitest, where stdin/stdout are not TTYs — i.e. exactly +// the non-interactive/automated posture the nightly backup routine runs in. +describe("confirmOverwriteExportDirectory (non-interactive)", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), "pc-export-force-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("resolves when the output directory does not exist", async () => { + const missing = path.join(dir, "does-not-exist"); + await expect(confirmOverwriteExportDirectory(missing)).resolves.toBeUndefined(); + }); + + it("resolves when the output directory is empty", async () => { + await expect(confirmOverwriteExportDirectory(dir)).resolves.toBeUndefined(); + }); + + it("throws non-interactively when the output directory is non-empty and --force is not set", async () => { + await writeFile(path.join(dir, "BACKUP-README.md"), "keep me"); + await mkdir(path.join(dir, ".git")); + await expect(confirmOverwriteExportDirectory(dir)).rejects.toThrow(/already contains files/); + }); + + it("resolves on a non-empty output directory when --force is set", async () => { + await writeFile(path.join(dir, "BACKUP-README.md"), "keep me"); + await mkdir(path.join(dir, ".git")); + await expect( + confirmOverwriteExportDirectory(dir, { force: true }), + ).resolves.toBeUndefined(); + }); + + it("throws when the output path exists but is a file", async () => { + const filePath = path.join(dir, "not-a-dir"); + await writeFile(filePath, "x"); + await expect(confirmOverwriteExportDirectory(filePath, { force: true })).rejects.toThrow( + /exists and is not a directory/, + ); + }); +}); diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index 00a14f1bc1..e8368ccf9d 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -58,6 +58,7 @@ interface CompanyExportOptions extends BaseClientOptions { issues?: string; projectIssues?: string; expandReferencedSkills?: boolean; + force?: boolean; } interface CompanyFeedbackOptions extends BaseClientOptions { @@ -987,7 +988,10 @@ export function resolveExportOutputPath(root: string, relativePath: string): str return filePath; } -async function confirmOverwriteExportDirectory(outDir: string): Promise { +export async function confirmOverwriteExportDirectory( + outDir: string, + opts: { force?: boolean } = {}, +): Promise { const root = path.resolve(outDir); const stats = await stat(root).catch(() => null); if (!stats) return; @@ -998,8 +1002,13 @@ async function confirmOverwriteExportDirectory(outDir: string): Promise { const entries = await readdir(root); if (entries.length === 0) return; + // --force skips the guard for non-interactive/automated callers (e.g. the + // nightly backup routine, which exports into a git clone that legitimately + // still holds .git and BACKUP-README.md after cleaning tracked content). + if (opts.force) return; + if (!process.stdin.isTTY || !process.stdout.isTTY) { - throw new Error(`Export output directory ${root} already contains files. Re-run interactively or choose an empty directory.`); + throw new Error(`Export output directory ${root} already contains files. Re-run interactively, pass --force, or choose an empty directory.`); } const confirmed = await p.confirm({ @@ -1338,6 +1347,11 @@ export function registerCompanyCommands(program: Command): void { .option("--issues ", "Comma-separated issue identifiers/ids to export") .option("--project-issues ", "Comma-separated project shortnames/ids whose issues should be exported") .option("--expand-referenced-skills", "Vendor skill contents instead of exporting upstream references", false) + .option( + "--force", + "Overwrite a non-empty output directory without the interactive confirmation (required for non-interactive/automated runs such as the nightly backup routine)", + false, + ) .action(async (companyId: string, opts: CompanyExportOptions) => { try { const ctx = resolveCommandContext(opts); @@ -1356,7 +1370,7 @@ export function registerCompanyCommands(program: Command): void { if (!exported) { throw new Error("Export request returned no data"); } - await confirmOverwriteExportDirectory(opts.out!); + await confirmOverwriteExportDirectory(opts.out!, { force: Boolean(opts.force) }); await writeExportToFolder(opts.out!, exported); printOutput( {