| 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 }; |
| } |
|
|