feat(cli): add client commands and home-based local runtime defaults
This commit is contained in:
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)]),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user