mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 23:36:51 +02:00
## Thinking Path > - Paperclip is a control plane for AI-agent companies, with the CLI acting as a scriptable operator and agent interface to that control plane. > - The REST API surface has grown across companies, agents, issues, routines, plugins, auth, workspaces, secrets, and operational inspection commands. > - The CLI had drifted from that API surface: some commands were missing, some command shapes differed from docs/reference material, and several edge cases only failed during end-to-end local-source testing. > - The local development runbook requires these tests to be disposable and isolated from a real `~/.paperclip`, `~/.codex`, or `~/.claude` installation. > - This pull request adds broad CLI/API parity coverage, fixes the actionable bugs found during that pass, and records the reproducible test log under `doc/logs`. > - The benefit is a more complete, scriptable CLI surface with regression coverage for the command families exercised by the parity run. ## What Changed - Added or expanded CLI command coverage for access/auth, companies, agents, projects, goals, issues and subresources, routines, plugins, workspaces, activity/run/cost/dashboard inspection, assets, skills, secrets, tokens, prompt/wake flows, and local setup helpers. - Fixed CLI/API parity bugs found during the run, including context profile patching, issue interaction optional payloads, malformed tree-hold errors, environment duplicate handling, configure invalid-section exit codes, worktree pnpm invocation, token agent ID resolution, plugin tool worker lookup, and routine webhook secret cleanup. - Added missing CLI wrappers and route coverage for health/access, invite resolution URL forwarding, join status normalization, secret lifecycle commands, LLM docs routes, available-skill isolation, positive board-claim coverage, and interactive `connect` prompt-flow tests. - Added a schema-backed `/api/openapi.json` route sufficient for CLI parity and `paperclipai openapi --json` smoke coverage. - Added `doc/logs/2026-05-24-cli-api-parity-e2e-log.md` with the detailed living test/bug log and renamed the log directory from `doc/bugs` to `doc/logs`. - Added `doc/plans/2026-05-23-cli-api-parity.md` and the OpenAPI parity reference used during the pass. OpenAPI note: this PR intentionally does not try to subsume `feature/openapi-spec`. The OpenAPI implementation here is schema-backed and better than the earlier route-inventory stub, but `feature/openapi-spec` is the fuller/better OpenAPI branch because it includes exact mounted-route coverage tests and additional current route coverage. That branch should stay as its own PR and can supersede this OpenAPI route implementation. ## Verification Targeted automated checks run: - `pnpm exec vitest run server/src/__tests__/openapi-routes.test.ts` - `pnpm exec vitest run server/src/__tests__/board-claim.test.ts` - `pnpm exec vitest run cli/src/__tests__/connect.test.ts` - `pnpm exec vitest run cli/src/__tests__/agent-lifecycle.test.ts` - `pnpm exec vitest run server/src/__tests__/plugin-database.test.ts` - `pnpm exec vitest run server/src/__tests__/routines-service.test.ts` - `pnpm --dir cli typecheck` - `pnpm --dir server typecheck` Manual/local E2E verification: - Ran the full disposable local-source CLI/API parity pass with isolated `PAPERCLIP_HOME`, `PAPERCLIP_CONFIG`, `PAPERCLIP_CONTEXT`, `PAPERCLIP_AUTH_STORE`, `CODEX_HOME`, and `CLAUDE_HOME` under `tmp/cli-api-parity`. - Verified `DATABASE_URL` and `DATABASE_MIGRATION_URL` stayed unset for the scratch server. - Verified live health and schema-backed OpenAPI responses on non-default port `3197`. - Revoked created board/agent tokens and cleaned up temporary plugins, secrets, non-default environments, and project workspaces. - See `doc/logs/2026-05-24-cli-api-parity-e2e-log.md` for the full command-by-command reproduction log. Not run: - Full `pnpm test`, `pnpm test:run`, or `pnpm build` were not run after the entire branch because the branch is broad and the parity pass used focused test/typecheck verification plus live isolated CLI reruns. ## Risks - This is a broad PR and touches many CLI command modules, so review surface is high. The changes are grouped around one theme, but a split may be easier if maintainers prefer narrower PRs. - The OpenAPI route in this PR is not the final/best OpenAPI implementation. `feature/openapi-spec` has stronger exact-route coverage and should remain the source for the dedicated OpenAPI PR. - The living log is intentionally detailed and large. It is useful for reproducibility but adds documentation weight. - No UI changes are intended; screenshots are not applicable. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-based coding agent in Codex desktop. Exact served model/context-window identifier was not exposed in the local app. Work used shell/Git/GitHub CLI tooling, local source inspection, targeted test execution, and live isolated Paperclip CLI/API smoke testing. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Devin Foley <devin@devinfoley.com>
727 lines
25 KiB
TypeScript
727 lines
25 KiB
TypeScript
import { Command } from "commander";
|
|
import pc from "picocolors";
|
|
import type {
|
|
Agent,
|
|
AgentEnvConfig,
|
|
CompanyPortabilityEnvInput,
|
|
CompanyPortabilityExportPreviewResult,
|
|
CompanyPortabilityInclude,
|
|
CompanySecret,
|
|
EnvBinding,
|
|
SecretProvider,
|
|
SecretProviderDescriptor,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
addCommonClientOptions,
|
|
apiPath,
|
|
formatInlineRecord,
|
|
handleCommandError,
|
|
printOutput,
|
|
resolveCommandContext,
|
|
type BaseClientOptions,
|
|
} from "./common.js";
|
|
|
|
interface SecretListOptions extends BaseClientOptions {
|
|
companyId?: string;
|
|
}
|
|
|
|
interface SecretDeclarationsOptions extends BaseClientOptions {
|
|
companyId?: string;
|
|
include?: string;
|
|
kind?: "all" | "secret" | "plain";
|
|
}
|
|
|
|
interface SecretCreateOptions extends BaseClientOptions {
|
|
companyId?: string;
|
|
name?: string;
|
|
key?: string;
|
|
provider?: SecretProvider;
|
|
value?: string;
|
|
valueEnv?: string;
|
|
description?: string;
|
|
}
|
|
|
|
interface SecretUpdateOptions extends BaseClientOptions {
|
|
payloadJson?: string;
|
|
}
|
|
|
|
interface SecretRotateOptions extends BaseClientOptions {
|
|
value?: string;
|
|
valueEnv?: string;
|
|
}
|
|
|
|
interface SecretDeleteOptions extends BaseClientOptions {
|
|
yes?: boolean;
|
|
confirm?: string;
|
|
}
|
|
|
|
interface SecretLinkOptions extends BaseClientOptions {
|
|
companyId?: string;
|
|
name?: string;
|
|
key?: string;
|
|
provider?: SecretProvider;
|
|
externalRef?: string;
|
|
providerVersionRef?: string;
|
|
description?: string;
|
|
}
|
|
|
|
interface SecretDoctorOptions extends BaseClientOptions {
|
|
companyId?: string;
|
|
}
|
|
|
|
interface SecretMigrateInlineEnvOptions extends BaseClientOptions {
|
|
companyId?: string;
|
|
apply?: boolean;
|
|
}
|
|
|
|
interface SecretJsonOptions extends BaseClientOptions {
|
|
companyId?: string;
|
|
payloadJson?: string;
|
|
}
|
|
|
|
interface SecretProviderHealth {
|
|
provider: SecretProvider;
|
|
status: "ok" | "warn" | "error";
|
|
message: string;
|
|
warnings?: string[];
|
|
backupGuidance?: string[];
|
|
details?: Record<string, unknown>;
|
|
}
|
|
|
|
interface SecretProviderHealthResponse {
|
|
providers: SecretProviderHealth[];
|
|
}
|
|
|
|
export interface InlineSecretMigrationCandidate {
|
|
agentId: string;
|
|
agentName: string;
|
|
envKey: string;
|
|
secretName: string;
|
|
existingSecretId: string | null;
|
|
}
|
|
|
|
const SENSITIVE_ENV_KEY_RE =
|
|
/(^token$|[-_]?token$|api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
|
|
|
const DEFAULT_DECLARATION_INCLUDE: CompanyPortabilityInclude = {
|
|
company: true,
|
|
agents: true,
|
|
projects: true,
|
|
issues: false,
|
|
skills: false,
|
|
};
|
|
|
|
export function parseSecretsInclude(input: string | undefined): CompanyPortabilityInclude {
|
|
if (!input?.trim()) return { ...DEFAULT_DECLARATION_INCLUDE };
|
|
const values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
|
|
const include = {
|
|
company: values.includes("company"),
|
|
agents: values.includes("agents"),
|
|
projects: values.includes("projects"),
|
|
issues: values.includes("issues") || values.includes("tasks"),
|
|
skills: values.includes("skills"),
|
|
};
|
|
if (!Object.values(include).some(Boolean)) {
|
|
throw new Error("Invalid --include value. Use one or more of: company,agents,projects,issues,tasks,skills");
|
|
}
|
|
return include;
|
|
}
|
|
|
|
export function isSensitiveEnvKey(key: string): boolean {
|
|
return SENSITIVE_ENV_KEY_RE.test(key);
|
|
}
|
|
|
|
export function toPlainEnvValue(binding: unknown): string | null {
|
|
if (typeof binding === "string") return binding;
|
|
if (typeof binding !== "object" || binding === null || Array.isArray(binding)) return null;
|
|
const record = binding as Record<string, unknown>;
|
|
if (record.type === "plain" && typeof record.value === "string") return record.value;
|
|
return null;
|
|
}
|
|
|
|
export function buildInlineMigrationSecretName(agentId: string, key: string): string {
|
|
return `agent_${agentId.slice(0, 8)}_${key.toLowerCase()}`;
|
|
}
|
|
|
|
export function collectInlineSecretMigrationCandidates(
|
|
agents: Agent[],
|
|
existingSecrets: CompanySecret[],
|
|
): InlineSecretMigrationCandidate[] {
|
|
const secretByName = new Map(existingSecrets.map((secret) => [secret.name, secret]));
|
|
const candidates: InlineSecretMigrationCandidate[] = [];
|
|
|
|
for (const agent of agents) {
|
|
const env = asRecord(agent.adapterConfig.env);
|
|
if (!env) continue;
|
|
for (const [envKey, binding] of Object.entries(env)) {
|
|
if (!isSensitiveEnvKey(envKey)) continue;
|
|
const plain = toPlainEnvValue(binding);
|
|
if (plain === null || plain.trim().length === 0) continue;
|
|
const secretName = buildInlineMigrationSecretName(agent.id, envKey);
|
|
candidates.push({
|
|
agentId: agent.id,
|
|
agentName: agent.name,
|
|
envKey,
|
|
secretName,
|
|
existingSecretId: secretByName.get(secretName)?.id ?? null,
|
|
});
|
|
}
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
export function buildMigratedAgentEnv(
|
|
env: Record<string, unknown>,
|
|
secretIdByEnvKey: Map<string, string>,
|
|
): AgentEnvConfig {
|
|
const next: AgentEnvConfig = { ...(env as Record<string, EnvBinding>) };
|
|
for (const [envKey, secretId] of secretIdByEnvKey) {
|
|
next[envKey] = {
|
|
type: "secret_ref",
|
|
secretId,
|
|
version: "latest",
|
|
};
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function readValueFromOptions(opts: { value?: string; valueEnv?: string }): string {
|
|
if (opts.value !== undefined && opts.valueEnv !== undefined) {
|
|
throw new Error("Use only one of --value or --value-env.");
|
|
}
|
|
if (opts.valueEnv !== undefined) {
|
|
const value = process.env[opts.valueEnv];
|
|
if (!value) throw new Error(`Environment variable ${opts.valueEnv} is empty or unset.`);
|
|
return value;
|
|
}
|
|
if (opts.value !== undefined) return opts.value;
|
|
throw new Error("Secret value is required. Pass --value or --value-env.");
|
|
}
|
|
|
|
function renderDeclaration(input: CompanyPortabilityEnvInput): Record<string, unknown> {
|
|
const scope = input.agentSlug
|
|
? `agent:${input.agentSlug}`
|
|
: input.projectSlug
|
|
? `project:${input.projectSlug}`
|
|
: "company";
|
|
return {
|
|
key: input.key,
|
|
scope,
|
|
kind: input.kind,
|
|
requirement: input.requirement,
|
|
portability: input.portability,
|
|
hasDefault: input.defaultValue !== null && input.defaultValue.length > 0,
|
|
description: input.description,
|
|
};
|
|
}
|
|
|
|
function renderSecret(secret: CompanySecret): Record<string, unknown> {
|
|
return {
|
|
id: secret.id,
|
|
name: secret.name,
|
|
key: secret.key,
|
|
provider: secret.provider,
|
|
status: secret.status,
|
|
managedMode: secret.managedMode,
|
|
latestVersion: secret.latestVersion,
|
|
externalRef: secret.externalRef ? "yes" : "no",
|
|
};
|
|
}
|
|
|
|
function printProviderHealth(rows: SecretProviderHealth[], json: boolean): void {
|
|
if (json) {
|
|
printOutput(rows, { json: true });
|
|
return;
|
|
}
|
|
if (rows.length === 0) {
|
|
printOutput([], { json: false });
|
|
return;
|
|
}
|
|
for (const row of rows) {
|
|
console.log(
|
|
formatInlineRecord({
|
|
id: row.provider,
|
|
status: row.status,
|
|
message: row.message,
|
|
}),
|
|
);
|
|
for (const warning of row.warnings ?? []) {
|
|
console.log(pc.yellow(`warning=${warning}`));
|
|
}
|
|
const missingConfig = asStringArray(row.details?.missingConfig);
|
|
if (missingConfig.length > 0) {
|
|
console.log(pc.dim(`missingConfig=${missingConfig.join(",")}`));
|
|
}
|
|
const credentialSource = typeof row.details?.credentialSource === "string"
|
|
? row.details.credentialSource
|
|
: null;
|
|
if (credentialSource) {
|
|
console.log(pc.dim(`credentialSource=${credentialSource}`));
|
|
}
|
|
const detectedCredentialSources = asStringArray(row.details?.detectedCredentialSources);
|
|
if (detectedCredentialSources.length > 0) {
|
|
console.log(pc.dim(`detectedCredentialSources=${detectedCredentialSources.join(",")}`));
|
|
}
|
|
for (const guidance of row.backupGuidance ?? []) {
|
|
console.log(pc.dim(`backup=${guidance}`));
|
|
}
|
|
}
|
|
}
|
|
|
|
function asStringArray(value: unknown): string[] {
|
|
return Array.isArray(value)
|
|
? value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
|
: [];
|
|
}
|
|
|
|
async function migrateInlineEnv(opts: SecretMigrateInlineEnvOptions): Promise<void> {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const companyId = ctx.companyId!;
|
|
const agents = (await ctx.api.get<Agent[]>(apiPath`/api/companies/${companyId}/agents`)) ?? [];
|
|
const secrets = (await ctx.api.get<CompanySecret[]>(apiPath`/api/companies/${companyId}/secrets`)) ?? [];
|
|
const candidates = collectInlineSecretMigrationCandidates(agents, secrets);
|
|
|
|
if (!opts.apply) {
|
|
printOutput(
|
|
{
|
|
apply: false,
|
|
agentsToUpdate: new Set(candidates.map((candidate) => candidate.agentId)).size,
|
|
secretsToCreate: candidates.filter((candidate) => !candidate.existingSecretId).length,
|
|
secretsToRotate: candidates.filter((candidate) => candidate.existingSecretId).length,
|
|
candidates,
|
|
},
|
|
{ json: ctx.json },
|
|
);
|
|
if (!ctx.json) {
|
|
console.log(pc.dim("Re-run with --apply to create/rotate secrets and update agent env bindings."));
|
|
}
|
|
return;
|
|
}
|
|
|
|
const createdOrRotated = new Map<string, string>();
|
|
let createdSecrets = 0;
|
|
let rotatedSecrets = 0;
|
|
|
|
for (const candidate of candidates) {
|
|
const agent = agents.find((row) => row.id === candidate.agentId);
|
|
const env = asRecord(agent?.adapterConfig.env);
|
|
const value = env ? toPlainEnvValue(env[candidate.envKey]) : null;
|
|
if (!value) continue;
|
|
|
|
if (candidate.existingSecretId) {
|
|
await ctx.api.post(apiPath`/api/secrets/${candidate.existingSecretId}/rotate`, { value });
|
|
createdOrRotated.set(`${candidate.agentId}:${candidate.envKey}`, candidate.existingSecretId);
|
|
rotatedSecrets += 1;
|
|
continue;
|
|
}
|
|
|
|
const created = await ctx.api.post<CompanySecret>(apiPath`/api/companies/${companyId}/secrets`, {
|
|
name: candidate.secretName,
|
|
provider: "local_encrypted",
|
|
value,
|
|
description: `Migrated from agent ${candidate.agentId} env ${candidate.envKey}`,
|
|
});
|
|
if (!created) throw new Error(`Secret create returned no data for ${candidate.secretName}`);
|
|
createdOrRotated.set(`${candidate.agentId}:${candidate.envKey}`, created.id);
|
|
createdSecrets += 1;
|
|
}
|
|
|
|
let updatedAgents = 0;
|
|
for (const agent of agents) {
|
|
const env = asRecord(agent.adapterConfig.env);
|
|
if (!env) continue;
|
|
const secretIdByEnvKey = new Map<string, string>();
|
|
for (const [key] of Object.entries(env)) {
|
|
const secretId = createdOrRotated.get(`${agent.id}:${key}`);
|
|
if (secretId) secretIdByEnvKey.set(key, secretId);
|
|
}
|
|
if (secretIdByEnvKey.size === 0) continue;
|
|
const adapterConfig = {
|
|
...agent.adapterConfig,
|
|
env: buildMigratedAgentEnv(env, secretIdByEnvKey),
|
|
};
|
|
await ctx.api.patch(apiPath`/api/agents/${agent.id}`, {
|
|
adapterConfig,
|
|
replaceAdapterConfig: true,
|
|
});
|
|
updatedAgents += 1;
|
|
}
|
|
|
|
printOutput(
|
|
{
|
|
apply: true,
|
|
updatedAgents,
|
|
createdSecrets,
|
|
rotatedSecrets,
|
|
},
|
|
{ json: ctx.json },
|
|
);
|
|
}
|
|
|
|
export function registerSecretCommands(program: Command): void {
|
|
const secrets = program.command("secrets").description("Secret declaration and provider operations");
|
|
|
|
addCommonClientOptions(
|
|
secrets
|
|
.command("list")
|
|
.description("List secret metadata for a company")
|
|
.requiredOption("-C, --company-id <id>", "Company ID")
|
|
.action(async (opts: SecretListOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const rows = (await ctx.api.get<CompanySecret[]>(apiPath`/api/companies/${ctx.companyId}/secrets`)) ?? [];
|
|
printOutput(ctx.json ? rows : rows.map(renderSecret), { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
secrets
|
|
.command("declarations")
|
|
.description("List portable env declarations emitted by company export")
|
|
.requiredOption("-C, --company-id <id>", "Company ID")
|
|
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents,projects")
|
|
.option("--kind <kind>", "Filter declarations: all | secret | plain", "all")
|
|
.action(async (opts: SecretDeclarationsOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const kind = opts.kind ?? "all";
|
|
if (!["all", "secret", "plain"].includes(kind)) {
|
|
throw new Error("Invalid --kind value. Use: all, secret, plain");
|
|
}
|
|
const preview = await ctx.api.post<CompanyPortabilityExportPreviewResult>(
|
|
apiPath`/api/companies/${ctx.companyId}/exports/preview`,
|
|
{ include: parseSecretsInclude(opts.include) },
|
|
);
|
|
const declarations = (preview?.manifest.envInputs ?? [])
|
|
.filter((entry) => kind === "all" || entry.kind === kind);
|
|
printOutput(ctx.json ? declarations : declarations.map(renderDeclaration), { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
secrets
|
|
.command("create")
|
|
.description("Create a Paperclip-managed secret")
|
|
.requiredOption("-C, --company-id <id>", "Company ID")
|
|
.requiredOption("--name <name>", "Secret display name")
|
|
.option("--key <key>", "Portable secret key")
|
|
.option("--provider <provider>", "Secret provider id")
|
|
.option("--value <value>", "Secret value")
|
|
.option("--value-env <name>", "Read secret value from an environment variable")
|
|
.option("--description <text>", "Description")
|
|
.action(async (opts: SecretCreateOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const created = await ctx.api.post<CompanySecret>(apiPath`/api/companies/${ctx.companyId}/secrets`, {
|
|
name: opts.name,
|
|
key: opts.key,
|
|
provider: opts.provider,
|
|
value: readValueFromOptions(opts),
|
|
description: opts.description,
|
|
});
|
|
printOutput(ctx.json ? created : renderSecret(created!), { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
secrets
|
|
.command("link")
|
|
.description("Link an external provider-owned secret without storing its value in Paperclip")
|
|
.requiredOption("-C, --company-id <id>", "Company ID")
|
|
.requiredOption("--name <name>", "Secret display name")
|
|
.requiredOption("--provider <provider>", "Secret provider id")
|
|
.requiredOption("--external-ref <ref>", "Provider secret ARN/name/path/reference")
|
|
.option("--key <key>", "Portable secret key")
|
|
.option("--provider-version-ref <ref>", "Provider version id or label")
|
|
.option("--description <text>", "Description")
|
|
.action(async (opts: SecretLinkOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const created = await ctx.api.post<CompanySecret>(apiPath`/api/companies/${ctx.companyId}/secrets`, {
|
|
name: opts.name,
|
|
key: opts.key,
|
|
provider: opts.provider,
|
|
managedMode: "external_reference",
|
|
externalRef: opts.externalRef,
|
|
providerVersionRef: opts.providerVersionRef,
|
|
description: opts.description,
|
|
});
|
|
printOutput(ctx.json ? created : renderSecret(created!), { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
secrets
|
|
.command("update")
|
|
.description("Update secret metadata")
|
|
.argument("<secretId>", "Secret ID")
|
|
.requiredOption("--payload-json <json>", "UpdateSecret JSON payload")
|
|
.action(async (secretId: string, opts: SecretUpdateOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
printOutput(await ctx.api.patch(apiPath`/api/secrets/${secretId}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
secrets
|
|
.command("rotate")
|
|
.description("Rotate a Paperclip-managed secret value")
|
|
.argument("<secretId>", "Secret ID")
|
|
.option("--value <value>", "New secret value")
|
|
.option("--value-env <name>", "Read new secret value from an environment variable")
|
|
.action(async (secretId: string, opts: SecretRotateOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
printOutput(await ctx.api.post(apiPath`/api/secrets/${secretId}/rotate`, { value: readValueFromOptions(opts) }), { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
secrets
|
|
.command("usage")
|
|
.description("Show where a secret is referenced")
|
|
.argument("<secretId>", "Secret ID")
|
|
.action(async (secretId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
printOutput(await ctx.api.get(apiPath`/api/secrets/${secretId}/usage`), { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
secrets
|
|
.command("access-events")
|
|
.description("List secret access events")
|
|
.argument("<secretId>", "Secret ID")
|
|
.action(async (secretId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
printOutput(await ctx.api.get(apiPath`/api/secrets/${secretId}/access-events`), { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
secrets
|
|
.command("delete")
|
|
.description("Delete a secret")
|
|
.argument("<secretId>", "Secret ID")
|
|
.option("--yes", "Required safety flag to confirm destructive action", false)
|
|
.option("--confirm <secretId>", "Repeat the secret ID to confirm deletion")
|
|
.action(async (secretId: string, opts: SecretDeleteOptions) => {
|
|
try {
|
|
if (!opts.yes) throw new Error("Deletion requires --yes.");
|
|
if (opts.confirm !== secretId) {
|
|
throw new Error("Deletion requires --confirm <secretId> matching the secret ID.");
|
|
}
|
|
const ctx = resolveCommandContext(opts);
|
|
printOutput(await ctx.api.delete(apiPath`/api/secrets/${secretId}`), { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
secrets
|
|
.command("doctor")
|
|
.description("Run secret provider health checks through the Paperclip API")
|
|
.requiredOption("-C, --company-id <id>", "Company ID")
|
|
.action(async (opts: SecretDoctorOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const health = await ctx.api.get<SecretProviderHealthResponse>(
|
|
apiPath`/api/companies/${ctx.companyId}/secret-providers/health`,
|
|
);
|
|
printProviderHealth(health?.providers ?? [], ctx.json);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
secrets
|
|
.command("providers")
|
|
.description("List configured secret provider descriptors")
|
|
.requiredOption("-C, --company-id <id>", "Company ID")
|
|
.action(async (opts: SecretDoctorOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const rows = (await ctx.api.get<SecretProviderDescriptor[]>(
|
|
apiPath`/api/companies/${ctx.companyId}/secret-providers`,
|
|
)) ?? [];
|
|
printOutput(rows, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
secrets
|
|
.command("provider-configs")
|
|
.description("List company secret provider vault configs")
|
|
.requiredOption("-C, --company-id <id>", "Company ID")
|
|
.action(async (opts: SecretDoctorOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
printOutput(await ctx.api.get(apiPath`/api/companies/${ctx.companyId}/secret-provider-configs`), { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCompanySecretJsonPost(secrets, "provider-config:create", "Create a secret provider vault config", "secret-provider-configs");
|
|
addCompanySecretJsonPost(
|
|
secrets,
|
|
"provider-config:discovery-preview",
|
|
"Preview provider vault secret discovery",
|
|
"secret-provider-configs/discovery/preview",
|
|
);
|
|
addSecretProviderConfigGet(secrets, "provider-config:get", "Get a secret provider vault config", "");
|
|
addSecretProviderConfigPatch(secrets, "provider-config:update", "Update a secret provider vault config", "");
|
|
addSecretProviderConfigPost(secrets, "provider-config:default", "Set the default provider vault config", "default");
|
|
addSecretProviderConfigPost(secrets, "provider-config:health", "Check provider vault health", "health");
|
|
addSecretProviderConfigDelete(secrets, "provider-config:delete", "Delete a secret provider vault config");
|
|
addCompanySecretJsonPost(secrets, "remote-import:preview", "Preview remote secret import", "secrets/remote-import/preview");
|
|
addCompanySecretJsonPost(secrets, "remote-import", "Import selected remote secrets", "secrets/remote-import");
|
|
|
|
addCommonClientOptions(
|
|
secrets
|
|
.command("migrate-inline-env")
|
|
.description("Migrate inline sensitive agent env values into secret references")
|
|
.requiredOption("-C, --company-id <id>", "Company ID")
|
|
.option("--apply", "Persist changes; default is a dry run", false)
|
|
.action(async (opts: SecretMigrateInlineEnvOptions) => {
|
|
try {
|
|
await migrateInlineEnv(opts);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
function addCompanySecretJsonPost(parent: Command, name: string, description: string, path: string): void {
|
|
addCommonClientOptions(
|
|
parent
|
|
.command(name)
|
|
.description(description)
|
|
.requiredOption("-C, --company-id <id>", "Company ID")
|
|
.requiredOption("--payload-json <json>", "JSON payload")
|
|
.action(async (opts: SecretJsonOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
printOutput(await ctx.api.post(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
function addSecretProviderConfigGet(parent: Command, name: string, description: string, suffix: string): void {
|
|
addCommonClientOptions(
|
|
parent
|
|
.command(name)
|
|
.description(description)
|
|
.argument("<configId>", "Provider config ID")
|
|
.action(async (configId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
printOutput(await ctx.api.get(`${apiPath`/api/secret-provider-configs/${configId}`}${suffix ? `/${suffix}` : ""}`), { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
function addSecretProviderConfigPatch(parent: Command, name: string, description: string, suffix: string): void {
|
|
addCommonClientOptions(
|
|
parent
|
|
.command(name)
|
|
.description(description)
|
|
.argument("<configId>", "Provider config ID")
|
|
.requiredOption("--payload-json <json>", "JSON payload")
|
|
.action(async (configId: string, opts: SecretJsonOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
printOutput(await ctx.api.patch(`${apiPath`/api/secret-provider-configs/${configId}`}${suffix ? `/${suffix}` : ""}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
function addSecretProviderConfigPost(parent: Command, name: string, description: string, suffix: string): void {
|
|
addCommonClientOptions(
|
|
parent
|
|
.command(name)
|
|
.description(description)
|
|
.argument("<configId>", "Provider config ID")
|
|
.action(async (configId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
printOutput(await ctx.api.post(`${apiPath`/api/secret-provider-configs/${configId}`}/${suffix}`, {}), { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
function addSecretProviderConfigDelete(parent: Command, name: string, description: string): void {
|
|
addCommonClientOptions(
|
|
parent
|
|
.command(name)
|
|
.description(description)
|
|
.argument("<configId>", "Provider config ID")
|
|
.action(async (configId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
printOutput(await ctx.api.delete(apiPath`/api/secret-provider-configs/${configId}`), { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
function parseJson(value: string): unknown {
|
|
return JSON.parse(value) as unknown;
|
|
}
|