@openpalm/lib 0.9.6 → 0.9.8
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.
- package/package.json +1 -1
- package/src/control-plane/channels.ts +3 -0
- package/src/control-plane/connection-mapping.ts +2 -2
- package/src/control-plane/core-asset-provider.ts +1 -0
- package/src/control-plane/core-assets.ts +28 -0
- package/src/control-plane/docker.ts +2 -1
- package/src/control-plane/env.test.ts +109 -0
- package/src/control-plane/env.ts +2 -2
- package/src/control-plane/fs-asset-provider.ts +4 -0
- package/src/control-plane/install-edge-cases.test.ts +1214 -0
- package/src/control-plane/lifecycle.ts +11 -2
- package/src/control-plane/model-runner.ts +27 -2
- package/src/control-plane/setup-status.ts +1 -1
- package/src/control-plane/setup.test.ts +720 -1
- package/src/control-plane/setup.ts +597 -115
- package/src/control-plane/stack-spec.ts +64 -0
- package/src/control-plane/staging.ts +29 -6
- package/src/control-plane/types.ts +2 -3
- package/src/index.ts +30 -0
- package/src/provider-constants.ts +13 -2
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
* This module does NOT include Docker operations (compose up, image pull, etc.)
|
|
8
8
|
* — those happen separately in the caller after setup completes.
|
|
9
9
|
*/
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
10
11
|
import { createLogger } from "../logger.js";
|
|
11
12
|
import {
|
|
12
13
|
PROVIDER_KEY_MAP,
|
|
13
14
|
EMBEDDING_DIMS,
|
|
14
15
|
OLLAMA_INSTACK_URL,
|
|
15
16
|
} from "../provider-constants.js";
|
|
17
|
+
import { mergeEnvContent } from "./env.js";
|
|
16
18
|
import { ensureXdgDirs } from "./paths.js";
|
|
17
19
|
import {
|
|
18
20
|
ensureSecrets,
|
|
@@ -24,6 +26,8 @@ import { buildMem0Mapping } from "./connection-mapping.js";
|
|
|
24
26
|
import { writeMemoryConfig } from "./memory-config.js";
|
|
25
27
|
import { ensureOpenCodeSystemConfig, ensureAdminOpenCodeConfig, ensureMemoryDir } from "./core-assets.js";
|
|
26
28
|
import { applyInstall, createState, writeSetupTokenFile } from "./lifecycle.js";
|
|
29
|
+
import { writeStackSpec } from "./stack-spec.js";
|
|
30
|
+
import type { StackSpec } from "./stack-spec.js";
|
|
27
31
|
import { detectLocalProviders } from "./model-runner.js";
|
|
28
32
|
import type { LocalProviderDetection } from "./model-runner.js";
|
|
29
33
|
import type { CoreAssetProvider } from "./core-asset-provider.js";
|
|
@@ -31,6 +35,14 @@ import type { ControlPlaneState, CapabilityAssignments } from "./types.js";
|
|
|
31
35
|
|
|
32
36
|
const logger = createLogger("setup");
|
|
33
37
|
|
|
38
|
+
/** Apply Ollama in-stack URL override to connections when Ollama is enabled. */
|
|
39
|
+
function resolveOllamaUrls(connections: SetupConnection[], ollamaEnabled: boolean): SetupConnection[] {
|
|
40
|
+
if (!ollamaEnabled) return connections;
|
|
41
|
+
return connections.map((c) =>
|
|
42
|
+
c.provider === "ollama" ? { ...c, baseUrl: OLLAMA_INSTACK_URL } : c
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
// ── Types ────────────────────────────────────────────────────────────────
|
|
35
47
|
|
|
36
48
|
export type SetupConnection = {
|
|
@@ -54,6 +66,16 @@ export type SetupInput = {
|
|
|
54
66
|
ollamaEnabled: boolean;
|
|
55
67
|
connections: SetupConnection[];
|
|
56
68
|
assignments: SetupAssignments;
|
|
69
|
+
voice?: {
|
|
70
|
+
tts?: string; // e.g. 'kokoro', 'piper', 'openai-tts', 'browser-tts', null
|
|
71
|
+
stt?: string; // e.g. 'whisper-local', 'openai-stt', 'browser-stt', null
|
|
72
|
+
};
|
|
73
|
+
channels?: string[]; // e.g. ['chat', 'discord', 'api']
|
|
74
|
+
services?: {
|
|
75
|
+
admin?: boolean;
|
|
76
|
+
openviking?: boolean;
|
|
77
|
+
ollama?: boolean;
|
|
78
|
+
};
|
|
57
79
|
};
|
|
58
80
|
|
|
59
81
|
export type SetupResult = {
|
|
@@ -63,6 +85,46 @@ export type SetupResult = {
|
|
|
63
85
|
started?: string[];
|
|
64
86
|
};
|
|
65
87
|
|
|
88
|
+
// ── SetupConfig (structured 7-section format) ────────────────────────────
|
|
89
|
+
|
|
90
|
+
export type SetupConfig = {
|
|
91
|
+
version: 1;
|
|
92
|
+
owner?: { name?: string; email?: string };
|
|
93
|
+
security: { adminToken: string };
|
|
94
|
+
connections: SetupConnection[];
|
|
95
|
+
assignments: SetupConfigAssignments;
|
|
96
|
+
memory?: { userId?: string };
|
|
97
|
+
channels?: Record<string, boolean | ChannelCredentials>;
|
|
98
|
+
services?: Record<string, boolean | ServiceConfig>;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type SetupConfigAssignments = {
|
|
102
|
+
llm: { connectionId: string; model: string; smallModel?: string };
|
|
103
|
+
embeddings: { connectionId: string; model: string; embeddingDims?: number };
|
|
104
|
+
tts?: { engine: string; connectionId?: string; model?: string } | string | null;
|
|
105
|
+
stt?: { engine: string; connectionId?: string; model?: string } | string | null;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export type ChannelCredentials = {
|
|
109
|
+
enabled?: boolean;
|
|
110
|
+
botToken?: string;
|
|
111
|
+
applicationId?: string;
|
|
112
|
+
registerCommands?: boolean;
|
|
113
|
+
allowedGuilds?: string;
|
|
114
|
+
allowedRoles?: string;
|
|
115
|
+
allowedUsers?: string;
|
|
116
|
+
blockedUsers?: string;
|
|
117
|
+
slackBotToken?: string;
|
|
118
|
+
slackAppToken?: string;
|
|
119
|
+
allowedChannels?: string;
|
|
120
|
+
[key: string]: unknown;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export type ServiceConfig = {
|
|
124
|
+
enabled: boolean;
|
|
125
|
+
[key: string]: unknown;
|
|
126
|
+
};
|
|
127
|
+
|
|
66
128
|
export type DetectedProvider = {
|
|
67
129
|
provider: string;
|
|
68
130
|
url: string;
|
|
@@ -81,9 +143,132 @@ const CONNECTION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
|
81
143
|
const WIZARD_PROVIDERS = new Set([
|
|
82
144
|
"openai", "anthropic", "ollama", "groq", "together",
|
|
83
145
|
"mistral", "deepseek", "xai", "lmstudio", "model-runner",
|
|
84
|
-
"ollama-instack",
|
|
146
|
+
"ollama-instack", "google", "huggingface",
|
|
85
147
|
]);
|
|
86
148
|
|
|
149
|
+
// ── Shared Validation Helpers ────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/** Validate a connections array. Pushes errors to the provided array. */
|
|
152
|
+
function validateConnectionsArray(
|
|
153
|
+
connections: unknown,
|
|
154
|
+
errors: string[]
|
|
155
|
+
): void {
|
|
156
|
+
if (!Array.isArray(connections) || connections.length === 0) {
|
|
157
|
+
errors.push("connections array is required and must be non-empty");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const seenIds = new Set<string>();
|
|
161
|
+
for (let i = 0; i < connections.length; i++) {
|
|
162
|
+
const c = connections[i];
|
|
163
|
+
if (typeof c !== "object" || c === null) {
|
|
164
|
+
errors.push(`connections[${i}] must be an object`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const conn = c as Record<string, unknown>;
|
|
168
|
+
const id = typeof conn.id === "string" ? conn.id.trim() : "";
|
|
169
|
+
const provider = typeof conn.provider === "string" ? conn.provider.trim() : "";
|
|
170
|
+
|
|
171
|
+
if (!id) {
|
|
172
|
+
errors.push(`connections[${i}].id is required`);
|
|
173
|
+
} else if (!CONNECTION_ID_RE.test(id)) {
|
|
174
|
+
errors.push(`connections[${i}].id must start with a letter or digit (allowed: A-Z, a-z, 0-9, _, -)`);
|
|
175
|
+
} else if (seenIds.has(id)) {
|
|
176
|
+
errors.push(`Duplicate connection ID: ${id}`);
|
|
177
|
+
} else {
|
|
178
|
+
seenIds.add(id);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const name = typeof conn.name === "string" ? conn.name.trim() : "";
|
|
182
|
+
if (!name) {
|
|
183
|
+
errors.push(`connections[${i}].name is required`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!provider) {
|
|
187
|
+
errors.push(`connections[${i}].provider is required`);
|
|
188
|
+
} else if (!WIZARD_PROVIDERS.has(provider)) {
|
|
189
|
+
errors.push(`connections[${i}].provider "${provider}" is outside wizard scope`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Validate assignments block (llm + embeddings) and cross-validate
|
|
196
|
+
* connectionIds against the connections array. Pushes errors to the
|
|
197
|
+
* provided array.
|
|
198
|
+
*/
|
|
199
|
+
function validateAssignmentsBlock(
|
|
200
|
+
assignments: unknown,
|
|
201
|
+
connections: unknown,
|
|
202
|
+
errors: string[]
|
|
203
|
+
): void {
|
|
204
|
+
if (typeof assignments !== "object" || assignments === null) {
|
|
205
|
+
errors.push("assignments object is required");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const assignmentsObj = assignments as Record<string, unknown>;
|
|
210
|
+
const llm = assignmentsObj.llm;
|
|
211
|
+
const embeddings = assignmentsObj.embeddings;
|
|
212
|
+
const preAssignmentLength = errors.length;
|
|
213
|
+
|
|
214
|
+
if (typeof llm !== "object" || llm === null) {
|
|
215
|
+
errors.push("assignments.llm is required");
|
|
216
|
+
} else {
|
|
217
|
+
const llmObj = llm as Record<string, unknown>;
|
|
218
|
+
if (!llmObj.connectionId || typeof llmObj.connectionId !== "string") {
|
|
219
|
+
errors.push("assignments.llm.connectionId is required");
|
|
220
|
+
}
|
|
221
|
+
if (!llmObj.model || typeof llmObj.model !== "string") {
|
|
222
|
+
errors.push("assignments.llm.model is required");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (typeof embeddings !== "object" || embeddings === null) {
|
|
227
|
+
errors.push("assignments.embeddings is required");
|
|
228
|
+
} else {
|
|
229
|
+
const embObj = embeddings as Record<string, unknown>;
|
|
230
|
+
if (!embObj.connectionId || typeof embObj.connectionId !== "string") {
|
|
231
|
+
errors.push("assignments.embeddings.connectionId is required");
|
|
232
|
+
}
|
|
233
|
+
if (!embObj.model || typeof embObj.model !== "string") {
|
|
234
|
+
errors.push("assignments.embeddings.model is required");
|
|
235
|
+
}
|
|
236
|
+
if (
|
|
237
|
+
embObj.embeddingDims !== undefined &&
|
|
238
|
+
(typeof embObj.embeddingDims !== "number" ||
|
|
239
|
+
!Number.isInteger(embObj.embeddingDims) ||
|
|
240
|
+
embObj.embeddingDims < 1)
|
|
241
|
+
) {
|
|
242
|
+
errors.push("assignments.embeddings.embeddingDims must be a positive integer");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Cross-validate: assignment connectionIds must reference a connection.
|
|
247
|
+
// Only run when no new assignment errors were added above.
|
|
248
|
+
if (Array.isArray(connections) && errors.length === preAssignmentLength) {
|
|
249
|
+
const connectionIds = new Set(
|
|
250
|
+
(connections as Array<Record<string, unknown>>).map(
|
|
251
|
+
(c) => typeof c.id === "string" ? c.id.trim() : ""
|
|
252
|
+
)
|
|
253
|
+
);
|
|
254
|
+
const llmConnId =
|
|
255
|
+
typeof (llm as Record<string, unknown>)?.connectionId === "string"
|
|
256
|
+
? ((llm as Record<string, unknown>).connectionId as string)
|
|
257
|
+
: "";
|
|
258
|
+
const embConnId =
|
|
259
|
+
typeof (embeddings as Record<string, unknown>)?.connectionId === "string"
|
|
260
|
+
? ((embeddings as Record<string, unknown>).connectionId as string)
|
|
261
|
+
: "";
|
|
262
|
+
|
|
263
|
+
if (llmConnId && !connectionIds.has(llmConnId)) {
|
|
264
|
+
errors.push(`assignments.llm.connectionId "${llmConnId}" does not match any connection`);
|
|
265
|
+
}
|
|
266
|
+
if (embConnId && !connectionIds.has(embConnId)) {
|
|
267
|
+
errors.push(`assignments.embeddings.connectionId "${embConnId}" does not match any connection`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
87
272
|
export function validateSetupInput(input: unknown): { valid: boolean; errors: string[] } {
|
|
88
273
|
const errors: string[] = [];
|
|
89
274
|
|
|
@@ -112,115 +297,63 @@ export function validateSetupInput(input: unknown): { valid: boolean; errors: st
|
|
|
112
297
|
if (body.memoryUserId !== undefined && typeof body.memoryUserId !== "string") {
|
|
113
298
|
errors.push("memoryUserId must be a string");
|
|
114
299
|
}
|
|
300
|
+
if (typeof body.memoryUserId === "string" && !/^[A-Za-z0-9_]+$/.test(body.memoryUserId)) {
|
|
301
|
+
errors.push("memoryUserId contains invalid characters (alphanumeric and underscores only)");
|
|
302
|
+
}
|
|
115
303
|
|
|
116
304
|
// ollamaEnabled
|
|
117
305
|
if (body.ollamaEnabled !== undefined && typeof body.ollamaEnabled !== "boolean") {
|
|
118
306
|
errors.push("ollamaEnabled must be a boolean");
|
|
119
307
|
}
|
|
120
308
|
|
|
121
|
-
//
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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`);
|
|
309
|
+
// voice (optional)
|
|
310
|
+
if (body.voice !== undefined) {
|
|
311
|
+
if (typeof body.voice !== "object" || body.voice === null) {
|
|
312
|
+
errors.push("voice must be an object if provided");
|
|
313
|
+
} else {
|
|
314
|
+
const voice = body.voice as Record<string, unknown>;
|
|
315
|
+
if (voice.tts !== undefined && voice.tts !== null && typeof voice.tts !== "string") {
|
|
316
|
+
errors.push("voice.tts must be a string or null if provided");
|
|
149
317
|
}
|
|
150
|
-
|
|
151
|
-
|
|
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`);
|
|
318
|
+
if (voice.stt !== undefined && voice.stt !== null && typeof voice.stt !== "string") {
|
|
319
|
+
errors.push("voice.stt must be a string or null if provided");
|
|
155
320
|
}
|
|
156
321
|
}
|
|
157
322
|
}
|
|
158
323
|
|
|
159
|
-
//
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
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");
|
|
324
|
+
// channels (optional)
|
|
325
|
+
if (body.channels !== undefined) {
|
|
326
|
+
if (!Array.isArray(body.channels)) {
|
|
327
|
+
errors.push("channels must be an array if provided");
|
|
169
328
|
} else {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (!llmObj.model || typeof llmObj.model !== "string") {
|
|
175
|
-
errors.push("assignments.llm.model is required");
|
|
329
|
+
for (let i = 0; i < body.channels.length; i++) {
|
|
330
|
+
if (typeof body.channels[i] !== "string") {
|
|
331
|
+
errors.push(`channels[${i}] must be a string`);
|
|
332
|
+
}
|
|
176
333
|
}
|
|
177
334
|
}
|
|
335
|
+
}
|
|
178
336
|
|
|
179
|
-
|
|
180
|
-
|
|
337
|
+
// services (optional)
|
|
338
|
+
if (body.services !== undefined) {
|
|
339
|
+
if (typeof body.services !== "object" || body.services === null) {
|
|
340
|
+
errors.push("services must be an object if provided");
|
|
181
341
|
} else {
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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`);
|
|
342
|
+
const services = body.services as Record<string, unknown>;
|
|
343
|
+
for (const [key, val] of Object.entries(services)) {
|
|
344
|
+
if (typeof val !== "boolean") {
|
|
345
|
+
errors.push(`services.${key} must be a boolean`);
|
|
346
|
+
}
|
|
220
347
|
}
|
|
221
348
|
}
|
|
222
349
|
}
|
|
223
350
|
|
|
351
|
+
// connections
|
|
352
|
+
validateConnectionsArray(body.connections, errors);
|
|
353
|
+
|
|
354
|
+
// assignments
|
|
355
|
+
validateAssignmentsBlock(body.assignments, body.connections, errors);
|
|
356
|
+
|
|
224
357
|
return { valid: errors.length === 0, errors };
|
|
225
358
|
}
|
|
226
359
|
|
|
@@ -246,12 +379,7 @@ export function buildSecretsFromSetup(input: SetupInput): Record<string, string>
|
|
|
246
379
|
if (ownerEmail) updates.OWNER_EMAIL = ownerEmail;
|
|
247
380
|
|
|
248
381
|
// Resolve effective base URLs (Ollama in-stack override)
|
|
249
|
-
const effectiveConnections = input.connections.
|
|
250
|
-
if (input.ollamaEnabled && c.provider === "ollama") {
|
|
251
|
-
return { ...c, baseUrl: OLLAMA_INSTACK_URL };
|
|
252
|
-
}
|
|
253
|
-
return c;
|
|
254
|
-
});
|
|
382
|
+
const effectiveConnections = resolveOllamaUrls(input.connections, input.ollamaEnabled);
|
|
255
383
|
|
|
256
384
|
// Build connectionId -> envVarName map
|
|
257
385
|
const connEnvVarMap = buildConnectionEnvVarMap(effectiveConnections);
|
|
@@ -347,18 +475,14 @@ export async function performSetup(
|
|
|
347
475
|
const state = opts?.state ?? createState(input.adminToken);
|
|
348
476
|
|
|
349
477
|
// ── Resolve effective connections (Ollama in-stack override) ──────────
|
|
350
|
-
const effectiveConnections = input.connections.
|
|
351
|
-
if (input.ollamaEnabled && c.provider === "ollama") {
|
|
352
|
-
return { ...c, baseUrl: OLLAMA_INSTACK_URL };
|
|
353
|
-
}
|
|
354
|
-
return c;
|
|
355
|
-
});
|
|
478
|
+
const effectiveConnections = resolveOllamaUrls(input.connections, input.ollamaEnabled);
|
|
356
479
|
|
|
357
480
|
// ── Build connection env var map ─────────────────────────────────────
|
|
358
481
|
const connEnvVarMap = buildConnectionEnvVarMap(effectiveConnections);
|
|
359
482
|
|
|
360
483
|
// ── Build secrets.env updates ────────────────────────────────────────
|
|
361
|
-
|
|
484
|
+
// Pass already-resolved connections to avoid a second resolveOllamaUrls call
|
|
485
|
+
const updates = buildSecretsFromSetup({ ...input, connections: effectiveConnections });
|
|
362
486
|
|
|
363
487
|
// ── Persist secrets.env ──────────────────────────────────────────────
|
|
364
488
|
try {
|
|
@@ -384,15 +508,27 @@ export async function performSetup(
|
|
|
384
508
|
const embModel = input.assignments.embeddings.model;
|
|
385
509
|
const embDims = input.assignments.embeddings.embeddingDims || 0;
|
|
386
510
|
|
|
387
|
-
const llmConnection = effectiveConnections.find((c) => c.id === llmConnectionId)
|
|
388
|
-
|
|
511
|
+
const llmConnection = effectiveConnections.find((c) => c.id === llmConnectionId);
|
|
512
|
+
if (!llmConnection) {
|
|
513
|
+
return { ok: false, error: `LLM connection "${llmConnectionId}" not found in connections list` };
|
|
514
|
+
}
|
|
515
|
+
const embConnection = effectiveConnections.find((c) => c.id === embConnectionId);
|
|
516
|
+
if (!embConnection) {
|
|
517
|
+
return { ok: false, error: `Embeddings connection "${embConnectionId}" not found in connections list` };
|
|
518
|
+
}
|
|
389
519
|
|
|
390
520
|
const memoryModel = llmSmallModel || llmModel;
|
|
391
521
|
|
|
392
|
-
const llmEnvVar = connEnvVarMap.get(llmConnection.id)
|
|
522
|
+
const llmEnvVar = connEnvVarMap.get(llmConnection.id);
|
|
523
|
+
if (!llmEnvVar) {
|
|
524
|
+
return { ok: false, error: `No env var mapping found for LLM connection "${llmConnection.id}"` };
|
|
525
|
+
}
|
|
393
526
|
const llmApiKeyEnvRef = llmConnection.apiKey ? `env:${llmEnvVar}` : "not-needed";
|
|
394
527
|
|
|
395
|
-
const embEnvVar = connEnvVarMap.get(embConnection.id)
|
|
528
|
+
const embEnvVar = connEnvVarMap.get(embConnection.id);
|
|
529
|
+
if (!embEnvVar) {
|
|
530
|
+
return { ok: false, error: `No env var mapping found for embeddings connection "${embConnection.id}"` };
|
|
531
|
+
}
|
|
396
532
|
const embApiKeyEnvRef = embConnection.apiKey ? `env:${embEnvVar}` : "not-needed";
|
|
397
533
|
|
|
398
534
|
const embLookupKey = `${embConnection.provider}/${embModel}`;
|
|
@@ -418,14 +554,17 @@ export async function performSetup(
|
|
|
418
554
|
writeMemoryConfig(state.dataDir, omConfig);
|
|
419
555
|
|
|
420
556
|
// ── Write connection profiles ────────────────────────────────────────
|
|
421
|
-
const profilesInput = effectiveConnections.map((conn) =>
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
557
|
+
const profilesInput = effectiveConnections.map((conn) => {
|
|
558
|
+
const apiKeyEnvVar = connEnvVarMap.get(conn.id);
|
|
559
|
+
return {
|
|
560
|
+
id: conn.id,
|
|
561
|
+
name: conn.name,
|
|
562
|
+
provider: conn.provider,
|
|
563
|
+
baseUrl: conn.baseUrl,
|
|
564
|
+
hasApiKey: Boolean(conn.apiKey) && Boolean(apiKeyEnvVar),
|
|
565
|
+
apiKeyEnvVar: apiKeyEnvVar ?? "",
|
|
566
|
+
};
|
|
567
|
+
});
|
|
429
568
|
|
|
430
569
|
writeConnectionsDocument(state.configDir, {
|
|
431
570
|
profiles: profilesInput,
|
|
@@ -445,6 +584,43 @@ export async function performSetup(
|
|
|
445
584
|
ensureAdminOpenCodeConfig(assetProvider);
|
|
446
585
|
ensureMemoryDir();
|
|
447
586
|
|
|
587
|
+
// ── Write stack spec (openpalm.yaml) ─────────────────────────────────
|
|
588
|
+
const stackSpec: StackSpec = {
|
|
589
|
+
version: 3,
|
|
590
|
+
connections: effectiveConnections.map((c) => ({
|
|
591
|
+
id: c.id,
|
|
592
|
+
name: c.name,
|
|
593
|
+
provider: c.provider,
|
|
594
|
+
baseUrl: c.baseUrl,
|
|
595
|
+
})),
|
|
596
|
+
assignments: {
|
|
597
|
+
llm: input.assignments.llm,
|
|
598
|
+
embeddings: {
|
|
599
|
+
connectionId: input.assignments.embeddings.connectionId,
|
|
600
|
+
model: input.assignments.embeddings.model,
|
|
601
|
+
embeddingDims: resolvedDims,
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
ollamaEnabled: input.ollamaEnabled,
|
|
605
|
+
...(input.voice ? { voice: input.voice } : {}),
|
|
606
|
+
...(input.channels ? { channels: input.channels } : {}),
|
|
607
|
+
...(input.services ? { services: input.services } : {}),
|
|
608
|
+
};
|
|
609
|
+
writeStackSpec(state.configDir, stackSpec);
|
|
610
|
+
|
|
611
|
+
// ── Mark setup complete in DATA_HOME stack.env before staging ────────
|
|
612
|
+
const dataStackEnv = `${state.dataDir}/stack.env`;
|
|
613
|
+
mkdirSync(state.dataDir, { recursive: true });
|
|
614
|
+
const stackBase = existsSync(dataStackEnv) ? readFileSync(dataStackEnv, "utf-8") : "";
|
|
615
|
+
writeFileSync(
|
|
616
|
+
dataStackEnv,
|
|
617
|
+
mergeEnvContent(stackBase, {
|
|
618
|
+
OPENPALM_SETUP_COMPLETE: "true",
|
|
619
|
+
OPENPALM_OLLAMA_ENABLED: input.ollamaEnabled ? "true" : "false",
|
|
620
|
+
OPENPALM_ADMIN_ENABLED: input.services?.admin ? "true" : "false",
|
|
621
|
+
})
|
|
622
|
+
);
|
|
623
|
+
|
|
448
624
|
// ── Apply install (stages artifacts, no Docker) ──────────────────────
|
|
449
625
|
applyInstall(state, assetProvider);
|
|
450
626
|
|
|
@@ -472,3 +648,309 @@ export async function detectProviders(): Promise<DetectedProvider[]> {
|
|
|
472
648
|
available: r.available,
|
|
473
649
|
}));
|
|
474
650
|
}
|
|
651
|
+
|
|
652
|
+
// ── Channel Credential Env Var Mapping ───────────────────────────────────
|
|
653
|
+
|
|
654
|
+
/** Maps channel IDs to their credential field → env var name mappings. */
|
|
655
|
+
export const CHANNEL_CREDENTIAL_ENV_MAP: Record<string, Record<string, string>> = {
|
|
656
|
+
discord: {
|
|
657
|
+
botToken: "DISCORD_BOT_TOKEN",
|
|
658
|
+
applicationId: "DISCORD_APPLICATION_ID",
|
|
659
|
+
registerCommands: "DISCORD_REGISTER_COMMANDS",
|
|
660
|
+
allowedGuilds: "DISCORD_ALLOWED_GUILDS",
|
|
661
|
+
allowedRoles: "DISCORD_ALLOWED_ROLES",
|
|
662
|
+
allowedUsers: "DISCORD_ALLOWED_USERS",
|
|
663
|
+
blockedUsers: "DISCORD_BLOCKED_USERS",
|
|
664
|
+
},
|
|
665
|
+
slack: {
|
|
666
|
+
slackBotToken: "SLACK_BOT_TOKEN",
|
|
667
|
+
slackAppToken: "SLACK_APP_TOKEN",
|
|
668
|
+
allowedChannels: "SLACK_ALLOWED_CHANNELS",
|
|
669
|
+
allowedUsers: "SLACK_ALLOWED_USERS",
|
|
670
|
+
blockedUsers: "SLACK_BLOCKED_USERS",
|
|
671
|
+
},
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
// ── SetupConfig Validation ───────────────────────────────────────────────
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Validate a SetupConfig object. Returns { valid, errors } in the same
|
|
678
|
+
* shape as validateSetupInput().
|
|
679
|
+
*/
|
|
680
|
+
export function validateSetupConfig(config: unknown): { valid: boolean; errors: string[] } {
|
|
681
|
+
const errors: string[] = [];
|
|
682
|
+
|
|
683
|
+
if (typeof config !== "object" || config === null) {
|
|
684
|
+
return { valid: false, errors: ["Config must be a non-null object"] };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const body = config as Record<string, unknown>;
|
|
688
|
+
|
|
689
|
+
// version
|
|
690
|
+
if (body.version !== 1) {
|
|
691
|
+
errors.push("version must be 1");
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// owner (optional)
|
|
695
|
+
if (body.owner !== undefined) {
|
|
696
|
+
if (typeof body.owner !== "object" || body.owner === null) {
|
|
697
|
+
errors.push("owner must be an object if provided");
|
|
698
|
+
} else {
|
|
699
|
+
const owner = body.owner as Record<string, unknown>;
|
|
700
|
+
if (owner.name !== undefined && typeof owner.name !== "string") {
|
|
701
|
+
errors.push("owner.name must be a string if provided");
|
|
702
|
+
}
|
|
703
|
+
if (owner.email !== undefined && typeof owner.email !== "string") {
|
|
704
|
+
errors.push("owner.email must be a string if provided");
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// security
|
|
710
|
+
if (typeof body.security !== "object" || body.security === null) {
|
|
711
|
+
errors.push("security object is required");
|
|
712
|
+
} else {
|
|
713
|
+
const security = body.security as Record<string, unknown>;
|
|
714
|
+
if (typeof security.adminToken !== "string" || !security.adminToken) {
|
|
715
|
+
errors.push("security.adminToken is required and must be a non-empty string");
|
|
716
|
+
} else if (security.adminToken.length < 8) {
|
|
717
|
+
errors.push("security.adminToken must be at least 8 characters");
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// connections
|
|
722
|
+
validateConnectionsArray(body.connections, errors);
|
|
723
|
+
|
|
724
|
+
// assignments
|
|
725
|
+
validateAssignmentsBlock(body.assignments, body.connections, errors);
|
|
726
|
+
|
|
727
|
+
// channels (optional)
|
|
728
|
+
if (body.channels !== undefined) {
|
|
729
|
+
if (typeof body.channels !== "object" || body.channels === null) {
|
|
730
|
+
errors.push("channels must be an object if provided");
|
|
731
|
+
} else {
|
|
732
|
+
const channels = body.channels as Record<string, unknown>;
|
|
733
|
+
for (const [channelId, value] of Object.entries(channels)) {
|
|
734
|
+
if (typeof value === "boolean") continue;
|
|
735
|
+
if (typeof value !== "object" || value === null) {
|
|
736
|
+
errors.push(`channels.${channelId} must be a boolean or object`);
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
// Channel-specific credential validation
|
|
740
|
+
if (channelId === "discord") {
|
|
741
|
+
const creds = value as Record<string, unknown>;
|
|
742
|
+
if (creds.enabled !== false && !creds.botToken) {
|
|
743
|
+
errors.push("channels.discord.botToken is required when discord is enabled");
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (channelId === "slack") {
|
|
747
|
+
const creds = value as Record<string, unknown>;
|
|
748
|
+
if (creds.enabled !== false) {
|
|
749
|
+
if (!creds.slackBotToken) {
|
|
750
|
+
errors.push("channels.slack.slackBotToken is required when slack is enabled");
|
|
751
|
+
}
|
|
752
|
+
if (!creds.slackAppToken) {
|
|
753
|
+
errors.push("channels.slack.slackAppToken is required when slack is enabled");
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// services (optional)
|
|
762
|
+
if (body.services !== undefined) {
|
|
763
|
+
if (typeof body.services !== "object" || body.services === null) {
|
|
764
|
+
errors.push("services must be an object if provided");
|
|
765
|
+
} else {
|
|
766
|
+
const services = body.services as Record<string, unknown>;
|
|
767
|
+
for (const [key, val] of Object.entries(services)) {
|
|
768
|
+
if (typeof val === "boolean") continue;
|
|
769
|
+
if (typeof val !== "object" || val === null) {
|
|
770
|
+
errors.push(`services.${key} must be a boolean or object`);
|
|
771
|
+
} else if (typeof (val as Record<string, unknown>).enabled !== "boolean") {
|
|
772
|
+
errors.push(`services.${key}.enabled must be a boolean`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// memory (optional)
|
|
779
|
+
if (body.memory !== undefined) {
|
|
780
|
+
if (typeof body.memory !== "object" || body.memory === null) {
|
|
781
|
+
errors.push("memory must be an object if provided");
|
|
782
|
+
} else {
|
|
783
|
+
const memory = body.memory as Record<string, unknown>;
|
|
784
|
+
if (memory.userId !== undefined && typeof memory.userId !== "string") {
|
|
785
|
+
errors.push("memory.userId must be a string if provided");
|
|
786
|
+
}
|
|
787
|
+
if (typeof memory.userId === "string" && !/^[A-Za-z0-9_]+$/.test(memory.userId)) {
|
|
788
|
+
errors.push("memoryUserId contains invalid characters (alphanumeric and underscores only)");
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return { valid: errors.length === 0, errors };
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ── Normalization ────────────────────────────────────────────────────────
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Convert a structured SetupConfig to the flat SetupInput format so that
|
|
800
|
+
* the existing performSetup() pipeline works unchanged.
|
|
801
|
+
*/
|
|
802
|
+
export function normalizeToSetupInput(config: SetupConfig): SetupInput {
|
|
803
|
+
// Resolve TTS/STT voice fields
|
|
804
|
+
let tts: string | undefined;
|
|
805
|
+
let stt: string | undefined;
|
|
806
|
+
|
|
807
|
+
if (config.assignments.tts !== undefined && config.assignments.tts !== null) {
|
|
808
|
+
// SetupInput.voice only carries engine strings; connectionId and model
|
|
809
|
+
// from the object form are dropped during normalization. To preserve
|
|
810
|
+
// them, the caller should write the original SetupConfig to
|
|
811
|
+
// openpalm.yaml separately.
|
|
812
|
+
tts = typeof config.assignments.tts === "string"
|
|
813
|
+
? config.assignments.tts
|
|
814
|
+
: config.assignments.tts.engine;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (config.assignments.stt !== undefined && config.assignments.stt !== null) {
|
|
818
|
+
// Same as tts above — connectionId and model are dropped during
|
|
819
|
+
// normalization and not written to the stack spec by performSetup().
|
|
820
|
+
stt = typeof config.assignments.stt === "string"
|
|
821
|
+
? config.assignments.stt
|
|
822
|
+
: config.assignments.stt.engine;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Extract enabled channels
|
|
826
|
+
const enabledChannels: string[] = [];
|
|
827
|
+
if (config.channels) {
|
|
828
|
+
for (const [id, value] of Object.entries(config.channels)) {
|
|
829
|
+
if (value === true) {
|
|
830
|
+
enabledChannels.push(id);
|
|
831
|
+
} else if (typeof value === "object" && value !== null) {
|
|
832
|
+
const creds = value as ChannelCredentials;
|
|
833
|
+
if (creds.enabled !== false) {
|
|
834
|
+
enabledChannels.push(id);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Extract enabled services
|
|
841
|
+
const enabledServices: Record<string, boolean> = {};
|
|
842
|
+
if (config.services) {
|
|
843
|
+
for (const [id, value] of Object.entries(config.services)) {
|
|
844
|
+
if (typeof value === "boolean") {
|
|
845
|
+
enabledServices[id] = value;
|
|
846
|
+
} else if (typeof value === "object" && value !== null) {
|
|
847
|
+
enabledServices[id] = (value as ServiceConfig).enabled;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Determine ollamaEnabled from services
|
|
853
|
+
const ollamaEnabled = enabledServices.ollama ?? false;
|
|
854
|
+
|
|
855
|
+
return {
|
|
856
|
+
adminToken: config.security.adminToken,
|
|
857
|
+
ownerName: config.owner?.name,
|
|
858
|
+
ownerEmail: config.owner?.email,
|
|
859
|
+
memoryUserId: config.memory?.userId || "default_user",
|
|
860
|
+
ollamaEnabled,
|
|
861
|
+
connections: config.connections,
|
|
862
|
+
assignments: {
|
|
863
|
+
llm: config.assignments.llm,
|
|
864
|
+
embeddings: config.assignments.embeddings,
|
|
865
|
+
},
|
|
866
|
+
...(tts !== undefined || stt !== undefined ? { voice: { tts, stt } } : {}),
|
|
867
|
+
...(enabledChannels.length > 0 ? { channels: enabledChannels } : {}),
|
|
868
|
+
...(Object.keys(enabledServices).length > 0 ? { services: enabledServices as SetupInput["services"] } : {}),
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ── Channel Credential Env Var Builder ───────────────────────────────────
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Extract credential fields from typed channel configs using
|
|
876
|
+
* CHANNEL_CREDENTIAL_ENV_MAP and return a Record<string, string> suitable
|
|
877
|
+
* for writing to secrets.env.
|
|
878
|
+
*
|
|
879
|
+
* Unknown channels (not in CHANNEL_CREDENTIAL_ENV_MAP) are skipped.
|
|
880
|
+
* Boolean values are converted to strings.
|
|
881
|
+
*/
|
|
882
|
+
export function buildChannelCredentialEnvVars(
|
|
883
|
+
channels: Record<string, boolean | ChannelCredentials> | undefined
|
|
884
|
+
): Record<string, string> {
|
|
885
|
+
const envVars: Record<string, string> = {};
|
|
886
|
+
if (!channels) return envVars;
|
|
887
|
+
|
|
888
|
+
for (const [channelId, value] of Object.entries(channels)) {
|
|
889
|
+
if (typeof value === "boolean") continue;
|
|
890
|
+
if (typeof value !== "object" || value === null) continue;
|
|
891
|
+
|
|
892
|
+
const mapping = CHANNEL_CREDENTIAL_ENV_MAP[channelId];
|
|
893
|
+
if (!mapping) continue;
|
|
894
|
+
|
|
895
|
+
const creds = value as ChannelCredentials;
|
|
896
|
+
for (const [field, envKey] of Object.entries(mapping)) {
|
|
897
|
+
const fieldValue = creds[field];
|
|
898
|
+
if (fieldValue === undefined || fieldValue === null) continue;
|
|
899
|
+
if (typeof fieldValue === "boolean") {
|
|
900
|
+
envVars[envKey] = String(fieldValue);
|
|
901
|
+
} else if (typeof fieldValue === "string" && fieldValue) {
|
|
902
|
+
envVars[envKey] = fieldValue;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
return envVars;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// ── Structured Setup Orchestration ───────────────────────────────────────
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Setup from a structured SetupConfig:
|
|
914
|
+
* 1. Validate the config
|
|
915
|
+
* 2. Write channel credential env vars to CONFIG_HOME/secrets.env
|
|
916
|
+
* 3. Normalize to SetupInput and call performSetup()
|
|
917
|
+
*
|
|
918
|
+
* Channel credentials are written BEFORE performSetup() so that when
|
|
919
|
+
* performSetup() stages secrets.env from CONFIG_HOME to STATE_HOME
|
|
920
|
+
* (via applyInstall), the staged copy already contains channel creds.
|
|
921
|
+
*/
|
|
922
|
+
export async function performSetupFromConfig(
|
|
923
|
+
config: SetupConfig,
|
|
924
|
+
assetProvider: CoreAssetProvider,
|
|
925
|
+
opts?: { state?: ControlPlaneState }
|
|
926
|
+
): Promise<SetupResult> {
|
|
927
|
+
// ── Validate ─────────────────────────────────────────────────────────
|
|
928
|
+
const validation = validateSetupConfig(config);
|
|
929
|
+
if (!validation.valid) {
|
|
930
|
+
return { ok: false, error: validation.errors.join("; ") };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// ── Write channel credentials to CONFIG_HOME/secrets.env FIRST ─────
|
|
934
|
+
// This must happen before performSetup() which stages secrets.env
|
|
935
|
+
// from CONFIG_HOME to STATE_HOME via applyInstall().
|
|
936
|
+
const input = normalizeToSetupInput(config);
|
|
937
|
+
const state = opts?.state ?? createState(config.security.adminToken);
|
|
938
|
+
const channelEnvVars = buildChannelCredentialEnvVars(config.channels);
|
|
939
|
+
if (Object.keys(channelEnvVars).length > 0) {
|
|
940
|
+
try {
|
|
941
|
+
ensureXdgDirs();
|
|
942
|
+
ensureSecrets(state);
|
|
943
|
+
updateSecretsEnv(state, channelEnvVars);
|
|
944
|
+
} catch (err) {
|
|
945
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
946
|
+
logger.error("failed to write channel credentials to secrets.env", { error: message });
|
|
947
|
+
return { ok: false, error: `Failed to write channel credentials: ${message}` };
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// ── Normalize and delegate to performSetup ─────────────────────────
|
|
952
|
+
// performSetup() writes its own secrets (admin token, API keys, etc.)
|
|
953
|
+
// and then stages the now-complete secrets.env to STATE_HOME.
|
|
954
|
+
const result = await performSetup(input, assetProvider, { ...opts, state });
|
|
955
|
+
return result;
|
|
956
|
+
}
|