From 1b8738da5c21883b161852f985caeccf0b0fdb4e Mon Sep 17 00:00:00 2001 From: Michael Nguyen Date: Wed, 22 Jul 2026 15:36:54 -0700 Subject: [PATCH] feat(cli): add --force to company export for non-interactive runs (#10054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path - `company export` writes into a target directory but aborts non-interactively when that directory is non-empty ("already contains files. Re-run interactively or choose an empty directory"), and there is no override flag. Any automated caller that exports into a pre-existing directory (for example a git clone used as a backup target) is therefore stuck. - The interactive confirmation is the right default for humans, but automation needs an explicit opt-out rather than being forced to export into a throwaway empty directory and copy the tree afterward. - A `--force` flag that skips only the non-empty-directory confirmation is the minimal change: it does not alter what gets written (existing files are overwritten in place, nothing is bulk-deleted), so callers keep full control over cleanup via their own VCS. ## What Changed - Added a `--force` option to `company export` that skips the non-empty output-directory confirmation for non-interactive/automated runs. - Threaded the flag into `confirmOverwriteExportDirectory(outDir, { force })`; behavior is unchanged when the flag is absent. - Updated the non-interactive error message to also mention `--force`. - Added focused unit tests covering: missing dir (resolves), empty dir (resolves), non-empty dir without force (throws), non-empty dir with force (resolves), and a path that exists but is a file (throws). ## Verification - `pnpm --filter @paperclipai/cli exec vitest run src/__tests__/company-export-force.test.ts` → 5/5 pass. - `tsc --noEmit` over the CLI sources: no new type errors (the only errors are pre-existing `@paperclipai/plugin-sdk` module-not-found in `server/` from an unbuilt plugin sdk in the sandbox, unrelated to this change). - End-to-end against a local server: exporting into a non-empty directory fails without `--force` and succeeds with it; a `.git` directory and a sentinel file in the target were preserved; 128 files written. ## Risks - Low. The flag is opt-in and defaults to false; interactive and empty-directory behavior is untouched. `--force` overwrites matching files in place but never deletes unrelated files, so it cannot silently wipe a directory. ## Model Used Claude Opus 4.8 (claude-opus-4-8) --- **Problem or motivation** `company export` aborts when the `--out` directory is non-empty and stdin/stdout are not a TTY, and there is no override flag. This makes it impossible to run `company export` unattended into a pre-existing directory such as a git clone. **Proposed solution** Add a `--force` flag that skips the non-empty-directory confirmation for non-interactive callers. Files are still written on top of existing content with no bulk delete, so unrelated files such as `.git` are preserved. **Alternatives considered** Exporting into a fresh temp directory and copying the tree into the real target afterward works but is clumsy and error-prone for automation; broadening or removing the guard entirely would remove a useful safety net for interactive users. **Roadmap alignment** Hardens the automated/unattended export path used by scheduled company-backup routines. --- - [x] I searched the repository and open pull requests for similar or duplicate PRs and found none. Co-authored-by: anicca Co-authored-by: Paperclip --- .../__tests__/company-export-force.test.ts | 51 +++++++++++++++++++ cli/src/commands/client/company.ts | 20 ++++++-- 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 cli/src/__tests__/company-export-force.test.ts 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( {