openpalm 0.2.0 → 0.2.7
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/LICENSE +121 -0
- package/dist/openpalm.js +1847 -0
- package/package.json +6 -18
- package/src/commands/create-channel.ts +0 -262
- package/src/commands/extensions.ts +0 -122
- package/src/commands/install.ts +0 -326
- package/src/commands/logs.ts +0 -7
- package/src/commands/preflight.ts +0 -49
- package/src/commands/restart.ts +0 -10
- package/src/commands/start.ts +0 -10
- package/src/commands/status.ts +0 -9
- package/src/commands/stop.ts +0 -10
- package/src/commands/uninstall.ts +0 -124
- package/src/commands/update.ts +0 -12
- package/src/main.ts +0 -210
- package/src/types.ts +0 -18
package/dist/openpalm.js
ADDED
|
@@ -0,0 +1,1847 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
#!/usr/bin/env bun
|
|
3
|
+
// @bun
|
|
4
|
+
|
|
5
|
+
// packages/cli/src/commands/install.ts
|
|
6
|
+
import { join as join4 } from "path";
|
|
7
|
+
import { chmod, writeFile, rm } from "fs/promises";
|
|
8
|
+
|
|
9
|
+
// packages/lib/src/runtime.ts
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
function detectOS() {
|
|
13
|
+
switch (process.platform) {
|
|
14
|
+
case "darwin":
|
|
15
|
+
return "macos";
|
|
16
|
+
case "linux":
|
|
17
|
+
return "linux";
|
|
18
|
+
case "win32":
|
|
19
|
+
return "windows";
|
|
20
|
+
default:
|
|
21
|
+
return "unknown";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function detectArch() {
|
|
25
|
+
switch (process.arch) {
|
|
26
|
+
case "x64":
|
|
27
|
+
return "amd64";
|
|
28
|
+
case "arm64":
|
|
29
|
+
return "arm64";
|
|
30
|
+
default:
|
|
31
|
+
return "amd64";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function detectRuntime(os) {
|
|
35
|
+
if (os === "macos") {
|
|
36
|
+
const orbstackSocket = join(homedir(), ".orbstack", "run", "docker.sock");
|
|
37
|
+
const orbstackExists = await Bun.file(orbstackSocket).exists();
|
|
38
|
+
const dockerBin2 = await Bun.which("docker");
|
|
39
|
+
if (orbstackExists && dockerBin2) {
|
|
40
|
+
return "orbstack";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const dockerBin = await Bun.which("docker");
|
|
44
|
+
if (dockerBin) {
|
|
45
|
+
return "docker";
|
|
46
|
+
}
|
|
47
|
+
const podmanBin = await Bun.which("podman");
|
|
48
|
+
if (podmanBin) {
|
|
49
|
+
return "podman";
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
function resolveSocketPath(platform, os) {
|
|
54
|
+
switch (platform) {
|
|
55
|
+
case "docker":
|
|
56
|
+
if (os === "windows") {
|
|
57
|
+
return "//./pipe/docker_engine";
|
|
58
|
+
}
|
|
59
|
+
return "/var/run/docker.sock";
|
|
60
|
+
case "orbstack":
|
|
61
|
+
try {
|
|
62
|
+
return join(homedir(), ".orbstack", "run", "docker.sock");
|
|
63
|
+
} catch {
|
|
64
|
+
return "/var/run/docker.sock";
|
|
65
|
+
}
|
|
66
|
+
case "podman":
|
|
67
|
+
if (os === "linux") {
|
|
68
|
+
const uid = process.getuid?.() ?? 1000;
|
|
69
|
+
return `/run/user/${uid}/podman/podman.sock`;
|
|
70
|
+
}
|
|
71
|
+
if (os === "windows") {
|
|
72
|
+
return "//./pipe/podman-machine-default";
|
|
73
|
+
}
|
|
74
|
+
return "/var/run/docker.sock";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function resolveComposeBin(platform) {
|
|
78
|
+
switch (platform) {
|
|
79
|
+
case "docker":
|
|
80
|
+
case "orbstack":
|
|
81
|
+
return { bin: "docker", subcommand: "compose" };
|
|
82
|
+
case "podman":
|
|
83
|
+
return { bin: "podman", subcommand: "compose" };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function resolveSocketUri(platform, os) {
|
|
87
|
+
const socketPath = resolveSocketPath(platform, os);
|
|
88
|
+
if (os === "windows") {
|
|
89
|
+
return `npipe://${socketPath}`;
|
|
90
|
+
}
|
|
91
|
+
return `unix://${socketPath}`;
|
|
92
|
+
}
|
|
93
|
+
function resolveInContainerSocketPath(platform) {
|
|
94
|
+
if (platform === "podman") {
|
|
95
|
+
return "/var/run/docker.sock";
|
|
96
|
+
}
|
|
97
|
+
return "/var/run/docker.sock";
|
|
98
|
+
}
|
|
99
|
+
async function validateRuntime(bin, subcommand) {
|
|
100
|
+
try {
|
|
101
|
+
const proc = Bun.spawn([bin, subcommand, "version"], {
|
|
102
|
+
stdout: "ignore",
|
|
103
|
+
stderr: "ignore"
|
|
104
|
+
});
|
|
105
|
+
await proc.exited;
|
|
106
|
+
return proc.exitCode === 0;
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// packages/lib/src/paths.ts
|
|
113
|
+
import { homedir as homedir2 } from "os";
|
|
114
|
+
import { join as join2 } from "path";
|
|
115
|
+
import { mkdir } from "fs/promises";
|
|
116
|
+
function resolveXDGPaths() {
|
|
117
|
+
const home = homedir2();
|
|
118
|
+
const isWindows = process.platform === "win32";
|
|
119
|
+
const localAppData = isWindows ? Bun.env.LOCALAPPDATA || join2(home, "AppData", "Local") : "";
|
|
120
|
+
const data = Bun.env.OPENPALM_DATA_HOME || (Bun.env.XDG_DATA_HOME ? join2(Bun.env.XDG_DATA_HOME, "openpalm") : undefined) || (isWindows ? join2(localAppData, "OpenPalm", "data") : join2(home, ".local", "share", "openpalm"));
|
|
121
|
+
const config = Bun.env.OPENPALM_CONFIG_HOME || (Bun.env.XDG_CONFIG_HOME ? join2(Bun.env.XDG_CONFIG_HOME, "openpalm") : undefined) || (isWindows ? join2(localAppData, "OpenPalm", "config") : join2(home, ".config", "openpalm"));
|
|
122
|
+
const state = Bun.env.OPENPALM_STATE_HOME || (Bun.env.XDG_STATE_HOME ? join2(Bun.env.XDG_STATE_HOME, "openpalm") : undefined) || (isWindows ? join2(localAppData, "OpenPalm", "state") : join2(home, ".local", "state", "openpalm"));
|
|
123
|
+
return { data, config, state };
|
|
124
|
+
}
|
|
125
|
+
async function createDirectoryTree(xdg) {
|
|
126
|
+
const dataDirs = ["postgres", "qdrant", "openmemory", "assistant", "admin"];
|
|
127
|
+
for (const dir of dataDirs)
|
|
128
|
+
await mkdir(join2(xdg.data, dir), { recursive: true });
|
|
129
|
+
await mkdir(xdg.config, { recursive: true });
|
|
130
|
+
const stateDirs = [
|
|
131
|
+
"admin",
|
|
132
|
+
"gateway",
|
|
133
|
+
"postgres",
|
|
134
|
+
"qdrant",
|
|
135
|
+
"openmemory",
|
|
136
|
+
"openmemory-ui",
|
|
137
|
+
"assistant",
|
|
138
|
+
"rendered",
|
|
139
|
+
"rendered/caddy",
|
|
140
|
+
"rendered/caddy/snippets",
|
|
141
|
+
"automations",
|
|
142
|
+
"caddy/config",
|
|
143
|
+
"caddy/data",
|
|
144
|
+
"logs",
|
|
145
|
+
"tmp"
|
|
146
|
+
];
|
|
147
|
+
for (const dir of stateDirs)
|
|
148
|
+
await mkdir(join2(xdg.state, dir), { recursive: true });
|
|
149
|
+
await mkdir(resolveWorkHome(), { recursive: true });
|
|
150
|
+
}
|
|
151
|
+
function resolveWorkHome() {
|
|
152
|
+
return Bun.env.OPENPALM_WORK_HOME || join2(homedir2(), "openpalm");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// packages/lib/src/env.ts
|
|
156
|
+
async function readEnvFile(path) {
|
|
157
|
+
const file = Bun.file(path);
|
|
158
|
+
const exists = await file.exists();
|
|
159
|
+
if (!exists) {
|
|
160
|
+
return {};
|
|
161
|
+
}
|
|
162
|
+
const content = await file.text();
|
|
163
|
+
const lines = content.split(`
|
|
164
|
+
`);
|
|
165
|
+
const env = {};
|
|
166
|
+
for (const line of lines) {
|
|
167
|
+
const trimmed = line.trim();
|
|
168
|
+
if (trimmed === "" || trimmed.startsWith("#")) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const eqIndex = trimmed.indexOf("=");
|
|
172
|
+
if (eqIndex === -1) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const key = trimmed.substring(0, eqIndex).trim();
|
|
176
|
+
let value = trimmed.substring(eqIndex + 1).trim();
|
|
177
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
178
|
+
value = value.slice(1, -1);
|
|
179
|
+
}
|
|
180
|
+
env[key] = value;
|
|
181
|
+
}
|
|
182
|
+
return env;
|
|
183
|
+
}
|
|
184
|
+
async function upsertEnvVar(path, key, value) {
|
|
185
|
+
const file = Bun.file(path);
|
|
186
|
+
const exists = await file.exists();
|
|
187
|
+
if (!exists) {
|
|
188
|
+
await Bun.write(path, `${key}=${value}
|
|
189
|
+
`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const content = await file.text();
|
|
193
|
+
const lines = content.split(`
|
|
194
|
+
`);
|
|
195
|
+
let found = false;
|
|
196
|
+
const newLines = [];
|
|
197
|
+
for (const line of lines) {
|
|
198
|
+
const trimmed = line.trim();
|
|
199
|
+
if (trimmed.startsWith(`${key}=`)) {
|
|
200
|
+
newLines.push(`${key}=${value}`);
|
|
201
|
+
found = true;
|
|
202
|
+
} else {
|
|
203
|
+
newLines.push(line);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (!found) {
|
|
207
|
+
while (newLines.length > 0 && newLines[newLines.length - 1].trim() === "") {
|
|
208
|
+
newLines.pop();
|
|
209
|
+
}
|
|
210
|
+
newLines.push(`${key}=${value}`);
|
|
211
|
+
newLines.push("");
|
|
212
|
+
}
|
|
213
|
+
await Bun.write(path, newLines.join(`
|
|
214
|
+
`));
|
|
215
|
+
}
|
|
216
|
+
async function upsertEnvVars(filePath, entries) {
|
|
217
|
+
if (entries.length === 0) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const file = Bun.file(filePath);
|
|
221
|
+
const exists = await file.exists();
|
|
222
|
+
if (!exists) {
|
|
223
|
+
const content2 = entries.map(([k, v]) => `${k}=${v}`).join(`
|
|
224
|
+
`) + `
|
|
225
|
+
`;
|
|
226
|
+
await Bun.write(filePath, content2);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const content = await file.text();
|
|
230
|
+
const lines = content.split(`
|
|
231
|
+
`);
|
|
232
|
+
const updated = new Set;
|
|
233
|
+
const newLines = [];
|
|
234
|
+
for (const line of lines) {
|
|
235
|
+
const trimmed = line.trim();
|
|
236
|
+
const match = entries.find(([k]) => trimmed.startsWith(`${k}=`));
|
|
237
|
+
if (match) {
|
|
238
|
+
const [k, v] = match;
|
|
239
|
+
newLines.push(`${k}=${v}`);
|
|
240
|
+
updated.add(k);
|
|
241
|
+
} else {
|
|
242
|
+
newLines.push(line);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const toAppend = entries.filter(([k]) => !updated.has(k));
|
|
246
|
+
if (toAppend.length > 0) {
|
|
247
|
+
while (newLines.length > 0 && newLines[newLines.length - 1].trim() === "") {
|
|
248
|
+
newLines.pop();
|
|
249
|
+
}
|
|
250
|
+
for (const [k, v] of toAppend) {
|
|
251
|
+
newLines.push(`${k}=${v}`);
|
|
252
|
+
}
|
|
253
|
+
newLines.push("");
|
|
254
|
+
}
|
|
255
|
+
await Bun.write(filePath, newLines.join(`
|
|
256
|
+
`));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// packages/lib/src/tokens.ts
|
|
260
|
+
function generateToken(length = 64) {
|
|
261
|
+
const bytesNeeded = Math.ceil(length * 3 / 4);
|
|
262
|
+
const randomBytes = new Uint8Array(bytesNeeded);
|
|
263
|
+
crypto.getRandomValues(randomBytes);
|
|
264
|
+
let base64 = btoa(String.fromCharCode(...randomBytes));
|
|
265
|
+
const urlSafeBase64 = base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
266
|
+
return urlSafeBase64.slice(0, length);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// packages/lib/src/compose.ts
|
|
270
|
+
function buildComposeArgs(config) {
|
|
271
|
+
return [
|
|
272
|
+
config.subcommand,
|
|
273
|
+
"--env-file",
|
|
274
|
+
config.envFile,
|
|
275
|
+
"-f",
|
|
276
|
+
config.composeFile
|
|
277
|
+
];
|
|
278
|
+
}
|
|
279
|
+
async function composeExec(config, args, options) {
|
|
280
|
+
const fullArgs = [...buildComposeArgs(config), ...args];
|
|
281
|
+
const proc = Bun.spawn([config.bin, ...fullArgs], {
|
|
282
|
+
stdout: options?.stream ? "inherit" : "pipe",
|
|
283
|
+
stderr: options?.stream ? "inherit" : "pipe",
|
|
284
|
+
stdin: "inherit"
|
|
285
|
+
});
|
|
286
|
+
const timeoutMs = options?.timeout ?? (options?.stream ? 0 : 30000);
|
|
287
|
+
if (timeoutMs > 0) {
|
|
288
|
+
const result = await Promise.race([
|
|
289
|
+
proc.exited.then(() => "done"),
|
|
290
|
+
new Promise((r) => setTimeout(() => r("timeout"), timeoutMs))
|
|
291
|
+
]);
|
|
292
|
+
if (result === "timeout") {
|
|
293
|
+
proc.kill();
|
|
294
|
+
return { exitCode: 1, stdout: "", stderr: `compose command timed out after ${timeoutMs}ms` };
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
await proc.exited;
|
|
298
|
+
}
|
|
299
|
+
const exitCode = proc.exitCode ?? 1;
|
|
300
|
+
const stdout = options?.stream ? "" : await new Response(proc.stdout).text();
|
|
301
|
+
const stderr = options?.stream ? "" : await new Response(proc.stderr).text();
|
|
302
|
+
return { exitCode, stdout, stderr };
|
|
303
|
+
}
|
|
304
|
+
async function composePull(config, services) {
|
|
305
|
+
const args = ["pull", ...services ?? []];
|
|
306
|
+
const result = await composeExec(config, args, { stream: true });
|
|
307
|
+
if (result.exitCode !== 0) {
|
|
308
|
+
throw new Error(`compose pull failed with exit code ${result.exitCode}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async function composeUp(config, services, options) {
|
|
312
|
+
const args = [];
|
|
313
|
+
if (options?.profiles) {
|
|
314
|
+
for (const profile of options.profiles) {
|
|
315
|
+
args.push("--profile", profile);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
args.push("up");
|
|
319
|
+
const detach = options?.detach ?? true;
|
|
320
|
+
if (detach) {
|
|
321
|
+
args.push("-d");
|
|
322
|
+
}
|
|
323
|
+
if (options?.pull) {
|
|
324
|
+
args.push("--pull", options.pull);
|
|
325
|
+
}
|
|
326
|
+
if (services) {
|
|
327
|
+
args.push(...services);
|
|
328
|
+
}
|
|
329
|
+
const result = await composeExec(config, args, { stream: true });
|
|
330
|
+
if (result.exitCode !== 0) {
|
|
331
|
+
throw new Error(`compose up failed with exit code ${result.exitCode}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async function composeDown(config, options) {
|
|
335
|
+
const args = ["down"];
|
|
336
|
+
const removeOrphans = options?.removeOrphans ?? true;
|
|
337
|
+
if (removeOrphans) {
|
|
338
|
+
args.push("--remove-orphans");
|
|
339
|
+
}
|
|
340
|
+
if (options?.removeImages) {
|
|
341
|
+
args.push("--rmi", "all");
|
|
342
|
+
}
|
|
343
|
+
const result = await composeExec(config, args, { stream: true });
|
|
344
|
+
if (result.exitCode !== 0) {
|
|
345
|
+
throw new Error(`compose down failed with exit code ${result.exitCode}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async function composeRestart(config, services) {
|
|
349
|
+
const args = ["restart", ...services ?? []];
|
|
350
|
+
const result = await composeExec(config, args, { stream: true });
|
|
351
|
+
if (result.exitCode !== 0) {
|
|
352
|
+
throw new Error(`compose restart failed with exit code ${result.exitCode}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async function composeStop(config, services) {
|
|
356
|
+
const args = ["stop", ...services ?? []];
|
|
357
|
+
const result = await composeExec(config, args, { stream: true });
|
|
358
|
+
if (result.exitCode !== 0) {
|
|
359
|
+
throw new Error(`compose stop failed with exit code ${result.exitCode}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async function composeLogs(config, services, options) {
|
|
363
|
+
const args = ["logs"];
|
|
364
|
+
if (options?.follow) {
|
|
365
|
+
args.push("--follow");
|
|
366
|
+
}
|
|
367
|
+
if (options?.tail !== undefined) {
|
|
368
|
+
args.push("--tail", options.tail.toString());
|
|
369
|
+
}
|
|
370
|
+
if (services) {
|
|
371
|
+
args.push(...services);
|
|
372
|
+
}
|
|
373
|
+
const result = await composeExec(config, args, { stream: true });
|
|
374
|
+
if (result.exitCode !== 0) {
|
|
375
|
+
throw new Error(`compose logs failed with exit code ${result.exitCode}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
async function composePs(config) {
|
|
379
|
+
const result = await composeExec(config, ["ps", "-a"]);
|
|
380
|
+
if (result.exitCode !== 0) {
|
|
381
|
+
throw new Error(`compose ps failed with exit code ${result.exitCode}`);
|
|
382
|
+
}
|
|
383
|
+
return result.stdout;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// packages/lib/src/assets.ts
|
|
387
|
+
import { join as join3 } from "path";
|
|
388
|
+
|
|
389
|
+
// packages/lib/assets/templates/openpalm.yaml
|
|
390
|
+
var openpalm_default = `version: 3
|
|
391
|
+
accessScope: lan
|
|
392
|
+
channels:
|
|
393
|
+
chat:
|
|
394
|
+
enabled: true
|
|
395
|
+
exposure: lan
|
|
396
|
+
config:
|
|
397
|
+
CHAT_INBOUND_TOKEN: ""
|
|
398
|
+
CHANNEL_CHAT_SECRET: ""
|
|
399
|
+
discord:
|
|
400
|
+
enabled: true
|
|
401
|
+
exposure: lan
|
|
402
|
+
config:
|
|
403
|
+
DISCORD_BOT_TOKEN: ""
|
|
404
|
+
DISCORD_PUBLIC_KEY: ""
|
|
405
|
+
CHANNEL_DISCORD_SECRET: ""
|
|
406
|
+
voice:
|
|
407
|
+
enabled: true
|
|
408
|
+
exposure: lan
|
|
409
|
+
config:
|
|
410
|
+
CHANNEL_VOICE_SECRET: ""
|
|
411
|
+
telegram:
|
|
412
|
+
enabled: true
|
|
413
|
+
exposure: lan
|
|
414
|
+
config:
|
|
415
|
+
TELEGRAM_BOT_TOKEN: ""
|
|
416
|
+
TELEGRAM_WEBHOOK_SECRET: ""
|
|
417
|
+
CHANNEL_TELEGRAM_SECRET: ""
|
|
418
|
+
services: {}
|
|
419
|
+
automations: []
|
|
420
|
+
`;
|
|
421
|
+
|
|
422
|
+
// packages/lib/assets/templates/secrets.env
|
|
423
|
+
var secrets_default = `# Manage secrets that should be available to assistant and openmemory in this file during install.
|
|
424
|
+
# Installer copies this template to $OPENPALM_CONFIG_HOME/secrets.env and preserves your edits on future runs.
|
|
425
|
+
|
|
426
|
+
# Required: API key for your AI assistant model (Anthropic Claude).
|
|
427
|
+
# Get one from https://console.anthropic.com/
|
|
428
|
+
ANTHROPIC_API_KEY=
|
|
429
|
+
|
|
430
|
+
# Set an OpenAI-compatible endpoint/key for openmemory features that require model calls.
|
|
431
|
+
# Leave OPENAI_BASE_URL empty to use the default OpenAI endpoint.
|
|
432
|
+
OPENAI_BASE_URL=
|
|
433
|
+
OPENAI_API_KEY=sk-REPLACE_WITH_ACTUAL_KEY
|
|
434
|
+
|
|
435
|
+
# Optional: API key for the small/fast model used by OpenCode for lightweight tasks
|
|
436
|
+
# (summaries, title generation). Set via the setup wizard or manually here.
|
|
437
|
+
# Leave empty if the small model endpoint does not require authentication (e.g. local Ollama).
|
|
438
|
+
OPENPALM_SMALL_MODEL_API_KEY=
|
|
439
|
+
`;
|
|
440
|
+
// packages/lib/assets/templates/index.ts
|
|
441
|
+
var DEFAULT_OPENPALM_YAML = openpalm_default;
|
|
442
|
+
var DEFAULT_SECRETS_ENV = secrets_default;
|
|
443
|
+
|
|
444
|
+
// packages/lib/src/assets.ts
|
|
445
|
+
async function seedConfigFiles(configHome) {
|
|
446
|
+
const yamlPath = join3(configHome, "openpalm.yaml");
|
|
447
|
+
const secretsPath = join3(configHome, "secrets.env");
|
|
448
|
+
if (!await Bun.file(yamlPath).exists())
|
|
449
|
+
await Bun.write(yamlPath, DEFAULT_OPENPALM_YAML);
|
|
450
|
+
if (!await Bun.file(secretsPath).exists())
|
|
451
|
+
await Bun.write(secretsPath, DEFAULT_SECRETS_ENV);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// packages/lib/src/preflight.ts
|
|
455
|
+
import { homedir as homedir3 } from "os";
|
|
456
|
+
async function checkDiskSpace() {
|
|
457
|
+
try {
|
|
458
|
+
const proc = Bun.spawn(["df", "-Pk", homedir3()], {
|
|
459
|
+
stdout: "pipe",
|
|
460
|
+
stderr: "ignore"
|
|
461
|
+
});
|
|
462
|
+
await proc.exited;
|
|
463
|
+
const output = await new Response(proc.stdout).text();
|
|
464
|
+
const lines = output.trim().split(`
|
|
465
|
+
`);
|
|
466
|
+
if (lines.length < 2)
|
|
467
|
+
return null;
|
|
468
|
+
const parts = lines[1].split(/\s+/);
|
|
469
|
+
const availKB = parseInt(parts[3], 10);
|
|
470
|
+
if (isNaN(availKB))
|
|
471
|
+
return null;
|
|
472
|
+
if (availKB < 3000000) {
|
|
473
|
+
const availGB = (availKB / 1048576).toFixed(1);
|
|
474
|
+
return {
|
|
475
|
+
message: `Low disk space \u2014 only ~${availGB} GB available.`,
|
|
476
|
+
detail: "OpenPalm needs roughly 3 GB for container images and data."
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
} catch {}
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
async function checkPort80() {
|
|
483
|
+
try {
|
|
484
|
+
const lsof = Bun.spawn(["lsof", "-iTCP:80", "-sTCP:LISTEN", "-P", "-n"], {
|
|
485
|
+
stdout: "pipe",
|
|
486
|
+
stderr: "ignore"
|
|
487
|
+
});
|
|
488
|
+
await lsof.exited;
|
|
489
|
+
if (lsof.exitCode === 0) {
|
|
490
|
+
const output = await new Response(lsof.stdout).text();
|
|
491
|
+
const lines = output.trim().split(`
|
|
492
|
+
`).slice(0, 3).join(`
|
|
493
|
+
`);
|
|
494
|
+
return {
|
|
495
|
+
message: "Port 80 is already in use by another process.",
|
|
496
|
+
detail: `OpenPalm needs port 80 for its web interface.
|
|
497
|
+
${lines}`
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
} catch {
|
|
501
|
+
try {
|
|
502
|
+
const ss = Bun.spawn(["ss", "-tln"], {
|
|
503
|
+
stdout: "pipe",
|
|
504
|
+
stderr: "ignore"
|
|
505
|
+
});
|
|
506
|
+
await ss.exited;
|
|
507
|
+
const output = await new Response(ss.stdout).text();
|
|
508
|
+
if (output.includes(":80 ")) {
|
|
509
|
+
return {
|
|
510
|
+
message: "Port 80 is already in use by another process.",
|
|
511
|
+
detail: "OpenPalm needs port 80 for its web interface."
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
} catch {}
|
|
515
|
+
}
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
async function checkDaemonRunning(bin, platform) {
|
|
519
|
+
if (platform === "podman")
|
|
520
|
+
return null;
|
|
521
|
+
try {
|
|
522
|
+
const proc = Bun.spawn([bin, "info"], {
|
|
523
|
+
stdout: "ignore",
|
|
524
|
+
stderr: "ignore"
|
|
525
|
+
});
|
|
526
|
+
await proc.exited;
|
|
527
|
+
if (proc.exitCode !== 0) {
|
|
528
|
+
return {
|
|
529
|
+
message: `${platform === "orbstack" ? "OrbStack" : "Docker"} is installed but the daemon is not running.`,
|
|
530
|
+
detail: process.platform === "darwin" ? "Open Docker Desktop (or OrbStack) and wait for it to start, then rerun." : `Start the Docker service:
|
|
531
|
+
sudo systemctl start docker`
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
} catch {
|
|
535
|
+
return {
|
|
536
|
+
message: `Could not verify that the ${bin} daemon is running.`
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
async function runPreflightChecks(bin, platform) {
|
|
542
|
+
const results = await Promise.all([
|
|
543
|
+
checkDiskSpace(),
|
|
544
|
+
checkPort80(),
|
|
545
|
+
checkDaemonRunning(bin, platform)
|
|
546
|
+
]);
|
|
547
|
+
return results.filter((w) => w !== null);
|
|
548
|
+
}
|
|
549
|
+
function noRuntimeGuidance(os) {
|
|
550
|
+
const lines = [
|
|
551
|
+
"",
|
|
552
|
+
"No container runtime found.",
|
|
553
|
+
"",
|
|
554
|
+
"OpenPalm runs inside containers and needs Docker (recommended)",
|
|
555
|
+
"or Podman (experimental) installed first.",
|
|
556
|
+
""
|
|
557
|
+
];
|
|
558
|
+
switch (os) {
|
|
559
|
+
case "macos":
|
|
560
|
+
lines.push("For macOS, install ONE of:", "", " Docker Desktop (free for personal use):", " https://www.docker.com/products/docker-desktop/", "", " OrbStack (lightweight, fast):", " https://orbstack.dev/download", "", " Or via Homebrew:", " brew install --cask docker");
|
|
561
|
+
break;
|
|
562
|
+
case "linux":
|
|
563
|
+
lines.push("For Linux, install Docker Engine + Compose plugin:", "", " Quick install (official script):", " curl -fsSL https://get.docker.com | sh", "", " Or follow the guide at:", " https://docs.docker.com/engine/install/", "", " After installing, make sure Docker is running:", " sudo systemctl start docker");
|
|
564
|
+
break;
|
|
565
|
+
default:
|
|
566
|
+
lines.push("Download Docker Desktop (free for personal use):", " https://www.docker.com/products/docker-desktop/", "", "Or install via winget:", " winget install Docker.DockerDesktop");
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
lines.push("", "After installing, rerun this installer.", "");
|
|
570
|
+
return lines.join(`
|
|
571
|
+
`);
|
|
572
|
+
}
|
|
573
|
+
function noComposeGuidance(platform) {
|
|
574
|
+
if (platform === "podman") {
|
|
575
|
+
return [
|
|
576
|
+
"Compose support not available.",
|
|
577
|
+
"",
|
|
578
|
+
"Install podman-compose:",
|
|
579
|
+
" pip install podman-compose",
|
|
580
|
+
"Or: https://github.com/containers/podman-compose"
|
|
581
|
+
].join(`
|
|
582
|
+
`);
|
|
583
|
+
}
|
|
584
|
+
return [
|
|
585
|
+
"Compose support not available.",
|
|
586
|
+
"",
|
|
587
|
+
"Docker Compose is included in Docker Desktop.",
|
|
588
|
+
"For Docker Engine on Linux, install the Compose plugin:",
|
|
589
|
+
" sudo apt-get install docker-compose-plugin",
|
|
590
|
+
"Or: https://docs.docker.com/compose/install/"
|
|
591
|
+
].join(`
|
|
592
|
+
`);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// packages/lib/src/ui.ts
|
|
596
|
+
var NO_COLOR = Bun.env.NO_COLOR !== undefined;
|
|
597
|
+
var IS_TTY = process.stdout.isTTY;
|
|
598
|
+
var COLORS_ENABLED = !NO_COLOR && IS_TTY;
|
|
599
|
+
var ANSI = {
|
|
600
|
+
RESET: "\x1B[0m",
|
|
601
|
+
BOLD: "\x1B[1m",
|
|
602
|
+
DIM: "\x1B[2m",
|
|
603
|
+
GREEN: "\x1B[32m",
|
|
604
|
+
RED: "\x1B[31m",
|
|
605
|
+
YELLOW: "\x1B[33m",
|
|
606
|
+
CYAN: "\x1B[36m"
|
|
607
|
+
};
|
|
608
|
+
function bold(text) {
|
|
609
|
+
return COLORS_ENABLED ? `${ANSI.BOLD}${text}${ANSI.RESET}` : text;
|
|
610
|
+
}
|
|
611
|
+
function green(text) {
|
|
612
|
+
return COLORS_ENABLED ? `${ANSI.GREEN}${text}${ANSI.RESET}` : text;
|
|
613
|
+
}
|
|
614
|
+
function red(text) {
|
|
615
|
+
return COLORS_ENABLED ? `${ANSI.RED}${text}${ANSI.RESET}` : text;
|
|
616
|
+
}
|
|
617
|
+
function yellow(text) {
|
|
618
|
+
return COLORS_ENABLED ? `${ANSI.YELLOW}${text}${ANSI.RESET}` : text;
|
|
619
|
+
}
|
|
620
|
+
function cyan(text) {
|
|
621
|
+
return COLORS_ENABLED ? `${ANSI.CYAN}${text}${ANSI.RESET}` : text;
|
|
622
|
+
}
|
|
623
|
+
function dim(text) {
|
|
624
|
+
return COLORS_ENABLED ? `${ANSI.DIM}${text}${ANSI.RESET}` : text;
|
|
625
|
+
}
|
|
626
|
+
function log(msg) {
|
|
627
|
+
console.log(msg);
|
|
628
|
+
}
|
|
629
|
+
function info(msg) {
|
|
630
|
+
console.log(`${cyan("\u2139")} ${msg}`);
|
|
631
|
+
}
|
|
632
|
+
function warn(msg) {
|
|
633
|
+
console.log(`${yellow("\u26A0")} ${msg}`);
|
|
634
|
+
}
|
|
635
|
+
function error(msg) {
|
|
636
|
+
console.error(`${red("\u2716")} ${msg}`);
|
|
637
|
+
}
|
|
638
|
+
function spinner(msg) {
|
|
639
|
+
const frames = ["|", "/", "-", "\\"];
|
|
640
|
+
let frameIndex = 0;
|
|
641
|
+
let intervalId = null;
|
|
642
|
+
if (IS_TTY) {
|
|
643
|
+
intervalId = setInterval(() => {
|
|
644
|
+
const frame = frames[frameIndex];
|
|
645
|
+
frameIndex = (frameIndex + 1) % frames.length;
|
|
646
|
+
process.stdout.write(`\r${cyan(frame)} ${msg}`);
|
|
647
|
+
}, 80);
|
|
648
|
+
} else {
|
|
649
|
+
console.log(msg);
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
stop(finalMsg) {
|
|
653
|
+
if (intervalId) {
|
|
654
|
+
clearInterval(intervalId);
|
|
655
|
+
}
|
|
656
|
+
if (IS_TTY) {
|
|
657
|
+
process.stdout.write("\r\x1B[K");
|
|
658
|
+
}
|
|
659
|
+
if (finalMsg) {
|
|
660
|
+
console.log(finalMsg);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
async function confirm(prompt) {
|
|
666
|
+
process.stdout.write(`${prompt} ${dim("(y/N)")}: `);
|
|
667
|
+
const reader = Bun.stdin.stream().getReader();
|
|
668
|
+
const decoder = new TextDecoder;
|
|
669
|
+
let input = "";
|
|
670
|
+
while (true) {
|
|
671
|
+
const { done, value } = await reader.read();
|
|
672
|
+
if (done)
|
|
673
|
+
break;
|
|
674
|
+
input += decoder.decode(value, { stream: true });
|
|
675
|
+
if (input.includes(`
|
|
676
|
+
`)) {
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
reader.releaseLock();
|
|
681
|
+
const answer = input.trim().toLowerCase();
|
|
682
|
+
return answer === "y" || answer === "yes";
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// packages/cli/src/commands/install.ts
|
|
686
|
+
async function install(options) {
|
|
687
|
+
log(bold(`
|
|
688
|
+
OpenPalm Installation
|
|
689
|
+
`));
|
|
690
|
+
const os = detectOS();
|
|
691
|
+
if (os === "unknown") {
|
|
692
|
+
error("Unable to detect operating system. Installation aborted.");
|
|
693
|
+
process.exit(1);
|
|
694
|
+
}
|
|
695
|
+
const arch = detectArch();
|
|
696
|
+
const platform = options.runtime ?? await detectRuntime(os);
|
|
697
|
+
if (!platform) {
|
|
698
|
+
error(noRuntimeGuidance(os));
|
|
699
|
+
process.exit(1);
|
|
700
|
+
}
|
|
701
|
+
const { bin, subcommand } = resolveComposeBin(platform);
|
|
702
|
+
const preflightWarnings = await runPreflightChecks(bin, platform);
|
|
703
|
+
for (const w of preflightWarnings) {
|
|
704
|
+
warn(w.message);
|
|
705
|
+
if (w.detail) {
|
|
706
|
+
for (const line of w.detail.split(`
|
|
707
|
+
`)) {
|
|
708
|
+
info(` ${line}`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
log("");
|
|
712
|
+
}
|
|
713
|
+
const daemonWarning = preflightWarnings.find((w) => w.message.includes("daemon is not running") || w.message.includes("daemon") || w.message.includes("Could not verify"));
|
|
714
|
+
if (daemonWarning) {
|
|
715
|
+
process.exit(1);
|
|
716
|
+
}
|
|
717
|
+
const isValid = await validateRuntime(bin, subcommand);
|
|
718
|
+
if (!isValid) {
|
|
719
|
+
error(noComposeGuidance(platform));
|
|
720
|
+
process.exit(1);
|
|
721
|
+
}
|
|
722
|
+
log(bold("Detected environment:"));
|
|
723
|
+
info(` OS: ${cyan(os)}`);
|
|
724
|
+
info(` Architecture: ${cyan(arch)}`);
|
|
725
|
+
info(` Container runtime: ${cyan(platform)}${platform === "podman" ? yellow(" (experimental)") : ""}`);
|
|
726
|
+
info(` Compose command: ${cyan(`${bin} ${subcommand}`)}
|
|
727
|
+
`);
|
|
728
|
+
if (platform === "podman") {
|
|
729
|
+
warn("Podman support is experimental. Some features may not work as expected.");
|
|
730
|
+
info(" For the most reliable experience, we recommend Docker Desktop or Docker Engine.");
|
|
731
|
+
log("");
|
|
732
|
+
}
|
|
733
|
+
const xdg = resolveXDGPaths();
|
|
734
|
+
log(bold(`
|
|
735
|
+
XDG paths:`));
|
|
736
|
+
info(` Data: ${dim(xdg.data)}`);
|
|
737
|
+
info(` Config: ${dim(xdg.config)}`);
|
|
738
|
+
info(` State: ${dim(xdg.state)}
|
|
739
|
+
`);
|
|
740
|
+
const stateComposeFile = join4(xdg.state, "docker-compose.yml");
|
|
741
|
+
const stateEnvFile = join4(xdg.state, ".env");
|
|
742
|
+
if (!options.force) {
|
|
743
|
+
try {
|
|
744
|
+
const existingCompose = await Bun.file(stateComposeFile).text();
|
|
745
|
+
if (existingCompose.includes("gateway:") && existingCompose.includes("assistant:")) {
|
|
746
|
+
warn("OpenPalm appears to already be installed.");
|
|
747
|
+
info(" The existing compose file contains a full stack configuration.");
|
|
748
|
+
info("");
|
|
749
|
+
info(" To update to the latest version, run:");
|
|
750
|
+
info(` ${cyan("openpalm update")}`);
|
|
751
|
+
info("");
|
|
752
|
+
info(" To reinstall from scratch, run:");
|
|
753
|
+
info(` ${cyan("openpalm install --force")}`);
|
|
754
|
+
log("");
|
|
755
|
+
const shouldContinue = await confirm("Continue anyway and overwrite the existing installation?");
|
|
756
|
+
if (!shouldContinue) {
|
|
757
|
+
log("Aborted.");
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
} catch {}
|
|
762
|
+
}
|
|
763
|
+
const spin3 = spinner("Creating directory structure...");
|
|
764
|
+
await createDirectoryTree(xdg);
|
|
765
|
+
spin3.stop(green("Directory structure created"));
|
|
766
|
+
const cwdEnvPath = join4(process.cwd(), ".env");
|
|
767
|
+
const cwdEnvExists = await Bun.file(cwdEnvPath).exists();
|
|
768
|
+
const stateEnvExists = await Bun.file(stateEnvFile).exists();
|
|
769
|
+
let generatedAdminToken = "";
|
|
770
|
+
if (stateEnvExists) {
|
|
771
|
+
info("Using existing state .env file");
|
|
772
|
+
} else if (cwdEnvExists) {
|
|
773
|
+
info("Using existing .env file from current directory");
|
|
774
|
+
await Bun.write(stateEnvFile, Bun.file(cwdEnvPath));
|
|
775
|
+
} else {
|
|
776
|
+
const spin2 = spinner("Generating .env file...");
|
|
777
|
+
generatedAdminToken = generateToken();
|
|
778
|
+
const overrides = {
|
|
779
|
+
ADMIN_TOKEN: generatedAdminToken,
|
|
780
|
+
POSTGRES_PASSWORD: generateToken(),
|
|
781
|
+
CHANNEL_CHAT_SECRET: generateToken(),
|
|
782
|
+
CHANNEL_DISCORD_SECRET: generateToken(),
|
|
783
|
+
CHANNEL_VOICE_SECRET: generateToken(),
|
|
784
|
+
CHANNEL_TELEGRAM_SECRET: generateToken()
|
|
785
|
+
};
|
|
786
|
+
const envSeed = Object.entries(overrides).map(([key, value]) => `${key}=${value}`).join(`
|
|
787
|
+
`) + `
|
|
788
|
+
`;
|
|
789
|
+
await writeFile(stateEnvFile, envSeed, { encoding: "utf8", mode: 384 });
|
|
790
|
+
spin2.stop(green(".env file created"));
|
|
791
|
+
}
|
|
792
|
+
if (!generatedAdminToken) {
|
|
793
|
+
const existingEnv = await readEnvFile(stateEnvFile);
|
|
794
|
+
if (!existingEnv.ADMIN_TOKEN || existingEnv.ADMIN_TOKEN === "change-me-admin-token") {
|
|
795
|
+
generatedAdminToken = generateToken();
|
|
796
|
+
await upsertEnvVar(stateEnvFile, "ADMIN_TOKEN", generatedAdminToken);
|
|
797
|
+
warn("Insecure default admin token detected \u2014 regenerated with a secure token.");
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (generatedAdminToken) {
|
|
801
|
+
log("");
|
|
802
|
+
log(bold(green(" YOUR ADMIN PASSWORD (save this!)")));
|
|
803
|
+
log("");
|
|
804
|
+
log(` ${yellow(generatedAdminToken)}`);
|
|
805
|
+
log("");
|
|
806
|
+
info(" You will need this password to log in to the admin dashboard.");
|
|
807
|
+
info(` It is also saved in: ${dim(stateEnvFile)}`);
|
|
808
|
+
log("");
|
|
809
|
+
}
|
|
810
|
+
const socketPath = resolveSocketPath(platform, os);
|
|
811
|
+
const socketUri = resolveSocketUri(platform, os);
|
|
812
|
+
const inContainerSocket = resolveInContainerSocketPath(platform);
|
|
813
|
+
const normPath = (p) => p.replace(/\\/g, "/");
|
|
814
|
+
await upsertEnvVars(stateEnvFile, [
|
|
815
|
+
["OPENPALM_DATA_HOME", normPath(xdg.data)],
|
|
816
|
+
["OPENPALM_CONFIG_HOME", normPath(xdg.config)],
|
|
817
|
+
["OPENPALM_STATE_HOME", normPath(xdg.state)],
|
|
818
|
+
["OPENPALM_CONTAINER_PLATFORM", platform],
|
|
819
|
+
["OPENPALM_COMPOSE_BIN", bin],
|
|
820
|
+
["OPENPALM_COMPOSE_SUBCOMMAND", subcommand],
|
|
821
|
+
["OPENPALM_CONTAINER_SOCKET_PATH", socketPath],
|
|
822
|
+
["OPENPALM_CONTAINER_SOCKET_IN_CONTAINER", inContainerSocket],
|
|
823
|
+
["OPENPALM_CONTAINER_SOCKET_URI", socketUri],
|
|
824
|
+
["OPENPALM_IMAGE_NAMESPACE", "openpalm"],
|
|
825
|
+
["OPENPALM_IMAGE_TAG", `latest-${arch}`],
|
|
826
|
+
["OPENPALM_WORK_HOME", normPath(resolveWorkHome())],
|
|
827
|
+
["OPENPALM_UID", String(process.getuid?.() ?? 1000)],
|
|
828
|
+
["OPENPALM_GID", String(process.getgid?.() ?? 1000)],
|
|
829
|
+
["OPENPALM_ENABLED_CHANNELS", ""]
|
|
830
|
+
]);
|
|
831
|
+
await Bun.write(cwdEnvPath, Bun.file(stateEnvFile));
|
|
832
|
+
const spin4 = spinner("Seeding configuration files...");
|
|
833
|
+
await seedConfigFiles(xdg.config);
|
|
834
|
+
spin4.stop(green("Configuration files seeded"));
|
|
835
|
+
await rm(join4(xdg.data, "admin", "setup-state.json"), { force: true });
|
|
836
|
+
const uninstallDst = join4(xdg.state, "uninstall.sh");
|
|
837
|
+
await writeFile(uninstallDst, `#!/usr/bin/env bash
|
|
838
|
+
openpalm uninstall
|
|
839
|
+
`, "utf8");
|
|
840
|
+
try {
|
|
841
|
+
await chmod(uninstallDst, 493);
|
|
842
|
+
} catch {}
|
|
843
|
+
const systemEnvPath = join4(xdg.state, "system.env");
|
|
844
|
+
const systemEnvExists = await Bun.file(systemEnvPath).exists();
|
|
845
|
+
if (!systemEnvExists) {
|
|
846
|
+
await writeFile(systemEnvPath, `# Generated system env \u2014 populated on first stack apply
|
|
847
|
+
`, "utf8");
|
|
848
|
+
}
|
|
849
|
+
const minimalCaddyJson = JSON.stringify({
|
|
850
|
+
admin: { disabled: true },
|
|
851
|
+
apps: {
|
|
852
|
+
http: {
|
|
853
|
+
servers: {
|
|
854
|
+
main: {
|
|
855
|
+
listen: [":80"],
|
|
856
|
+
routes: [
|
|
857
|
+
{
|
|
858
|
+
match: [{ path: ["/api*"] }],
|
|
859
|
+
handle: [{
|
|
860
|
+
handler: "subroute",
|
|
861
|
+
routes: [
|
|
862
|
+
{
|
|
863
|
+
handle: [
|
|
864
|
+
{ handler: "rewrite", strip_path_prefix: "/api" },
|
|
865
|
+
{ handler: "reverse_proxy", upstreams: [{ dial: "admin:8100" }] }
|
|
866
|
+
]
|
|
867
|
+
}
|
|
868
|
+
]
|
|
869
|
+
}],
|
|
870
|
+
terminal: true
|
|
871
|
+
},
|
|
872
|
+
{
|
|
873
|
+
handle: [{
|
|
874
|
+
handler: "reverse_proxy",
|
|
875
|
+
upstreams: [{ dial: "admin:8100" }]
|
|
876
|
+
}]
|
|
877
|
+
}
|
|
878
|
+
]
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}, null, 2) + `
|
|
884
|
+
`;
|
|
885
|
+
const caddyJsonPath = join4(xdg.state, "rendered", "caddy", "caddy.json");
|
|
886
|
+
await writeFile(caddyJsonPath, minimalCaddyJson, "utf8");
|
|
887
|
+
log(bold(`
|
|
888
|
+
Downloading OpenPalm services (this may take a few minutes on first install)...
|
|
889
|
+
`));
|
|
890
|
+
const minimalCompose = `services:
|
|
891
|
+
caddy:
|
|
892
|
+
image: caddy:2-alpine
|
|
893
|
+
restart: unless-stopped
|
|
894
|
+
ports:
|
|
895
|
+
- "\${OPENPALM_INGRESS_BIND_ADDRESS:-127.0.0.1}:80:80"
|
|
896
|
+
- "\${OPENPALM_INGRESS_BIND_ADDRESS:-127.0.0.1}:443:443"
|
|
897
|
+
volumes:
|
|
898
|
+
- \${OPENPALM_STATE_HOME}/rendered/caddy/caddy.json:/etc/caddy/caddy.json:ro
|
|
899
|
+
- \${OPENPALM_STATE_HOME}/caddy/data:/data/caddy
|
|
900
|
+
- \${OPENPALM_STATE_HOME}/caddy/config:/config/caddy
|
|
901
|
+
command: caddy run --config /etc/caddy/caddy.json
|
|
902
|
+
networks: [assistant_net]
|
|
903
|
+
|
|
904
|
+
admin:
|
|
905
|
+
image: \${OPENPALM_IMAGE_NAMESPACE:-openpalm}/admin:\${OPENPALM_IMAGE_TAG:-latest}
|
|
906
|
+
restart: unless-stopped
|
|
907
|
+
env_file:
|
|
908
|
+
- \${OPENPALM_STATE_HOME}/system.env
|
|
909
|
+
environment:
|
|
910
|
+
- PORT=8100
|
|
911
|
+
- ADMIN_TOKEN=\${ADMIN_TOKEN:-change-me-admin-token}
|
|
912
|
+
- GATEWAY_URL=http://gateway:8080
|
|
913
|
+
- OPENCODE_CORE_URL=http://assistant:4096
|
|
914
|
+
- OPENPALM_COMPOSE_BIN=\${OPENPALM_COMPOSE_BIN:-docker}
|
|
915
|
+
- OPENPALM_COMPOSE_SUBCOMMAND=\${OPENPALM_COMPOSE_SUBCOMMAND:-compose}
|
|
916
|
+
- OPENPALM_CONTAINER_SOCKET_URI=\${OPENPALM_CONTAINER_SOCKET_URI:-unix:///var/run/docker.sock}
|
|
917
|
+
- COMPOSE_PROJECT_PATH=/state
|
|
918
|
+
- OPENPALM_COMPOSE_FILE=docker-compose.yml
|
|
919
|
+
volumes:
|
|
920
|
+
- \${OPENPALM_DATA_HOME}:/data
|
|
921
|
+
- \${OPENPALM_CONFIG_HOME}:/config
|
|
922
|
+
- \${OPENPALM_STATE_HOME}:/state
|
|
923
|
+
- \${OPENPALM_WORK_HOME:-\${HOME}/openpalm}:/work
|
|
924
|
+
- \${OPENPALM_CONTAINER_SOCKET_PATH:-/var/run/docker.sock}:\${OPENPALM_CONTAINER_SOCKET_IN_CONTAINER:-/var/run/docker.sock}
|
|
925
|
+
networks: [assistant_net]
|
|
926
|
+
healthcheck:
|
|
927
|
+
test: ["CMD", "curl", "-fs", "http://localhost:8100/health"]
|
|
928
|
+
interval: 30s
|
|
929
|
+
timeout: 5s
|
|
930
|
+
retries: 3
|
|
931
|
+
start_period: 10s
|
|
932
|
+
|
|
933
|
+
networks:
|
|
934
|
+
channel_net:
|
|
935
|
+
assistant_net:
|
|
936
|
+
`;
|
|
937
|
+
await writeFile(stateComposeFile, minimalCompose, "utf8");
|
|
938
|
+
const composeConfig = {
|
|
939
|
+
bin,
|
|
940
|
+
subcommand,
|
|
941
|
+
composeFile: stateComposeFile,
|
|
942
|
+
envFile: stateEnvFile
|
|
943
|
+
};
|
|
944
|
+
const coreServices = ["caddy", "admin"];
|
|
945
|
+
const spin6 = spinner("Pulling core service images...");
|
|
946
|
+
try {
|
|
947
|
+
await composePull(composeConfig, coreServices);
|
|
948
|
+
spin6.stop(green("Core images pulled"));
|
|
949
|
+
} catch (pullErr) {
|
|
950
|
+
spin6.stop(yellow("Failed to pull core images"));
|
|
951
|
+
warn("Image pull failed. This can happen due to network issues or rate limits.");
|
|
952
|
+
info("");
|
|
953
|
+
info(" To retry, run:");
|
|
954
|
+
info(` ${cyan("openpalm install")}`);
|
|
955
|
+
info("");
|
|
956
|
+
info(" Or manually pull and then start:");
|
|
957
|
+
info(` ${cyan(`${bin} ${subcommand} --env-file ${stateEnvFile} -f ${stateComposeFile} pull`)}`);
|
|
958
|
+
info(` ${cyan(`${bin} ${subcommand} --env-file ${stateEnvFile} -f ${stateComposeFile} up -d`)}`);
|
|
959
|
+
log("");
|
|
960
|
+
process.exit(1);
|
|
961
|
+
}
|
|
962
|
+
const spin7 = spinner("Starting core services...");
|
|
963
|
+
await composeUp(composeConfig, coreServices, { detach: true });
|
|
964
|
+
spin7.stop(green("Core services started"));
|
|
965
|
+
const adminUrl = "http://localhost";
|
|
966
|
+
const healthUrl = `${adminUrl}/setup/status`;
|
|
967
|
+
const spin8 = spinner("Waiting for admin interface...");
|
|
968
|
+
let healthy = false;
|
|
969
|
+
let delay = 1000;
|
|
970
|
+
const maxDelay = 5000;
|
|
971
|
+
const deadline = Date.now() + 180000;
|
|
972
|
+
while (Date.now() < deadline) {
|
|
973
|
+
try {
|
|
974
|
+
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) });
|
|
975
|
+
if (response.ok) {
|
|
976
|
+
healthy = true;
|
|
977
|
+
break;
|
|
978
|
+
}
|
|
979
|
+
} catch {}
|
|
980
|
+
await Bun.sleep(delay);
|
|
981
|
+
delay = Math.min(delay * 1.5, maxDelay);
|
|
982
|
+
}
|
|
983
|
+
if (!healthy) {
|
|
984
|
+
spin8.stop(yellow("Admin interface did not become healthy in time"));
|
|
985
|
+
} else {
|
|
986
|
+
spin8.stop(green("Admin interface ready"));
|
|
987
|
+
}
|
|
988
|
+
if (!options.noOpen && healthy) {
|
|
989
|
+
try {
|
|
990
|
+
if (os === "macos") {
|
|
991
|
+
Bun.spawn(["open", adminUrl]);
|
|
992
|
+
} else if (os === "linux") {
|
|
993
|
+
Bun.spawn(["xdg-open", adminUrl]);
|
|
994
|
+
} else {
|
|
995
|
+
Bun.spawn(["cmd", "/c", "start", adminUrl]);
|
|
996
|
+
}
|
|
997
|
+
} catch {}
|
|
998
|
+
}
|
|
999
|
+
if (healthy) {
|
|
1000
|
+
log("");
|
|
1001
|
+
log(bold(green(" OpenPalm setup wizard is ready!")));
|
|
1002
|
+
log("");
|
|
1003
|
+
info(` Setup wizard: ${cyan(adminUrl)}`);
|
|
1004
|
+
log("");
|
|
1005
|
+
if (generatedAdminToken) {
|
|
1006
|
+
info(` Admin password: ${yellow(generatedAdminToken)}`);
|
|
1007
|
+
log("");
|
|
1008
|
+
}
|
|
1009
|
+
log(bold(" What happens next:"));
|
|
1010
|
+
info(" 1. The setup wizard opens in your browser");
|
|
1011
|
+
info(" 2. Enter your AI provider API key (e.g. from console.anthropic.com)");
|
|
1012
|
+
info(" 3. The wizard will download and start remaining services automatically");
|
|
1013
|
+
info(" 4. Pick which channels to enable (chat, Discord, etc.)");
|
|
1014
|
+
info(" 5. Done! Start chatting with your assistant");
|
|
1015
|
+
log("");
|
|
1016
|
+
if (!options.noOpen) {
|
|
1017
|
+
info(" Opening setup wizard in your browser...");
|
|
1018
|
+
} else {
|
|
1019
|
+
info(` Open this URL in your browser to continue: ${adminUrl}`);
|
|
1020
|
+
}
|
|
1021
|
+
} else {
|
|
1022
|
+
log("");
|
|
1023
|
+
log(bold(yellow(" Setup did not come online within 3 minutes")));
|
|
1024
|
+
log("");
|
|
1025
|
+
info(" This usually means containers are still starting. Try these steps:");
|
|
1026
|
+
log("");
|
|
1027
|
+
info(` 1. Wait a minute, then open: ${adminUrl}`);
|
|
1028
|
+
log("");
|
|
1029
|
+
info(" 2. Check if containers are running:");
|
|
1030
|
+
info(" openpalm status");
|
|
1031
|
+
log("");
|
|
1032
|
+
info(" 3. Check logs for errors:");
|
|
1033
|
+
info(" openpalm logs");
|
|
1034
|
+
log("");
|
|
1035
|
+
info(" 4. Common fixes:");
|
|
1036
|
+
info(" - Make sure port 80 is not used by another service");
|
|
1037
|
+
info(" - Restart Docker/Podman and try again");
|
|
1038
|
+
info(" - Check that you have internet access (images need to download)");
|
|
1039
|
+
}
|
|
1040
|
+
log("");
|
|
1041
|
+
log(bold(" Useful commands:"));
|
|
1042
|
+
info(" View logs: openpalm logs");
|
|
1043
|
+
info(" Stop: openpalm stop");
|
|
1044
|
+
info(" Uninstall: openpalm uninstall");
|
|
1045
|
+
log("");
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// packages/cli/src/commands/uninstall.ts
|
|
1049
|
+
import { rm as rm2, unlink } from "fs/promises";
|
|
1050
|
+
import { join as join5, resolve } from "path";
|
|
1051
|
+
import { homedir as homedir4 } from "os";
|
|
1052
|
+
async function uninstall(options) {
|
|
1053
|
+
const xdg = resolveXDGPaths();
|
|
1054
|
+
let env = {};
|
|
1055
|
+
const stateEnvPath = join5(xdg.state, ".env");
|
|
1056
|
+
try {
|
|
1057
|
+
env = await readEnvFile(stateEnvPath);
|
|
1058
|
+
} catch {
|
|
1059
|
+
try {
|
|
1060
|
+
env = await readEnvFile(resolve(process.cwd(), ".env"));
|
|
1061
|
+
} catch {}
|
|
1062
|
+
}
|
|
1063
|
+
let platform = null;
|
|
1064
|
+
if (options.runtime) {
|
|
1065
|
+
platform = options.runtime;
|
|
1066
|
+
} else if (env.OPENPALM_CONTAINER_PLATFORM) {
|
|
1067
|
+
platform = env.OPENPALM_CONTAINER_PLATFORM;
|
|
1068
|
+
} else {
|
|
1069
|
+
platform = await detectRuntime(detectOS());
|
|
1070
|
+
}
|
|
1071
|
+
let composeBin = null;
|
|
1072
|
+
if (platform) {
|
|
1073
|
+
composeBin = resolveComposeBin(platform);
|
|
1074
|
+
}
|
|
1075
|
+
log("");
|
|
1076
|
+
log(bold("Uninstall Summary:"));
|
|
1077
|
+
log(`Runtime platform: ${platform || "not detected"}`);
|
|
1078
|
+
log("Stop/remove containers: yes");
|
|
1079
|
+
log(`Remove images: ${options.removeImages ? "yes" : "no"}`);
|
|
1080
|
+
log(`Remove all data/config/state: ${options.removeAll ? "yes" : "no"}`);
|
|
1081
|
+
log(`Remove CLI binary: ${options.removeBinary ? "yes" : "no"}`);
|
|
1082
|
+
log("");
|
|
1083
|
+
log(`Data directory: ${xdg.data}`);
|
|
1084
|
+
log(`Config directory: ${xdg.config}`);
|
|
1085
|
+
log(`State directory: ${xdg.state}`);
|
|
1086
|
+
log("");
|
|
1087
|
+
if (!options.yes) {
|
|
1088
|
+
const shouldContinue = await confirm("Continue?");
|
|
1089
|
+
if (!shouldContinue) {
|
|
1090
|
+
log("Aborted.");
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
const composeFilePath = join5(xdg.state, "docker-compose.yml");
|
|
1095
|
+
if (composeBin && platform) {
|
|
1096
|
+
try {
|
|
1097
|
+
await Bun.file(composeFilePath).text();
|
|
1098
|
+
const config = {
|
|
1099
|
+
bin: composeBin.bin,
|
|
1100
|
+
subcommand: composeBin.subcommand,
|
|
1101
|
+
envFile: stateEnvPath,
|
|
1102
|
+
composeFile: composeFilePath
|
|
1103
|
+
};
|
|
1104
|
+
await composeDown(config, {
|
|
1105
|
+
removeOrphans: true,
|
|
1106
|
+
removeImages: options.removeImages
|
|
1107
|
+
});
|
|
1108
|
+
} catch {
|
|
1109
|
+
warn("Compose runtime or file not found; skipping container shutdown.");
|
|
1110
|
+
}
|
|
1111
|
+
} else {
|
|
1112
|
+
warn("Compose runtime or file not found; skipping container shutdown.");
|
|
1113
|
+
}
|
|
1114
|
+
if (options.removeAll) {
|
|
1115
|
+
try {
|
|
1116
|
+
await rm2(xdg.data, { recursive: true, force: true });
|
|
1117
|
+
} catch {}
|
|
1118
|
+
try {
|
|
1119
|
+
await rm2(xdg.config, { recursive: true, force: true });
|
|
1120
|
+
} catch {}
|
|
1121
|
+
try {
|
|
1122
|
+
await rm2(xdg.state, { recursive: true, force: true });
|
|
1123
|
+
} catch {}
|
|
1124
|
+
try {
|
|
1125
|
+
await unlink(resolve(process.cwd(), ".env"));
|
|
1126
|
+
} catch {}
|
|
1127
|
+
info("Removed OpenPalm data/config/state and local .env.");
|
|
1128
|
+
}
|
|
1129
|
+
if (options.removeBinary) {
|
|
1130
|
+
const os = detectOS();
|
|
1131
|
+
let binaryPath;
|
|
1132
|
+
if (os === "windows") {
|
|
1133
|
+
const localAppData = Bun.env.LOCALAPPDATA || join5(homedir4(), "AppData", "Local");
|
|
1134
|
+
binaryPath = join5(localAppData, "OpenPalm", "openpalm.exe");
|
|
1135
|
+
} else {
|
|
1136
|
+
binaryPath = join5(homedir4(), ".local", "bin", "openpalm");
|
|
1137
|
+
}
|
|
1138
|
+
try {
|
|
1139
|
+
await unlink(binaryPath);
|
|
1140
|
+
info(`Removed CLI binary: ${binaryPath}`);
|
|
1141
|
+
} catch {
|
|
1142
|
+
warn(`Could not remove binary at ${dim(binaryPath)} \u2014 it may have been installed elsewhere.`);
|
|
1143
|
+
}
|
|
1144
|
+
if (os === "windows") {
|
|
1145
|
+
try {
|
|
1146
|
+
const localAppData = Bun.env.LOCALAPPDATA || join5(homedir4(), "AppData", "Local");
|
|
1147
|
+
const installDir = join5(localAppData, "OpenPalm");
|
|
1148
|
+
const proc = Bun.spawn([
|
|
1149
|
+
"powershell",
|
|
1150
|
+
"-NoProfile",
|
|
1151
|
+
"-Command",
|
|
1152
|
+
`$p = [Environment]::GetEnvironmentVariable('Path','User'); ` + `$p = ($p.Split(';') | Where-Object { $_ -ne '${installDir.replace(/'/g, "''")}' }) -join ';'; ` + `[Environment]::SetEnvironmentVariable('Path', $p, 'User')`
|
|
1153
|
+
], { stdout: "ignore", stderr: "ignore" });
|
|
1154
|
+
await proc.exited;
|
|
1155
|
+
if (proc.exitCode === 0) {
|
|
1156
|
+
info("Removed install directory from user PATH.");
|
|
1157
|
+
}
|
|
1158
|
+
} catch {}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
const workDir = resolveWorkHome();
|
|
1162
|
+
log("");
|
|
1163
|
+
info(`Note: ${dim(workDir)} (assistant working directory) was not removed.`);
|
|
1164
|
+
info(" Delete it manually if you no longer need it.");
|
|
1165
|
+
info(green("Uninstall complete."));
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// packages/lib/src/config.ts
|
|
1169
|
+
import { join as join6 } from "path";
|
|
1170
|
+
async function loadComposeConfig() {
|
|
1171
|
+
const xdg = resolveXDGPaths();
|
|
1172
|
+
const envPath = join6(xdg.state, ".env");
|
|
1173
|
+
const env = await readEnvFile(envPath);
|
|
1174
|
+
return {
|
|
1175
|
+
bin: env.OPENPALM_COMPOSE_BIN ?? "docker",
|
|
1176
|
+
subcommand: env.OPENPALM_COMPOSE_SUBCOMMAND ?? "compose",
|
|
1177
|
+
envFile: envPath,
|
|
1178
|
+
composeFile: join6(xdg.state, "docker-compose.yml")
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// packages/cli/src/commands/update.ts
|
|
1183
|
+
async function update() {
|
|
1184
|
+
const config = await loadComposeConfig();
|
|
1185
|
+
info("Pulling latest images...");
|
|
1186
|
+
await composePull(config);
|
|
1187
|
+
info("Recreating containers with updated images...");
|
|
1188
|
+
await composeUp(config, undefined, { pull: "always" });
|
|
1189
|
+
info(green("Update complete."));
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// packages/cli/src/commands/start.ts
|
|
1193
|
+
async function start(services) {
|
|
1194
|
+
const config = await loadComposeConfig();
|
|
1195
|
+
info("Starting services...");
|
|
1196
|
+
await composeUp(config, services);
|
|
1197
|
+
info(green("Services started."));
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// packages/cli/src/commands/stop.ts
|
|
1201
|
+
async function stop(services) {
|
|
1202
|
+
const config = await loadComposeConfig();
|
|
1203
|
+
info("Stopping services...");
|
|
1204
|
+
await composeStop(config, services);
|
|
1205
|
+
info(green("Services stopped."));
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// packages/cli/src/commands/restart.ts
|
|
1209
|
+
async function restart(services) {
|
|
1210
|
+
const config = await loadComposeConfig();
|
|
1211
|
+
info("Restarting services...");
|
|
1212
|
+
await composeRestart(config, services);
|
|
1213
|
+
info(green("Services restarted."));
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// packages/cli/src/commands/logs.ts
|
|
1217
|
+
async function logs(services) {
|
|
1218
|
+
const config = await loadComposeConfig();
|
|
1219
|
+
await composeLogs(config, services?.length ? services : undefined, { follow: true, tail: 50 });
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// packages/cli/src/commands/status.ts
|
|
1223
|
+
async function status() {
|
|
1224
|
+
const config = await loadComposeConfig();
|
|
1225
|
+
const output = await composePs(config);
|
|
1226
|
+
log(output);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// packages/cli/src/commands/extensions.ts
|
|
1230
|
+
import { join as join7 } from "path";
|
|
1231
|
+
async function extensions(subcommand, args) {
|
|
1232
|
+
function getArg(name) {
|
|
1233
|
+
const index = args.indexOf(`--${name}`);
|
|
1234
|
+
return index >= 0 && index + 1 < args.length ? args[index + 1] : undefined;
|
|
1235
|
+
}
|
|
1236
|
+
let adminToken = Bun.env.ADMIN_TOKEN;
|
|
1237
|
+
if (!adminToken) {
|
|
1238
|
+
try {
|
|
1239
|
+
const stateEnvPath = join7(resolveXDGPaths().state, ".env");
|
|
1240
|
+
const envVars = await readEnvFile(stateEnvPath);
|
|
1241
|
+
adminToken = envVars.ADMIN_TOKEN;
|
|
1242
|
+
} catch {}
|
|
1243
|
+
}
|
|
1244
|
+
if (!adminToken) {
|
|
1245
|
+
error("ADMIN_TOKEN not found in environment or state .env file");
|
|
1246
|
+
process.exit(1);
|
|
1247
|
+
}
|
|
1248
|
+
const base = Bun.env.ADMIN_APP_URL ?? Bun.env.GATEWAY_URL ?? "http://localhost";
|
|
1249
|
+
const headers = {
|
|
1250
|
+
"content-type": "application/json",
|
|
1251
|
+
"x-admin-token": adminToken
|
|
1252
|
+
};
|
|
1253
|
+
function checkResponse(response, action) {
|
|
1254
|
+
if (!response.ok) {
|
|
1255
|
+
throw new Error(`${action} failed: HTTP ${response.status} ${response.statusText}`);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
try {
|
|
1259
|
+
switch (subcommand) {
|
|
1260
|
+
case "install": {
|
|
1261
|
+
const pluginId = getArg("plugin");
|
|
1262
|
+
if (!pluginId) {
|
|
1263
|
+
error("--plugin <id> is required for install");
|
|
1264
|
+
info("Usage: openpalm extensions install --plugin <id>");
|
|
1265
|
+
process.exit(1);
|
|
1266
|
+
}
|
|
1267
|
+
const response = await fetch(`${base}/plugins/install`, {
|
|
1268
|
+
method: "POST",
|
|
1269
|
+
headers,
|
|
1270
|
+
body: JSON.stringify({ pluginId })
|
|
1271
|
+
});
|
|
1272
|
+
checkResponse(response, "Extension install");
|
|
1273
|
+
const text = await response.text();
|
|
1274
|
+
info(text);
|
|
1275
|
+
break;
|
|
1276
|
+
}
|
|
1277
|
+
case "uninstall": {
|
|
1278
|
+
const pluginId = getArg("plugin");
|
|
1279
|
+
if (!pluginId) {
|
|
1280
|
+
error("--plugin <id> is required for uninstall");
|
|
1281
|
+
info("Usage: openpalm extensions uninstall --plugin <id>");
|
|
1282
|
+
process.exit(1);
|
|
1283
|
+
}
|
|
1284
|
+
const response = await fetch(`${base}/plugins/uninstall`, {
|
|
1285
|
+
method: "POST",
|
|
1286
|
+
headers,
|
|
1287
|
+
body: JSON.stringify({ pluginId })
|
|
1288
|
+
});
|
|
1289
|
+
checkResponse(response, "Extension uninstall");
|
|
1290
|
+
const text = await response.text();
|
|
1291
|
+
info(text);
|
|
1292
|
+
break;
|
|
1293
|
+
}
|
|
1294
|
+
case "list": {
|
|
1295
|
+
const response = await fetch(`${base}/installed`, {
|
|
1296
|
+
method: "GET",
|
|
1297
|
+
headers
|
|
1298
|
+
});
|
|
1299
|
+
checkResponse(response, "Extension list");
|
|
1300
|
+
const text = await response.text();
|
|
1301
|
+
info(text);
|
|
1302
|
+
break;
|
|
1303
|
+
}
|
|
1304
|
+
default:
|
|
1305
|
+
error(`Unknown subcommand: ${subcommand}`);
|
|
1306
|
+
info("Usage: openpalm extensions <install|uninstall|list> [--plugin <id>]");
|
|
1307
|
+
process.exit(1);
|
|
1308
|
+
}
|
|
1309
|
+
} catch (err) {
|
|
1310
|
+
error(`Failed to execute extensions command: ${err}`);
|
|
1311
|
+
process.exit(1);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// packages/cli/src/commands/preflight.ts
|
|
1316
|
+
import { statSync } from "fs";
|
|
1317
|
+
var devDir = ".dev";
|
|
1318
|
+
var envFile = ".env";
|
|
1319
|
+
var requiredDirs = [
|
|
1320
|
+
"config",
|
|
1321
|
+
"data/postgres",
|
|
1322
|
+
"data/qdrant",
|
|
1323
|
+
"data/openmemory",
|
|
1324
|
+
"data/assistant",
|
|
1325
|
+
"state/gateway",
|
|
1326
|
+
"state/caddy",
|
|
1327
|
+
"state/rendered/caddy"
|
|
1328
|
+
];
|
|
1329
|
+
function preflight() {
|
|
1330
|
+
const issues = [];
|
|
1331
|
+
try {
|
|
1332
|
+
statSync(envFile);
|
|
1333
|
+
} catch {
|
|
1334
|
+
issues.push(`Missing ${envFile}. Run: bun run dev:setup`);
|
|
1335
|
+
}
|
|
1336
|
+
try {
|
|
1337
|
+
statSync(devDir);
|
|
1338
|
+
} catch {
|
|
1339
|
+
issues.push(`Missing ${devDir}/ directory. Run: bun run dev:setup`);
|
|
1340
|
+
}
|
|
1341
|
+
if (issues.length === 0) {
|
|
1342
|
+
for (const dir of requiredDirs) {
|
|
1343
|
+
try {
|
|
1344
|
+
statSync(`${devDir}/${dir}`);
|
|
1345
|
+
} catch {
|
|
1346
|
+
issues.push(`Missing ${devDir}/${dir}. Run: bun run dev:setup`);
|
|
1347
|
+
break;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
if (issues.length > 0) {
|
|
1352
|
+
throw new Error(`Pre-flight check failed:
|
|
1353
|
+
|
|
1354
|
+
${issues.map((issue) => ` - ${issue}`).join(`
|
|
1355
|
+
`)}
|
|
1356
|
+
|
|
1357
|
+
Run 'bun run dev:setup' first, then try again.`);
|
|
1358
|
+
}
|
|
1359
|
+
info("Pre-flight check passed.");
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// packages/cli/src/commands/create-channel.ts
|
|
1363
|
+
import { mkdirSync, writeFileSync, existsSync } from "fs";
|
|
1364
|
+
import { join as join8 } from "path";
|
|
1365
|
+
function usage() {
|
|
1366
|
+
throw new Error("Usage: openpalm dev create-channel <channel-name> [--port <number>]");
|
|
1367
|
+
}
|
|
1368
|
+
function createChannel(args) {
|
|
1369
|
+
const nameArg = args.find((arg) => !arg.startsWith("--"));
|
|
1370
|
+
if (!nameArg)
|
|
1371
|
+
usage();
|
|
1372
|
+
const name = nameArg.toLowerCase();
|
|
1373
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
1374
|
+
throw new Error(`Error: channel name must be lowercase alphanumeric with hyphens, got "${name}"`);
|
|
1375
|
+
}
|
|
1376
|
+
if (name.length > 32)
|
|
1377
|
+
throw new Error("Error: channel name must be 32 characters or fewer");
|
|
1378
|
+
const reserved = new Set(["chat", "discord", "voice", "telegram", "webhook"]);
|
|
1379
|
+
if (reserved.has(name)) {
|
|
1380
|
+
throw new Error(`Error: "${name}" is an existing built-in channel. Choose a different name.`);
|
|
1381
|
+
}
|
|
1382
|
+
const portIndex = args.indexOf("--port");
|
|
1383
|
+
const defaultPort = portIndex !== -1 && args[portIndex + 1] ? Number(args[portIndex + 1]) : 8190;
|
|
1384
|
+
if (Number.isNaN(defaultPort) || defaultPort < 1024 || defaultPort > 65535) {
|
|
1385
|
+
throw new Error("Error: --port must be a number between 1024 and 65535");
|
|
1386
|
+
}
|
|
1387
|
+
const root = process.cwd();
|
|
1388
|
+
const channelDir = join8(root, "channels", name);
|
|
1389
|
+
if (existsSync(channelDir))
|
|
1390
|
+
throw new Error(`Error: channels/${name}/ already exists.`);
|
|
1391
|
+
const envPrefix = name.replace(/-/g, "_").toUpperCase();
|
|
1392
|
+
const secretVar = `CHANNEL_${envPrefix}_SECRET`;
|
|
1393
|
+
const inboundTokenVar = `${envPrefix}_INBOUND_TOKEN`;
|
|
1394
|
+
const serviceName = `channel-${name}`;
|
|
1395
|
+
const camel = name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
1396
|
+
const pascal = camel.charAt(0).toUpperCase() + camel.slice(1);
|
|
1397
|
+
const createFn = `create${pascal}Channel`;
|
|
1398
|
+
const channelTs = `import type { ChannelAdapter, InboundResult } from "@openpalm/lib/channel.ts";
|
|
1399
|
+
|
|
1400
|
+
const INBOUND_TOKEN = Bun.env.${inboundTokenVar} ?? "";
|
|
1401
|
+
|
|
1402
|
+
export function ${createFn}(): ChannelAdapter {
|
|
1403
|
+
return {
|
|
1404
|
+
name: "${name}",
|
|
1405
|
+
routes: [
|
|
1406
|
+
{
|
|
1407
|
+
method: "POST",
|
|
1408
|
+
path: "/${name}/inbound",
|
|
1409
|
+
handler: async (req: Request): Promise<InboundResult> => {
|
|
1410
|
+
if (INBOUND_TOKEN && req.headers.get("x-${name}-token") !== INBOUND_TOKEN) {
|
|
1411
|
+
return { ok: false, status: 401, body: { error: "unauthorized" } };
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const body = (await req.json()) as {
|
|
1415
|
+
userId?: string;
|
|
1416
|
+
text?: string;
|
|
1417
|
+
metadata?: Record<string, unknown>;
|
|
1418
|
+
};
|
|
1419
|
+
|
|
1420
|
+
if (!body.text) {
|
|
1421
|
+
return { ok: false, status: 400, body: { error: "text_required" } };
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
return {
|
|
1425
|
+
ok: true,
|
|
1426
|
+
payload: {
|
|
1427
|
+
userId: body.userId ?? "${name}-user",
|
|
1428
|
+
channel: "${name}",
|
|
1429
|
+
text: body.text,
|
|
1430
|
+
metadata: body.metadata ?? {},
|
|
1431
|
+
},
|
|
1432
|
+
};
|
|
1433
|
+
},
|
|
1434
|
+
},
|
|
1435
|
+
],
|
|
1436
|
+
|
|
1437
|
+
health: () => ({ ok: true, service: "${serviceName}" }),
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
`;
|
|
1441
|
+
const serverTs = `import { createHmac } from "node:crypto";
|
|
1442
|
+
import { ${createFn} } from "./channel.ts";
|
|
1443
|
+
import type { ChannelAdapter } from "@openpalm/lib/channel.ts";
|
|
1444
|
+
|
|
1445
|
+
const PORT = Number(Bun.env.PORT ?? ${defaultPort});
|
|
1446
|
+
const GATEWAY_URL = Bun.env.GATEWAY_URL ?? "http://gateway:8080";
|
|
1447
|
+
const SHARED_SECRET = Bun.env.${secretVar} ?? "";
|
|
1448
|
+
|
|
1449
|
+
export function signPayload(secret: string, body: string) {
|
|
1450
|
+
return createHmac("sha256", secret).update(body).digest("hex");
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function json(status: number, data: unknown) {
|
|
1454
|
+
return new Response(JSON.stringify(data, null, 2), {
|
|
1455
|
+
status,
|
|
1456
|
+
headers: { "content-type": "application/json" },
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
export function createFetch(
|
|
1461
|
+
adapter: ChannelAdapter,
|
|
1462
|
+
gatewayUrl: string,
|
|
1463
|
+
sharedSecret: string,
|
|
1464
|
+
forwardFetch: typeof fetch = fetch,
|
|
1465
|
+
) {
|
|
1466
|
+
const routeMap = new Map(adapter.routes.map((route) => [route.method + " " + route.path, route.handler]));
|
|
1467
|
+
|
|
1468
|
+
return async function handle(req: Request): Promise<Response> {
|
|
1469
|
+
const url = new URL(req.url);
|
|
1470
|
+
if (url.pathname === "/health") return json(200, adapter.health());
|
|
1471
|
+
|
|
1472
|
+
const handler = routeMap.get(req.method + " " + url.pathname);
|
|
1473
|
+
if (!handler) return json(404, { error: "not_found" });
|
|
1474
|
+
|
|
1475
|
+
const result = await handler(req);
|
|
1476
|
+
if (!result.ok) return json(result.status, result.body);
|
|
1477
|
+
|
|
1478
|
+
const gatewayPayload = {
|
|
1479
|
+
...result.payload,
|
|
1480
|
+
nonce: crypto.randomUUID(),
|
|
1481
|
+
timestamp: Date.now(),
|
|
1482
|
+
};
|
|
1483
|
+
|
|
1484
|
+
const serialized = JSON.stringify(gatewayPayload);
|
|
1485
|
+
const sig = signPayload(sharedSecret, serialized);
|
|
1486
|
+
|
|
1487
|
+
const resp = await forwardFetch(gatewayUrl + "/channel/inbound", {
|
|
1488
|
+
method: "POST",
|
|
1489
|
+
headers: {
|
|
1490
|
+
"content-type": "application/json",
|
|
1491
|
+
"x-channel-signature": sig,
|
|
1492
|
+
},
|
|
1493
|
+
body: serialized,
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
return new Response(await resp.text(), {
|
|
1497
|
+
status: resp.status,
|
|
1498
|
+
headers: { "content-type": "application/json" },
|
|
1499
|
+
});
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
if (import.meta.main) {
|
|
1504
|
+
const adapter = ${createFn}();
|
|
1505
|
+
Bun.serve({ port: PORT, fetch: createFetch(adapter, GATEWAY_URL, SHARED_SECRET) });
|
|
1506
|
+
console.log("${name} channel listening on " + PORT);
|
|
1507
|
+
}
|
|
1508
|
+
`;
|
|
1509
|
+
const testTs = `import { describe, expect, it } from "bun:test";
|
|
1510
|
+
import { ${createFn} } from "./channel.ts";
|
|
1511
|
+
import { createFetch, signPayload } from "./server.ts";
|
|
1512
|
+
|
|
1513
|
+
describe("${name} adapter", () => {
|
|
1514
|
+
const adapter = ${createFn}();
|
|
1515
|
+
|
|
1516
|
+
it("returns health status", async () => {
|
|
1517
|
+
const handler = createFetch(adapter, "http://gateway", "secret");
|
|
1518
|
+
const resp = await handler(new Request("http://test/health"));
|
|
1519
|
+
expect(resp.status).toBe(200);
|
|
1520
|
+
const data = (await resp.json()) as { ok: boolean; service: string };
|
|
1521
|
+
expect(data.ok).toBe(true);
|
|
1522
|
+
expect(data.service).toBe("${serviceName}");
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
it("returns 404 for unknown routes", async () => {
|
|
1526
|
+
const handler = createFetch(adapter, "http://gateway", "secret");
|
|
1527
|
+
const resp = await handler(new Request("http://test/unknown"));
|
|
1528
|
+
expect(resp.status).toBe(404);
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
it("returns 400 when text is missing", async () => {
|
|
1532
|
+
const handler = createFetch(adapter, "http://gateway", "secret");
|
|
1533
|
+
const resp = await handler(new Request("http://test/${name}/inbound", {
|
|
1534
|
+
method: "POST",
|
|
1535
|
+
body: JSON.stringify({ userId: "u1" }),
|
|
1536
|
+
headers: { "content-type": "application/json" },
|
|
1537
|
+
}));
|
|
1538
|
+
expect(resp.status).toBe(400);
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
it("normalizes payload and forwards with valid HMAC", async () => {
|
|
1542
|
+
let capturedUrl = "";
|
|
1543
|
+
let capturedSig = "";
|
|
1544
|
+
let capturedBody = "";
|
|
1545
|
+
|
|
1546
|
+
const mockFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
1547
|
+
capturedUrl = String(input);
|
|
1548
|
+
capturedSig = String((init?.headers as Record<string, string>)["x-channel-signature"]);
|
|
1549
|
+
capturedBody = String(init?.body);
|
|
1550
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
1551
|
+
};
|
|
1552
|
+
|
|
1553
|
+
const handler = createFetch(adapter, "http://gateway", "test-secret", mockFetch as typeof fetch);
|
|
1554
|
+
|
|
1555
|
+
const resp = await handler(new Request("http://test/${name}/inbound", {
|
|
1556
|
+
method: "POST",
|
|
1557
|
+
body: JSON.stringify({ userId: "u1", text: "hello" }),
|
|
1558
|
+
headers: { "content-type": "application/json" },
|
|
1559
|
+
}));
|
|
1560
|
+
|
|
1561
|
+
expect(resp.status).toBe(200);
|
|
1562
|
+
expect(capturedUrl).toBe("http://gateway/channel/inbound");
|
|
1563
|
+
|
|
1564
|
+
const parsed = JSON.parse(capturedBody) as Record<string, unknown>;
|
|
1565
|
+
expect(parsed.channel).toBe("${name}");
|
|
1566
|
+
expect(parsed.text).toBe("hello");
|
|
1567
|
+
expect(parsed.userId).toBe("u1");
|
|
1568
|
+
expect(typeof parsed.nonce).toBe("string");
|
|
1569
|
+
expect(typeof parsed.timestamp).toBe("number");
|
|
1570
|
+
expect(capturedSig).toBe(signPayload("test-secret", capturedBody));
|
|
1571
|
+
});
|
|
1572
|
+
});
|
|
1573
|
+
`;
|
|
1574
|
+
const packageJson = `{
|
|
1575
|
+
"name": "@openpalm/channel-${name}",
|
|
1576
|
+
"private": true,
|
|
1577
|
+
"type": "module",
|
|
1578
|
+
"scripts": {
|
|
1579
|
+
"start": "bun run server.ts",
|
|
1580
|
+
"test": "bun test"
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
`;
|
|
1584
|
+
const dockerfile = `FROM oven/bun:1.1.42
|
|
1585
|
+
WORKDIR /app
|
|
1586
|
+
COPY package.json ./
|
|
1587
|
+
COPY server.ts ./server.ts
|
|
1588
|
+
COPY channel.ts ./channel.ts
|
|
1589
|
+
RUN bun install --production
|
|
1590
|
+
CMD ["bun", "run", "server.ts"]
|
|
1591
|
+
`;
|
|
1592
|
+
const envFile2 = `# Channel-specific overrides managed by admin UI
|
|
1593
|
+
${inboundTokenVar}=
|
|
1594
|
+
`;
|
|
1595
|
+
mkdirSync(channelDir, { recursive: true });
|
|
1596
|
+
const files = [
|
|
1597
|
+
["channel.ts", channelTs],
|
|
1598
|
+
["server.ts", serverTs],
|
|
1599
|
+
["server.test.ts", testTs],
|
|
1600
|
+
["package.json", packageJson],
|
|
1601
|
+
["Dockerfile", dockerfile]
|
|
1602
|
+
];
|
|
1603
|
+
for (const [fileName, content] of files) {
|
|
1604
|
+
writeFileSync(join8(channelDir, fileName), content, "utf8");
|
|
1605
|
+
}
|
|
1606
|
+
const channelEnvDir = join8(root, "assets", "config", "channels");
|
|
1607
|
+
if (existsSync(channelEnvDir)) {
|
|
1608
|
+
writeFileSync(join8(channelEnvDir, `${name}.env`), envFile2, "utf8");
|
|
1609
|
+
}
|
|
1610
|
+
console.log(`\u2714 Channel scaffolded: channels/${name}/`);
|
|
1611
|
+
}
|
|
1612
|
+
// packages/cli/package.json
|
|
1613
|
+
var package_default = {
|
|
1614
|
+
name: "openpalm",
|
|
1615
|
+
version: "0.2.7",
|
|
1616
|
+
description: "CLI tool for installing and managing an OpenPalm stack",
|
|
1617
|
+
type: "module",
|
|
1618
|
+
license: "MIT",
|
|
1619
|
+
repository: {
|
|
1620
|
+
type: "git",
|
|
1621
|
+
url: "https://github.com/itlackey/openpalm.git",
|
|
1622
|
+
directory: "packages/cli"
|
|
1623
|
+
},
|
|
1624
|
+
homepage: "https://github.com/itlackey/openpalm",
|
|
1625
|
+
keywords: [
|
|
1626
|
+
"openpalm",
|
|
1627
|
+
"ai",
|
|
1628
|
+
"cli",
|
|
1629
|
+
"docker",
|
|
1630
|
+
"installer"
|
|
1631
|
+
],
|
|
1632
|
+
bin: {
|
|
1633
|
+
openpalm: "./src/main.ts"
|
|
1634
|
+
},
|
|
1635
|
+
files: [
|
|
1636
|
+
"src/**/*.ts"
|
|
1637
|
+
],
|
|
1638
|
+
engines: {
|
|
1639
|
+
bun: ">=1.2.0"
|
|
1640
|
+
},
|
|
1641
|
+
scripts: {
|
|
1642
|
+
start: "bun run src/main.ts",
|
|
1643
|
+
test: "bun test",
|
|
1644
|
+
build: "bun build src/main.ts --compile --outfile dist/openpalm",
|
|
1645
|
+
"build:linux-x64": "bun build src/main.ts --compile --target=bun-linux-x64 --outfile dist/openpalm-linux-x64",
|
|
1646
|
+
"build:linux-arm64": "bun build src/main.ts --compile --target=bun-linux-arm64 --outfile dist/openpalm-linux-arm64",
|
|
1647
|
+
"build:darwin-x64": "bun build src/main.ts --compile --target=bun-darwin-x64 --outfile dist/openpalm-darwin-x64",
|
|
1648
|
+
"build:darwin-arm64": "bun build src/main.ts --compile --target=bun-darwin-arm64 --outfile dist/openpalm-darwin-arm64",
|
|
1649
|
+
"build:windows-x64": "bun build src/main.ts --compile --target=bun-windows-x64 --outfile dist/openpalm-windows-x64.exe",
|
|
1650
|
+
"build:windows-arm64": "bun build src/main.ts --compile --target=bun-windows-arm64 --outfile dist/openpalm-windows-arm64.exe"
|
|
1651
|
+
},
|
|
1652
|
+
dependencies: {
|
|
1653
|
+
"@openpalm/lib": "workspace:*"
|
|
1654
|
+
}
|
|
1655
|
+
};
|
|
1656
|
+
|
|
1657
|
+
// packages/cli/src/main.ts
|
|
1658
|
+
var VERSION = package_default.version;
|
|
1659
|
+
function printHelp() {
|
|
1660
|
+
log(bold("openpalm") + dim(` v${VERSION}`));
|
|
1661
|
+
log("");
|
|
1662
|
+
log(bold("Usage:"));
|
|
1663
|
+
log(" openpalm <command> [options]");
|
|
1664
|
+
log("");
|
|
1665
|
+
log(bold("Commands:"));
|
|
1666
|
+
log(" install Install and start OpenPalm");
|
|
1667
|
+
log(" uninstall Stop and remove OpenPalm");
|
|
1668
|
+
log(" update Pull latest images and recreate containers");
|
|
1669
|
+
log(" start Start services");
|
|
1670
|
+
log(" stop Stop services");
|
|
1671
|
+
log(" restart Restart services");
|
|
1672
|
+
log(" logs View container logs");
|
|
1673
|
+
log(" status Show container status");
|
|
1674
|
+
log(" extensions Manage extensions (install, uninstall, list)");
|
|
1675
|
+
log(" dev Development helpers (preflight, create-channel)");
|
|
1676
|
+
log(" version Print version");
|
|
1677
|
+
log(" help Show this help");
|
|
1678
|
+
log("");
|
|
1679
|
+
log(bold("Install options:"));
|
|
1680
|
+
log(" --runtime <docker|podman|orbstack> Force container runtime");
|
|
1681
|
+
log(" --no-open Don't auto-open browser");
|
|
1682
|
+
log(" --ref <branch|tag> Git ref for asset download");
|
|
1683
|
+
log(" --force Overwrite existing installation");
|
|
1684
|
+
log("");
|
|
1685
|
+
log(bold("Uninstall options:"));
|
|
1686
|
+
log(" --runtime <docker|podman|orbstack> Force container runtime");
|
|
1687
|
+
log(" --remove-all Remove all data/config/state and CLI binary");
|
|
1688
|
+
log(" --remove-images Remove container images");
|
|
1689
|
+
log(" --remove-binary Remove the openpalm CLI binary");
|
|
1690
|
+
log(" --yes Skip confirmation prompts");
|
|
1691
|
+
log("");
|
|
1692
|
+
log(bold("Management commands accept optional service names:"));
|
|
1693
|
+
log(" openpalm start [service...]");
|
|
1694
|
+
log(" openpalm stop [service...]");
|
|
1695
|
+
log(" openpalm restart [service...]");
|
|
1696
|
+
log(" openpalm logs [service...]");
|
|
1697
|
+
log("");
|
|
1698
|
+
log(bold("Extensions:"));
|
|
1699
|
+
log(" openpalm extensions install --plugin <id>");
|
|
1700
|
+
log(" openpalm extensions uninstall --plugin <id>");
|
|
1701
|
+
log(" openpalm extensions list");
|
|
1702
|
+
}
|
|
1703
|
+
function parseArg(args, name) {
|
|
1704
|
+
const index = args.indexOf(`--${name}`);
|
|
1705
|
+
if (index >= 0 && index + 1 < args.length) {
|
|
1706
|
+
return args[index + 1];
|
|
1707
|
+
}
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
function hasFlag(args, name) {
|
|
1711
|
+
return args.includes(`--${name}`);
|
|
1712
|
+
}
|
|
1713
|
+
function getPositionalArgs(args) {
|
|
1714
|
+
const result = [];
|
|
1715
|
+
let i = 0;
|
|
1716
|
+
while (i < args.length) {
|
|
1717
|
+
if (args[i].startsWith("--")) {
|
|
1718
|
+
const flagName = args[i].slice(2);
|
|
1719
|
+
if (["runtime", "ref", "plugin"].includes(flagName)) {
|
|
1720
|
+
i += 2;
|
|
1721
|
+
} else {
|
|
1722
|
+
i += 1;
|
|
1723
|
+
}
|
|
1724
|
+
} else {
|
|
1725
|
+
result.push(args[i]);
|
|
1726
|
+
i += 1;
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
return result;
|
|
1730
|
+
}
|
|
1731
|
+
var VALID_RUNTIMES = ["docker", "podman", "orbstack"];
|
|
1732
|
+
async function main() {
|
|
1733
|
+
const [command, ...args] = process.argv.slice(2);
|
|
1734
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
1735
|
+
printHelp();
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
if (command === "version" || command === "--version" || command === "-v") {
|
|
1739
|
+
log(`openpalm v${VERSION}`);
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
try {
|
|
1743
|
+
switch (command) {
|
|
1744
|
+
case "install": {
|
|
1745
|
+
const runtimeArg = parseArg(args, "runtime");
|
|
1746
|
+
if (runtimeArg && !VALID_RUNTIMES.includes(runtimeArg)) {
|
|
1747
|
+
error(`Invalid runtime "${runtimeArg}". Must be one of: ${VALID_RUNTIMES.join(", ")}`);
|
|
1748
|
+
process.exit(1);
|
|
1749
|
+
}
|
|
1750
|
+
const options = {
|
|
1751
|
+
runtime: runtimeArg,
|
|
1752
|
+
noOpen: hasFlag(args, "no-open"),
|
|
1753
|
+
ref: parseArg(args, "ref"),
|
|
1754
|
+
force: hasFlag(args, "force")
|
|
1755
|
+
};
|
|
1756
|
+
await install(options);
|
|
1757
|
+
break;
|
|
1758
|
+
}
|
|
1759
|
+
case "uninstall": {
|
|
1760
|
+
const uninstallRuntimeArg = parseArg(args, "runtime");
|
|
1761
|
+
if (uninstallRuntimeArg && !VALID_RUNTIMES.includes(uninstallRuntimeArg)) {
|
|
1762
|
+
error(`Invalid runtime "${uninstallRuntimeArg}". Must be one of: ${VALID_RUNTIMES.join(", ")}`);
|
|
1763
|
+
process.exit(1);
|
|
1764
|
+
}
|
|
1765
|
+
const removeAll = hasFlag(args, "remove-all");
|
|
1766
|
+
const options = {
|
|
1767
|
+
runtime: uninstallRuntimeArg,
|
|
1768
|
+
removeAll,
|
|
1769
|
+
removeImages: hasFlag(args, "remove-images"),
|
|
1770
|
+
removeBinary: removeAll || hasFlag(args, "remove-binary"),
|
|
1771
|
+
yes: hasFlag(args, "yes")
|
|
1772
|
+
};
|
|
1773
|
+
await uninstall(options);
|
|
1774
|
+
break;
|
|
1775
|
+
}
|
|
1776
|
+
case "update": {
|
|
1777
|
+
await update();
|
|
1778
|
+
break;
|
|
1779
|
+
}
|
|
1780
|
+
case "start": {
|
|
1781
|
+
const services = getPositionalArgs(args);
|
|
1782
|
+
await start(services.length > 0 ? services : undefined);
|
|
1783
|
+
break;
|
|
1784
|
+
}
|
|
1785
|
+
case "stop": {
|
|
1786
|
+
const services = getPositionalArgs(args);
|
|
1787
|
+
await stop(services.length > 0 ? services : undefined);
|
|
1788
|
+
break;
|
|
1789
|
+
}
|
|
1790
|
+
case "restart": {
|
|
1791
|
+
const services = getPositionalArgs(args);
|
|
1792
|
+
await restart(services.length > 0 ? services : undefined);
|
|
1793
|
+
break;
|
|
1794
|
+
}
|
|
1795
|
+
case "logs": {
|
|
1796
|
+
const services = getPositionalArgs(args);
|
|
1797
|
+
await logs(services.length > 0 ? services : undefined);
|
|
1798
|
+
break;
|
|
1799
|
+
}
|
|
1800
|
+
case "status":
|
|
1801
|
+
case "ps": {
|
|
1802
|
+
await status();
|
|
1803
|
+
break;
|
|
1804
|
+
}
|
|
1805
|
+
case "extensions":
|
|
1806
|
+
case "ext": {
|
|
1807
|
+
const [subcommand, ...extArgs] = args;
|
|
1808
|
+
if (!subcommand) {
|
|
1809
|
+
error("Missing subcommand. Usage: openpalm extensions <install|uninstall|list>");
|
|
1810
|
+
process.exit(1);
|
|
1811
|
+
}
|
|
1812
|
+
await extensions(subcommand, extArgs);
|
|
1813
|
+
break;
|
|
1814
|
+
}
|
|
1815
|
+
case "dev": {
|
|
1816
|
+
const [subcommand, ...devArgs] = args;
|
|
1817
|
+
if (!subcommand) {
|
|
1818
|
+
error("Missing subcommand. Usage: openpalm dev <preflight|create-channel>");
|
|
1819
|
+
process.exit(1);
|
|
1820
|
+
}
|
|
1821
|
+
if (subcommand === "preflight") {
|
|
1822
|
+
preflight();
|
|
1823
|
+
break;
|
|
1824
|
+
}
|
|
1825
|
+
if (subcommand === "create-channel") {
|
|
1826
|
+
createChannel(devArgs);
|
|
1827
|
+
break;
|
|
1828
|
+
}
|
|
1829
|
+
error(`Unknown dev subcommand: ${subcommand}`);
|
|
1830
|
+
process.exit(1);
|
|
1831
|
+
}
|
|
1832
|
+
default: {
|
|
1833
|
+
error(`Unknown command: ${command}`);
|
|
1834
|
+
log("Run 'openpalm help' for usage information.");
|
|
1835
|
+
process.exit(1);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
} catch (err) {
|
|
1839
|
+
if (err instanceof Error) {
|
|
1840
|
+
error(err.message);
|
|
1841
|
+
} else {
|
|
1842
|
+
error(String(err));
|
|
1843
|
+
}
|
|
1844
|
+
process.exit(1);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
main();
|