feat(cli): add --data-dir flag to isolate local state
Add --data-dir option to all CLI commands, allowing users to override the default ~/.paperclip root for config, context, database, logs, and storage. Includes preAction hook to auto-derive --config and --context paths when --data-dir is set. Add unit tests and doc updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
79
cli/src/__tests__/data-dir.test.ts
Normal file
79
cli/src/__tests__/data-dir.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { applyDataDirOverride } from "../config/data-dir.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
describe("applyDataDirOverride", () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.PAPERCLIP_HOME;
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
delete process.env.PAPERCLIP_CONTEXT;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
it("sets PAPERCLIP_HOME and isolated default config/context paths", () => {
|
||||
const home = applyDataDirOverride({
|
||||
dataDir: "~/paperclip-data",
|
||||
config: undefined,
|
||||
context: undefined,
|
||||
}, { hasConfigOption: true, hasContextOption: true });
|
||||
|
||||
const expectedHome = path.resolve(os.homedir(), "paperclip-data");
|
||||
expect(home).toBe(expectedHome);
|
||||
expect(process.env.PAPERCLIP_HOME).toBe(expectedHome);
|
||||
expect(process.env.PAPERCLIP_CONFIG).toBe(
|
||||
path.resolve(expectedHome, "instances", "default", "config.json"),
|
||||
);
|
||||
expect(process.env.PAPERCLIP_CONTEXT).toBe(path.resolve(expectedHome, "context.json"));
|
||||
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("default");
|
||||
});
|
||||
|
||||
it("uses the provided instance id when deriving default config path", () => {
|
||||
const home = applyDataDirOverride({
|
||||
dataDir: "/tmp/paperclip-alt",
|
||||
instance: "dev_1",
|
||||
config: undefined,
|
||||
context: undefined,
|
||||
}, { hasConfigOption: true, hasContextOption: true });
|
||||
|
||||
expect(home).toBe(path.resolve("/tmp/paperclip-alt"));
|
||||
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("dev_1");
|
||||
expect(process.env.PAPERCLIP_CONFIG).toBe(
|
||||
path.resolve("/tmp/paperclip-alt", "instances", "dev_1", "config.json"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not override explicit config/context settings", () => {
|
||||
process.env.PAPERCLIP_CONFIG = "/env/config.json";
|
||||
process.env.PAPERCLIP_CONTEXT = "/env/context.json";
|
||||
|
||||
applyDataDirOverride({
|
||||
dataDir: "/tmp/paperclip-alt",
|
||||
config: "/flag/config.json",
|
||||
context: "/flag/context.json",
|
||||
}, { hasConfigOption: true, hasContextOption: true });
|
||||
|
||||
expect(process.env.PAPERCLIP_CONFIG).toBe("/env/config.json");
|
||||
expect(process.env.PAPERCLIP_CONTEXT).toBe("/env/context.json");
|
||||
});
|
||||
|
||||
it("only applies defaults for options supported by the command", () => {
|
||||
applyDataDirOverride(
|
||||
{
|
||||
dataDir: "/tmp/paperclip-alt",
|
||||
},
|
||||
{ hasConfigOption: false, hasContextOption: false },
|
||||
);
|
||||
|
||||
expect(process.env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-alt"));
|
||||
expect(process.env.PAPERCLIP_CONFIG).toBeUndefined();
|
||||
expect(process.env.PAPERCLIP_CONTEXT).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { ApiRequestError, PaperclipApiClient } from "../../client/http.js";
|
||||
|
||||
export interface BaseClientOptions {
|
||||
config?: string;
|
||||
dataDir?: string;
|
||||
context?: string;
|
||||
profile?: string;
|
||||
apiBase?: string;
|
||||
@@ -25,6 +26,7 @@ export interface ResolvedClientContext {
|
||||
export function addCommonClientOptions(command: Command, opts?: { includeCompany?: boolean }): Command {
|
||||
command
|
||||
.option("-c, --config <path>", "Path to Paperclip config file")
|
||||
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
|
||||
.option("--context <path>", "Path to CLI context file")
|
||||
.option("--profile <name>", "CLI context profile name")
|
||||
.option("--api-base <url>", "Base URL for the Paperclip API")
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { printOutput } from "./common.js";
|
||||
|
||||
interface ContextOptions {
|
||||
dataDir?: string;
|
||||
context?: string;
|
||||
profile?: string;
|
||||
json?: boolean;
|
||||
@@ -28,6 +29,7 @@ export function registerContextCommands(program: Command): void {
|
||||
context
|
||||
.command("show")
|
||||
.description("Show current context and active profile")
|
||||
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
|
||||
.option("--context <path>", "Path to CLI context file")
|
||||
.option("--profile <name>", "Profile to inspect")
|
||||
.option("--json", "Output raw JSON")
|
||||
@@ -48,6 +50,7 @@ export function registerContextCommands(program: Command): void {
|
||||
context
|
||||
.command("list")
|
||||
.description("List available context profiles")
|
||||
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
|
||||
.option("--context <path>", "Path to CLI context file")
|
||||
.option("--json", "Output raw JSON")
|
||||
.action((opts: ContextOptions) => {
|
||||
@@ -66,6 +69,7 @@ export function registerContextCommands(program: Command): void {
|
||||
.command("use")
|
||||
.description("Set active context profile")
|
||||
.argument("<profile>", "Profile name")
|
||||
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
|
||||
.option("--context <path>", "Path to CLI context file")
|
||||
.action((profile: string, opts: ContextOptions) => {
|
||||
setCurrentProfile(profile, opts.context);
|
||||
@@ -75,6 +79,7 @@ export function registerContextCommands(program: Command): void {
|
||||
context
|
||||
.command("set")
|
||||
.description("Set values on a profile")
|
||||
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
|
||||
.option("--context <path>", "Path to CLI context file")
|
||||
.option("--profile <name>", "Profile name (default: current profile)")
|
||||
.option("--api-base <url>", "Default API base URL")
|
||||
|
||||
48
cli/src/config/data-dir.ts
Normal file
48
cli/src/config/data-dir.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
expandHomePrefix,
|
||||
resolveDefaultConfigPath,
|
||||
resolveDefaultContextPath,
|
||||
resolvePaperclipInstanceId,
|
||||
} from "./home.js";
|
||||
|
||||
export interface DataDirOptionLike {
|
||||
dataDir?: string;
|
||||
config?: string;
|
||||
context?: string;
|
||||
instance?: string;
|
||||
}
|
||||
|
||||
export interface DataDirCommandSupport {
|
||||
hasConfigOption?: boolean;
|
||||
hasContextOption?: boolean;
|
||||
}
|
||||
|
||||
export function applyDataDirOverride(
|
||||
options: DataDirOptionLike,
|
||||
support: DataDirCommandSupport = {},
|
||||
): string | null {
|
||||
const rawDataDir = options.dataDir?.trim();
|
||||
if (!rawDataDir) return null;
|
||||
|
||||
const resolvedDataDir = path.resolve(expandHomePrefix(rawDataDir));
|
||||
process.env.PAPERCLIP_HOME = resolvedDataDir;
|
||||
|
||||
if (support.hasConfigOption) {
|
||||
const hasConfigOverride = Boolean(options.config?.trim()) || Boolean(process.env.PAPERCLIP_CONFIG?.trim());
|
||||
if (!hasConfigOverride) {
|
||||
const instanceId = resolvePaperclipInstanceId(options.instance);
|
||||
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
|
||||
process.env.PAPERCLIP_CONFIG = resolveDefaultConfigPath(instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
if (support.hasContextOption) {
|
||||
const hasContextOverride = Boolean(options.context?.trim()) || Boolean(process.env.PAPERCLIP_CONTEXT?.trim());
|
||||
if (!hasContextOverride) {
|
||||
process.env.PAPERCLIP_CONTEXT = resolveDefaultContextPath();
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedDataDir;
|
||||
}
|
||||
@@ -15,24 +15,38 @@ import { registerAgentCommands } from "./commands/client/agent.js";
|
||||
import { registerApprovalCommands } from "./commands/client/approval.js";
|
||||
import { registerActivityCommands } from "./commands/client/activity.js";
|
||||
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
||||
|
||||
const program = new Command();
|
||||
const DATA_DIR_OPTION_HELP =
|
||||
"Paperclip data directory root (isolates state from ~/.paperclip)";
|
||||
|
||||
program
|
||||
.name("paperclip")
|
||||
.description("Paperclip CLI — setup, diagnose, and configure your instance")
|
||||
.version("0.0.1");
|
||||
|
||||
program.hook("preAction", (_thisCommand, actionCommand) => {
|
||||
const options = actionCommand.optsWithGlobals() as DataDirOptionLike;
|
||||
const optionNames = new Set(actionCommand.options.map((option) => option.attributeName()));
|
||||
applyDataDirOverride(options, {
|
||||
hasConfigOption: optionNames.has("config"),
|
||||
hasContextOption: optionNames.has("context"),
|
||||
});
|
||||
});
|
||||
|
||||
program
|
||||
.command("onboard")
|
||||
.description("Interactive first-run setup wizard")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||
.action(onboard);
|
||||
|
||||
program
|
||||
.command("doctor")
|
||||
.description("Run diagnostic checks on your Paperclip setup")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||
.option("--repair", "Attempt to repair issues automatically")
|
||||
.alias("--fix")
|
||||
.option("-y, --yes", "Skip repair confirmation prompts")
|
||||
@@ -44,12 +58,14 @@ program
|
||||
.command("env")
|
||||
.description("Print environment variables for deployment")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||
.action(envCommand);
|
||||
|
||||
program
|
||||
.command("configure")
|
||||
.description("Update configuration sections")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||
.option("-s, --section <section>", "Section to configure (llm, database, logging, server, storage, secrets)")
|
||||
.action(configure);
|
||||
|
||||
@@ -58,12 +74,14 @@ program
|
||||
.description("Allow a hostname for authenticated/private mode access")
|
||||
.argument("<host>", "Hostname to allow (for example dotta-macbook-pro)")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||
.action(addAllowedHostname);
|
||||
|
||||
program
|
||||
.command("run")
|
||||
.description("Bootstrap local setup (onboard + doctor) and run Paperclip")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||
.option("-i, --instance <id>", "Local instance id (default: default)")
|
||||
.option("--repair", "Attempt automatic repairs during doctor", true)
|
||||
.option("--no-repair", "Disable automatic repairs during doctor")
|
||||
@@ -76,6 +94,7 @@ heartbeat
|
||||
.description("Run one agent heartbeat and stream live logs")
|
||||
.requiredOption("-a, --agent-id <agentId>", "Agent ID to invoke")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||
.option("--context <path>", "Path to CLI context file")
|
||||
.option("--profile <name>", "CLI context profile name")
|
||||
.option("--api-base <url>", "Base URL for the Paperclip server API")
|
||||
@@ -105,6 +124,7 @@ auth
|
||||
.command("bootstrap-ceo")
|
||||
.description("Create a one-time bootstrap invite URL for first instance admin")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||
.option("--force", "Create new invite even if admin already exists", false)
|
||||
.option("--expires-hours <hours>", "Invite expiration window in hours", (value) => Number(value))
|
||||
.option("--base-url <url>", "Public base URL used to print invite link")
|
||||
|
||||
@@ -45,6 +45,7 @@ pnpm paperclip allowed-hostname dotta-macbook-pro
|
||||
|
||||
All client commands support:
|
||||
|
||||
- `--data-dir <path>`
|
||||
- `--api-base <url>`
|
||||
- `--api-key <token>`
|
||||
- `--context <path>`
|
||||
@@ -53,6 +54,13 @@ All client commands support:
|
||||
|
||||
Company-scoped commands also support `--company-id <id>`.
|
||||
|
||||
Use `--data-dir` on any CLI command to isolate all default local state (config/context/db/logs/storage/secrets) away from `~/.paperclip`:
|
||||
|
||||
```sh
|
||||
pnpm paperclip run --data-dir ./tmp/paperclip-dev
|
||||
pnpm paperclip issue list --data-dir ./tmp/paperclip-dev
|
||||
```
|
||||
|
||||
## Context Profiles
|
||||
|
||||
Store local defaults in `~/.paperclip/context.json`:
|
||||
|
||||
@@ -19,6 +19,7 @@ All commands support:
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--data-dir <path>` | Local Paperclip data root (isolates from `~/.paperclip`) |
|
||||
| `--api-base <url>` | API base URL |
|
||||
| `--api-key <token>` | API authentication token |
|
||||
| `--context <path>` | Context file path |
|
||||
@@ -27,6 +28,12 @@ All commands support:
|
||||
|
||||
Company-scoped commands also accept `--company-id <id>`.
|
||||
|
||||
For clean local instances, pass `--data-dir` on the command you run:
|
||||
|
||||
```sh
|
||||
pnpm paperclip run --data-dir ./tmp/paperclip-dev
|
||||
```
|
||||
|
||||
## Context Profiles
|
||||
|
||||
Store defaults to avoid repeating flags:
|
||||
|
||||
@@ -100,3 +100,10 @@ Override with:
|
||||
```sh
|
||||
PAPERCLIP_HOME=/custom/home PAPERCLIP_INSTANCE_ID=dev pnpm paperclip run
|
||||
```
|
||||
|
||||
Or pass `--data-dir` directly on any command:
|
||||
|
||||
```sh
|
||||
pnpm paperclip run --data-dir ./tmp/paperclip-dev
|
||||
pnpm paperclip doctor --data-dir ./tmp/paperclip-dev
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user