@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,448 @@
1
+ /**
2
+ * Docker integration — executes real docker compose commands.
3
+ *
4
+ * This module shells out to `docker compose` for lifecycle operations.
5
+ * It reads the generated docker-compose.yml from the state directory
6
+ * and uses it for all operations.
7
+ *
8
+ * Security: All commands use execFile with argument arrays to prevent
9
+ * command injection. No user input is ever interpolated into shell strings.
10
+ */
11
+ import { execFile, spawn } from "node:child_process";
12
+ import { existsSync, readFileSync } from "node:fs";
13
+
14
+ export type DockerResult = {
15
+ ok: boolean;
16
+ stdout: string;
17
+ stderr: string;
18
+ code: number;
19
+ };
20
+
21
+ /**
22
+ * Parse a dotenv file into a key-value map.
23
+ * Handles `KEY=value` lines; ignores comments and blank lines.
24
+ */
25
+ function parseEnvFile(path: string): Record<string, string> {
26
+ const vars: Record<string, string> = {};
27
+ try {
28
+ const content = readFileSync(path, "utf-8");
29
+ for (const line of content.split("\n")) {
30
+ const trimmed = line.trim();
31
+ if (!trimmed || trimmed.startsWith("#")) continue;
32
+ const eqIdx = trimmed.indexOf("=");
33
+ if (eqIdx > 0) {
34
+ vars[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
35
+ }
36
+ }
37
+ } catch {
38
+ // File not readable — skip
39
+ }
40
+ return vars;
41
+ }
42
+
43
+ /** Execute docker with an argument array — no shell interpolation. */
44
+ function run(
45
+ args: string[],
46
+ cwd?: string,
47
+ timeoutMs = 120_000,
48
+ envOverrides?: Record<string, string>
49
+ ): Promise<DockerResult> {
50
+ return new Promise((resolve) => {
51
+ execFile(
52
+ "docker",
53
+ args,
54
+ { cwd, timeout: timeoutMs, env: { ...process.env, ...envOverrides } },
55
+ (error, stdout, stderr) => {
56
+ resolve({
57
+ ok: !error,
58
+ stdout: stdout?.toString() ?? "",
59
+ stderr: stderr?.toString() ?? "",
60
+ code: error?.code ? Number(error.code) : 0
61
+ });
62
+ }
63
+ );
64
+ });
65
+ }
66
+
67
+ /** Get the compose file path from state directory */
68
+ function composeFile(stateDir: string): string {
69
+ return `${stateDir}/artifacts/docker-compose.yml`;
70
+ }
71
+
72
+ /** Check if Docker is available */
73
+ export async function checkDocker(): Promise<DockerResult> {
74
+ return new Promise((resolve) => {
75
+ execFile(
76
+ "docker",
77
+ ["info", "--format", "{{.ServerVersion}}"],
78
+ (error, stdout, stderr) => {
79
+ const stdoutStr = stdout?.toString().trim() ?? "";
80
+ const stderrStr = stderr?.toString() ?? "";
81
+ // docker info may exit non-zero when the daemon reports warnings
82
+ // (e.g. "No swap limit support") even though it is fully functional.
83
+ // Treat Docker as available when stdout contains a version string.
84
+ const available = stdoutStr.length > 0 || !error;
85
+ resolve({
86
+ ok: available,
87
+ stdout: stdoutStr,
88
+ stderr: stderrStr,
89
+ code: error?.code ? Number(error.code) : 0
90
+ });
91
+ }
92
+ );
93
+ });
94
+ }
95
+
96
+ /** Check if docker compose is available */
97
+ export async function checkDockerCompose(): Promise<DockerResult> {
98
+ return new Promise((resolve) => {
99
+ execFile("docker", ["compose", "version"], (error, stdout, stderr) => {
100
+ resolve({
101
+ ok: !error,
102
+ stdout: stdout?.toString() ?? "",
103
+ stderr: stderr?.toString() ?? "",
104
+ code: error?.code ? Number(error.code) : 0
105
+ });
106
+ });
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Build the `-f file1 -f file2 ...` args for docker compose.
112
+ * Returns a flat array like ["-f", "path1", "-f", "path2"].
113
+ */
114
+ function composeFileArgs(stateDir: string, files?: string[]): string[] {
115
+ const fileList = files ?? [composeFile(stateDir)];
116
+ return fileList.flatMap((f) => ["-f", f]);
117
+ }
118
+
119
+ /**
120
+ * Append `--env-file <path>` args for each existing env file.
121
+ * Files that do not exist are silently skipped.
122
+ */
123
+ function pushEnvFileArgs(args: string[], envFiles?: string[]): void {
124
+ for (const ef of envFiles ?? []) {
125
+ if (existsSync(ef)) args.push("--env-file", ef);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Build the common prefix args for all docker compose commands:
131
+ * docker compose -f <file> ... --project-name openpalm [--env-file ...]
132
+ */
133
+ function buildComposeArgs(stateDir: string, options: { files?: string[]; envFiles?: string[] } = {}): string[] {
134
+ const args = ["compose", ...composeFileArgs(stateDir, options.files), "--project-name", "openpalm"];
135
+ pushEnvFileArgs(args, options.envFiles);
136
+ return args;
137
+ }
138
+
139
+ /**
140
+ * Run `docker compose up -d` with the generated compose file(s).
141
+ * Pass `files` to merge multiple compose overlays (e.g. core + channel files).
142
+ */
143
+ export async function composeUp(
144
+ stateDir: string,
145
+ options: {
146
+ files?: string[];
147
+ profiles?: string[];
148
+ services?: string[];
149
+ envFiles?: string[];
150
+ forceRecreate?: boolean;
151
+ removeOrphans?: boolean;
152
+ } = {}
153
+ ): Promise<DockerResult> {
154
+ const primaryFile = options.files?.[0] ?? composeFile(stateDir);
155
+ if (!existsSync(primaryFile)) {
156
+ return {
157
+ ok: false,
158
+ stdout: "",
159
+ stderr: "Compose file not found",
160
+ code: 1
161
+ };
162
+ }
163
+
164
+ const args = buildComposeArgs(stateDir, options);
165
+
166
+ if (options.profiles) {
167
+ for (const p of options.profiles) {
168
+ args.push("--profile", p);
169
+ }
170
+ }
171
+
172
+ args.push("up", "-d");
173
+
174
+ if (options.forceRecreate) {
175
+ args.push("--force-recreate");
176
+ }
177
+
178
+ if (options.removeOrphans) {
179
+ args.push("--remove-orphans");
180
+ }
181
+
182
+ if (options.services && options.services.length > 0) {
183
+ args.push(...options.services);
184
+ }
185
+
186
+ // Merge env file values into the process environment so Docker Compose
187
+ // resolves ${VAR} from fresh env files, not stale admin process env.
188
+ // Process env takes precedence over --env-file in Docker Compose,
189
+ // so we must override it explicitly.
190
+ const envOverrides: Record<string, string> = {};
191
+ for (const ef of options.envFiles ?? []) {
192
+ Object.assign(envOverrides, parseEnvFile(ef));
193
+ }
194
+
195
+ return run(args, stateDir, 300_000, envOverrides);
196
+ }
197
+
198
+ /**
199
+ * Run `docker compose down` to stop and remove containers.
200
+ */
201
+ export async function composeDown(
202
+ stateDir: string,
203
+ options: {
204
+ files?: string[];
205
+ profiles?: string[];
206
+ removeVolumes?: boolean;
207
+ envFiles?: string[];
208
+ } = {}
209
+ ): Promise<DockerResult> {
210
+ const primaryFile = options.files?.[0] ?? composeFile(stateDir);
211
+ if (!existsSync(primaryFile)) {
212
+ return {
213
+ ok: false,
214
+ stdout: "",
215
+ stderr: "Compose file not found",
216
+ code: 1
217
+ };
218
+ }
219
+
220
+ const args = buildComposeArgs(stateDir, options);
221
+
222
+ if (options.profiles) {
223
+ for (const p of options.profiles) {
224
+ args.push("--profile", p);
225
+ }
226
+ }
227
+
228
+ args.push("down");
229
+
230
+ if (options.removeVolumes) {
231
+ args.push("-v");
232
+ }
233
+
234
+ return run(args, stateDir);
235
+ }
236
+
237
+ /**
238
+ * Restart specific services.
239
+ */
240
+ export async function composeRestart(
241
+ stateDir: string,
242
+ services: string[],
243
+ options: { files?: string[]; envFiles?: string[] } = {}
244
+ ): Promise<DockerResult> {
245
+ const primaryFile = options.files?.[0] ?? composeFile(stateDir);
246
+ if (!existsSync(primaryFile)) {
247
+ return {
248
+ ok: false,
249
+ stdout: "",
250
+ stderr: "Compose file not found",
251
+ code: 1
252
+ };
253
+ }
254
+
255
+ const args = buildComposeArgs(stateDir, options);
256
+ args.push("restart", ...services);
257
+
258
+ return run(args, stateDir);
259
+ }
260
+
261
+ /**
262
+ * Stop specific services.
263
+ */
264
+ export async function composeStop(
265
+ stateDir: string,
266
+ services: string[],
267
+ options: { files?: string[]; envFiles?: string[] } = {}
268
+ ): Promise<DockerResult> {
269
+ const args = buildComposeArgs(stateDir, options);
270
+ args.push("stop", ...services);
271
+
272
+ return run(args, stateDir);
273
+ }
274
+
275
+ /**
276
+ * Start specific services (must already be created).
277
+ */
278
+ export async function composeStart(
279
+ stateDir: string,
280
+ services: string[],
281
+ options: { files?: string[]; envFiles?: string[] } = {}
282
+ ): Promise<DockerResult> {
283
+ const args = buildComposeArgs(stateDir, options);
284
+ // Use up -d for specific services to ensure they're created
285
+ args.push("up", "-d", ...services);
286
+
287
+ return run(args, stateDir);
288
+ }
289
+
290
+ /**
291
+ * Get the status of all containers in the project.
292
+ */
293
+ export async function composePs(
294
+ stateDir: string,
295
+ options: { files?: string[]; envFiles?: string[] } = {}
296
+ ): Promise<DockerResult> {
297
+ const primaryFile = options.files?.[0] ?? composeFile(stateDir);
298
+ if (!existsSync(primaryFile)) {
299
+ // If no compose file, just list containers with the project label
300
+ return run(
301
+ [
302
+ "ps",
303
+ "--filter",
304
+ "label=com.docker.compose.project=openpalm",
305
+ "--format",
306
+ "json"
307
+ ],
308
+ stateDir
309
+ );
310
+ }
311
+
312
+ const args = buildComposeArgs(stateDir, options);
313
+ args.push("ps", "--format", "json");
314
+
315
+ return run(args, stateDir);
316
+ }
317
+
318
+ /**
319
+ * Get logs for specific services or all services.
320
+ */
321
+ export async function composeLogs(
322
+ stateDir: string,
323
+ services?: string[],
324
+ tail = 100,
325
+ options: { files?: string[]; envFiles?: string[]; since?: string } = {}
326
+ ): Promise<DockerResult> {
327
+ const args = buildComposeArgs(stateDir, options);
328
+ args.push("logs", "--tail", String(tail));
329
+
330
+ if (options.since) {
331
+ args.push("--since", options.since);
332
+ }
333
+
334
+ if (services && services.length > 0) {
335
+ args.push(...services);
336
+ }
337
+
338
+ return run(args, stateDir);
339
+ }
340
+
341
+ /**
342
+ * Reload Caddy configuration by restarting the container.
343
+ */
344
+ export async function caddyReload(
345
+ stateDir: string,
346
+ options: { files?: string[]; envFiles?: string[] } = {}
347
+ ): Promise<DockerResult> {
348
+ return composeRestart(stateDir, ["caddy"], options);
349
+ }
350
+
351
+ /**
352
+ * Pull image for a single service.
353
+ */
354
+ export async function composePullService(
355
+ stateDir: string,
356
+ service: string,
357
+ options: { files?: string[]; envFiles?: string[] } = {}
358
+ ): Promise<DockerResult> {
359
+ const args = buildComposeArgs(stateDir, options);
360
+ args.push("pull", service);
361
+
362
+ const envOverrides: Record<string, string> = {};
363
+ for (const ef of options.envFiles ?? []) {
364
+ Object.assign(envOverrides, parseEnvFile(ef));
365
+ }
366
+
367
+ return run(args, stateDir, 300_000, envOverrides);
368
+ }
369
+
370
+ /**
371
+ * Pull latest images for all services.
372
+ */
373
+ export async function composePull(
374
+ stateDir: string,
375
+ options: { files?: string[]; envFiles?: string[] } = {}
376
+ ): Promise<DockerResult> {
377
+ const args = buildComposeArgs(stateDir, options);
378
+ args.push("pull");
379
+
380
+ const envOverrides: Record<string, string> = {};
381
+ for (const ef of options.envFiles ?? []) {
382
+ Object.assign(envOverrides, parseEnvFile(ef));
383
+ }
384
+
385
+ return run(args, stateDir, 300_000, envOverrides);
386
+ }
387
+
388
+ /**
389
+ * Get resource usage stats for all containers in the project.
390
+ */
391
+ export async function composeStats(
392
+ stateDir: string,
393
+ options: { files?: string[]; envFiles?: string[] } = {}
394
+ ): Promise<DockerResult> {
395
+ const args = buildComposeArgs(stateDir, options);
396
+ args.push("stats", "--no-stream", "--format", "json");
397
+
398
+ return run(args, stateDir);
399
+ }
400
+
401
+ /**
402
+ * Get recent Docker events for the compose project.
403
+ */
404
+ export async function getDockerEvents(
405
+ projectName: string,
406
+ since = "1h"
407
+ ): Promise<DockerResult> {
408
+ const args = [
409
+ "events",
410
+ "--filter", `label=com.docker.compose.project=${projectName}`,
411
+ "--since", since,
412
+ "--until", "now",
413
+ "--format", "json"
414
+ ];
415
+
416
+ return run(args, undefined, 15_000);
417
+ }
418
+
419
+ /**
420
+ * Fire-and-forget recreation of the admin container.
421
+ */
422
+ export function selfRecreateAdmin(
423
+ stateDir: string,
424
+ options: { files?: string[]; envFiles?: string[] } = {}
425
+ ): void {
426
+ const args = buildComposeArgs(stateDir, options);
427
+ args.push("--profile", "admin", "up", "-d", "--force-recreate", "--remove-orphans", "admin");
428
+
429
+ const envOverrides: Record<string, string> = {};
430
+ for (const ef of options.envFiles ?? []) {
431
+ Object.assign(envOverrides, parseEnvFile(ef));
432
+ }
433
+
434
+ try {
435
+ const child = spawn("docker", args, {
436
+ cwd: stateDir,
437
+ stdio: "ignore",
438
+ detached: true,
439
+ env: { ...process.env, ...envOverrides }
440
+ });
441
+ child.on("error", (err) => {
442
+ console.error("[selfRecreateAdmin] spawn error:", err.message);
443
+ });
444
+ child.unref();
445
+ } catch (err) {
446
+ console.error("[selfRecreateAdmin] failed to spawn:", err instanceof Error ? err.message : err);
447
+ }
448
+ }
@@ -0,0 +1,70 @@
1
+ import { parse as dotenvParse } from 'dotenv';
2
+ import { readFileSync, existsSync } from 'node:fs';
3
+
4
+ export function parseEnvContent(content: string): Record<string, string> {
5
+ return dotenvParse(content);
6
+ }
7
+
8
+ export function parseEnvFile(filePath: string): Record<string, string> {
9
+ if (!existsSync(filePath)) return {};
10
+ try {
11
+ return dotenvParse(readFileSync(filePath, 'utf-8'));
12
+ } catch {
13
+ return {};
14
+ }
15
+ }
16
+
17
+ function quoteEnvValue(value: string): string {
18
+ if (value.length === 0) return '';
19
+ const needsQuoting = /[#"'\\\n\r]/.test(value) || value !== value.trim();
20
+ if (!needsQuoting) return value;
21
+
22
+ if (!value.includes("'")) return `'${value}'`;
23
+
24
+ const escaped = value.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
25
+ return `"${escaped}"`;
26
+ }
27
+
28
+ export function mergeEnvContent(
29
+ content: string,
30
+ updates: Record<string, string>,
31
+ options: { uncomment?: boolean; sectionHeader?: string } = {}
32
+ ): string {
33
+ const lines = content.split('\n');
34
+ const remaining = new Map(Object.entries(updates));
35
+
36
+ for (let i = 0; i < lines.length; i++) {
37
+ let testLine = lines[i].trim();
38
+ if (options.uncomment) {
39
+ testLine = testLine.replace(/^#\s*/, '').trim();
40
+ }
41
+ // Strip `export ` prefix so we can match the key name
42
+ const hadExport = testLine.startsWith('export ');
43
+ if (hadExport) {
44
+ testLine = testLine.slice(7).trimStart();
45
+ }
46
+ const eq = testLine.indexOf('=');
47
+ if (eq <= 0) continue;
48
+ const key = testLine.slice(0, eq).trim();
49
+ if (remaining.has(key)) {
50
+ // Preserve the export prefix if the original line had one
51
+ const prefix = hadExport ? 'export ' : '';
52
+ lines[i] = `${prefix}${key}=${quoteEnvValue(remaining.get(key)!)}`;
53
+ remaining.delete(key);
54
+ }
55
+ }
56
+
57
+ if (remaining.size > 0) {
58
+ if (lines.length === 0 || lines[lines.length - 1] !== '') {
59
+ lines.push('');
60
+ }
61
+ if (options.sectionHeader) {
62
+ lines.push(options.sectionHeader);
63
+ }
64
+ for (const [key, value] of remaining) {
65
+ lines.push(`${key}=${quoteEnvValue(value)}`);
66
+ }
67
+ }
68
+
69
+ return lines.join('\n');
70
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * FilesystemAssetProvider — reads core assets from DATA_HOME on disk.
3
+ *
4
+ * Used by the CLI and any non-Vite consumer. Assets are downloaded from
5
+ * GitHub during `openpalm install` and stored in DATA_HOME.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import type { CoreAssetProvider } from "./core-asset-provider.js";
10
+
11
+ export class FilesystemAssetProvider implements CoreAssetProvider {
12
+ constructor(private readonly assetsDir: string) {}
13
+
14
+ private read(relPath: string): string {
15
+ return readFileSync(join(this.assetsDir, relPath), "utf-8");
16
+ }
17
+
18
+ coreCompose(): string {
19
+ return this.read("docker-compose.yml");
20
+ }
21
+
22
+ caddyfile(): string {
23
+ return this.read("caddy/Caddyfile");
24
+ }
25
+
26
+ ollamaCompose(): string {
27
+ return this.read("ollama.yml");
28
+ }
29
+
30
+ agentsMd(): string {
31
+ return this.read("assistant/AGENTS.md");
32
+ }
33
+
34
+ opencodeConfig(): string {
35
+ return this.read("assistant/opencode.jsonc");
36
+ }
37
+
38
+ adminOpencodeConfig(): string {
39
+ return this.read("admin/opencode.jsonc");
40
+ }
41
+
42
+ secretsSchema(): string {
43
+ return this.read("secrets.env.schema");
44
+ }
45
+
46
+ stackSchema(): string {
47
+ return this.read("stack.env.schema");
48
+ }
49
+
50
+ cleanupLogs(): string {
51
+ return this.read("automations/cleanup-logs.yml");
52
+ }
53
+
54
+ cleanupData(): string {
55
+ return this.read("automations/cleanup-data.yml");
56
+ }
57
+
58
+ validateConfig(): string {
59
+ return this.read("automations/validate-config.yml");
60
+ }
61
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * FilesystemRegistryProvider — reads registry catalog from a directory on disk.
3
+ *
4
+ * Used by the CLI. Reads .yml and .caddy files from the registry/ directory,
5
+ * which is downloaded from GitHub during install or available in the repo.
6
+ */
7
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import type { RegistryProvider } from "./registry-provider.js";
10
+
11
+ export class FilesystemRegistryProvider implements RegistryProvider {
12
+ constructor(private readonly registryDir: string) {}
13
+
14
+ channelYml(): Record<string, string> {
15
+ return this.loadDir("channels", ".yml");
16
+ }
17
+
18
+ channelCaddy(): Record<string, string> {
19
+ return this.loadDir("channels", ".caddy");
20
+ }
21
+
22
+ channelNames(): string[] {
23
+ return Object.keys(this.channelYml());
24
+ }
25
+
26
+ automationYml(): Record<string, string> {
27
+ return this.loadDir("automations", ".yml");
28
+ }
29
+
30
+ automationNames(): string[] {
31
+ return Object.keys(this.automationYml());
32
+ }
33
+
34
+ private loadDir(subdir: string, ext: string): Record<string, string> {
35
+ const dir = join(this.registryDir, subdir);
36
+ if (!existsSync(dir)) return {};
37
+
38
+ const result: Record<string, string> = {};
39
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
40
+ if (!entry.isFile() || !entry.name.endsWith(ext)) continue;
41
+ const name = entry.name.replace(new RegExp(`\\${ext}$`), "");
42
+ result[name] = readFileSync(join(dir, entry.name), "utf-8");
43
+ }
44
+ return result;
45
+ }
46
+ }