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 {
|
export interface BaseClientOptions {
|
||||||
config?: string;
|
config?: string;
|
||||||
|
dataDir?: string;
|
||||||
context?: string;
|
context?: string;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
apiBase?: string;
|
apiBase?: string;
|
||||||
@@ -25,6 +26,7 @@ export interface ResolvedClientContext {
|
|||||||
export function addCommonClientOptions(command: Command, opts?: { includeCompany?: boolean }): Command {
|
export function addCommonClientOptions(command: Command, opts?: { includeCompany?: boolean }): Command {
|
||||||
command
|
command
|
||||||
.option("-c, --config <path>", "Path to Paperclip config file")
|
.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("--context <path>", "Path to CLI context file")
|
||||||
.option("--profile <name>", "CLI context profile name")
|
.option("--profile <name>", "CLI context profile name")
|
||||||
.option("--api-base <url>", "Base URL for the Paperclip API")
|
.option("--api-base <url>", "Base URL for the Paperclip API")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { printOutput } from "./common.js";
|
import { printOutput } from "./common.js";
|
||||||
|
|
||||||
interface ContextOptions {
|
interface ContextOptions {
|
||||||
|
dataDir?: string;
|
||||||
context?: string;
|
context?: string;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
@@ -28,6 +29,7 @@ export function registerContextCommands(program: Command): void {
|
|||||||
context
|
context
|
||||||
.command("show")
|
.command("show")
|
||||||
.description("Show current context and active profile")
|
.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("--context <path>", "Path to CLI context file")
|
||||||
.option("--profile <name>", "Profile to inspect")
|
.option("--profile <name>", "Profile to inspect")
|
||||||
.option("--json", "Output raw JSON")
|
.option("--json", "Output raw JSON")
|
||||||
@@ -48,6 +50,7 @@ export function registerContextCommands(program: Command): void {
|
|||||||
context
|
context
|
||||||
.command("list")
|
.command("list")
|
||||||
.description("List available context profiles")
|
.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("--context <path>", "Path to CLI context file")
|
||||||
.option("--json", "Output raw JSON")
|
.option("--json", "Output raw JSON")
|
||||||
.action((opts: ContextOptions) => {
|
.action((opts: ContextOptions) => {
|
||||||
@@ -66,6 +69,7 @@ export function registerContextCommands(program: Command): void {
|
|||||||
.command("use")
|
.command("use")
|
||||||
.description("Set active context profile")
|
.description("Set active context profile")
|
||||||
.argument("<profile>", "Profile name")
|
.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")
|
.option("--context <path>", "Path to CLI context file")
|
||||||
.action((profile: string, opts: ContextOptions) => {
|
.action((profile: string, opts: ContextOptions) => {
|
||||||
setCurrentProfile(profile, opts.context);
|
setCurrentProfile(profile, opts.context);
|
||||||
@@ -75,6 +79,7 @@ export function registerContextCommands(program: Command): void {
|
|||||||
context
|
context
|
||||||
.command("set")
|
.command("set")
|
||||||
.description("Set values on a profile")
|
.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("--context <path>", "Path to CLI context file")
|
||||||
.option("--profile <name>", "Profile name (default: current profile)")
|
.option("--profile <name>", "Profile name (default: current profile)")
|
||||||
.option("--api-base <url>", "Default API base URL")
|
.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 { registerApprovalCommands } from "./commands/client/approval.js";
|
||||||
import { registerActivityCommands } from "./commands/client/activity.js";
|
import { registerActivityCommands } from "./commands/client/activity.js";
|
||||||
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||||
|
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
const DATA_DIR_OPTION_HELP =
|
||||||
|
"Paperclip data directory root (isolates state from ~/.paperclip)";
|
||||||
|
|
||||||
program
|
program
|
||||||
.name("paperclip")
|
.name("paperclip")
|
||||||
.description("Paperclip CLI — setup, diagnose, and configure your instance")
|
.description("Paperclip CLI — setup, diagnose, and configure your instance")
|
||||||
.version("0.0.1");
|
.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
|
program
|
||||||
.command("onboard")
|
.command("onboard")
|
||||||
.description("Interactive first-run setup wizard")
|
.description("Interactive first-run setup wizard")
|
||||||
.option("-c, --config <path>", "Path to config file")
|
.option("-c, --config <path>", "Path to config file")
|
||||||
|
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||||
.action(onboard);
|
.action(onboard);
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("doctor")
|
.command("doctor")
|
||||||
.description("Run diagnostic checks on your Paperclip setup")
|
.description("Run diagnostic checks on your Paperclip setup")
|
||||||
.option("-c, --config <path>", "Path to config file")
|
.option("-c, --config <path>", "Path to config file")
|
||||||
|
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||||
.option("--repair", "Attempt to repair issues automatically")
|
.option("--repair", "Attempt to repair issues automatically")
|
||||||
.alias("--fix")
|
.alias("--fix")
|
||||||
.option("-y, --yes", "Skip repair confirmation prompts")
|
.option("-y, --yes", "Skip repair confirmation prompts")
|
||||||
@@ -44,12 +58,14 @@ program
|
|||||||
.command("env")
|
.command("env")
|
||||||
.description("Print environment variables for deployment")
|
.description("Print environment variables for deployment")
|
||||||
.option("-c, --config <path>", "Path to config file")
|
.option("-c, --config <path>", "Path to config file")
|
||||||
|
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||||
.action(envCommand);
|
.action(envCommand);
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("configure")
|
.command("configure")
|
||||||
.description("Update configuration sections")
|
.description("Update configuration sections")
|
||||||
.option("-c, --config <path>", "Path to config file")
|
.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)")
|
.option("-s, --section <section>", "Section to configure (llm, database, logging, server, storage, secrets)")
|
||||||
.action(configure);
|
.action(configure);
|
||||||
|
|
||||||
@@ -58,12 +74,14 @@ program
|
|||||||
.description("Allow a hostname for authenticated/private mode access")
|
.description("Allow a hostname for authenticated/private mode access")
|
||||||
.argument("<host>", "Hostname to allow (for example dotta-macbook-pro)")
|
.argument("<host>", "Hostname to allow (for example dotta-macbook-pro)")
|
||||||
.option("-c, --config <path>", "Path to config file")
|
.option("-c, --config <path>", "Path to config file")
|
||||||
|
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||||
.action(addAllowedHostname);
|
.action(addAllowedHostname);
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("run")
|
.command("run")
|
||||||
.description("Bootstrap local setup (onboard + doctor) and run Paperclip")
|
.description("Bootstrap local setup (onboard + doctor) and run Paperclip")
|
||||||
.option("-c, --config <path>", "Path to config file")
|
.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("-i, --instance <id>", "Local instance id (default: default)")
|
||||||
.option("--repair", "Attempt automatic repairs during doctor", true)
|
.option("--repair", "Attempt automatic repairs during doctor", true)
|
||||||
.option("--no-repair", "Disable automatic repairs during doctor")
|
.option("--no-repair", "Disable automatic repairs during doctor")
|
||||||
@@ -76,6 +94,7 @@ heartbeat
|
|||||||
.description("Run one agent heartbeat and stream live logs")
|
.description("Run one agent heartbeat and stream live logs")
|
||||||
.requiredOption("-a, --agent-id <agentId>", "Agent ID to invoke")
|
.requiredOption("-a, --agent-id <agentId>", "Agent ID to invoke")
|
||||||
.option("-c, --config <path>", "Path to config file")
|
.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("--context <path>", "Path to CLI context file")
|
||||||
.option("--profile <name>", "CLI context profile name")
|
.option("--profile <name>", "CLI context profile name")
|
||||||
.option("--api-base <url>", "Base URL for the Paperclip server API")
|
.option("--api-base <url>", "Base URL for the Paperclip server API")
|
||||||
@@ -105,6 +124,7 @@ auth
|
|||||||
.command("bootstrap-ceo")
|
.command("bootstrap-ceo")
|
||||||
.description("Create a one-time bootstrap invite URL for first instance admin")
|
.description("Create a one-time bootstrap invite URL for first instance admin")
|
||||||
.option("-c, --config <path>", "Path to config file")
|
.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("--force", "Create new invite even if admin already exists", false)
|
||||||
.option("--expires-hours <hours>", "Invite expiration window in hours", (value) => Number(value))
|
.option("--expires-hours <hours>", "Invite expiration window in hours", (value) => Number(value))
|
||||||
.option("--base-url <url>", "Public base URL used to print invite link")
|
.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:
|
All client commands support:
|
||||||
|
|
||||||
|
- `--data-dir <path>`
|
||||||
- `--api-base <url>`
|
- `--api-base <url>`
|
||||||
- `--api-key <token>`
|
- `--api-key <token>`
|
||||||
- `--context <path>`
|
- `--context <path>`
|
||||||
@@ -53,6 +54,13 @@ All client commands support:
|
|||||||
|
|
||||||
Company-scoped commands also support `--company-id <id>`.
|
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
|
## Context Profiles
|
||||||
|
|
||||||
Store local defaults in `~/.paperclip/context.json`:
|
Store local defaults in `~/.paperclip/context.json`:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ All commands support:
|
|||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
|
| `--data-dir <path>` | Local Paperclip data root (isolates from `~/.paperclip`) |
|
||||||
| `--api-base <url>` | API base URL |
|
| `--api-base <url>` | API base URL |
|
||||||
| `--api-key <token>` | API authentication token |
|
| `--api-key <token>` | API authentication token |
|
||||||
| `--context <path>` | Context file path |
|
| `--context <path>` | Context file path |
|
||||||
@@ -27,6 +28,12 @@ All commands support:
|
|||||||
|
|
||||||
Company-scoped commands also accept `--company-id <id>`.
|
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
|
## Context Profiles
|
||||||
|
|
||||||
Store defaults to avoid repeating flags:
|
Store defaults to avoid repeating flags:
|
||||||
|
|||||||
@@ -100,3 +100,10 @@ Override with:
|
|||||||
```sh
|
```sh
|
||||||
PAPERCLIP_HOME=/custom/home PAPERCLIP_INSTANCE_ID=dev pnpm paperclip run
|
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