| import type { LlmConnection } from '@maka/core/llm-connections'; |
| |
| /** |
| * Config import / export — Alma-style selective bundle. |
| * |
| * A single JSON bundle carries a manifest (`includedData`) plus a `data` map |
| * keyed by category. The user chooses which categories to export; the manifest |
| * records exactly what is present, and import applies only those categories. |
| * |
| * This module is category-agnostic on purpose: each category payload is opaque |
| * (`unknown`) here. The desktop layer owns what goes into each one — including |
| * the sensitive parts (deciding whether to gather credentials, and stripping |
| * secret fields out of `settings` when credentials are NOT included). Keeping |
| * that knowledge out of this module means the pure transfer logic never has to |
| * understand the AppSettings/credential shapes. |
| * |
| * `credentials` is an opt-in category that carries plaintext secrets. It is |
| * never implied: if it is not listed in `includedData`, any `data.credentials` |
| * payload is dropped on read so a hand-edited or mislabeled file cannot sneak |
| * secrets past a config-only import. The UI must warn before selecting it. |
| * |
| * Reads fail closed on unknown schema versions (mirrors credential-store). |
| */ |
| |
| export const CONFIG_TRANSFER_SCHEMA_VERSION = 1; |
| |
| export const CONFIG_CATEGORIES = ['connections', 'settings', 'credentials', 'memory'] as const; |
| export type ConfigCategory = (typeof CONFIG_CATEGORIES)[number]; |
| |
| /** Categories that carry plaintext secrets and must be explicitly opted into. */ |
| export const SENSITIVE_CATEGORIES: ReadonlySet<ConfigCategory> = new Set(['credentials']); |
| |
| export type ConfigData = Partial<Record<ConfigCategory, unknown>>; |
| |
| export interface ConfigBundle { |
| schemaVersion: number; |
| exportedAt: string; |
| appVersion: string; |
| includedData: ConfigCategory[]; |
| data: ConfigData; |
| } |
| |
| export interface BuildConfigBundleInput { |
| appVersion: string; |
| /** Only the categories the user selected; presence here == inclusion. */ |
| data: ConfigData; |
| /** Injectable clock for deterministic tests. */ |
| now?: () => Date; |
| } |
| |
| export function buildConfigBundle(input: BuildConfigBundleInput): ConfigBundle { |
| const now = input.now ?? (() => new Date()); |
| const includedData = CONFIG_CATEGORIES.filter((c) => input.data[c] !== undefined); |
| const data: ConfigData = {}; |
| for (const category of includedData) { |
| data[category] = cloneJson(input.data[category]); |
| } |
| return { |
| schemaVersion: CONFIG_TRANSFER_SCHEMA_VERSION, |
| exportedAt: now().toISOString(), |
| appVersion: input.appVersion, |
| includedData, |
| data, |
| }; |
| } |
| |
| export function serializeConfigBundle(bundle: ConfigBundle): string { |
| return `${JSON.stringify(bundle, null, 2)}\n`; |
| } |
| |
| export type ConfigParseFailure = { |
| ok: false; |
| reason: 'not_json' | 'malformed' | 'unsupported_version'; |
| message: string; |
| }; |
| |
| export type ConfigParseResult = { ok: true; bundle: ConfigBundle } | ConfigParseFailure; |
| |
| export function parseConfigBundle(raw: string): ConfigParseResult { |
| let parsed: unknown; |
| try { |
| parsed = JSON.parse(raw); |
| } catch { |
| return { ok: false, reason: 'not_json', message: 'File is not valid JSON.' }; |
| } |
| if (!isJsonObject(parsed)) { |
| return { ok: false, reason: 'malformed', message: 'Config bundle must be a JSON object.' }; |
| } |
| const version = parsed.schemaVersion; |
| if (typeof version !== 'number') { |
| return { |
| ok: false, |
| reason: 'malformed', |
| message: 'Config bundle is missing a numeric schemaVersion.', |
| }; |
| } |
| if (version !== CONFIG_TRANSFER_SCHEMA_VERSION) { |
| return { |
| ok: false, |
| reason: 'unsupported_version', |
| message: `Unsupported config schemaVersion ${version} (this build reads ${CONFIG_TRANSFER_SCHEMA_VERSION}).`, |
| }; |
| } |
| if (!Array.isArray(parsed.includedData) || !parsed.includedData.every(isConfigCategory)) { |
| return { |
| ok: false, |
| reason: 'malformed', |
| message: 'includedData must be an array of known categories.', |
| }; |
| } |
| const rawData = isJsonObject(parsed.data) ? parsed.data : {}; |
| const includedData = [...new Set(parsed.includedData as ConfigCategory[])]; |
| const data: ConfigData = {}; |
| // Only surface categories that are BOTH declared in the manifest AND present |
| // in `data`. A category listed but absent, or present but not listed, is |
| // ignored — never guessed. This is what drops an unlisted `credentials`. |
| for (const category of includedData) { |
| if (rawData[category] !== undefined) data[category] = rawData[category]; |
| } |
| const bundle: ConfigBundle = { |
| schemaVersion: CONFIG_TRANSFER_SCHEMA_VERSION, |
| exportedAt: typeof parsed.exportedAt === 'string' ? parsed.exportedAt : '', |
| appVersion: typeof parsed.appVersion === 'string' ? parsed.appVersion : '', |
| includedData: includedData.filter((c) => data[c] !== undefined), |
| data, |
| }; |
| return { ok: true, bundle }; |
| } |
| |
| // --- connection merge planning (import applies this against the live store) --- |
| |
| export type ConnectionConflictStrategy = 'skip' | 'overwrite'; |
| |
| export interface ConnectionMergePlan { |
| create: LlmConnection[]; |
| overwrite: LlmConnection[]; |
| skipped: Array<{ slug: string; reason: 'exists' }>; |
| } |
| |
| export function planConnectionMerge( |
| existing: readonly LlmConnection[], |
| incoming: readonly LlmConnection[], |
| strategy: ConnectionConflictStrategy, |
| ): ConnectionMergePlan { |
| const existingSlugs = new Set(existing.map((c) => c.slug)); |
| const plan: ConnectionMergePlan = { create: [], overwrite: [], skipped: [] }; |
| const seen = new Set<string>(); |
| for (const conn of incoming) { |
| if (seen.has(conn.slug)) continue; // de-dupe within the imported set |
| seen.add(conn.slug); |
| if (existingSlugs.has(conn.slug)) { |
| if (strategy === 'overwrite') plan.overwrite.push(cloneJson(conn)); |
| else plan.skipped.push({ slug: conn.slug, reason: 'exists' }); |
| } else { |
| plan.create.push(cloneJson(conn)); |
| } |
| } |
| return plan; |
| } |
| |
| export function isConfigCategory(value: unknown): value is ConfigCategory { |
| return typeof value === 'string' && (CONFIG_CATEGORIES as readonly string[]).includes(value); |
| } |
| |
| function isJsonObject(value: unknown): value is Record<string, unknown> { |
| return typeof value === 'object' && value !== null && !Array.isArray(value); |
| } |
| |
| function cloneJson<T>(value: T): T { |
| return JSON.parse(JSON.stringify(value)) as T; |
| } |