@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.
- package/README.md +83 -0
- package/package.json +30 -0
- package/src/control-plane/audit.ts +40 -0
- package/src/control-plane/channels.ts +196 -0
- package/src/control-plane/connection-mapping.ts +191 -0
- package/src/control-plane/connection-migration-flags.ts +40 -0
- package/src/control-plane/connection-profiles.ts +317 -0
- package/src/control-plane/core-asset-provider.ts +20 -0
- package/src/control-plane/core-assets.ts +292 -0
- package/src/control-plane/docker.ts +448 -0
- package/src/control-plane/env.ts +70 -0
- package/src/control-plane/fs-asset-provider.ts +61 -0
- package/src/control-plane/fs-registry-provider.ts +46 -0
- package/src/control-plane/lifecycle.ts +373 -0
- package/src/control-plane/memory-config.ts +424 -0
- package/src/control-plane/model-runner.ts +101 -0
- package/src/control-plane/paths.ts +77 -0
- package/src/control-plane/registry-provider.ts +19 -0
- package/src/control-plane/scheduler.ts +498 -0
- package/src/control-plane/secrets.ts +177 -0
- package/src/control-plane/setup-status.ts +31 -0
- package/src/control-plane/setup.test.ts +476 -0
- package/src/control-plane/setup.ts +474 -0
- package/src/control-plane/staging.ts +376 -0
- package/src/control-plane/types.ts +165 -0
- package/src/index.ts +295 -0
- package/src/logger.ts +14 -0
- package/src/provider-constants.ts +106 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { PROVIDER_KEY_MAP } from '../provider-constants.js';
|
|
3
|
+
import type {
|
|
4
|
+
CapabilityAssignments,
|
|
5
|
+
CanonicalConnectionProfile,
|
|
6
|
+
CanonicalConnectionsDocument,
|
|
7
|
+
ConnectionKind,
|
|
8
|
+
} from './types.js';
|
|
9
|
+
|
|
10
|
+
const CONNECTIONS_DIRNAME = 'connections';
|
|
11
|
+
const CONNECTION_PROFILES_FILENAME = 'profiles.json';
|
|
12
|
+
|
|
13
|
+
const LOCAL_PROVIDERS = new Set(['ollama', 'lmstudio', 'model-runner']);
|
|
14
|
+
const INSTACK_PROVIDERS = new Set(['ollama-instack']);
|
|
15
|
+
|
|
16
|
+
function normalizeConnectionKind(provider: string): ConnectionKind {
|
|
17
|
+
if (INSTACK_PROVIDERS.has(provider)) return 'ollama_local';
|
|
18
|
+
if (LOCAL_PROVIDERS.has(provider)) return 'openai_compatible_local';
|
|
19
|
+
return 'openai_compatible_remote';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getConnectionProfilesDir(configDir: string): string {
|
|
23
|
+
return `${configDir}/${CONNECTIONS_DIRNAME}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getConnectionProfilesPath(configDir: string): string {
|
|
27
|
+
return `${getConnectionProfilesDir(configDir)}/${CONNECTION_PROFILES_FILENAME}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Validation helpers ──────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
33
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
37
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isValidProfile(value: unknown): value is CanonicalConnectionProfile {
|
|
41
|
+
if (!isRecord(value)) return false;
|
|
42
|
+
if (!isNonEmptyString(value.id)) return false;
|
|
43
|
+
if (!isNonEmptyString(value.name)) return false;
|
|
44
|
+
if (value.kind !== 'openai_compatible_remote' && value.kind !== 'openai_compatible_local' && value.kind !== 'ollama_local') return false;
|
|
45
|
+
if (!isNonEmptyString(value.provider)) return false;
|
|
46
|
+
if (typeof value.baseUrl !== 'string') return false;
|
|
47
|
+
if (!isRecord(value.auth)) return false;
|
|
48
|
+
if (value.auth.mode !== 'api_key' && value.auth.mode !== 'none') return false;
|
|
49
|
+
if (value.auth.mode === 'api_key' && !isNonEmptyString(value.auth.apiKeySecretRef)) return false;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isValidConnectionDocument(value: unknown): value is CanonicalConnectionsDocument {
|
|
54
|
+
if (!isRecord(value)) return false;
|
|
55
|
+
if (value.version !== 1) return false;
|
|
56
|
+
if (!Array.isArray(value.profiles) || value.profiles.length === 0) return false;
|
|
57
|
+
if (!isRecord(value.assignments)) return false;
|
|
58
|
+
|
|
59
|
+
if (!value.profiles.every(isValidProfile)) return false;
|
|
60
|
+
|
|
61
|
+
const llm = value.assignments.llm;
|
|
62
|
+
const embeddings = value.assignments.embeddings;
|
|
63
|
+
if (!isRecord(llm) || !isRecord(embeddings)) return false;
|
|
64
|
+
if (!isNonEmptyString(llm.connectionId) || !isNonEmptyString(llm.model)) return false;
|
|
65
|
+
if (!isNonEmptyString(embeddings.connectionId) || !isNonEmptyString(embeddings.model)) return false;
|
|
66
|
+
const embeddingDims = embeddings.embeddingDims;
|
|
67
|
+
if (
|
|
68
|
+
embeddingDims !== undefined
|
|
69
|
+
&& (typeof embeddingDims !== 'number' || !Number.isInteger(embeddingDims) || embeddingDims <= 0)
|
|
70
|
+
) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Read / Write ────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
export function writeConnectionProfilesDocument(
|
|
80
|
+
configDir: string,
|
|
81
|
+
document: CanonicalConnectionsDocument
|
|
82
|
+
): void {
|
|
83
|
+
const dir = getConnectionProfilesDir(configDir);
|
|
84
|
+
mkdirSync(dir, { recursive: true });
|
|
85
|
+
writeFileSync(
|
|
86
|
+
getConnectionProfilesPath(configDir),
|
|
87
|
+
JSON.stringify(document, null, 2) + '\n'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function readConnectionProfilesDocument(configDir: string): CanonicalConnectionsDocument {
|
|
92
|
+
const path = getConnectionProfilesPath(configDir);
|
|
93
|
+
if (!existsSync(path)) {
|
|
94
|
+
return {
|
|
95
|
+
version: 1,
|
|
96
|
+
profiles: [],
|
|
97
|
+
assignments: {
|
|
98
|
+
llm: { connectionId: '', model: '' },
|
|
99
|
+
embeddings: { connectionId: '', model: '' },
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let parsed: unknown;
|
|
105
|
+
try {
|
|
106
|
+
parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown;
|
|
107
|
+
} catch {
|
|
108
|
+
throw new Error('connections/profiles.json is invalid JSON');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!isValidConnectionDocument(parsed)) {
|
|
112
|
+
throw new Error('connections/profiles.json is invalid: expected CanonicalConnectionsDocument v1');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return parsed;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function ensureConnectionProfilesStore(configDir: string): void {
|
|
119
|
+
mkdirSync(getConnectionProfilesDir(configDir), { recursive: true });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Multi-connection write ──────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
export type WriteConnectionsInput = {
|
|
125
|
+
profiles: Array<{
|
|
126
|
+
id: string;
|
|
127
|
+
name: string;
|
|
128
|
+
provider: string;
|
|
129
|
+
baseUrl: string;
|
|
130
|
+
hasApiKey: boolean;
|
|
131
|
+
apiKeyEnvVar: string;
|
|
132
|
+
}>;
|
|
133
|
+
assignments: CapabilityAssignments;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export function writeConnectionsDocument(
|
|
137
|
+
configDir: string,
|
|
138
|
+
input: WriteConnectionsInput
|
|
139
|
+
): CanonicalConnectionsDocument {
|
|
140
|
+
if (input.profiles.length === 0) {
|
|
141
|
+
throw new Error('writeConnectionsDocument: profiles must not be empty');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const profileIds = new Set(input.profiles.map((p) => p.id));
|
|
145
|
+
if (!profileIds.has(input.assignments.llm.connectionId)) {
|
|
146
|
+
throw new Error(`writeConnectionsDocument: llm.connectionId "${input.assignments.llm.connectionId}" not found in profiles`);
|
|
147
|
+
}
|
|
148
|
+
if (!profileIds.has(input.assignments.embeddings.connectionId)) {
|
|
149
|
+
throw new Error(`writeConnectionsDocument: embeddings.connectionId "${input.assignments.embeddings.connectionId}" not found in profiles`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const document: CanonicalConnectionsDocument = {
|
|
153
|
+
version: 1,
|
|
154
|
+
profiles: input.profiles.map((p) => ({
|
|
155
|
+
id: p.id,
|
|
156
|
+
name: p.name,
|
|
157
|
+
kind: normalizeConnectionKind(p.provider),
|
|
158
|
+
provider: p.provider,
|
|
159
|
+
baseUrl: p.baseUrl,
|
|
160
|
+
auth: {
|
|
161
|
+
mode: p.hasApiKey ? 'api_key' as const : 'none' as const,
|
|
162
|
+
...(p.hasApiKey ? { apiKeySecretRef: `env:${p.apiKeyEnvVar}` } : {}),
|
|
163
|
+
},
|
|
164
|
+
})),
|
|
165
|
+
assignments: input.assignments,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
writeConnectionProfilesDocument(configDir, document);
|
|
169
|
+
return document;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── CRUD operations ─────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
type MutationResult<T> =
|
|
175
|
+
| { ok: true; value: T }
|
|
176
|
+
| { ok: false; status: 400 | 404 | 409; message: string };
|
|
177
|
+
|
|
178
|
+
function validateProfile(profile: CanonicalConnectionProfile): MutationResult<CanonicalConnectionProfile> {
|
|
179
|
+
if (!profile.id.trim()) {
|
|
180
|
+
return { ok: false, status: 400, message: 'profile.id is required' };
|
|
181
|
+
}
|
|
182
|
+
if (!profile.name.trim()) {
|
|
183
|
+
return { ok: false, status: 400, message: 'profile.name is required' };
|
|
184
|
+
}
|
|
185
|
+
if (!profile.provider.trim()) {
|
|
186
|
+
return { ok: false, status: 400, message: 'profile.provider is required' };
|
|
187
|
+
}
|
|
188
|
+
if (profile.auth.mode !== 'api_key' && profile.auth.mode !== 'none') {
|
|
189
|
+
return { ok: false, status: 400, message: 'profile.auth.mode must be api_key or none' };
|
|
190
|
+
}
|
|
191
|
+
if (profile.auth.mode === 'api_key' && !profile.auth.apiKeySecretRef?.trim()) {
|
|
192
|
+
return { ok: false, status: 400, message: 'profile.auth.apiKeySecretRef is required when auth.mode is api_key' };
|
|
193
|
+
}
|
|
194
|
+
return { ok: true, value: profile };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function validateAssignments(
|
|
198
|
+
assignments: CapabilityAssignments,
|
|
199
|
+
profileIds: Set<string>
|
|
200
|
+
): MutationResult<CapabilityAssignments> {
|
|
201
|
+
if (!isRecord(assignments.llm)) {
|
|
202
|
+
return { ok: false, status: 400, message: 'assignments.llm must be an object' };
|
|
203
|
+
}
|
|
204
|
+
if (!isRecord(assignments.embeddings)) {
|
|
205
|
+
return { ok: false, status: 400, message: 'assignments.embeddings must be an object' };
|
|
206
|
+
}
|
|
207
|
+
if (!isNonEmptyString(assignments.llm.connectionId) || !isNonEmptyString(assignments.llm.model)) {
|
|
208
|
+
return { ok: false, status: 400, message: 'assignments.llm requires connectionId and model' };
|
|
209
|
+
}
|
|
210
|
+
if (!isNonEmptyString(assignments.embeddings.connectionId) || !isNonEmptyString(assignments.embeddings.model)) {
|
|
211
|
+
return { ok: false, status: 400, message: 'assignments.embeddings requires connectionId and model' };
|
|
212
|
+
}
|
|
213
|
+
if (!profileIds.has(assignments.llm.connectionId)) {
|
|
214
|
+
return { ok: false, status: 409, message: `assignments.llm.connectionId not found: ${assignments.llm.connectionId}` };
|
|
215
|
+
}
|
|
216
|
+
if (!profileIds.has(assignments.embeddings.connectionId)) {
|
|
217
|
+
return { ok: false, status: 409, message: `assignments.embeddings.connectionId not found: ${assignments.embeddings.connectionId}` };
|
|
218
|
+
}
|
|
219
|
+
if (
|
|
220
|
+
assignments.embeddings.embeddingDims !== undefined
|
|
221
|
+
&& (!Number.isInteger(assignments.embeddings.embeddingDims) || assignments.embeddings.embeddingDims <= 0)
|
|
222
|
+
) {
|
|
223
|
+
return { ok: false, status: 400, message: 'assignments.embeddings.embeddingDims must be a positive integer' };
|
|
224
|
+
}
|
|
225
|
+
return { ok: true, value: assignments };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function listConnectionProfiles(configDir: string): CanonicalConnectionProfile[] {
|
|
229
|
+
return readConnectionProfilesDocument(configDir).profiles;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function getCapabilityAssignments(configDir: string): CapabilityAssignments {
|
|
233
|
+
return readConnectionProfilesDocument(configDir).assignments;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function createConnectionProfile(
|
|
237
|
+
configDir: string,
|
|
238
|
+
profile: CanonicalConnectionProfile
|
|
239
|
+
): MutationResult<CanonicalConnectionProfile> {
|
|
240
|
+
const validated = validateProfile(profile);
|
|
241
|
+
if (!validated.ok) return validated;
|
|
242
|
+
|
|
243
|
+
const document = readConnectionProfilesDocument(configDir);
|
|
244
|
+
if (document.profiles.some((existing) => existing.id === profile.id)) {
|
|
245
|
+
return { ok: false, status: 409, message: `profile already exists: ${profile.id}` };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const updated: CanonicalConnectionsDocument = {
|
|
249
|
+
...document,
|
|
250
|
+
profiles: [...document.profiles, profile],
|
|
251
|
+
};
|
|
252
|
+
writeConnectionProfilesDocument(configDir, updated);
|
|
253
|
+
return { ok: true, value: profile };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function updateConnectionProfile(
|
|
257
|
+
configDir: string,
|
|
258
|
+
profile: CanonicalConnectionProfile
|
|
259
|
+
): MutationResult<CanonicalConnectionProfile> {
|
|
260
|
+
const validated = validateProfile(profile);
|
|
261
|
+
if (!validated.ok) return validated;
|
|
262
|
+
|
|
263
|
+
const document = readConnectionProfilesDocument(configDir);
|
|
264
|
+
const index = document.profiles.findIndex((existing) => existing.id === profile.id);
|
|
265
|
+
if (index < 0) {
|
|
266
|
+
return { ok: false, status: 404, message: `profile not found: ${profile.id}` };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const profiles = [...document.profiles];
|
|
270
|
+
profiles[index] = profile;
|
|
271
|
+
writeConnectionProfilesDocument(configDir, { ...document, profiles });
|
|
272
|
+
return { ok: true, value: profile };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function deleteConnectionProfile(
|
|
276
|
+
configDir: string,
|
|
277
|
+
id: string
|
|
278
|
+
): MutationResult<{ id: string }> {
|
|
279
|
+
if (!id.trim()) {
|
|
280
|
+
return { ok: false, status: 400, message: 'profile id is required' };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const document = readConnectionProfilesDocument(configDir);
|
|
284
|
+
const existing = document.profiles.find((profile) => profile.id === id);
|
|
285
|
+
if (!existing) {
|
|
286
|
+
return { ok: false, status: 404, message: `profile not found: ${id}` };
|
|
287
|
+
}
|
|
288
|
+
const assignmentFields = ['llm', 'embeddings', 'reranking', 'tts', 'stt'] as const;
|
|
289
|
+
for (const field of assignmentFields) {
|
|
290
|
+
const assignment = document.assignments[field];
|
|
291
|
+
if (assignment && 'connectionId' in assignment && assignment.connectionId === id) {
|
|
292
|
+
return { ok: false, status: 409, message: `Cannot delete profile: it is assigned to ${field}` };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
writeConnectionProfilesDocument(configDir, {
|
|
297
|
+
...document,
|
|
298
|
+
profiles: document.profiles.filter((profile) => profile.id !== id),
|
|
299
|
+
});
|
|
300
|
+
return { ok: true, value: { id } };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function saveCapabilityAssignments(
|
|
304
|
+
configDir: string,
|
|
305
|
+
assignments: CapabilityAssignments
|
|
306
|
+
): MutationResult<CapabilityAssignments> {
|
|
307
|
+
const document = readConnectionProfilesDocument(configDir);
|
|
308
|
+
const profileIds = new Set(document.profiles.map((profile) => profile.id));
|
|
309
|
+
const validated = validateAssignments(assignments, profileIds);
|
|
310
|
+
if (!validated.ok) return validated;
|
|
311
|
+
|
|
312
|
+
writeConnectionProfilesDocument(configDir, {
|
|
313
|
+
...document,
|
|
314
|
+
assignments,
|
|
315
|
+
});
|
|
316
|
+
return { ok: true, value: assignments };
|
|
317
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CoreAssetProvider interface — dependency injection for bundled assets.
|
|
3
|
+
*
|
|
4
|
+
* Admin implements this with Vite $assets imports (ViteAssetProvider).
|
|
5
|
+
* CLI/lib implements this by reading from DATA_HOME (FilesystemAssetProvider).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface CoreAssetProvider {
|
|
9
|
+
coreCompose(): string;
|
|
10
|
+
caddyfile(): string;
|
|
11
|
+
ollamaCompose(): string;
|
|
12
|
+
agentsMd(): string;
|
|
13
|
+
opencodeConfig(): string;
|
|
14
|
+
adminOpencodeConfig(): string;
|
|
15
|
+
secretsSchema(): string;
|
|
16
|
+
stackSchema(): string;
|
|
17
|
+
cleanupLogs(): string;
|
|
18
|
+
cleanupData(): string;
|
|
19
|
+
validateConfig(): string;
|
|
20
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core asset management for the OpenPalm control plane.
|
|
3
|
+
*
|
|
4
|
+
* Manages DATA_HOME source-of-truth files: Caddyfile and docker-compose.yml.
|
|
5
|
+
* All asset content is provided by a CoreAssetProvider (injected), not by
|
|
6
|
+
* Vite $assets imports — making this module portable across Bun/Node/Vite.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync, renameSync } from "node:fs";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { resolveDataHome } from "./paths.js";
|
|
12
|
+
import { createLogger } from "../logger.js";
|
|
13
|
+
import type { CoreAssetProvider } from "./core-asset-provider.js";
|
|
14
|
+
|
|
15
|
+
const logger = createLogger("core-assets");
|
|
16
|
+
|
|
17
|
+
// ── Constants ──────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const PUBLIC_ACCESS_IMPORT = "import public_access";
|
|
20
|
+
const LAN_ONLY_IMPORT = "import lan_only";
|
|
21
|
+
|
|
22
|
+
/** IP ranges for each access scope mode */
|
|
23
|
+
const HOST_ONLY_IPS = "127.0.0.0/8 ::1";
|
|
24
|
+
const LAN_IPS = "10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 127.0.0.0/8 ::1 fc00::/7 fe80::/10";
|
|
25
|
+
const REMOTE_IP_LINE_RE = /@denied not remote_ip [^\n]+/;
|
|
26
|
+
|
|
27
|
+
// Re-export for use by staging.ts Caddyfile staging
|
|
28
|
+
export { PUBLIC_ACCESS_IMPORT, LAN_ONLY_IMPORT };
|
|
29
|
+
|
|
30
|
+
/** SHA-256 hex digest of a string. */
|
|
31
|
+
function sha256(content: string): string {
|
|
32
|
+
return createHash("sha256").update(content).digest("hex");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Write content to a file if it has changed, backing up the old version.
|
|
37
|
+
*/
|
|
38
|
+
function writeIfChanged(path: string, content: string): void {
|
|
39
|
+
if (!existsSync(path)) {
|
|
40
|
+
writeFileSync(path, content);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const existing = readFileSync(path, "utf-8");
|
|
44
|
+
if (sha256(existing) === sha256(content)) return;
|
|
45
|
+
|
|
46
|
+
const backupDir = join(dirname(path), "backups");
|
|
47
|
+
mkdirSync(backupDir, { recursive: true });
|
|
48
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
49
|
+
const basename = path.split("/").pop()!;
|
|
50
|
+
copyFileSync(path, join(backupDir, `${basename}.${ts}`));
|
|
51
|
+
writeFileSync(path, content);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Core Caddyfile (DATA_HOME source of truth) ─────────────────────────
|
|
55
|
+
|
|
56
|
+
function coreCaddyfilePath(): string {
|
|
57
|
+
return `${resolveDataHome()}/caddy/Caddyfile`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Ensure the system-managed core Caddyfile exists in DATA_HOME.
|
|
62
|
+
* Seeds the bundled asset on first run. On subsequent runs, leaves the
|
|
63
|
+
* existing file intact (user may have customized access scope).
|
|
64
|
+
*/
|
|
65
|
+
export function ensureCoreCaddyfile(assets: CoreAssetProvider): string {
|
|
66
|
+
const path = coreCaddyfilePath();
|
|
67
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
68
|
+
if (!existsSync(path)) {
|
|
69
|
+
writeFileSync(path, assets.caddyfile());
|
|
70
|
+
}
|
|
71
|
+
return path;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function readCoreCaddyfile(assets: CoreAssetProvider): string {
|
|
75
|
+
const path = ensureCoreCaddyfile(assets);
|
|
76
|
+
return readFileSync(path, "utf-8");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Env Schema Files (DATA_HOME root) ────────────────────────────────
|
|
80
|
+
|
|
81
|
+
export function ensureSecretsSchema(assets: CoreAssetProvider): string {
|
|
82
|
+
const path = `${resolveDataHome()}/secrets.env.schema`;
|
|
83
|
+
if (!existsSync(path)) {
|
|
84
|
+
writeFileSync(path, assets.secretsSchema());
|
|
85
|
+
}
|
|
86
|
+
return path;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function ensureStackSchema(assets: CoreAssetProvider): string {
|
|
90
|
+
const path = `${resolveDataHome()}/stack.env.schema`;
|
|
91
|
+
if (!existsSync(path)) {
|
|
92
|
+
writeFileSync(path, assets.stackSchema());
|
|
93
|
+
}
|
|
94
|
+
return path;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function detectAccessScope(rawCaddyfile: string): "host" | "lan" | "custom" {
|
|
98
|
+
const match = rawCaddyfile.match(REMOTE_IP_LINE_RE);
|
|
99
|
+
if (!match) return "custom";
|
|
100
|
+
const ips = match[0].replace("@denied not remote_ip", "").trim();
|
|
101
|
+
if (ips === HOST_ONLY_IPS) return "host";
|
|
102
|
+
if (ips === LAN_IPS) return "lan";
|
|
103
|
+
return "custom";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function setCoreCaddyAccessScope(
|
|
107
|
+
scope: "host" | "lan",
|
|
108
|
+
assets: CoreAssetProvider
|
|
109
|
+
): { ok: true } | { ok: false; error: string } {
|
|
110
|
+
const path = ensureCoreCaddyfile(assets);
|
|
111
|
+
const raw = readFileSync(path, "utf-8");
|
|
112
|
+
if (!REMOTE_IP_LINE_RE.test(raw)) {
|
|
113
|
+
return { ok: false, error: "core Caddyfile missing '@denied not remote_ip' line" };
|
|
114
|
+
}
|
|
115
|
+
const ips = scope === "host" ? HOST_ONLY_IPS : LAN_IPS;
|
|
116
|
+
const updated = raw.replace(REMOTE_IP_LINE_RE, `@denied not remote_ip ${ips}`);
|
|
117
|
+
writeFileSync(path, updated);
|
|
118
|
+
return { ok: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Memory data directory (DATA_HOME) ────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
export function ensureMemoryDir(): string {
|
|
124
|
+
const dataHome = resolveDataHome();
|
|
125
|
+
const dir = `${dataHome}/memory`;
|
|
126
|
+
const legacyDir = `${dataHome}/openmemory`;
|
|
127
|
+
|
|
128
|
+
if (!existsSync(dir) && existsSync(legacyDir)) {
|
|
129
|
+
try {
|
|
130
|
+
renameSync(legacyDir, dir);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const code = error instanceof Error && "code" in error ? String(error.code) : "unknown";
|
|
133
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
134
|
+
logger.warn("failed to migrate legacy memory dir", { legacyDir, dir, code, message });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
mkdirSync(dir, { recursive: true });
|
|
139
|
+
return dir;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Core Compose (DATA_HOME source of truth) ──────────────────────────
|
|
143
|
+
|
|
144
|
+
function coreComposePath(): string {
|
|
145
|
+
return `${resolveDataHome()}/docker-compose.yml`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function ensureCoreCompose(assets: CoreAssetProvider): string {
|
|
149
|
+
const path = coreComposePath();
|
|
150
|
+
const content = assets.coreCompose();
|
|
151
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
152
|
+
if (!existsSync(path)) {
|
|
153
|
+
writeFileSync(path, content);
|
|
154
|
+
} else if (sha256(readFileSync(path, "utf-8")) !== sha256(content)) {
|
|
155
|
+
const backupDir = join(dirname(path), "backups");
|
|
156
|
+
mkdirSync(backupDir, { recursive: true });
|
|
157
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
158
|
+
copyFileSync(path, join(backupDir, `docker-compose.${ts}.yml`));
|
|
159
|
+
writeFileSync(path, content);
|
|
160
|
+
}
|
|
161
|
+
return path;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function readCoreCompose(assets: CoreAssetProvider): string {
|
|
165
|
+
const path = ensureCoreCompose(assets);
|
|
166
|
+
return readFileSync(path, "utf-8");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Ollama Compose Overlay (DATA_HOME source of truth) ──────────────
|
|
170
|
+
|
|
171
|
+
function ollamaComposePath(): string {
|
|
172
|
+
return `${resolveDataHome()}/ollama.yml`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function ensureOllamaCompose(assets: CoreAssetProvider): string {
|
|
176
|
+
const path = ollamaComposePath();
|
|
177
|
+
const content = assets.ollamaCompose();
|
|
178
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
179
|
+
if (!existsSync(path)) {
|
|
180
|
+
writeFileSync(path, content);
|
|
181
|
+
} else if (sha256(readFileSync(path, "utf-8")) !== sha256(content)) {
|
|
182
|
+
const backupDir = join(dirname(path), "backups");
|
|
183
|
+
mkdirSync(backupDir, { recursive: true });
|
|
184
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
185
|
+
copyFileSync(path, join(backupDir, `ollama.${ts}.yml`));
|
|
186
|
+
writeFileSync(path, content);
|
|
187
|
+
}
|
|
188
|
+
return path;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function readOllamaCompose(assets: CoreAssetProvider): string {
|
|
192
|
+
const path = ensureOllamaCompose(assets);
|
|
193
|
+
return readFileSync(path, "utf-8");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── OpenCode System Config (DATA_HOME source of truth) ──────────────
|
|
197
|
+
|
|
198
|
+
export function ensureOpenCodeSystemConfig(assets: CoreAssetProvider): void {
|
|
199
|
+
const dir = `${resolveDataHome()}/assistant`;
|
|
200
|
+
mkdirSync(dir, { recursive: true });
|
|
201
|
+
writeIfChanged(`${dir}/opencode.jsonc`, assets.opencodeConfig());
|
|
202
|
+
writeIfChanged(`${dir}/AGENTS.md`, assets.agentsMd());
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function ensureAdminOpenCodeConfig(assets: CoreAssetProvider): void {
|
|
206
|
+
const dir = `${resolveDataHome()}/admin`;
|
|
207
|
+
mkdirSync(dir, { recursive: true });
|
|
208
|
+
writeIfChanged(`${dir}/opencode.jsonc`, assets.adminOpencodeConfig());
|
|
209
|
+
writeIfChanged(`${dir}/AGENTS.md`, assets.agentsMd());
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Core Automations (DATA_HOME source of truth) ────────────────────
|
|
213
|
+
|
|
214
|
+
export function ensureCoreAutomations(assets: CoreAssetProvider): void {
|
|
215
|
+
const dir = `${resolveDataHome()}/automations`;
|
|
216
|
+
mkdirSync(dir, { recursive: true });
|
|
217
|
+
|
|
218
|
+
const coreAutomations = [
|
|
219
|
+
{ filename: "cleanup-logs.yml", content: assets.cleanupLogs() },
|
|
220
|
+
{ filename: "cleanup-data.yml", content: assets.cleanupData() },
|
|
221
|
+
{ filename: "validate-config.yml", content: assets.validateConfig() },
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
for (const { filename, content } of coreAutomations) {
|
|
225
|
+
writeIfChanged(join(dir, filename), content);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Asset Refresh (GitHub download) ──────────────────────────────────
|
|
230
|
+
|
|
231
|
+
const REPO = "itlackey/openpalm";
|
|
232
|
+
const VERSION = process.env.OPENPALM_ASSET_VERSION ?? "main";
|
|
233
|
+
|
|
234
|
+
const MANAGED_ASSETS: { dataRelPath: string; githubFilename: string }[] = [
|
|
235
|
+
{ dataRelPath: "docker-compose.yml", githubFilename: "docker-compose.yml" },
|
|
236
|
+
{ dataRelPath: "caddy/Caddyfile", githubFilename: "Caddyfile" },
|
|
237
|
+
{ dataRelPath: "assistant/opencode.jsonc", githubFilename: "opencode.jsonc" },
|
|
238
|
+
{ dataRelPath: "admin/opencode.jsonc", githubFilename: "admin-opencode.jsonc" },
|
|
239
|
+
{ dataRelPath: "assistant/AGENTS.md", githubFilename: "AGENTS.md" },
|
|
240
|
+
{ dataRelPath: "ollama.yml", githubFilename: "ollama.yml" },
|
|
241
|
+
{ dataRelPath: "secrets.env.schema", githubFilename: "secrets.env.schema" },
|
|
242
|
+
{ dataRelPath: "stack.env.schema", githubFilename: "stack.env.schema" }
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
async function downloadAsset(filename: string): Promise<string> {
|
|
246
|
+
const releaseUrl = `https://github.com/${REPO}/releases/download/${VERSION}/${filename}`;
|
|
247
|
+
const rawUrl = `https://raw.githubusercontent.com/${REPO}/${VERSION}/assets/${filename}`;
|
|
248
|
+
|
|
249
|
+
for (const url of [releaseUrl, rawUrl]) {
|
|
250
|
+
try {
|
|
251
|
+
const res = await fetch(url);
|
|
252
|
+
if (res.ok) return await res.text();
|
|
253
|
+
} catch {
|
|
254
|
+
// try next URL
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
throw new Error(`Failed to download ${filename} from GitHub (tried release and raw URLs for version "${VERSION}")`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function refreshCoreAssets(): Promise<{
|
|
261
|
+
backupDir: string | null;
|
|
262
|
+
updated: string[];
|
|
263
|
+
}> {
|
|
264
|
+
const dataHome = resolveDataHome();
|
|
265
|
+
const updated: string[] = [];
|
|
266
|
+
let backupDir: string | null = null;
|
|
267
|
+
|
|
268
|
+
for (const asset of MANAGED_ASSETS) {
|
|
269
|
+
const freshContent = await downloadAsset(asset.githubFilename);
|
|
270
|
+
const targetPath = join(dataHome, asset.dataRelPath);
|
|
271
|
+
|
|
272
|
+
if (existsSync(targetPath)) {
|
|
273
|
+
const currentContent = readFileSync(targetPath, "utf-8");
|
|
274
|
+
if (sha256(currentContent) === sha256(freshContent)) {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!backupDir) {
|
|
279
|
+
backupDir = join(dataHome, "backups", new Date().toISOString().replace(/[:.]/g, "-"));
|
|
280
|
+
}
|
|
281
|
+
const backupPath = join(backupDir, asset.dataRelPath);
|
|
282
|
+
mkdirSync(dirname(backupPath), { recursive: true });
|
|
283
|
+
copyFileSync(targetPath, backupPath);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
287
|
+
writeFileSync(targetPath, freshContent);
|
|
288
|
+
updated.push(asset.dataRelPath);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { backupDir, updated };
|
|
292
|
+
}
|