176 lines
5.1 KiB
TypeScript
176 lines
5.1 KiB
TypeScript
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 };
|
|
}
|