Files
PaperClipAI/cli/src/__tests__/company-export-force.test.ts
T
1b8738da5c feat(cli): add --force to company export for non-interactive runs (#10054)
## 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 <annica@MichaelacStudio.localdomain>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-22 15:36:54 -07:00

52 lines
2.0 KiB
TypeScript

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/,
);
});
});