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>
This commit is contained in:
parent
b431d4eca1
commit
1b8738da5c
|
|
@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void> {
|
||||
export async function confirmOverwriteExportDirectory(
|
||||
outDir: string,
|
||||
opts: { force?: boolean } = {},
|
||||
): Promise<void> {
|
||||
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<void> {
|
|||
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 <values>", "Comma-separated issue identifiers/ids to export")
|
||||
.option("--project-issues <values>", "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(
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue