@openpalm/lib 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,474 @@
1
+ /**
2
+ * Shared setup orchestration for the OpenPalm control plane.
3
+ *
4
+ * Extracts the reusable core logic from the admin's POST /admin/setup handler
5
+ * so that both the CLI setup wizard and the admin UI can call `performSetup()`.
6
+ *
7
+ * This module does NOT include Docker operations (compose up, image pull, etc.)
8
+ * — those happen separately in the caller after setup completes.
9
+ */
10
+ import { createLogger } from "../logger.js";
11
+ import {
12
+ PROVIDER_KEY_MAP,
13
+ EMBEDDING_DIMS,
14
+ OLLAMA_INSTACK_URL,
15
+ } from "../provider-constants.js";
16
+ import { ensureXdgDirs } from "./paths.js";
17
+ import {
18
+ ensureSecrets,
19
+ updateSecretsEnv,
20
+ ensureOpenCodeConfig,
21
+ } from "./secrets.js";
22
+ import { ensureConnectionProfilesStore, writeConnectionsDocument } from "./connection-profiles.js";
23
+ import { buildMem0Mapping } from "./connection-mapping.js";
24
+ import { writeMemoryConfig } from "./memory-config.js";
25
+ import { ensureOpenCodeSystemConfig, ensureAdminOpenCodeConfig, ensureMemoryDir } from "./core-assets.js";
26
+ import { applyInstall, createState, writeSetupTokenFile } from "./lifecycle.js";
27
+ import { detectLocalProviders } from "./model-runner.js";
28
+ import type { LocalProviderDetection } from "./model-runner.js";
29
+ import type { CoreAssetProvider } from "./core-asset-provider.js";
30
+ import type { ControlPlaneState, CapabilityAssignments } from "./types.js";
31
+
32
+ const logger = createLogger("setup");
33
+
34
+ // ── Types ────────────────────────────────────────────────────────────────
35
+
36
+ export type SetupConnection = {
37
+ id: string;
38
+ name: string;
39
+ provider: string;
40
+ baseUrl: string;
41
+ apiKey: string;
42
+ };
43
+
44
+ export type SetupAssignments = {
45
+ llm: { connectionId: string; model: string; smallModel?: string };
46
+ embeddings: { connectionId: string; model: string; embeddingDims?: number };
47
+ };
48
+
49
+ export type SetupInput = {
50
+ adminToken: string;
51
+ ownerName?: string;
52
+ ownerEmail?: string;
53
+ memoryUserId: string;
54
+ ollamaEnabled: boolean;
55
+ connections: SetupConnection[];
56
+ assignments: SetupAssignments;
57
+ };
58
+
59
+ export type SetupResult = {
60
+ ok: boolean;
61
+ error?: string;
62
+ /** Services that should be started after setup. */
63
+ started?: string[];
64
+ };
65
+
66
+ export type DetectedProvider = {
67
+ provider: string;
68
+ url: string;
69
+ available: boolean;
70
+ };
71
+
72
+ // ── Validation ───────────────────────────────────────────────────────────
73
+
74
+ /** Safe env var key pattern: uppercase alphanumeric + underscores, starting with a letter. */
75
+ const SAFE_ENV_KEY_RE = /^[A-Z][A-Z0-9_]*$/;
76
+
77
+ /** Valid connection ID pattern: starts with letter or digit, allows A-Z, a-z, 0-9, _, -. */
78
+ const CONNECTION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
79
+
80
+ /** Providers that are valid for setup wizard connections. */
81
+ const WIZARD_PROVIDERS = new Set([
82
+ "openai", "anthropic", "ollama", "groq", "together",
83
+ "mistral", "deepseek", "xai", "lmstudio", "model-runner",
84
+ "ollama-instack",
85
+ ]);
86
+
87
+ export function validateSetupInput(input: unknown): { valid: boolean; errors: string[] } {
88
+ const errors: string[] = [];
89
+
90
+ if (typeof input !== "object" || input === null) {
91
+ return { valid: false, errors: ["Input must be a non-null object"] };
92
+ }
93
+
94
+ const body = input as Record<string, unknown>;
95
+
96
+ // adminToken
97
+ if (typeof body.adminToken !== "string" || !body.adminToken) {
98
+ errors.push("adminToken is required and must be a non-empty string");
99
+ } else if (body.adminToken.length < 8) {
100
+ errors.push("adminToken must be at least 8 characters");
101
+ }
102
+
103
+ // ownerName and ownerEmail are optional strings
104
+ if (body.ownerName !== undefined && typeof body.ownerName !== "string") {
105
+ errors.push("ownerName must be a string if provided");
106
+ }
107
+ if (body.ownerEmail !== undefined && typeof body.ownerEmail !== "string") {
108
+ errors.push("ownerEmail must be a string if provided");
109
+ }
110
+
111
+ // memoryUserId
112
+ if (body.memoryUserId !== undefined && typeof body.memoryUserId !== "string") {
113
+ errors.push("memoryUserId must be a string");
114
+ }
115
+
116
+ // ollamaEnabled
117
+ if (body.ollamaEnabled !== undefined && typeof body.ollamaEnabled !== "boolean") {
118
+ errors.push("ollamaEnabled must be a boolean");
119
+ }
120
+
121
+ // connections
122
+ if (!Array.isArray(body.connections) || body.connections.length === 0) {
123
+ errors.push("connections array is required and must be non-empty");
124
+ } else {
125
+ const seenIds = new Set<string>();
126
+ for (let i = 0; i < body.connections.length; i++) {
127
+ const c = body.connections[i];
128
+ if (typeof c !== "object" || c === null) {
129
+ errors.push(`connections[${i}] must be an object`);
130
+ continue;
131
+ }
132
+ const conn = c as Record<string, unknown>;
133
+ const id = typeof conn.id === "string" ? conn.id.trim() : "";
134
+ const provider = typeof conn.provider === "string" ? conn.provider.trim() : "";
135
+
136
+ if (!id) {
137
+ errors.push(`connections[${i}].id is required`);
138
+ } else if (!CONNECTION_ID_RE.test(id)) {
139
+ errors.push(`connections[${i}].id must start with a letter or digit (allowed: A-Z, a-z, 0-9, _, -)`);
140
+ } else if (seenIds.has(id)) {
141
+ errors.push(`Duplicate connection ID: ${id}`);
142
+ } else {
143
+ seenIds.add(id);
144
+ }
145
+
146
+ const name = typeof conn.name === "string" ? conn.name.trim() : "";
147
+ if (!name) {
148
+ errors.push(`connections[${i}].name is required`);
149
+ }
150
+
151
+ if (!provider) {
152
+ errors.push(`connections[${i}].provider is required`);
153
+ } else if (!WIZARD_PROVIDERS.has(provider)) {
154
+ errors.push(`connections[${i}].provider "${provider}" is outside wizard scope`);
155
+ }
156
+ }
157
+ }
158
+
159
+ // assignments
160
+ if (typeof body.assignments !== "object" || body.assignments === null) {
161
+ errors.push("assignments object is required");
162
+ } else {
163
+ const assignments = body.assignments as Record<string, unknown>;
164
+ const llm = assignments.llm;
165
+ const embeddings = assignments.embeddings;
166
+
167
+ if (typeof llm !== "object" || llm === null) {
168
+ errors.push("assignments.llm is required");
169
+ } else {
170
+ const llmObj = llm as Record<string, unknown>;
171
+ if (!llmObj.connectionId || typeof llmObj.connectionId !== "string") {
172
+ errors.push("assignments.llm.connectionId is required");
173
+ }
174
+ if (!llmObj.model || typeof llmObj.model !== "string") {
175
+ errors.push("assignments.llm.model is required");
176
+ }
177
+ }
178
+
179
+ if (typeof embeddings !== "object" || embeddings === null) {
180
+ errors.push("assignments.embeddings is required");
181
+ } else {
182
+ const embObj = embeddings as Record<string, unknown>;
183
+ if (!embObj.connectionId || typeof embObj.connectionId !== "string") {
184
+ errors.push("assignments.embeddings.connectionId is required");
185
+ }
186
+ if (!embObj.model || typeof embObj.model !== "string") {
187
+ errors.push("assignments.embeddings.model is required");
188
+ }
189
+ if (
190
+ embObj.embeddingDims !== undefined &&
191
+ (typeof embObj.embeddingDims !== "number" ||
192
+ !Number.isInteger(embObj.embeddingDims) ||
193
+ embObj.embeddingDims < 1)
194
+ ) {
195
+ errors.push("assignments.embeddings.embeddingDims must be a positive integer");
196
+ }
197
+ }
198
+
199
+ // Cross-validate: assignment connectionIds must reference a connection
200
+ if (Array.isArray(body.connections) && errors.length === 0) {
201
+ const connectionIds = new Set(
202
+ (body.connections as Array<Record<string, unknown>>).map(
203
+ (c) => typeof c.id === "string" ? c.id.trim() : ""
204
+ )
205
+ );
206
+ const llmConnId =
207
+ typeof (llm as Record<string, unknown>)?.connectionId === "string"
208
+ ? ((llm as Record<string, unknown>).connectionId as string)
209
+ : "";
210
+ const embConnId =
211
+ typeof (embeddings as Record<string, unknown>)?.connectionId === "string"
212
+ ? ((embeddings as Record<string, unknown>).connectionId as string)
213
+ : "";
214
+
215
+ if (llmConnId && !connectionIds.has(llmConnId)) {
216
+ errors.push(`assignments.llm.connectionId "${llmConnId}" does not match any connection`);
217
+ }
218
+ if (embConnId && !connectionIds.has(embConnId)) {
219
+ errors.push(`assignments.embeddings.connectionId "${embConnId}" does not match any connection`);
220
+ }
221
+ }
222
+ }
223
+
224
+ return { valid: errors.length === 0, errors };
225
+ }
226
+
227
+ // ── Secrets Builder ──────────────────────────────────────────────────────
228
+
229
+ /**
230
+ * Build the env var map from connections + assignments.
231
+ *
232
+ * Returns a Record<string, string> of secrets.env updates that should be
233
+ * written during setup.
234
+ */
235
+ export function buildSecretsFromSetup(input: SetupInput): Record<string, string> {
236
+ const updates: Record<string, string> = {};
237
+
238
+ // Admin token
239
+ updates.OPENPALM_ADMIN_TOKEN = input.adminToken;
240
+ updates.ADMIN_TOKEN = input.adminToken;
241
+
242
+ // Owner info — strip control characters to prevent env-file injection
243
+ const ownerName = (input.ownerName?.trim() ?? "").replace(/[\r\n\0]/g, "").slice(0, 200);
244
+ const ownerEmail = (input.ownerEmail?.trim() ?? "").replace(/[\r\n\0]/g, "").slice(0, 200);
245
+ if (ownerName) updates.OWNER_NAME = ownerName;
246
+ if (ownerEmail) updates.OWNER_EMAIL = ownerEmail;
247
+
248
+ // Resolve effective base URLs (Ollama in-stack override)
249
+ const effectiveConnections = input.connections.map((c) => {
250
+ if (input.ollamaEnabled && c.provider === "ollama") {
251
+ return { ...c, baseUrl: OLLAMA_INSTACK_URL };
252
+ }
253
+ return c;
254
+ });
255
+
256
+ // Build connectionId -> envVarName map
257
+ const connEnvVarMap = buildConnectionEnvVarMap(effectiveConnections);
258
+
259
+ // Write API keys
260
+ for (const conn of effectiveConnections) {
261
+ if (!conn.apiKey) continue;
262
+ const envVar = connEnvVarMap.get(conn.id);
263
+ if (envVar) updates[envVar] = conn.apiKey;
264
+ }
265
+
266
+ // System LLM vars
267
+ const llmConnection = effectiveConnections.find((c) => c.id === input.assignments.llm.connectionId);
268
+ if (llmConnection) {
269
+ updates.SYSTEM_LLM_PROVIDER = llmConnection.provider;
270
+ updates.SYSTEM_LLM_MODEL = input.assignments.llm.model;
271
+ if (llmConnection.baseUrl) {
272
+ updates.SYSTEM_LLM_BASE_URL = llmConnection.baseUrl;
273
+ const normalizedUrl = llmConnection.baseUrl.replace(/\/+$/, "");
274
+ updates.OPENAI_BASE_URL = normalizedUrl.endsWith("/v1") ? normalizedUrl : `${normalizedUrl}/v1`;
275
+ }
276
+ }
277
+
278
+ // Memory user ID
279
+ updates.MEMORY_USER_ID = input.memoryUserId || "default_user";
280
+
281
+ return updates;
282
+ }
283
+
284
+ // ── Connection Env Var Map Builder ───────────────────────────────────────
285
+
286
+ /**
287
+ * Build a Map<connectionId, envVarName> from connections, using PROVIDER_KEY_MAP
288
+ * for the canonical mapping and falling back to namespaced vars for duplicates.
289
+ */
290
+ export function buildConnectionEnvVarMap(
291
+ connections: SetupConnection[]
292
+ ): Map<string, string> {
293
+ const connEnvVarMap = new Map<string, string>();
294
+ const claimedEnvVars = new Set<string>();
295
+
296
+ for (const conn of connections) {
297
+ let envVarName = PROVIDER_KEY_MAP[conn.provider] ?? "OPENAI_API_KEY";
298
+ if (claimedEnvVars.has(envVarName)) {
299
+ envVarName = `${envVarName}_${conn.id}`;
300
+ }
301
+ const upperKey = envVarName.toUpperCase();
302
+ if (!SAFE_ENV_KEY_RE.test(upperKey)) {
303
+ logger.warn("skipping connection with unsafe env var key", { connectionId: conn.id, envVarName });
304
+ continue;
305
+ }
306
+ claimedEnvVars.add(upperKey);
307
+ connEnvVarMap.set(conn.id, upperKey);
308
+ }
309
+
310
+ return connEnvVarMap;
311
+ }
312
+
313
+ // ── Core Setup Orchestration ─────────────────────────────────────────────
314
+
315
+ /**
316
+ * Core setup orchestration -- shared by CLI and admin.
317
+ *
318
+ * Steps:
319
+ * 1. Validate input fields
320
+ * 2. Build connection env var map
321
+ * 3. Update secrets.env with API keys and system config
322
+ * 4. Build and write memory config via buildMem0Mapping()
323
+ * 5. Write connection profiles
324
+ * 6. Ensure OpenCode configs
325
+ * 7. Apply install via applyInstall()
326
+ *
327
+ * Does NOT include Docker operations (compose up, pull, etc.) — the caller
328
+ * handles those separately after setup completes.
329
+ */
330
+ export async function performSetup(
331
+ input: SetupInput,
332
+ assetProvider: CoreAssetProvider,
333
+ opts?: { state?: ControlPlaneState }
334
+ ): Promise<SetupResult> {
335
+ // ── Validate ─────────────────────────────────────────────────────────
336
+ const validation = validateSetupInput(input);
337
+ if (!validation.valid) {
338
+ return { ok: false, error: validation.errors.join("; ") };
339
+ }
340
+
341
+ logger.info("performing setup", {
342
+ connectionCount: input.connections.length,
343
+ ollamaEnabled: input.ollamaEnabled,
344
+ });
345
+
346
+ // ── Resolve state ────────────────────────────────────────────────────
347
+ const state = opts?.state ?? createState(input.adminToken);
348
+
349
+ // ── Resolve effective connections (Ollama in-stack override) ──────────
350
+ const effectiveConnections = input.connections.map((c) => {
351
+ if (input.ollamaEnabled && c.provider === "ollama") {
352
+ return { ...c, baseUrl: OLLAMA_INSTACK_URL };
353
+ }
354
+ return c;
355
+ });
356
+
357
+ // ── Build connection env var map ─────────────────────────────────────
358
+ const connEnvVarMap = buildConnectionEnvVarMap(effectiveConnections);
359
+
360
+ // ── Build secrets.env updates ────────────────────────────────────────
361
+ const updates = buildSecretsFromSetup(input);
362
+
363
+ // ── Persist secrets.env ──────────────────────────────────────────────
364
+ try {
365
+ ensureXdgDirs();
366
+ ensureSecrets(state);
367
+ ensureConnectionProfilesStore(state.configDir);
368
+ updateSecretsEnv(state, updates);
369
+ } catch (err) {
370
+ const message = err instanceof Error ? err.message : String(err);
371
+ logger.error("failed to update secrets.env", { error: message });
372
+ return { ok: false, error: `Failed to update secrets.env: ${message}` };
373
+ }
374
+
375
+ // Update state with new admin token
376
+ state.adminToken = input.adminToken;
377
+ writeSetupTokenFile(state);
378
+
379
+ // ── Build and persist Memory config ──────────────────────────────────
380
+ const llmConnectionId = input.assignments.llm.connectionId;
381
+ const embConnectionId = input.assignments.embeddings.connectionId;
382
+ const llmModel = input.assignments.llm.model;
383
+ const llmSmallModel = input.assignments.llm.smallModel || "";
384
+ const embModel = input.assignments.embeddings.model;
385
+ const embDims = input.assignments.embeddings.embeddingDims || 0;
386
+
387
+ const llmConnection = effectiveConnections.find((c) => c.id === llmConnectionId)!;
388
+ const embConnection = effectiveConnections.find((c) => c.id === embConnectionId)!;
389
+
390
+ const memoryModel = llmSmallModel || llmModel;
391
+
392
+ const llmEnvVar = connEnvVarMap.get(llmConnection.id)!;
393
+ const llmApiKeyEnvRef = llmConnection.apiKey ? `env:${llmEnvVar}` : "not-needed";
394
+
395
+ const embEnvVar = connEnvVarMap.get(embConnection.id)!;
396
+ const embApiKeyEnvRef = embConnection.apiKey ? `env:${embEnvVar}` : "not-needed";
397
+
398
+ const embLookupKey = `${embConnection.provider}/${embModel}`;
399
+ const resolvedDims = embDims || EMBEDDING_DIMS[embLookupKey] || 1536;
400
+
401
+ const omConfig = buildMem0Mapping({
402
+ llm: {
403
+ provider: llmConnection.provider,
404
+ baseUrl: llmConnection.baseUrl,
405
+ model: memoryModel,
406
+ apiKeyRef: llmApiKeyEnvRef,
407
+ },
408
+ embedder: {
409
+ provider: embConnection.provider,
410
+ baseUrl: embConnection.baseUrl,
411
+ model: embModel || "text-embedding-3-small",
412
+ apiKeyRef: embApiKeyEnvRef,
413
+ },
414
+ embeddingDims: resolvedDims,
415
+ customInstructions: "",
416
+ });
417
+
418
+ writeMemoryConfig(state.dataDir, omConfig);
419
+
420
+ // ── Write connection profiles ────────────────────────────────────────
421
+ const profilesInput = effectiveConnections.map((conn) => ({
422
+ id: conn.id,
423
+ name: conn.name,
424
+ provider: conn.provider,
425
+ baseUrl: conn.baseUrl,
426
+ hasApiKey: Boolean(conn.apiKey),
427
+ apiKeyEnvVar: connEnvVarMap.get(conn.id)!,
428
+ }));
429
+
430
+ writeConnectionsDocument(state.configDir, {
431
+ profiles: profilesInput,
432
+ assignments: {
433
+ llm: input.assignments.llm,
434
+ embeddings: {
435
+ connectionId: input.assignments.embeddings.connectionId,
436
+ model: input.assignments.embeddings.model,
437
+ embeddingDims: resolvedDims,
438
+ },
439
+ } as CapabilityAssignments,
440
+ });
441
+
442
+ // ── Ensure OpenCode configs ──────────────────────────────────────────
443
+ ensureOpenCodeConfig();
444
+ ensureOpenCodeSystemConfig(assetProvider);
445
+ ensureAdminOpenCodeConfig(assetProvider);
446
+ ensureMemoryDir();
447
+
448
+ // ── Apply install (stages artifacts, no Docker) ──────────────────────
449
+ applyInstall(state, assetProvider);
450
+
451
+ logger.info("setup complete", {
452
+ connectionCount: input.connections.length,
453
+ llmProvider: llmConnection.provider,
454
+ llmModel,
455
+ embModel,
456
+ });
457
+
458
+ return { ok: true };
459
+ }
460
+
461
+ // ── Provider Detection ───────────────────────────────────────────────────
462
+
463
+ /**
464
+ * Detect available local providers in a setup-friendly format.
465
+ * Wraps detectLocalProviders() from model-runner.ts.
466
+ */
467
+ export async function detectProviders(): Promise<DetectedProvider[]> {
468
+ const raw = await detectLocalProviders();
469
+ return raw.map((r: LocalProviderDetection) => ({
470
+ provider: r.provider,
471
+ url: r.url,
472
+ available: r.available,
473
+ }));
474
+ }