feat(cli): add client commands and home-based local runtime defaults
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"paperclip": "./src/index.ts"
|
||||
"paperclip": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
@@ -17,6 +17,7 @@
|
||||
"@paperclip/adapter-codex-local": "workspace:*",
|
||||
"@paperclip/adapter-utils": "workspace:*",
|
||||
"@paperclip/db": "workspace:*",
|
||||
"@paperclip/server": "workspace:*",
|
||||
"@paperclip/shared": "workspace:*",
|
||||
"dotenv": "^17.0.1",
|
||||
"commander": "^13.1.0",
|
||||
|
||||
98
cli/src/__tests__/common.test.ts
Normal file
98
cli/src/__tests__/common.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { writeContext } from "../client/context.js";
|
||||
import { resolveCommandContext } from "../commands/client/common.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
function createTempPath(name: string): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-common-"));
|
||||
return path.join(dir, name);
|
||||
}
|
||||
|
||||
describe("resolveCommandContext", () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_COMPANY_ID;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
it("uses profile defaults when options/env are not provided", () => {
|
||||
const contextPath = createTempPath("context.json");
|
||||
|
||||
writeContext(
|
||||
{
|
||||
version: 1,
|
||||
currentProfile: "ops",
|
||||
profiles: {
|
||||
ops: {
|
||||
apiBase: "http://127.0.0.1:9999",
|
||||
companyId: "company-profile",
|
||||
apiKeyEnvVarName: "AGENT_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
contextPath,
|
||||
);
|
||||
process.env.AGENT_KEY = "key-from-env";
|
||||
|
||||
const resolved = resolveCommandContext({ context: contextPath }, { requireCompany: true });
|
||||
expect(resolved.api.apiBase).toBe("http://127.0.0.1:9999");
|
||||
expect(resolved.companyId).toBe("company-profile");
|
||||
expect(resolved.api.apiKey).toBe("key-from-env");
|
||||
});
|
||||
|
||||
it("prefers explicit options over profile values", () => {
|
||||
const contextPath = createTempPath("context.json");
|
||||
writeContext(
|
||||
{
|
||||
version: 1,
|
||||
currentProfile: "default",
|
||||
profiles: {
|
||||
default: {
|
||||
apiBase: "http://profile:3100",
|
||||
companyId: "company-profile",
|
||||
},
|
||||
},
|
||||
},
|
||||
contextPath,
|
||||
);
|
||||
|
||||
const resolved = resolveCommandContext(
|
||||
{
|
||||
context: contextPath,
|
||||
apiBase: "http://override:3200",
|
||||
apiKey: "direct-token",
|
||||
companyId: "company-override",
|
||||
},
|
||||
{ requireCompany: true },
|
||||
);
|
||||
|
||||
expect(resolved.api.apiBase).toBe("http://override:3200");
|
||||
expect(resolved.companyId).toBe("company-override");
|
||||
expect(resolved.api.apiKey).toBe("direct-token");
|
||||
});
|
||||
|
||||
it("throws when company is required but unresolved", () => {
|
||||
const contextPath = createTempPath("context.json");
|
||||
writeContext(
|
||||
{
|
||||
version: 1,
|
||||
currentProfile: "default",
|
||||
profiles: { default: {} },
|
||||
},
|
||||
contextPath,
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
resolveCommandContext({ context: contextPath, apiBase: "http://localhost:3100" }, { requireCompany: true }),
|
||||
).toThrow(/Company ID is required/);
|
||||
});
|
||||
});
|
||||
70
cli/src/__tests__/context.test.ts
Normal file
70
cli/src/__tests__/context.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
defaultClientContext,
|
||||
readContext,
|
||||
setCurrentProfile,
|
||||
upsertProfile,
|
||||
writeContext,
|
||||
} from "../client/context.js";
|
||||
|
||||
function createTempContextPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-context-"));
|
||||
return path.join(dir, "context.json");
|
||||
}
|
||||
|
||||
describe("client context store", () => {
|
||||
it("returns default context when file does not exist", () => {
|
||||
const contextPath = createTempContextPath();
|
||||
const context = readContext(contextPath);
|
||||
expect(context).toEqual(defaultClientContext());
|
||||
});
|
||||
|
||||
it("upserts profile values and switches current profile", () => {
|
||||
const contextPath = createTempContextPath();
|
||||
|
||||
upsertProfile(
|
||||
"work",
|
||||
{
|
||||
apiBase: "http://localhost:3100",
|
||||
companyId: "company-123",
|
||||
apiKeyEnvVarName: "PAPERCLIP_AGENT_TOKEN",
|
||||
},
|
||||
contextPath,
|
||||
);
|
||||
|
||||
setCurrentProfile("work", contextPath);
|
||||
const context = readContext(contextPath);
|
||||
|
||||
expect(context.currentProfile).toBe("work");
|
||||
expect(context.profiles.work).toEqual({
|
||||
apiBase: "http://localhost:3100",
|
||||
companyId: "company-123",
|
||||
apiKeyEnvVarName: "PAPERCLIP_AGENT_TOKEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes invalid file content to safe defaults", () => {
|
||||
const contextPath = createTempContextPath();
|
||||
writeContext(
|
||||
{
|
||||
version: 1,
|
||||
currentProfile: "x",
|
||||
profiles: {
|
||||
x: {
|
||||
apiBase: " ",
|
||||
companyId: " ",
|
||||
apiKeyEnvVarName: " ",
|
||||
},
|
||||
},
|
||||
},
|
||||
contextPath,
|
||||
);
|
||||
|
||||
const context = readContext(contextPath);
|
||||
expect(context.currentProfile).toBe("x");
|
||||
expect(context.profiles.x).toEqual({});
|
||||
});
|
||||
});
|
||||
44
cli/src/__tests__/home-paths.test.ts
Normal file
44
cli/src/__tests__/home-paths.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
describeLocalInstancePaths,
|
||||
expandHomePrefix,
|
||||
resolvePaperclipHomeDir,
|
||||
resolvePaperclipInstanceId,
|
||||
} from "../config/home.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
describe("home path resolution", () => {
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
it("defaults to ~/.paperclip and default instance", () => {
|
||||
delete process.env.PAPERCLIP_HOME;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
|
||||
const paths = describeLocalInstancePaths();
|
||||
expect(paths.homeDir).toBe(path.resolve(os.homedir(), ".paperclip"));
|
||||
expect(paths.instanceId).toBe("default");
|
||||
expect(paths.configPath).toBe(path.resolve(os.homedir(), ".paperclip", "instances", "default", "config.json"));
|
||||
});
|
||||
|
||||
it("supports PAPERCLIP_HOME and explicit instance ids", () => {
|
||||
process.env.PAPERCLIP_HOME = "~/paperclip-home";
|
||||
|
||||
const home = resolvePaperclipHomeDir();
|
||||
expect(home).toBe(path.resolve(os.homedir(), "paperclip-home"));
|
||||
expect(resolvePaperclipInstanceId("dev_1")).toBe("dev_1");
|
||||
});
|
||||
|
||||
it("rejects invalid instance ids", () => {
|
||||
expect(() => resolvePaperclipInstanceId("bad/id")).toThrow(/Invalid instance id/);
|
||||
});
|
||||
|
||||
it("expands ~ prefixes", () => {
|
||||
expect(expandHomePrefix("~")).toBe(os.homedir());
|
||||
expect(expandHomePrefix("~/x/y")).toBe(path.resolve(os.homedir(), "x/y"));
|
||||
});
|
||||
});
|
||||
61
cli/src/__tests__/http.test.ts
Normal file
61
cli/src/__tests__/http.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiRequestError, PaperclipApiClient } from "../client/http.js";
|
||||
|
||||
describe("PaperclipApiClient", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("adds authorization and run-id headers", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new PaperclipApiClient({
|
||||
apiBase: "http://localhost:3100",
|
||||
apiKey: "token-123",
|
||||
runId: "run-abc",
|
||||
});
|
||||
|
||||
await client.post("/api/test", { hello: "world" });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const call = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(call[0]).toContain("/api/test");
|
||||
|
||||
const headers = call[1].headers as Record<string, string>;
|
||||
expect(headers.authorization).toBe("Bearer token-123");
|
||||
expect(headers["x-paperclip-run-id"]).toBe("run-abc");
|
||||
expect(headers["content-type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
it("returns null on ignoreNotFound", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "Not found" }), { status: 404 }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
|
||||
const result = await client.get("/api/missing", { ignoreNotFound: true });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("throws ApiRequestError with details", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ error: "Issue checkout conflict", details: { issueId: "1" } }),
|
||||
{ status: 409 },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
|
||||
|
||||
await expect(client.post("/api/issues/1/checkout", {})).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: "Issue checkout conflict",
|
||||
details: { issueId: "1" },
|
||||
} satisfies Partial<ApiRequestError>);
|
||||
});
|
||||
});
|
||||
175
cli/src/client/context.ts
Normal file
175
cli/src/client/context.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveDefaultContextPath } from "../config/home.js";
|
||||
|
||||
const DEFAULT_CONTEXT_BASENAME = "context.json";
|
||||
const DEFAULT_PROFILE = "default";
|
||||
|
||||
export interface ClientContextProfile {
|
||||
apiBase?: string;
|
||||
companyId?: string;
|
||||
apiKeyEnvVarName?: string;
|
||||
}
|
||||
|
||||
export interface ClientContext {
|
||||
version: 1;
|
||||
currentProfile: string;
|
||||
profiles: Record<string, ClientContextProfile>;
|
||||
}
|
||||
|
||||
function findContextFileFromAncestors(startDir: string): string | null {
|
||||
const absoluteStartDir = path.resolve(startDir);
|
||||
let currentDir = absoluteStartDir;
|
||||
|
||||
while (true) {
|
||||
const candidate = path.resolve(currentDir, ".paperclip", DEFAULT_CONTEXT_BASENAME);
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const nextDir = path.resolve(currentDir, "..");
|
||||
if (nextDir === currentDir) break;
|
||||
currentDir = nextDir;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveContextPath(overridePath?: string): string {
|
||||
if (overridePath) return path.resolve(overridePath);
|
||||
if (process.env.PAPERCLIP_CONTEXT) return path.resolve(process.env.PAPERCLIP_CONTEXT);
|
||||
return findContextFileFromAncestors(process.cwd()) ?? resolveDefaultContextPath();
|
||||
}
|
||||
|
||||
export function defaultClientContext(): ClientContext {
|
||||
return {
|
||||
version: 1,
|
||||
currentProfile: DEFAULT_PROFILE,
|
||||
profiles: {
|
||||
[DEFAULT_PROFILE]: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseJson(filePath: string): unknown {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to parse JSON at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toStringOrUndefined(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function normalizeProfile(value: unknown): ClientContextProfile {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
||||
const profile = value as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
apiBase: toStringOrUndefined(profile.apiBase),
|
||||
companyId: toStringOrUndefined(profile.companyId),
|
||||
apiKeyEnvVarName: toStringOrUndefined(profile.apiKeyEnvVarName),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeContext(raw: unknown): ClientContext {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||
return defaultClientContext();
|
||||
}
|
||||
|
||||
const record = raw as Record<string, unknown>;
|
||||
const version = record.version === 1 ? 1 : 1;
|
||||
const currentProfile = toStringOrUndefined(record.currentProfile) ?? DEFAULT_PROFILE;
|
||||
|
||||
const rawProfiles = record.profiles;
|
||||
const profiles: Record<string, ClientContextProfile> = {};
|
||||
|
||||
if (typeof rawProfiles === "object" && rawProfiles !== null && !Array.isArray(rawProfiles)) {
|
||||
for (const [name, profile] of Object.entries(rawProfiles as Record<string, unknown>)) {
|
||||
if (!name.trim()) continue;
|
||||
profiles[name] = normalizeProfile(profile);
|
||||
}
|
||||
}
|
||||
|
||||
if (!profiles[currentProfile]) {
|
||||
profiles[currentProfile] = {};
|
||||
}
|
||||
|
||||
if (Object.keys(profiles).length === 0) {
|
||||
profiles[DEFAULT_PROFILE] = {};
|
||||
}
|
||||
|
||||
return {
|
||||
version,
|
||||
currentProfile,
|
||||
profiles,
|
||||
};
|
||||
}
|
||||
|
||||
export function readContext(contextPath?: string): ClientContext {
|
||||
const filePath = resolveContextPath(contextPath);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return defaultClientContext();
|
||||
}
|
||||
|
||||
const raw = parseJson(filePath);
|
||||
return normalizeContext(raw);
|
||||
}
|
||||
|
||||
export function writeContext(context: ClientContext, contextPath?: string): void {
|
||||
const filePath = resolveContextPath(contextPath);
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const normalized = normalizeContext(context);
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
export function upsertProfile(
|
||||
profileName: string,
|
||||
patch: Partial<ClientContextProfile>,
|
||||
contextPath?: string,
|
||||
): ClientContext {
|
||||
const context = readContext(contextPath);
|
||||
const existing = context.profiles[profileName] ?? {};
|
||||
const merged: ClientContextProfile = {
|
||||
...existing,
|
||||
...patch,
|
||||
};
|
||||
|
||||
if (patch.apiBase !== undefined && patch.apiBase.trim().length === 0) {
|
||||
delete merged.apiBase;
|
||||
}
|
||||
if (patch.companyId !== undefined && patch.companyId.trim().length === 0) {
|
||||
delete merged.companyId;
|
||||
}
|
||||
if (patch.apiKeyEnvVarName !== undefined && patch.apiKeyEnvVarName.trim().length === 0) {
|
||||
delete merged.apiKeyEnvVarName;
|
||||
}
|
||||
|
||||
context.profiles[profileName] = merged;
|
||||
context.currentProfile = context.currentProfile || profileName;
|
||||
writeContext(context, contextPath);
|
||||
return context;
|
||||
}
|
||||
|
||||
export function setCurrentProfile(profileName: string, contextPath?: string): ClientContext {
|
||||
const context = readContext(contextPath);
|
||||
if (!context.profiles[profileName]) {
|
||||
context.profiles[profileName] = {};
|
||||
}
|
||||
context.currentProfile = profileName;
|
||||
writeContext(context, contextPath);
|
||||
return context;
|
||||
}
|
||||
|
||||
export function resolveProfile(
|
||||
context: ClientContext,
|
||||
profileName?: string,
|
||||
): { name: string; profile: ClientContextProfile } {
|
||||
const name = profileName?.trim() || context.currentProfile || DEFAULT_PROFILE;
|
||||
const profile = context.profiles[name] ?? {};
|
||||
return { name, profile };
|
||||
}
|
||||
148
cli/src/client/http.ts
Normal file
148
cli/src/client/http.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { URL } from "node:url";
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
status: number;
|
||||
details?: unknown;
|
||||
body?: unknown;
|
||||
|
||||
constructor(status: number, message: string, details?: unknown, body?: unknown) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
ignoreNotFound?: boolean;
|
||||
}
|
||||
|
||||
interface ApiClientOptions {
|
||||
apiBase: string;
|
||||
apiKey?: string;
|
||||
runId?: string;
|
||||
}
|
||||
|
||||
export class PaperclipApiClient {
|
||||
readonly apiBase: string;
|
||||
readonly apiKey?: string;
|
||||
readonly runId?: string;
|
||||
|
||||
constructor(opts: ApiClientOptions) {
|
||||
this.apiBase = opts.apiBase.replace(/\/+$/, "");
|
||||
this.apiKey = opts.apiKey?.trim() || undefined;
|
||||
this.runId = opts.runId?.trim() || undefined;
|
||||
}
|
||||
|
||||
get<T>(path: string, opts?: RequestOptions): Promise<T | null> {
|
||||
return this.request<T>(path, { method: "GET" }, opts);
|
||||
}
|
||||
|
||||
post<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> {
|
||||
return this.request<T>(path, {
|
||||
method: "POST",
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}, opts);
|
||||
}
|
||||
|
||||
patch<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> {
|
||||
return this.request<T>(path, {
|
||||
method: "PATCH",
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}, opts);
|
||||
}
|
||||
|
||||
delete<T>(path: string, opts?: RequestOptions): Promise<T | null> {
|
||||
return this.request<T>(path, { method: "DELETE" }, opts);
|
||||
}
|
||||
|
||||
private async request<T>(path: string, init: RequestInit, opts?: RequestOptions): Promise<T | null> {
|
||||
const url = buildUrl(this.apiBase, path);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
accept: "application/json",
|
||||
...toStringRecord(init.headers),
|
||||
};
|
||||
|
||||
if (init.body !== undefined) {
|
||||
headers["content-type"] = headers["content-type"] ?? "application/json";
|
||||
}
|
||||
|
||||
if (this.apiKey) {
|
||||
headers.authorization = `Bearer ${this.apiKey}`;
|
||||
}
|
||||
|
||||
if (this.runId) {
|
||||
headers["x-paperclip-run-id"] = this.runId;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (opts?.ignoreNotFound && response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw await toApiError(response);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return safeParseJson(text) as T;
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(apiBase: string, path: string): string {
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
const url = new URL(apiBase);
|
||||
url.pathname = `${url.pathname.replace(/\/+$/, "")}${normalizedPath}`;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function safeParseJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
async function toApiError(response: Response): Promise<ApiRequestError> {
|
||||
const text = await response.text();
|
||||
const parsed = safeParseJson(text);
|
||||
|
||||
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
||||
const body = parsed as Record<string, unknown>;
|
||||
const message =
|
||||
(typeof body.error === "string" && body.error.trim()) ||
|
||||
(typeof body.message === "string" && body.message.trim()) ||
|
||||
`Request failed with status ${response.status}`;
|
||||
|
||||
return new ApiRequestError(response.status, message, body.details, parsed);
|
||||
}
|
||||
|
||||
return new ApiRequestError(response.status, `Request failed with status ${response.status}`, undefined, parsed);
|
||||
}
|
||||
|
||||
function toStringRecord(headers: HeadersInit | undefined): Record<string, string> {
|
||||
if (!headers) return {};
|
||||
if (Array.isArray(headers)) {
|
||||
return Object.fromEntries(headers.map(([key, value]) => [key, String(value)]));
|
||||
}
|
||||
if (headers instanceof Headers) {
|
||||
return Object.fromEntries(headers.entries());
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers).map(([key, value]) => [key, String(value)]),
|
||||
);
|
||||
}
|
||||
71
cli/src/commands/client/activity.ts
Normal file
71
cli/src/commands/client/activity.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Command } from "commander";
|
||||
import type { ActivityEvent } from "@paperclip/shared";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
formatInlineRecord,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
|
||||
interface ActivityListOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
agentId?: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
}
|
||||
|
||||
export function registerActivityCommands(program: Command): void {
|
||||
const activity = program.command("activity").description("Activity log operations");
|
||||
|
||||
addCommonClientOptions(
|
||||
activity
|
||||
.command("list")
|
||||
.description("List company activity log entries")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.option("--agent-id <id>", "Filter by agent ID")
|
||||
.option("--entity-type <type>", "Filter by entity type")
|
||||
.option("--entity-id <id>", "Filter by entity ID")
|
||||
.action(async (opts: ActivityListOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const params = new URLSearchParams();
|
||||
if (opts.agentId) params.set("agentId", opts.agentId);
|
||||
if (opts.entityType) params.set("entityType", opts.entityType);
|
||||
if (opts.entityId) params.set("entityId", opts.entityId);
|
||||
|
||||
const query = params.toString();
|
||||
const path = `/api/companies/${ctx.companyId}/activity${query ? `?${query}` : ""}`;
|
||||
const rows = (await ctx.api.get<ActivityEvent[]>(path)) ?? [];
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(rows, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
printOutput([], { json: false });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
console.log(
|
||||
formatInlineRecord({
|
||||
id: row.id,
|
||||
action: row.action,
|
||||
actorType: row.actorType,
|
||||
actorId: row.actorId,
|
||||
entityType: row.entityType,
|
||||
entityId: row.entityId,
|
||||
createdAt: String(row.createdAt),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: false },
|
||||
);
|
||||
}
|
||||
74
cli/src/commands/client/agent.ts
Normal file
74
cli/src/commands/client/agent.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Command } from "commander";
|
||||
import type { Agent } from "@paperclip/shared";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
formatInlineRecord,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
|
||||
interface AgentListOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
}
|
||||
|
||||
export function registerAgentCommands(program: Command): void {
|
||||
const agent = program.command("agent").description("Agent operations");
|
||||
|
||||
addCommonClientOptions(
|
||||
agent
|
||||
.command("list")
|
||||
.description("List agents for a company")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.action(async (opts: AgentListOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const rows = (await ctx.api.get<Agent[]>(`/api/companies/${ctx.companyId}/agents`)) ?? [];
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(rows, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
printOutput([], { json: false });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
console.log(
|
||||
formatInlineRecord({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
role: row.role,
|
||||
status: row.status,
|
||||
reportsTo: row.reportsTo,
|
||||
budgetMonthlyCents: row.budgetMonthlyCents,
|
||||
spentMonthlyCents: row.spentMonthlyCents,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: false },
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
agent
|
||||
.command("get")
|
||||
.description("Get one agent")
|
||||
.argument("<agentId>", "Agent ID")
|
||||
.action(async (agentId: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const row = await ctx.api.get<Agent>(`/api/agents/${agentId}`);
|
||||
printOutput(row, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
259
cli/src/commands/client/approval.ts
Normal file
259
cli/src/commands/client/approval.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
createApprovalSchema,
|
||||
requestApprovalRevisionSchema,
|
||||
resolveApprovalSchema,
|
||||
resubmitApprovalSchema,
|
||||
type Approval,
|
||||
type ApprovalComment,
|
||||
} from "@paperclip/shared";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
formatInlineRecord,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
|
||||
interface ApprovalListOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface ApprovalDecisionOptions extends BaseClientOptions {
|
||||
decisionNote?: string;
|
||||
decidedByUserId?: string;
|
||||
}
|
||||
|
||||
interface ApprovalCreateOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
type: string;
|
||||
requestedByAgentId?: string;
|
||||
payload: string;
|
||||
issueIds?: string;
|
||||
}
|
||||
|
||||
interface ApprovalResubmitOptions extends BaseClientOptions {
|
||||
payload?: string;
|
||||
}
|
||||
|
||||
interface ApprovalCommentOptions extends BaseClientOptions {
|
||||
body: string;
|
||||
}
|
||||
|
||||
export function registerApprovalCommands(program: Command): void {
|
||||
const approval = program.command("approval").description("Approval operations");
|
||||
|
||||
addCommonClientOptions(
|
||||
approval
|
||||
.command("list")
|
||||
.description("List approvals for a company")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.option("--status <status>", "Status filter")
|
||||
.action(async (opts: ApprovalListOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const params = new URLSearchParams();
|
||||
if (opts.status) params.set("status", opts.status);
|
||||
const query = params.toString();
|
||||
const rows =
|
||||
(await ctx.api.get<Approval[]>(
|
||||
`/api/companies/${ctx.companyId}/approvals${query ? `?${query}` : ""}`,
|
||||
)) ?? [];
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(rows, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
printOutput([], { json: false });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
console.log(
|
||||
formatInlineRecord({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
requestedByAgentId: row.requestedByAgentId,
|
||||
requestedByUserId: row.requestedByUserId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: false },
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
approval
|
||||
.command("get")
|
||||
.description("Get one approval")
|
||||
.argument("<approvalId>", "Approval ID")
|
||||
.action(async (approvalId: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const row = await ctx.api.get<Approval>(`/api/approvals/${approvalId}`);
|
||||
printOutput(row, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
approval
|
||||
.command("create")
|
||||
.description("Create an approval request")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.requiredOption("--type <type>", "Approval type (hire_agent|approve_ceo_strategy)")
|
||||
.requiredOption("--payload <json>", "Approval payload as JSON object")
|
||||
.option("--requested-by-agent-id <id>", "Requesting agent ID")
|
||||
.option("--issue-ids <csv>", "Comma-separated linked issue IDs")
|
||||
.action(async (opts: ApprovalCreateOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const payloadJson = parseJsonObject(opts.payload, "payload");
|
||||
const payload = createApprovalSchema.parse({
|
||||
type: opts.type,
|
||||
payload: payloadJson,
|
||||
requestedByAgentId: opts.requestedByAgentId,
|
||||
issueIds: parseCsv(opts.issueIds),
|
||||
});
|
||||
const created = await ctx.api.post<Approval>(`/api/companies/${ctx.companyId}/approvals`, payload);
|
||||
printOutput(created, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: false },
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
approval
|
||||
.command("approve")
|
||||
.description("Approve an approval request")
|
||||
.argument("<approvalId>", "Approval ID")
|
||||
.option("--decision-note <text>", "Decision note")
|
||||
.option("--decided-by-user-id <id>", "Decision actor user ID")
|
||||
.action(async (approvalId: string, opts: ApprovalDecisionOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const payload = resolveApprovalSchema.parse({
|
||||
decisionNote: opts.decisionNote,
|
||||
decidedByUserId: opts.decidedByUserId,
|
||||
});
|
||||
const updated = await ctx.api.post<Approval>(`/api/approvals/${approvalId}/approve`, payload);
|
||||
printOutput(updated, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
approval
|
||||
.command("reject")
|
||||
.description("Reject an approval request")
|
||||
.argument("<approvalId>", "Approval ID")
|
||||
.option("--decision-note <text>", "Decision note")
|
||||
.option("--decided-by-user-id <id>", "Decision actor user ID")
|
||||
.action(async (approvalId: string, opts: ApprovalDecisionOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const payload = resolveApprovalSchema.parse({
|
||||
decisionNote: opts.decisionNote,
|
||||
decidedByUserId: opts.decidedByUserId,
|
||||
});
|
||||
const updated = await ctx.api.post<Approval>(`/api/approvals/${approvalId}/reject`, payload);
|
||||
printOutput(updated, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
approval
|
||||
.command("request-revision")
|
||||
.description("Request revision for an approval")
|
||||
.argument("<approvalId>", "Approval ID")
|
||||
.option("--decision-note <text>", "Decision note")
|
||||
.option("--decided-by-user-id <id>", "Decision actor user ID")
|
||||
.action(async (approvalId: string, opts: ApprovalDecisionOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const payload = requestApprovalRevisionSchema.parse({
|
||||
decisionNote: opts.decisionNote,
|
||||
decidedByUserId: opts.decidedByUserId,
|
||||
});
|
||||
const updated = await ctx.api.post<Approval>(`/api/approvals/${approvalId}/request-revision`, payload);
|
||||
printOutput(updated, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
approval
|
||||
.command("resubmit")
|
||||
.description("Resubmit an approval (optionally with new payload)")
|
||||
.argument("<approvalId>", "Approval ID")
|
||||
.option("--payload <json>", "Payload JSON object")
|
||||
.action(async (approvalId: string, opts: ApprovalResubmitOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const payload = resubmitApprovalSchema.parse({
|
||||
payload: opts.payload ? parseJsonObject(opts.payload, "payload") : undefined,
|
||||
});
|
||||
const updated = await ctx.api.post<Approval>(`/api/approvals/${approvalId}/resubmit`, payload);
|
||||
printOutput(updated, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
approval
|
||||
.command("comment")
|
||||
.description("Add comment to an approval")
|
||||
.argument("<approvalId>", "Approval ID")
|
||||
.requiredOption("--body <text>", "Comment body")
|
||||
.action(async (approvalId: string, opts: ApprovalCommentOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const created = await ctx.api.post<ApprovalComment>(`/api/approvals/${approvalId}/comments`, {
|
||||
body: opts.body,
|
||||
});
|
||||
printOutput(created, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function parseCsv(value: string | undefined): string[] | undefined {
|
||||
if (!value) return undefined;
|
||||
const rows = value.split(",").map((v) => v.trim()).filter(Boolean);
|
||||
return rows.length > 0 ? rows : undefined;
|
||||
}
|
||||
|
||||
function parseJsonObject(value: string, name: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error(`${name} must be a JSON object`);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid ${name} JSON: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
185
cli/src/commands/client/common.ts
Normal file
185
cli/src/commands/client/common.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import pc from "picocolors";
|
||||
import type { Command } from "commander";
|
||||
import { readConfig } from "../../config/store.js";
|
||||
import { readContext, resolveProfile, type ClientContextProfile } from "../../client/context.js";
|
||||
import { ApiRequestError, PaperclipApiClient } from "../../client/http.js";
|
||||
|
||||
export interface BaseClientOptions {
|
||||
config?: string;
|
||||
context?: string;
|
||||
profile?: string;
|
||||
apiBase?: string;
|
||||
apiKey?: string;
|
||||
companyId?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedClientContext {
|
||||
api: PaperclipApiClient;
|
||||
companyId?: string;
|
||||
profileName: string;
|
||||
profile: ClientContextProfile;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
export function addCommonClientOptions(command: Command, opts?: { includeCompany?: boolean }): Command {
|
||||
command
|
||||
.option("-c, --config <path>", "Path to Paperclip config file")
|
||||
.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")
|
||||
.option("--api-key <token>", "Bearer token for agent-authenticated calls")
|
||||
.option("--json", "Output raw JSON");
|
||||
|
||||
if (opts?.includeCompany) {
|
||||
command.option("-C, --company-id <id>", "Company ID (overrides context default)");
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
export function resolveCommandContext(
|
||||
options: BaseClientOptions,
|
||||
opts?: { requireCompany?: boolean },
|
||||
): ResolvedClientContext {
|
||||
const context = readContext(options.context);
|
||||
const { name: profileName, profile } = resolveProfile(context, options.profile);
|
||||
|
||||
const apiBase =
|
||||
options.apiBase?.trim() ||
|
||||
process.env.PAPERCLIP_API_URL?.trim() ||
|
||||
profile.apiBase ||
|
||||
inferApiBaseFromConfig(options.config);
|
||||
|
||||
const apiKey =
|
||||
options.apiKey?.trim() ||
|
||||
process.env.PAPERCLIP_API_KEY?.trim() ||
|
||||
readKeyFromProfileEnv(profile);
|
||||
|
||||
const companyId =
|
||||
options.companyId?.trim() ||
|
||||
process.env.PAPERCLIP_COMPANY_ID?.trim() ||
|
||||
profile.companyId;
|
||||
|
||||
if (opts?.requireCompany && !companyId) {
|
||||
throw new Error(
|
||||
"Company ID is required. Pass --company-id, set PAPERCLIP_COMPANY_ID, or set context profile companyId via `paperclip context set`.",
|
||||
);
|
||||
}
|
||||
|
||||
const api = new PaperclipApiClient({ apiBase, apiKey });
|
||||
return {
|
||||
api,
|
||||
companyId,
|
||||
profileName,
|
||||
profile,
|
||||
json: Boolean(options.json),
|
||||
};
|
||||
}
|
||||
|
||||
export function printOutput(data: unknown, opts: { json?: boolean; label?: string } = {}): void {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.label) {
|
||||
console.log(pc.bold(opts.label));
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length === 0) {
|
||||
console.log(pc.dim("(empty)"));
|
||||
return;
|
||||
}
|
||||
for (const item of data) {
|
||||
if (typeof item === "object" && item !== null) {
|
||||
console.log(formatInlineRecord(item as Record<string, unknown>));
|
||||
} else {
|
||||
console.log(String(item));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof data === "object" && data !== null) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data === undefined || data === null) {
|
||||
console.log(pc.dim("(null)"));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(String(data));
|
||||
}
|
||||
|
||||
export function formatInlineRecord(record: Record<string, unknown>): string {
|
||||
const keyOrder = ["identifier", "id", "name", "status", "priority", "title", "action"];
|
||||
const seen = new Set<string>();
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const key of keyOrder) {
|
||||
if (!(key in record)) continue;
|
||||
parts.push(`${key}=${renderValue(record[key])}`);
|
||||
seen.add(key);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (seen.has(key)) continue;
|
||||
if (typeof value === "object") continue;
|
||||
parts.push(`${key}=${renderValue(value)}`);
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function renderValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "-";
|
||||
if (typeof value === "string") {
|
||||
const compact = value.replace(/\s+/g, " ").trim();
|
||||
return compact.length > 90 ? `${compact.slice(0, 87)}...` : compact;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return "[object]";
|
||||
}
|
||||
|
||||
function inferApiBaseFromConfig(configPath?: string): string {
|
||||
const envHost = process.env.PAPERCLIP_SERVER_HOST?.trim() || "localhost";
|
||||
let port = Number(process.env.PAPERCLIP_SERVER_PORT || "");
|
||||
|
||||
if (!Number.isFinite(port) || port <= 0) {
|
||||
try {
|
||||
const config = readConfig(configPath);
|
||||
port = Number(config?.server?.port ?? 3100);
|
||||
} catch {
|
||||
port = 3100;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(port) || port <= 0) {
|
||||
port = 3100;
|
||||
}
|
||||
|
||||
return `http://${envHost}:${port}`;
|
||||
}
|
||||
|
||||
function readKeyFromProfileEnv(profile: ClientContextProfile): string | undefined {
|
||||
if (!profile.apiKeyEnvVarName) return undefined;
|
||||
return process.env[profile.apiKeyEnvVarName]?.trim() || undefined;
|
||||
}
|
||||
|
||||
export function handleCommandError(error: unknown): never {
|
||||
if (error instanceof ApiRequestError) {
|
||||
const detailSuffix = error.details !== undefined ? ` details=${JSON.stringify(error.details)}` : "";
|
||||
console.error(pc.red(`API error ${error.status}: ${error.message}${detailSuffix}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(pc.red(message));
|
||||
process.exit(1);
|
||||
}
|
||||
67
cli/src/commands/client/company.ts
Normal file
67
cli/src/commands/client/company.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Command } from "commander";
|
||||
import type { Company } from "@paperclip/shared";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
formatInlineRecord,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
|
||||
interface CompanyCommandOptions extends BaseClientOptions {}
|
||||
|
||||
export function registerCompanyCommands(program: Command): void {
|
||||
const company = program.command("company").description("Company operations");
|
||||
|
||||
addCommonClientOptions(
|
||||
company
|
||||
.command("list")
|
||||
.description("List companies")
|
||||
.action(async (opts: CompanyCommandOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const rows = (await ctx.api.get<Company[]>("/api/companies")) ?? [];
|
||||
if (ctx.json) {
|
||||
printOutput(rows, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
printOutput([], { json: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const formatted = rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
status: row.status,
|
||||
budgetMonthlyCents: row.budgetMonthlyCents,
|
||||
spentMonthlyCents: row.spentMonthlyCents,
|
||||
requireBoardApprovalForNewAgents: row.requireBoardApprovalForNewAgents,
|
||||
}));
|
||||
for (const row of formatted) {
|
||||
console.log(formatInlineRecord(row));
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
company
|
||||
.command("get")
|
||||
.description("Get one company")
|
||||
.argument("<companyId>", "Company ID")
|
||||
.action(async (companyId: string, opts: CompanyCommandOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const row = await ctx.api.get<Company>(`/api/companies/${companyId}`);
|
||||
printOutput(row, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
120
cli/src/commands/client/context.ts
Normal file
120
cli/src/commands/client/context.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Command } from "commander";
|
||||
import pc from "picocolors";
|
||||
import {
|
||||
readContext,
|
||||
resolveContextPath,
|
||||
resolveProfile,
|
||||
setCurrentProfile,
|
||||
upsertProfile,
|
||||
} from "../../client/context.js";
|
||||
import { printOutput } from "./common.js";
|
||||
|
||||
interface ContextOptions {
|
||||
context?: string;
|
||||
profile?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface ContextSetOptions extends ContextOptions {
|
||||
apiBase?: string;
|
||||
companyId?: string;
|
||||
apiKeyEnvVarName?: string;
|
||||
use?: boolean;
|
||||
}
|
||||
|
||||
export function registerContextCommands(program: Command): void {
|
||||
const context = program.command("context").description("Manage CLI client context profiles");
|
||||
|
||||
context
|
||||
.command("show")
|
||||
.description("Show current context and active profile")
|
||||
.option("--context <path>", "Path to CLI context file")
|
||||
.option("--profile <name>", "Profile to inspect")
|
||||
.option("--json", "Output raw JSON")
|
||||
.action((opts: ContextOptions) => {
|
||||
const contextPath = resolveContextPath(opts.context);
|
||||
const store = readContext(opts.context);
|
||||
const resolved = resolveProfile(store, opts.profile);
|
||||
const payload = {
|
||||
contextPath,
|
||||
currentProfile: store.currentProfile,
|
||||
profileName: resolved.name,
|
||||
profile: resolved.profile,
|
||||
profiles: store.profiles,
|
||||
};
|
||||
printOutput(payload, { json: opts.json });
|
||||
});
|
||||
|
||||
context
|
||||
.command("list")
|
||||
.description("List available context profiles")
|
||||
.option("--context <path>", "Path to CLI context file")
|
||||
.option("--json", "Output raw JSON")
|
||||
.action((opts: ContextOptions) => {
|
||||
const store = readContext(opts.context);
|
||||
const rows = Object.entries(store.profiles).map(([name, profile]) => ({
|
||||
name,
|
||||
current: name === store.currentProfile,
|
||||
apiBase: profile.apiBase ?? null,
|
||||
companyId: profile.companyId ?? null,
|
||||
apiKeyEnvVarName: profile.apiKeyEnvVarName ?? null,
|
||||
}));
|
||||
printOutput(rows, { json: opts.json });
|
||||
});
|
||||
|
||||
context
|
||||
.command("use")
|
||||
.description("Set active context profile")
|
||||
.argument("<profile>", "Profile name")
|
||||
.option("--context <path>", "Path to CLI context file")
|
||||
.action((profile: string, opts: ContextOptions) => {
|
||||
setCurrentProfile(profile, opts.context);
|
||||
console.log(pc.green(`Active profile set to '${profile}'.`));
|
||||
});
|
||||
|
||||
context
|
||||
.command("set")
|
||||
.description("Set values on a profile")
|
||||
.option("--context <path>", "Path to CLI context file")
|
||||
.option("--profile <name>", "Profile name (default: current profile)")
|
||||
.option("--api-base <url>", "Default API base URL")
|
||||
.option("--company-id <id>", "Default company ID")
|
||||
.option("--api-key-env-var-name <name>", "Env var containing API key (recommended)")
|
||||
.option("--use", "Set this profile as active")
|
||||
.option("--json", "Output raw JSON")
|
||||
.action((opts: ContextSetOptions) => {
|
||||
const existing = readContext(opts.context);
|
||||
const targetProfile = opts.profile?.trim() || existing.currentProfile || "default";
|
||||
|
||||
upsertProfile(
|
||||
targetProfile,
|
||||
{
|
||||
apiBase: opts.apiBase,
|
||||
companyId: opts.companyId,
|
||||
apiKeyEnvVarName: opts.apiKeyEnvVarName,
|
||||
},
|
||||
opts.context,
|
||||
);
|
||||
|
||||
if (opts.use) {
|
||||
setCurrentProfile(targetProfile, opts.context);
|
||||
}
|
||||
|
||||
const updated = readContext(opts.context);
|
||||
const resolved = resolveProfile(updated, targetProfile);
|
||||
const payload = {
|
||||
contextPath: resolveContextPath(opts.context),
|
||||
currentProfile: updated.currentProfile,
|
||||
profileName: resolved.name,
|
||||
profile: resolved.profile,
|
||||
};
|
||||
|
||||
if (!opts.json) {
|
||||
console.log(pc.green(`Updated profile '${targetProfile}'.`));
|
||||
if (opts.use) {
|
||||
console.log(pc.green(`Set '${targetProfile}' as active profile.`));
|
||||
}
|
||||
}
|
||||
printOutput(payload, { json: opts.json });
|
||||
});
|
||||
}
|
||||
34
cli/src/commands/client/dashboard.ts
Normal file
34
cli/src/commands/client/dashboard.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Command } from "commander";
|
||||
import type { DashboardSummary } from "@paperclip/shared";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
|
||||
interface DashboardGetOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
}
|
||||
|
||||
export function registerDashboardCommands(program: Command): void {
|
||||
const dashboard = program.command("dashboard").description("Dashboard summary operations");
|
||||
|
||||
addCommonClientOptions(
|
||||
dashboard
|
||||
.command("get")
|
||||
.description("Get dashboard summary for a company")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.action(async (opts: DashboardGetOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const row = await ctx.api.get<DashboardSummary>(`/api/companies/${ctx.companyId}/dashboard`);
|
||||
printOutput(row, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: false },
|
||||
);
|
||||
}
|
||||
313
cli/src/commands/client/issue.ts
Normal file
313
cli/src/commands/client/issue.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
addIssueCommentSchema,
|
||||
checkoutIssueSchema,
|
||||
createIssueSchema,
|
||||
updateIssueSchema,
|
||||
type Issue,
|
||||
type IssueComment,
|
||||
} from "@paperclip/shared";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
formatInlineRecord,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
|
||||
interface IssueBaseOptions extends BaseClientOptions {
|
||||
status?: string;
|
||||
assigneeAgentId?: string;
|
||||
projectId?: string;
|
||||
match?: string;
|
||||
}
|
||||
|
||||
interface IssueCreateOptions extends BaseClientOptions {
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
assigneeAgentId?: string;
|
||||
projectId?: string;
|
||||
goalId?: string;
|
||||
parentId?: string;
|
||||
requestDepth?: string;
|
||||
billingCode?: string;
|
||||
}
|
||||
|
||||
interface IssueUpdateOptions extends BaseClientOptions {
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
assigneeAgentId?: string;
|
||||
projectId?: string;
|
||||
goalId?: string;
|
||||
parentId?: string;
|
||||
requestDepth?: string;
|
||||
billingCode?: string;
|
||||
comment?: string;
|
||||
hiddenAt?: string;
|
||||
}
|
||||
|
||||
interface IssueCommentOptions extends BaseClientOptions {
|
||||
body: string;
|
||||
reopen?: boolean;
|
||||
}
|
||||
|
||||
interface IssueCheckoutOptions extends BaseClientOptions {
|
||||
agentId: string;
|
||||
expectedStatuses?: string;
|
||||
}
|
||||
|
||||
export function registerIssueCommands(program: Command): void {
|
||||
const issue = program.command("issue").description("Issue operations");
|
||||
|
||||
addCommonClientOptions(
|
||||
issue
|
||||
.command("list")
|
||||
.description("List issues for a company")
|
||||
.option("-C, --company-id <id>", "Company ID")
|
||||
.option("--status <csv>", "Comma-separated statuses")
|
||||
.option("--assignee-agent-id <id>", "Filter by assignee agent ID")
|
||||
.option("--project-id <id>", "Filter by project ID")
|
||||
.option("--match <text>", "Local text match on identifier/title/description")
|
||||
.action(async (opts: IssueBaseOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const params = new URLSearchParams();
|
||||
if (opts.status) params.set("status", opts.status);
|
||||
if (opts.assigneeAgentId) params.set("assigneeAgentId", opts.assigneeAgentId);
|
||||
if (opts.projectId) params.set("projectId", opts.projectId);
|
||||
|
||||
const query = params.toString();
|
||||
const path = `/api/companies/${ctx.companyId}/issues${query ? `?${query}` : ""}`;
|
||||
const rows = (await ctx.api.get<Issue[]>(path)) ?? [];
|
||||
|
||||
const filtered = filterIssueRows(rows, opts.match);
|
||||
if (ctx.json) {
|
||||
printOutput(filtered, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
printOutput([], { json: false });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of filtered) {
|
||||
console.log(
|
||||
formatInlineRecord({
|
||||
identifier: item.identifier,
|
||||
id: item.id,
|
||||
status: item.status,
|
||||
priority: item.priority,
|
||||
assigneeAgentId: item.assigneeAgentId,
|
||||
title: item.title,
|
||||
projectId: item.projectId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: false },
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
issue
|
||||
.command("get")
|
||||
.description("Get an issue by UUID or identifier (e.g. PC-12)")
|
||||
.argument("<idOrIdentifier>", "Issue ID or identifier")
|
||||
.action(async (idOrIdentifier: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const row = await ctx.api.get<Issue>(`/api/issues/${idOrIdentifier}`);
|
||||
printOutput(row, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
issue
|
||||
.command("create")
|
||||
.description("Create an issue")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.requiredOption("--title <title>", "Issue title")
|
||||
.option("--description <text>", "Issue description")
|
||||
.option("--status <status>", "Issue status")
|
||||
.option("--priority <priority>", "Issue priority")
|
||||
.option("--assignee-agent-id <id>", "Assignee agent ID")
|
||||
.option("--project-id <id>", "Project ID")
|
||||
.option("--goal-id <id>", "Goal ID")
|
||||
.option("--parent-id <id>", "Parent issue ID")
|
||||
.option("--request-depth <n>", "Request depth integer")
|
||||
.option("--billing-code <code>", "Billing code")
|
||||
.action(async (opts: IssueCreateOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const payload = createIssueSchema.parse({
|
||||
title: opts.title,
|
||||
description: opts.description,
|
||||
status: opts.status,
|
||||
priority: opts.priority,
|
||||
assigneeAgentId: opts.assigneeAgentId,
|
||||
projectId: opts.projectId,
|
||||
goalId: opts.goalId,
|
||||
parentId: opts.parentId,
|
||||
requestDepth: parseOptionalInt(opts.requestDepth),
|
||||
billingCode: opts.billingCode,
|
||||
});
|
||||
|
||||
const created = await ctx.api.post<Issue>(`/api/companies/${ctx.companyId}/issues`, payload);
|
||||
printOutput(created, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: false },
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
issue
|
||||
.command("update")
|
||||
.description("Update an issue")
|
||||
.argument("<issueId>", "Issue ID")
|
||||
.option("--title <title>", "Issue title")
|
||||
.option("--description <text>", "Issue description")
|
||||
.option("--status <status>", "Issue status")
|
||||
.option("--priority <priority>", "Issue priority")
|
||||
.option("--assignee-agent-id <id>", "Assignee agent ID")
|
||||
.option("--project-id <id>", "Project ID")
|
||||
.option("--goal-id <id>", "Goal ID")
|
||||
.option("--parent-id <id>", "Parent issue ID")
|
||||
.option("--request-depth <n>", "Request depth integer")
|
||||
.option("--billing-code <code>", "Billing code")
|
||||
.option("--comment <text>", "Optional comment to add with update")
|
||||
.option("--hidden-at <iso8601|null>", "Set hiddenAt timestamp or literal 'null'")
|
||||
.action(async (issueId: string, opts: IssueUpdateOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const payload = updateIssueSchema.parse({
|
||||
title: opts.title,
|
||||
description: opts.description,
|
||||
status: opts.status,
|
||||
priority: opts.priority,
|
||||
assigneeAgentId: opts.assigneeAgentId,
|
||||
projectId: opts.projectId,
|
||||
goalId: opts.goalId,
|
||||
parentId: opts.parentId,
|
||||
requestDepth: parseOptionalInt(opts.requestDepth),
|
||||
billingCode: opts.billingCode,
|
||||
comment: opts.comment,
|
||||
hiddenAt: parseHiddenAt(opts.hiddenAt),
|
||||
});
|
||||
|
||||
const updated = await ctx.api.patch<Issue & { comment?: IssueComment | null }>(`/api/issues/${issueId}`, payload);
|
||||
printOutput(updated, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
issue
|
||||
.command("comment")
|
||||
.description("Add comment to issue")
|
||||
.argument("<issueId>", "Issue ID")
|
||||
.requiredOption("--body <text>", "Comment body")
|
||||
.option("--reopen", "Reopen if issue is done/cancelled")
|
||||
.action(async (issueId: string, opts: IssueCommentOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const payload = addIssueCommentSchema.parse({
|
||||
body: opts.body,
|
||||
reopen: opts.reopen,
|
||||
});
|
||||
const comment = await ctx.api.post<IssueComment>(`/api/issues/${issueId}/comments`, payload);
|
||||
printOutput(comment, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
issue
|
||||
.command("checkout")
|
||||
.description("Checkout issue for an agent")
|
||||
.argument("<issueId>", "Issue ID")
|
||||
.requiredOption("--agent-id <id>", "Agent ID")
|
||||
.option(
|
||||
"--expected-statuses <csv>",
|
||||
"Expected current statuses",
|
||||
"todo,backlog,blocked",
|
||||
)
|
||||
.action(async (issueId: string, opts: IssueCheckoutOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const payload = checkoutIssueSchema.parse({
|
||||
agentId: opts.agentId,
|
||||
expectedStatuses: parseCsv(opts.expectedStatuses),
|
||||
});
|
||||
const updated = await ctx.api.post<Issue>(`/api/issues/${issueId}/checkout`, payload);
|
||||
printOutput(updated, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
issue
|
||||
.command("release")
|
||||
.description("Release issue back to todo and clear assignee")
|
||||
.argument("<issueId>", "Issue ID")
|
||||
.action(async (issueId: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const updated = await ctx.api.post<Issue>(`/api/issues/${issueId}/release`, {});
|
||||
printOutput(updated, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function parseCsv(value: string | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return value.split(",").map((v) => v.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function parseOptionalInt(value: string | undefined): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error(`Invalid integer value: ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseHiddenAt(value: string | undefined): string | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value.trim().toLowerCase() === "null") return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
function filterIssueRows(rows: Issue[], match: string | undefined): Issue[] {
|
||||
if (!match?.trim()) return rows;
|
||||
const needle = match.trim().toLowerCase();
|
||||
return rows.filter((row) => {
|
||||
const text = [row.identifier, row.title, row.description]
|
||||
.filter((part): part is string => Boolean(part))
|
||||
.join("\n")
|
||||
.toLowerCase();
|
||||
return text.includes(needle);
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,11 @@ import { promptLlm } from "../prompts/llm.js";
|
||||
import { promptLogging } from "../prompts/logging.js";
|
||||
import { defaultSecretsConfig, promptSecrets } from "../prompts/secrets.js";
|
||||
import { promptServer } from "../prompts/server.js";
|
||||
import {
|
||||
resolveDefaultEmbeddedPostgresDir,
|
||||
resolveDefaultLogsDir,
|
||||
resolvePaperclipInstanceId,
|
||||
} from "../config/home.js";
|
||||
|
||||
type Section = "llm" | "database" | "logging" | "server" | "secrets";
|
||||
|
||||
@@ -20,6 +25,7 @@ const SECTION_LABELS: Record<Section, string> = {
|
||||
};
|
||||
|
||||
function defaultConfig(): PaperclipConfig {
|
||||
const instanceId = resolvePaperclipInstanceId();
|
||||
return {
|
||||
$meta: {
|
||||
version: 1,
|
||||
@@ -28,12 +34,12 @@ function defaultConfig(): PaperclipConfig {
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: "./data/embedded-postgres",
|
||||
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(instanceId),
|
||||
embeddedPostgresPort: 54329,
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: "./data/logs",
|
||||
logDir: resolveDefaultLogsDir(instanceId),
|
||||
},
|
||||
server: {
|
||||
port: 3100,
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function doctor(opts: {
|
||||
config?: string;
|
||||
repair?: boolean;
|
||||
yes?: boolean;
|
||||
}): Promise<void> {
|
||||
}): Promise<{ passed: number; warned: number; failed: number }> {
|
||||
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
|
||||
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
@@ -35,8 +35,7 @@ export async function doctor(opts: {
|
||||
printResult(cfgResult);
|
||||
|
||||
if (cfgResult.status === "fail") {
|
||||
printSummary(results);
|
||||
return;
|
||||
return printSummary(results);
|
||||
}
|
||||
|
||||
let config: PaperclipConfig;
|
||||
@@ -52,8 +51,7 @@ export async function doctor(opts: {
|
||||
};
|
||||
results.push(readResult);
|
||||
printResult(readResult);
|
||||
printSummary(results);
|
||||
return;
|
||||
return printSummary(results);
|
||||
}
|
||||
|
||||
// 2. Agent JWT check
|
||||
@@ -91,7 +89,7 @@ export async function doctor(opts: {
|
||||
printResult(portResult);
|
||||
|
||||
// Summary
|
||||
printSummary(results);
|
||||
return printSummary(results);
|
||||
}
|
||||
|
||||
function printResult(result: CheckResult): void {
|
||||
@@ -129,7 +127,7 @@ async function maybeRepair(
|
||||
}
|
||||
}
|
||||
|
||||
function printSummary(results: CheckResult[]): void {
|
||||
function printSummary(results: CheckResult[]): { passed: number; warned: number; failed: number } {
|
||||
const passed = results.filter((r) => r.status === "pass").length;
|
||||
const warned = results.filter((r) => r.status === "warn").length;
|
||||
const failed = results.filter((r) => r.status === "fail").length;
|
||||
@@ -148,4 +146,6 @@ function printSummary(results: CheckResult[]): void {
|
||||
} else {
|
||||
p.outro(pc.green("All checks passed!"));
|
||||
}
|
||||
|
||||
return { passed, warned, failed };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
readAgentJwtSecretFromEnvFile,
|
||||
resolveAgentJwtEnvFile,
|
||||
} from "../config/env.js";
|
||||
import {
|
||||
resolveDefaultSecretsKeyFilePath,
|
||||
resolvePaperclipInstanceId,
|
||||
} from "../config/home.js";
|
||||
|
||||
type EnvSource = "env" | "config" | "file" | "default" | "missing";
|
||||
|
||||
@@ -23,7 +27,9 @@ const DEFAULT_AGENT_JWT_ISSUER = "paperclip";
|
||||
const DEFAULT_AGENT_JWT_AUDIENCE = "paperclip-api";
|
||||
const DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS = "30000";
|
||||
const DEFAULT_SECRETS_PROVIDER = "local_encrypted";
|
||||
const DEFAULT_SECRETS_KEY_FILE_PATH = "./data/secrets/master.key";
|
||||
function defaultSecretsKeyFilePath(): string {
|
||||
return resolveDefaultSecretsKeyFilePath(resolvePaperclipInstanceId());
|
||||
}
|
||||
|
||||
export async function envCommand(opts: { config?: string }): Promise<void> {
|
||||
p.intro(pc.bgCyan(pc.black(" paperclip env ")));
|
||||
@@ -120,7 +126,7 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st
|
||||
const secretsKeyFilePath =
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE ??
|
||||
config?.secrets?.localEncrypted?.keyFilePath ??
|
||||
DEFAULT_SECRETS_KEY_FILE_PATH;
|
||||
defaultSecretsKeyFilePath();
|
||||
|
||||
const rows: EnvVarRow[] = [
|
||||
{
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import pc from "picocolors";
|
||||
import type { Agent, HeartbeatRun, HeartbeatRunEvent, HeartbeatRunStatus } from "@paperclip/shared";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { readConfig } from "../config/store.js";
|
||||
import { getCLIAdapter } from "../adapters/index.js";
|
||||
import { resolveCommandContext } from "./client/common.js";
|
||||
|
||||
const HEARTBEAT_SOURCES = ["timer", "assignment", "on_demand", "automation"] as const;
|
||||
const HEARTBEAT_TRIGGERS = ["manual", "ping", "callback", "system"] as const;
|
||||
@@ -19,12 +18,16 @@ interface HeartbeatRunEventRecord extends HeartbeatRunEvent {
|
||||
|
||||
interface HeartbeatRunOptions {
|
||||
config?: string;
|
||||
context?: string;
|
||||
profile?: string;
|
||||
agentId: string;
|
||||
apiBase?: string;
|
||||
apiKey?: string;
|
||||
source: string;
|
||||
trigger: string;
|
||||
timeoutMs: string;
|
||||
debug?: boolean;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
@@ -63,35 +66,27 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise<void> {
|
||||
? (opts.trigger as HeartbeatTrigger)
|
||||
: "manual";
|
||||
|
||||
let config: PaperclipConfig | null = null;
|
||||
try {
|
||||
config = readConfig(opts.config);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
pc.yellow(
|
||||
`Config warning: ${err instanceof Error ? err.message : String(err)}\nContinuing with API base fallback settings.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
const apiBase = getApiBase(config, opts.apiBase);
|
||||
|
||||
const agent = await requestJson<Agent>(`${apiBase}/api/agents/${opts.agentId}`, {
|
||||
method: "GET",
|
||||
const ctx = resolveCommandContext({
|
||||
config: opts.config,
|
||||
context: opts.context,
|
||||
profile: opts.profile,
|
||||
apiBase: opts.apiBase,
|
||||
apiKey: opts.apiKey,
|
||||
json: opts.json,
|
||||
});
|
||||
const api = ctx.api;
|
||||
|
||||
const agent = await api.get<Agent>(`/api/agents/${opts.agentId}`);
|
||||
if (!agent || typeof agent !== "object" || !agent.id) {
|
||||
console.error(pc.red(`Agent not found: ${opts.agentId}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const invokeRes = await requestJson<InvokedHeartbeat>(
|
||||
`${apiBase}/api/agents/${opts.agentId}/wakeup`,
|
||||
const invokeRes = await api.post<InvokedHeartbeat>(
|
||||
`/api/agents/${opts.agentId}/wakeup`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
source: source,
|
||||
triggerDetail: triggerDetail,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!invokeRes) {
|
||||
@@ -221,16 +216,15 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise<void> {
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const events = await requestJson<HeartbeatRunEvent[]>(
|
||||
`${apiBase}/api/heartbeat-runs/${activeRunId}/events?afterSeq=${lastEventSeq}&limit=100`,
|
||||
{ method: "GET" },
|
||||
const events = await api.get<HeartbeatRunEvent[]>(
|
||||
`/api/heartbeat-runs/${activeRunId}/events?afterSeq=${lastEventSeq}&limit=100`,
|
||||
);
|
||||
for (const event of Array.isArray(events) ? (events as HeartbeatRunEventRecord[]) : []) {
|
||||
handleEvent(event);
|
||||
}
|
||||
|
||||
const runList = (await requestJson<(HeartbeatRun | null)[]>(
|
||||
`${apiBase}/api/companies/${agent.companyId}/heartbeat-runs?agentId=${agent.id}`,
|
||||
const runList = (await api.get<(HeartbeatRun | null)[]>(
|
||||
`/api/companies/${agent.companyId}/heartbeat-runs?agentId=${agent.id}`,
|
||||
)) || [];
|
||||
const currentRun = runList.find((r) => r && r.id === activeRunId) ?? null;
|
||||
|
||||
@@ -259,9 +253,8 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise<void> {
|
||||
break;
|
||||
}
|
||||
|
||||
const logResult = await requestJson<{ content: string; nextOffset?: number }>(
|
||||
`${apiBase}/api/heartbeat-runs/${activeRunId}/log?offset=${logOffset}&limitBytes=16384`,
|
||||
{ method: "GET" },
|
||||
const logResult = await api.get<{ content: string; nextOffset?: number }>(
|
||||
`/api/heartbeat-runs/${activeRunId}/log?offset=${logOffset}&limitBytes=16384`,
|
||||
{ ignoreNotFound: true },
|
||||
);
|
||||
if (logResult && logResult.content) {
|
||||
@@ -349,50 +342,3 @@ function safeParseLogLine(line: string): { stream: "stdout" | "stderr" | "system
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getApiBase(config: PaperclipConfig | null, apiBaseOverride?: string): string {
|
||||
if (apiBaseOverride?.trim()) return apiBaseOverride.trim();
|
||||
const envBase = process.env.PAPERCLIP_API_URL?.trim();
|
||||
if (envBase) return envBase;
|
||||
const envHost = process.env.PAPERCLIP_SERVER_HOST?.trim() || "localhost";
|
||||
const envPort = Number(process.env.PAPERCLIP_SERVER_PORT || config?.server?.port || 3100);
|
||||
return `http://${envHost}:${Number.isFinite(envPort) && envPort > 0 ? envPort : 3100}`;
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
opts?: { ignoreNotFound?: boolean },
|
||||
): Promise<T | null> {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (opts?.ignoreNotFound && res.status === 404) {
|
||||
return null;
|
||||
}
|
||||
const text = await safeReadText(res);
|
||||
console.error(pc.red(`Request failed (${res.status}): ${text || res.statusText}`));
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async function safeReadText(res: Response): Promise<string> {
|
||||
try {
|
||||
const text = await res.text();
|
||||
if (!text) return "";
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed[0] === "{" || trimmed[0] === "[") return trimmed;
|
||||
return trimmed;
|
||||
} catch (_err) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,16 @@ import { promptLlm } from "../prompts/llm.js";
|
||||
import { promptLogging } from "../prompts/logging.js";
|
||||
import { defaultSecretsConfig } from "../prompts/secrets.js";
|
||||
import { promptServer } from "../prompts/server.js";
|
||||
import { describeLocalInstancePaths, resolvePaperclipInstanceId } from "../config/home.js";
|
||||
|
||||
export async function onboard(opts: { config?: string }): Promise<void> {
|
||||
p.intro(pc.bgCyan(pc.black(" paperclip onboard ")));
|
||||
const instance = describeLocalInstancePaths(resolvePaperclipInstanceId());
|
||||
p.log.message(
|
||||
pc.dim(
|
||||
`Local home: ${instance.homeDir} | instance: ${instance.instanceId} | config: ${resolveConfigPath(opts.config)}`,
|
||||
),
|
||||
);
|
||||
|
||||
// Check for existing config
|
||||
if (configExists(opts.config)) {
|
||||
|
||||
104
cli/src/commands/run.ts
Normal file
104
cli/src/commands/run.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { onboard } from "./onboard.js";
|
||||
import { doctor } from "./doctor.js";
|
||||
import { configExists, resolveConfigPath } from "../config/store.js";
|
||||
import {
|
||||
describeLocalInstancePaths,
|
||||
resolvePaperclipHomeDir,
|
||||
resolvePaperclipInstanceId,
|
||||
} from "../config/home.js";
|
||||
|
||||
interface RunOptions {
|
||||
config?: string;
|
||||
instance?: string;
|
||||
repair?: boolean;
|
||||
yes?: boolean;
|
||||
}
|
||||
|
||||
export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
const instanceId = resolvePaperclipInstanceId(opts.instance);
|
||||
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
|
||||
|
||||
const homeDir = resolvePaperclipHomeDir();
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
|
||||
const paths = describeLocalInstancePaths(instanceId);
|
||||
fs.mkdirSync(paths.instanceRoot, { recursive: true });
|
||||
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
process.env.PAPERCLIP_CONFIG = configPath;
|
||||
|
||||
p.intro(pc.bgCyan(pc.black(" paperclip run ")));
|
||||
p.log.message(pc.dim(`Home: ${paths.homeDir}`));
|
||||
p.log.message(pc.dim(`Instance: ${paths.instanceId}`));
|
||||
p.log.message(pc.dim(`Config: ${configPath}`));
|
||||
|
||||
if (!configExists(configPath)) {
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
p.log.error("No config found and terminal is non-interactive.");
|
||||
p.log.message(`Run ${pc.cyan("paperclip onboard")} once, then retry ${pc.cyan("paperclip run")}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
p.log.step("No config found. Starting onboarding...");
|
||||
await onboard({ config: configPath });
|
||||
}
|
||||
|
||||
p.log.step("Running doctor checks...");
|
||||
const summary = await doctor({
|
||||
config: configPath,
|
||||
repair: opts.repair ?? true,
|
||||
yes: opts.yes ?? true,
|
||||
});
|
||||
|
||||
if (summary.failed > 0) {
|
||||
p.log.error("Doctor found blocking issues. Not starting server.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
p.log.step("Starting Paperclip server...");
|
||||
await importServerEntry();
|
||||
}
|
||||
|
||||
async function importServerEntry(): Promise<void> {
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const fileCandidates = [
|
||||
path.resolve(projectRoot, "server/dist/index.js"),
|
||||
path.resolve(projectRoot, "server/src/index.ts"),
|
||||
];
|
||||
|
||||
const specifierCandidates: string[] = [
|
||||
"@paperclip/server/dist/index.js",
|
||||
"@paperclip/server/src/index.ts",
|
||||
];
|
||||
|
||||
const importErrors: string[] = [];
|
||||
|
||||
for (const specifier of specifierCandidates) {
|
||||
try {
|
||||
await import(specifier);
|
||||
return;
|
||||
} catch (err) {
|
||||
importErrors.push(`${specifier}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const filePath of fileCandidates) {
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
try {
|
||||
await import(pathToFileURL(filePath).href);
|
||||
return;
|
||||
} catch (err) {
|
||||
importErrors.push(`${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Could not start Paperclip server entrypoint. Tried: ${[...specifierCandidates, ...fileCandidates].join(", ")}\n` +
|
||||
importErrors.join("\n"),
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,6 @@ const JWT_SECRET_ENV_KEY = "PAPERCLIP_AGENT_JWT_SECRET";
|
||||
function resolveEnvFilePath() {
|
||||
return path.resolve(path.dirname(resolveConfigPath()), ".env");
|
||||
}
|
||||
|
||||
const ENV_FILE_PATH = resolveEnvFilePath();
|
||||
const loadedEnvFiles = new Set<string>();
|
||||
|
||||
function isNonEmpty(value: unknown): value is string {
|
||||
@@ -35,10 +33,10 @@ function renderEnvFile(entries: Record<string, string>) {
|
||||
}
|
||||
|
||||
export function resolveAgentJwtEnvFile(): string {
|
||||
return ENV_FILE_PATH;
|
||||
return resolveEnvFilePath();
|
||||
}
|
||||
|
||||
export function loadAgentJwtEnvFile(filePath = ENV_FILE_PATH): void {
|
||||
export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void {
|
||||
if (loadedEnvFiles.has(filePath)) return;
|
||||
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
@@ -52,7 +50,7 @@ export function readAgentJwtSecretFromEnv(): string | null {
|
||||
return isNonEmpty(raw) ? raw!.trim() : null;
|
||||
}
|
||||
|
||||
export function readAgentJwtSecretFromEnvFile(filePath = ENV_FILE_PATH): string | null {
|
||||
export function readAgentJwtSecretFromEnvFile(filePath = resolveEnvFilePath()): string | null {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
@@ -78,7 +76,7 @@ export function ensureAgentJwtSecret(): { secret: string; created: boolean } {
|
||||
return { secret, created };
|
||||
}
|
||||
|
||||
export function writeAgentJwtEnv(secret: string, filePath = ENV_FILE_PATH): void {
|
||||
export function writeAgentJwtEnv(secret: string, filePath = resolveEnvFilePath()): void {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
|
||||
66
cli/src/config/home.ts
Normal file
66
cli/src/config/home.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_INSTANCE_ID = "default";
|
||||
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
export function resolvePaperclipHomeDir(): string {
|
||||
const envHome = process.env.PAPERCLIP_HOME?.trim();
|
||||
if (envHome) return path.resolve(expandHomePrefix(envHome));
|
||||
return path.resolve(os.homedir(), ".paperclip");
|
||||
}
|
||||
|
||||
export function resolvePaperclipInstanceId(override?: string): string {
|
||||
const raw = override?.trim() || process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID;
|
||||
if (!INSTANCE_ID_RE.test(raw)) {
|
||||
throw new Error(
|
||||
`Invalid instance id '${raw}'. Allowed characters: letters, numbers, '_' and '-'.`,
|
||||
);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function resolvePaperclipInstanceRoot(instanceId?: string): string {
|
||||
const id = resolvePaperclipInstanceId(instanceId);
|
||||
return path.resolve(resolvePaperclipHomeDir(), "instances", id);
|
||||
}
|
||||
|
||||
export function resolveDefaultConfigPath(instanceId?: string): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "config.json");
|
||||
}
|
||||
|
||||
export function resolveDefaultContextPath(): string {
|
||||
return path.resolve(resolvePaperclipHomeDir(), "context.json");
|
||||
}
|
||||
|
||||
export function resolveDefaultEmbeddedPostgresDir(instanceId?: string): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "db");
|
||||
}
|
||||
|
||||
export function resolveDefaultLogsDir(instanceId?: string): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "logs");
|
||||
}
|
||||
|
||||
export function resolveDefaultSecretsKeyFilePath(instanceId?: string): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "secrets", "master.key");
|
||||
}
|
||||
|
||||
export function expandHomePrefix(value: string): string {
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
export function describeLocalInstancePaths(instanceId?: string) {
|
||||
const resolvedInstanceId = resolvePaperclipInstanceId(instanceId);
|
||||
const instanceRoot = resolvePaperclipInstanceRoot(resolvedInstanceId);
|
||||
return {
|
||||
homeDir: resolvePaperclipHomeDir(),
|
||||
instanceId: resolvedInstanceId,
|
||||
instanceRoot,
|
||||
configPath: resolveDefaultConfigPath(resolvedInstanceId),
|
||||
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(resolvedInstanceId),
|
||||
logDir: resolveDefaultLogsDir(resolvedInstanceId),
|
||||
secretsKeyFilePath: resolveDefaultSecretsKeyFilePath(resolvedInstanceId),
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { paperclipConfigSchema, type PaperclipConfig } from "./schema.js";
|
||||
import {
|
||||
resolveDefaultConfigPath,
|
||||
resolvePaperclipInstanceId,
|
||||
} from "./home.js";
|
||||
|
||||
const DEFAULT_CONFIG_PATH = ".paperclip/config.json";
|
||||
const DEFAULT_CONFIG_BASENAME = "config.json";
|
||||
|
||||
function findConfigFileFromAncestors(startDir: string): string | null {
|
||||
@@ -26,7 +29,7 @@ function findConfigFileFromAncestors(startDir: string): string | null {
|
||||
export function resolveConfigPath(overridePath?: string): string {
|
||||
if (overridePath) return path.resolve(overridePath);
|
||||
if (process.env.PAPERCLIP_CONFIG) return path.resolve(process.env.PAPERCLIP_CONFIG);
|
||||
return findConfigFileFromAncestors(process.cwd()) ?? path.resolve(process.cwd(), DEFAULT_CONFIG_PATH);
|
||||
return findConfigFileFromAncestors(process.cwd()) ?? resolveDefaultConfigPath(resolvePaperclipInstanceId());
|
||||
}
|
||||
|
||||
function parseJson(filePath: string): unknown {
|
||||
|
||||
@@ -5,6 +5,14 @@ import { doctor } from "./commands/doctor.js";
|
||||
import { envCommand } from "./commands/env.js";
|
||||
import { configure } from "./commands/configure.js";
|
||||
import { heartbeatRun } from "./commands/heartbeat-run.js";
|
||||
import { runCommand } from "./commands/run.js";
|
||||
import { registerContextCommands } from "./commands/client/context.js";
|
||||
import { registerCompanyCommands } from "./commands/client/company.js";
|
||||
import { registerIssueCommands } from "./commands/client/issue.js";
|
||||
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";
|
||||
|
||||
const program = new Command();
|
||||
|
||||
@@ -26,7 +34,9 @@ program
|
||||
.option("--repair", "Attempt to repair issues automatically")
|
||||
.alias("--fix")
|
||||
.option("-y, --yes", "Skip repair confirmation prompts")
|
||||
.action(doctor);
|
||||
.action(async (opts) => {
|
||||
await doctor(opts);
|
||||
});
|
||||
|
||||
program
|
||||
.command("env")
|
||||
@@ -41,6 +51,15 @@ program
|
||||
.option("-s, --section <section>", "Section to configure (llm, database, logging, server, secrets)")
|
||||
.action(configure);
|
||||
|
||||
program
|
||||
.command("run")
|
||||
.description("Bootstrap local setup (onboard + doctor) and run Paperclip")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.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")
|
||||
.action(runCommand);
|
||||
|
||||
const heartbeat = program.command("heartbeat").description("Heartbeat utilities");
|
||||
|
||||
heartbeat
|
||||
@@ -48,7 +67,10 @@ 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("--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")
|
||||
.option("--api-key <token>", "Bearer token for agent-authenticated calls")
|
||||
.option(
|
||||
"--source <source>",
|
||||
"Invocation source (timer | assignment | on_demand | automation)",
|
||||
@@ -56,7 +78,19 @@ heartbeat
|
||||
)
|
||||
.option("--trigger <trigger>", "Trigger detail (manual | ping | callback | system)", "manual")
|
||||
.option("--timeout-ms <ms>", "Max time to wait before giving up", "0")
|
||||
.option("--json", "Output raw JSON where applicable")
|
||||
.option("--debug", "Show raw adapter stdout/stderr JSON chunks")
|
||||
.action(heartbeatRun);
|
||||
|
||||
program.parse();
|
||||
registerContextCommands(program);
|
||||
registerCompanyCommands(program);
|
||||
registerIssueCommands(program);
|
||||
registerAgentCommands(program);
|
||||
registerApprovalCommands(program);
|
||||
registerActivityCommands(program);
|
||||
registerDashboardCommands(program);
|
||||
|
||||
program.parseAsync().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import type { DatabaseConfig } from "../config/schema.js";
|
||||
import { resolveDefaultEmbeddedPostgresDir, resolvePaperclipInstanceId } from "../config/home.js";
|
||||
|
||||
export async function promptDatabase(): Promise<DatabaseConfig> {
|
||||
const defaultEmbeddedDir = resolveDefaultEmbeddedPostgresDir(resolvePaperclipInstanceId());
|
||||
|
||||
const mode = await p.select({
|
||||
message: "Database mode",
|
||||
options: [
|
||||
@@ -33,15 +36,15 @@ export async function promptDatabase(): Promise<DatabaseConfig> {
|
||||
return {
|
||||
mode: "postgres",
|
||||
connectionString,
|
||||
embeddedPostgresDataDir: "./data/embedded-postgres",
|
||||
embeddedPostgresDataDir: defaultEmbeddedDir,
|
||||
embeddedPostgresPort: 54329,
|
||||
};
|
||||
}
|
||||
|
||||
const embeddedPostgresDataDir = await p.text({
|
||||
message: "Embedded PostgreSQL data directory",
|
||||
defaultValue: "./data/embedded-postgres",
|
||||
placeholder: "./data/embedded-postgres",
|
||||
defaultValue: defaultEmbeddedDir,
|
||||
placeholder: defaultEmbeddedDir,
|
||||
});
|
||||
|
||||
if (p.isCancel(embeddedPostgresDataDir)) {
|
||||
@@ -66,7 +69,7 @@ export async function promptDatabase(): Promise<DatabaseConfig> {
|
||||
|
||||
return {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: embeddedPostgresDataDir || "./data/embedded-postgres",
|
||||
embeddedPostgresDataDir: embeddedPostgresDataDir || defaultEmbeddedDir,
|
||||
embeddedPostgresPort: Number(embeddedPostgresPort || "54329"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import type { LoggingConfig } from "../config/schema.js";
|
||||
import { resolveDefaultLogsDir, resolvePaperclipInstanceId } from "../config/home.js";
|
||||
|
||||
export async function promptLogging(): Promise<LoggingConfig> {
|
||||
const defaultLogDir = resolveDefaultLogsDir(resolvePaperclipInstanceId());
|
||||
const mode = await p.select({
|
||||
message: "Logging mode",
|
||||
options: [
|
||||
@@ -18,8 +20,8 @@ export async function promptLogging(): Promise<LoggingConfig> {
|
||||
if (mode === "file") {
|
||||
const logDir = await p.text({
|
||||
message: "Log directory",
|
||||
defaultValue: "./data/logs",
|
||||
placeholder: "./data/logs",
|
||||
defaultValue: defaultLogDir,
|
||||
placeholder: defaultLogDir,
|
||||
});
|
||||
|
||||
if (p.isCancel(logDir)) {
|
||||
@@ -27,9 +29,9 @@ export async function promptLogging(): Promise<LoggingConfig> {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
return { mode: "file", logDir: logDir || "./data/logs" };
|
||||
return { mode: "file", logDir: logDir || defaultLogDir };
|
||||
}
|
||||
|
||||
p.note("Cloud logging is coming soon. Using file-based logging for now.");
|
||||
return { mode: "file", logDir: "./data/logs" };
|
||||
return { mode: "file", logDir: defaultLogDir };
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import type { SecretProvider } from "@paperclip/shared";
|
||||
import type { SecretsConfig } from "../config/schema.js";
|
||||
import { resolveDefaultSecretsKeyFilePath, resolvePaperclipInstanceId } from "../config/home.js";
|
||||
|
||||
const DEFAULT_KEY_FILE_PATH = "./data/secrets/master.key";
|
||||
function defaultKeyFilePath(): string {
|
||||
return resolveDefaultSecretsKeyFilePath(resolvePaperclipInstanceId());
|
||||
}
|
||||
|
||||
export function defaultSecretsConfig(): SecretsConfig {
|
||||
const keyFilePath = defaultKeyFilePath();
|
||||
return {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: DEFAULT_KEY_FILE_PATH,
|
||||
keyFilePath,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -59,12 +63,13 @@ export async function promptSecrets(current?: SecretsConfig): Promise<SecretsCon
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let keyFilePath = base.localEncrypted.keyFilePath || DEFAULT_KEY_FILE_PATH;
|
||||
const fallbackDefault = defaultKeyFilePath();
|
||||
let keyFilePath = base.localEncrypted.keyFilePath || fallbackDefault;
|
||||
if (provider === "local_encrypted") {
|
||||
const keyPath = await p.text({
|
||||
message: "Local encrypted key file path",
|
||||
defaultValue: keyFilePath,
|
||||
placeholder: DEFAULT_KEY_FILE_PATH,
|
||||
placeholder: fallbackDefault,
|
||||
validate: (value) => {
|
||||
if (!value || value.trim().length === 0) return "Key file path is required";
|
||||
},
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expandHomePrefix } from "../config/home.js";
|
||||
|
||||
function unique(items: string[]): string[] {
|
||||
return Array.from(new Set(items));
|
||||
}
|
||||
|
||||
export function resolveRuntimeLikePath(value: string, configPath?: string): string {
|
||||
if (path.isAbsolute(value)) return value;
|
||||
const expanded = expandHomePrefix(value);
|
||||
if (path.isAbsolute(expanded)) return path.resolve(expanded);
|
||||
|
||||
const cwd = process.cwd();
|
||||
const configDir = configPath ? path.dirname(configPath) : null;
|
||||
const workspaceRoot = configDir ? path.resolve(configDir, "..") : cwd;
|
||||
|
||||
const candidates = unique([
|
||||
path.resolve(workspaceRoot, "server", value),
|
||||
path.resolve(workspaceRoot, value),
|
||||
path.resolve(cwd, value),
|
||||
...(configDir ? [path.resolve(configDir, value)] : []),
|
||||
...(configDir ? [path.resolve(configDir, expanded)] : []),
|
||||
path.resolve(workspaceRoot, "server", expanded),
|
||||
path.resolve(workspaceRoot, expanded),
|
||||
path.resolve(cwd, expanded),
|
||||
]);
|
||||
|
||||
return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0];
|
||||
|
||||
7
cli/vitest.config.ts
Normal file
7
cli/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
128
doc/CLI.md
Normal file
128
doc/CLI.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# CLI Reference
|
||||
|
||||
Paperclip CLI now supports both:
|
||||
|
||||
- instance setup/diagnostics (`onboard`, `doctor`, `configure`, `env`)
|
||||
- control-plane client operations (issues, approvals, agents, activity, dashboard)
|
||||
|
||||
## Base Usage
|
||||
|
||||
Use repo script in development:
|
||||
|
||||
```sh
|
||||
pnpm paperclip --help
|
||||
```
|
||||
|
||||
First-time local bootstrap + run:
|
||||
|
||||
```sh
|
||||
pnpm paperclip run
|
||||
```
|
||||
|
||||
Choose local instance:
|
||||
|
||||
```sh
|
||||
pnpm paperclip run --instance dev
|
||||
```
|
||||
|
||||
All client commands support:
|
||||
|
||||
- `--api-base <url>`
|
||||
- `--api-key <token>`
|
||||
- `--context <path>`
|
||||
- `--profile <name>`
|
||||
- `--json`
|
||||
|
||||
Company-scoped commands also support `--company-id <id>`.
|
||||
|
||||
## Context Profiles
|
||||
|
||||
Store local defaults in `~/.paperclip/context.json`:
|
||||
|
||||
```sh
|
||||
pnpm paperclip context set --api-base http://localhost:3100 --company-id <company-id>
|
||||
pnpm paperclip context show
|
||||
pnpm paperclip context list
|
||||
pnpm paperclip context use default
|
||||
```
|
||||
|
||||
To avoid storing secrets in context, set `apiKeyEnvVarName` and keep the key in env:
|
||||
|
||||
```sh
|
||||
pnpm paperclip context set --api-key-env-var-name PAPERCLIP_API_KEY
|
||||
export PAPERCLIP_API_KEY=...
|
||||
```
|
||||
|
||||
## Company Commands
|
||||
|
||||
```sh
|
||||
pnpm paperclip company list
|
||||
pnpm paperclip company get <company-id>
|
||||
```
|
||||
|
||||
## Issue Commands
|
||||
|
||||
```sh
|
||||
pnpm paperclip issue list --company-id <company-id> [--status todo,in_progress] [--assignee-agent-id <agent-id>] [--match text]
|
||||
pnpm paperclip issue get <issue-id-or-identifier>
|
||||
pnpm paperclip issue create --company-id <company-id> --title "..." [--description "..."] [--status todo] [--priority high]
|
||||
pnpm paperclip issue update <issue-id> [--status in_progress] [--comment "..."]
|
||||
pnpm paperclip issue comment <issue-id> --body "..." [--reopen]
|
||||
pnpm paperclip issue checkout <issue-id> --agent-id <agent-id> [--expected-statuses todo,backlog,blocked]
|
||||
pnpm paperclip issue release <issue-id>
|
||||
```
|
||||
|
||||
## Agent Commands
|
||||
|
||||
```sh
|
||||
pnpm paperclip agent list --company-id <company-id>
|
||||
pnpm paperclip agent get <agent-id>
|
||||
```
|
||||
|
||||
## Approval Commands
|
||||
|
||||
```sh
|
||||
pnpm paperclip approval list --company-id <company-id> [--status pending]
|
||||
pnpm paperclip approval get <approval-id>
|
||||
pnpm paperclip approval create --company-id <company-id> --type hire_agent --payload '{"name":"..."}' [--issue-ids <id1,id2>]
|
||||
pnpm paperclip approval approve <approval-id> [--decision-note "..."]
|
||||
pnpm paperclip approval reject <approval-id> [--decision-note "..."]
|
||||
pnpm paperclip approval request-revision <approval-id> [--decision-note "..."]
|
||||
pnpm paperclip approval resubmit <approval-id> [--payload '{"...":"..."}']
|
||||
pnpm paperclip approval comment <approval-id> --body "..."
|
||||
```
|
||||
|
||||
## Activity Commands
|
||||
|
||||
```sh
|
||||
pnpm paperclip activity list --company-id <company-id> [--agent-id <agent-id>] [--entity-type issue] [--entity-id <id>]
|
||||
```
|
||||
|
||||
## Dashboard Commands
|
||||
|
||||
```sh
|
||||
pnpm paperclip dashboard get --company-id <company-id>
|
||||
```
|
||||
|
||||
## Heartbeat Command
|
||||
|
||||
`heartbeat run` now also supports context/api-key options and uses the shared client stack:
|
||||
|
||||
```sh
|
||||
pnpm paperclip heartbeat run --agent-id <agent-id> [--api-base http://localhost:3100] [--api-key <token>]
|
||||
```
|
||||
|
||||
## Local Storage Defaults
|
||||
|
||||
Default local instance root is `~/.paperclip/instances/default`:
|
||||
|
||||
- config: `~/.paperclip/instances/default/config.json`
|
||||
- embedded db: `~/.paperclip/instances/default/db`
|
||||
- logs: `~/.paperclip/instances/default/logs`
|
||||
- secrets key: `~/.paperclip/instances/default/secrets/master.key`
|
||||
|
||||
Override base home or instance with env vars:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_HOME=/custom/home PAPERCLIP_INSTANCE_ID=dev pnpm paperclip run
|
||||
```
|
||||
@@ -12,12 +12,12 @@ pnpm dev
|
||||
|
||||
That's it. On first start the server:
|
||||
|
||||
1. Creates a `./server/data/embedded-postgres/` directory for storage
|
||||
1. Creates a `~/.paperclip/instances/default/db/` directory for storage
|
||||
2. Ensures the `paperclip` database exists
|
||||
3. Runs migrations automatically for empty databases
|
||||
4. Starts serving requests
|
||||
|
||||
Data persists across restarts in `./server/data/embedded-postgres/`. To reset local dev data, delete that directory.
|
||||
Data persists across restarts in `~/.paperclip/instances/default/db/`. To reset local dev data, delete that directory.
|
||||
|
||||
This mode is ideal for local development and one-command installs.
|
||||
|
||||
@@ -115,7 +115,7 @@ The database mode is controlled by `DATABASE_URL`:
|
||||
|
||||
| `DATABASE_URL` | Mode |
|
||||
|---|---|
|
||||
| Not set | Embedded PostgreSQL (`./server/data/embedded-postgres/`) |
|
||||
| Not set | Embedded PostgreSQL (`~/.paperclip/instances/default/db/`) |
|
||||
| `postgres://...localhost...` | Local Docker PostgreSQL |
|
||||
| `postgres://...supabase.com...` | Hosted Supabase |
|
||||
|
||||
@@ -131,8 +131,8 @@ Paperclip stores secret metadata and versions in:
|
||||
For local/default installs, the active provider is `local_encrypted`:
|
||||
|
||||
- Secret material is encrypted at rest with a local master key.
|
||||
- Default key file: `./data/secrets/master.key` (auto-created if missing).
|
||||
- CLI config location: `.paperclip/config.json` under `secrets.localEncrypted.keyFilePath`.
|
||||
- Default key file: `~/.paperclip/instances/default/secrets/master.key` (auto-created if missing).
|
||||
- CLI config location: `~/.paperclip/instances/default/config.json` under `secrets.localEncrypted.keyFilePath`.
|
||||
|
||||
Optional overrides:
|
||||
|
||||
|
||||
@@ -21,12 +21,32 @@ This starts:
|
||||
- API server: `http://localhost:3100`
|
||||
- UI: served by the API server in dev middleware mode (same origin as API)
|
||||
|
||||
## One-Command Local Run
|
||||
|
||||
For a first-time local install, you can bootstrap and run in one command:
|
||||
|
||||
```sh
|
||||
pnpm paperclip run
|
||||
```
|
||||
|
||||
`paperclip run` does:
|
||||
|
||||
1. auto-onboard if config is missing
|
||||
2. `paperclip doctor` with repair enabled
|
||||
3. starts the server when checks pass
|
||||
|
||||
## Database in Dev (Auto-Handled)
|
||||
|
||||
For local development, leave `DATABASE_URL` unset.
|
||||
The server will automatically use embedded PostgreSQL and persist data at:
|
||||
|
||||
- `./data/embedded-postgres`
|
||||
- `~/.paperclip/instances/default/db`
|
||||
|
||||
Override home and instance:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_HOME=/custom/path PAPERCLIP_INSTANCE_ID=dev pnpm paperclip run
|
||||
```
|
||||
|
||||
No Docker or external database is required for this mode.
|
||||
|
||||
@@ -49,7 +69,7 @@ Expected:
|
||||
To wipe local dev data and start fresh:
|
||||
|
||||
```sh
|
||||
rm -rf server/data/embedded-postgres
|
||||
rm -rf ~/.paperclip/instances/default/db
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
@@ -61,7 +81,7 @@ If you set `DATABASE_URL`, the server will use that instead of embedded PostgreS
|
||||
|
||||
Agent env vars now support secret references. By default, secret values are stored with local encryption and only secret refs are persisted in agent config.
|
||||
|
||||
- Default local key path: `./data/secrets/master.key`
|
||||
- Default local key path: `~/.paperclip/instances/default/secrets/master.key`
|
||||
- Override key material directly: `PAPERCLIP_SECRETS_MASTER_KEY`
|
||||
- Override key file path: `PAPERCLIP_SECRETS_MASTER_KEY_FILE`
|
||||
|
||||
@@ -85,3 +105,30 @@ Migration helper for existing inline env secrets:
|
||||
pnpm secrets:migrate-inline-env # dry run
|
||||
pnpm secrets:migrate-inline-env --apply # apply migration
|
||||
```
|
||||
|
||||
## CLI Client Operations
|
||||
|
||||
Paperclip CLI now includes client-side control-plane commands in addition to setup commands.
|
||||
|
||||
Quick examples:
|
||||
|
||||
```sh
|
||||
pnpm paperclip issue list --company-id <company-id>
|
||||
pnpm paperclip issue create --company-id <company-id> --title "Investigate checkout conflict"
|
||||
pnpm paperclip issue update <issue-id> --status in_progress --comment "Started triage"
|
||||
```
|
||||
|
||||
Set defaults once with context profiles:
|
||||
|
||||
```sh
|
||||
pnpm paperclip context set --api-base http://localhost:3100 --company-id <company-id>
|
||||
```
|
||||
|
||||
Then run commands without repeating flags:
|
||||
|
||||
```sh
|
||||
pnpm paperclip issue list
|
||||
pnpm paperclip dashboard get
|
||||
```
|
||||
|
||||
See full command reference in `doc/CLI.md`.
|
||||
|
||||
@@ -92,7 +92,7 @@ V1 implementation extends this baseline into a company-centric, governance-aware
|
||||
## 6.2 Data Stores
|
||||
|
||||
- Primary: PostgreSQL
|
||||
- Local default: embedded PostgreSQL at `./server/data/embedded-postgres`
|
||||
- Local default: embedded PostgreSQL at `~/.paperclip/instances/default/db`
|
||||
- Optional local prod-like: Docker Postgres
|
||||
- Optional hosted: Supabase/Postgres-compatible
|
||||
|
||||
|
||||
@@ -15,13 +15,13 @@ export const llmConfigSchema = z.object({
|
||||
export const databaseConfigSchema = z.object({
|
||||
mode: z.enum(["embedded-postgres", "postgres"]).default("embedded-postgres"),
|
||||
connectionString: z.string().optional(),
|
||||
embeddedPostgresDataDir: z.string().default("./data/embedded-postgres"),
|
||||
embeddedPostgresDataDir: z.string().default("~/.paperclip/instances/default/db"),
|
||||
embeddedPostgresPort: z.number().int().min(1).max(65535).default(54329),
|
||||
});
|
||||
|
||||
export const loggingConfigSchema = z.object({
|
||||
mode: z.enum(["file", "cloud"]),
|
||||
logDir: z.string().default("./data/logs"),
|
||||
logDir: z.string().default("~/.paperclip/instances/default/logs"),
|
||||
});
|
||||
|
||||
export const serverConfigSchema = z.object({
|
||||
@@ -30,14 +30,14 @@ export const serverConfigSchema = z.object({
|
||||
});
|
||||
|
||||
export const secretsLocalEncryptedConfigSchema = z.object({
|
||||
keyFilePath: z.string().default("./data/secrets/master.key"),
|
||||
keyFilePath: z.string().default("~/.paperclip/instances/default/secrets/master.key"),
|
||||
});
|
||||
|
||||
export const secretsConfigSchema = z.object({
|
||||
provider: z.enum(SECRET_PROVIDERS).default("local_encrypted"),
|
||||
strictMode: z.boolean().default(false),
|
||||
localEncrypted: secretsLocalEncryptedConfigSchema.default({
|
||||
keyFilePath: "./data/secrets/master.key",
|
||||
keyFilePath: "~/.paperclip/instances/default/secrets/master.key",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export const paperclipConfigSchema = z.object({
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: "./data/secrets/master.key",
|
||||
keyFilePath: "~/.paperclip/instances/default/secrets/master.key",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -32,6 +32,9 @@ importers:
|
||||
'@paperclip/db':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/db
|
||||
'@paperclip/server':
|
||||
specifier: workspace:*
|
||||
version: link:../server
|
||||
'@paperclip/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/shared
|
||||
|
||||
@@ -3,6 +3,11 @@ import { existsSync } from "node:fs";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { resolvePaperclipEnvPath } from "./paths.js";
|
||||
import { SECRET_PROVIDERS, type SecretProvider } from "@paperclip/shared";
|
||||
import {
|
||||
resolveDefaultEmbeddedPostgresDir,
|
||||
resolveDefaultSecretsKeyFilePath,
|
||||
resolveHomeAwarePath,
|
||||
} from "./home-paths.js";
|
||||
|
||||
const PAPERCLIP_ENV_FILE_PATH = resolvePaperclipEnvPath();
|
||||
if (existsSync(PAPERCLIP_ENV_FILE_PATH)) {
|
||||
@@ -54,7 +59,9 @@ export function loadConfig(): Config {
|
||||
port: Number(process.env.PORT) || fileConfig?.server.port || 3100,
|
||||
databaseMode: fileDatabaseMode,
|
||||
databaseUrl: process.env.DATABASE_URL ?? fileDbUrl,
|
||||
embeddedPostgresDataDir: fileConfig?.database.embeddedPostgresDataDir ?? "./data/embedded-postgres",
|
||||
embeddedPostgresDataDir: resolveHomeAwarePath(
|
||||
fileConfig?.database.embeddedPostgresDataDir ?? resolveDefaultEmbeddedPostgresDir(),
|
||||
),
|
||||
embeddedPostgresPort: fileConfig?.database.embeddedPostgresPort ?? 54329,
|
||||
serveUi:
|
||||
process.env.SERVE_UI !== undefined
|
||||
@@ -64,9 +71,11 @@ export function loadConfig(): Config {
|
||||
secretsProvider,
|
||||
secretsStrictMode,
|
||||
secretsMasterKeyFilePath:
|
||||
resolveHomeAwarePath(
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE ??
|
||||
fileSecrets?.localEncrypted.keyFilePath ??
|
||||
"./data/secrets/master.key",
|
||||
resolveDefaultSecretsKeyFilePath(),
|
||||
),
|
||||
heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false",
|
||||
heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000),
|
||||
};
|
||||
|
||||
49
server/src/home-paths.ts
Normal file
49
server/src/home-paths.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_INSTANCE_ID = "default";
|
||||
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
function expandHomePrefix(value: string): string {
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
export function resolvePaperclipHomeDir(): string {
|
||||
const envHome = process.env.PAPERCLIP_HOME?.trim();
|
||||
if (envHome) return path.resolve(expandHomePrefix(envHome));
|
||||
return path.resolve(os.homedir(), ".paperclip");
|
||||
}
|
||||
|
||||
export function resolvePaperclipInstanceId(): string {
|
||||
const raw = process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID;
|
||||
if (!INSTANCE_ID_RE.test(raw)) {
|
||||
throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function resolvePaperclipInstanceRoot(): string {
|
||||
return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId());
|
||||
}
|
||||
|
||||
export function resolveDefaultConfigPath(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "config.json");
|
||||
}
|
||||
|
||||
export function resolveDefaultEmbeddedPostgresDir(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "db");
|
||||
}
|
||||
|
||||
export function resolveDefaultLogsDir(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "logs");
|
||||
}
|
||||
|
||||
export function resolveDefaultSecretsKeyFilePath(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "secrets", "master.key");
|
||||
}
|
||||
|
||||
export function resolveHomeAwarePath(value: string): string {
|
||||
return path.resolve(expandHomePrefix(value));
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveDefaultConfigPath } from "./home-paths.js";
|
||||
|
||||
const PAPERCLIP_CONFIG_BASENAME = "config.json";
|
||||
const PAPERCLIP_ENV_FILENAME = ".env";
|
||||
@@ -25,7 +26,7 @@ function findConfigFileFromAncestors(startDir: string): string | null {
|
||||
export function resolvePaperclipConfigPath(overridePath?: string): string {
|
||||
if (overridePath) return path.resolve(overridePath);
|
||||
if (process.env.PAPERCLIP_CONFIG) return path.resolve(process.env.PAPERCLIP_CONFIG);
|
||||
return findConfigFileFromAncestors(process.cwd()) ?? path.resolve(process.cwd(), ".paperclip", PAPERCLIP_CONFIG_BASENAME);
|
||||
return findConfigFileFromAncestors(process.cwd()) ?? resolveDefaultConfigPath();
|
||||
}
|
||||
|
||||
export function resolvePaperclipEnvPath(overrideConfigPath?: string): string {
|
||||
|
||||
@@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
projects: ["packages/db", "server", "ui"],
|
||||
projects: ["packages/db", "server", "ui", "cli"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user