openpalm 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # openpalm CLI
2
+
3
+ CLI tool for installing, managing, and operating an OpenPalm stack. Published to npm as `openpalm`.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npx openpalm install
9
+ # or
10
+ bunx openpalm install
11
+ ```
12
+
13
+ ## Commands
14
+
15
+ | Command | Description |
16
+ |---|---|
17
+ | `install` | Install and start the OpenPalm stack |
18
+ | `uninstall` | Stop and remove OpenPalm |
19
+ | `update` | Pull latest images and recreate containers |
20
+ | `start [service...]` | Start services |
21
+ | `stop [service...]` | Stop services |
22
+ | `restart [service...]` | Restart services |
23
+ | `logs [service...]` | View container logs |
24
+ | `status` | Show container status |
25
+ | `extensions <install\|uninstall\|list>` | Manage extensions |
26
+ | `dev preflight` | Validate development environment |
27
+ | `dev create-channel` | Scaffold a new channel adapter |
28
+
29
+ ## Install options
30
+
31
+ - `--runtime <docker|podman|orbstack>` — Force container runtime
32
+ - `--no-open` — Don't auto-open browser after install
33
+ - `--ref <branch|tag>` — Git ref for asset download
34
+
35
+ ## Building
36
+
37
+ Cross-platform compiled binaries:
38
+
39
+ ```bash
40
+ bun run build # Default platform
41
+ bun run build:linux-x64 # Linux x64
42
+ bun run build:linux-arm64 # Linux ARM64
43
+ bun run build:darwin-x64 # macOS x64
44
+ bun run build:darwin-arm64 # macOS ARM64
45
+ bun run build:windows-x64 # Windows x64
46
+ bun run build:windows-arm64 # Windows ARM64
47
+ ```
48
+
49
+ Binaries are output to `dist/`.
50
+
51
+ ## Development
52
+
53
+ ```bash
54
+ # Run directly from source
55
+ bun run src/main.ts install
56
+
57
+ # Run tests
58
+ cd packages/cli && bun test
59
+ ```
60
+
61
+ ## Dependencies
62
+
63
+ Depends on `@openpalm/lib` (workspace package) for shared utilities like path resolution, runtime detection, and compose generation.
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "openpalm",
3
+ "version": "0.2.0",
4
+ "description": "CLI tool for installing and managing an OpenPalm stack",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/itlackey/openpalm.git",
10
+ "directory": "packages/cli"
11
+ },
12
+ "homepage": "https://github.com/itlackey/openpalm",
13
+ "keywords": [
14
+ "openpalm",
15
+ "ai",
16
+ "cli",
17
+ "docker",
18
+ "installer"
19
+ ],
20
+ "bin": {
21
+ "openpalm": "./src/main.ts"
22
+ },
23
+ "files": [
24
+ "src/**/*.ts"
25
+ ],
26
+ "engines": {
27
+ "bun": ">=1.0.0"
28
+ },
29
+ "scripts": {
30
+ "start": "bun run src/main.ts",
31
+ "test": "bun test",
32
+ "build": "bun build src/main.ts --compile --outfile dist/openpalm",
33
+ "build:linux-x64": "bun build src/main.ts --compile --target=bun-linux-x64 --outfile dist/openpalm-linux-x64",
34
+ "build:linux-arm64": "bun build src/main.ts --compile --target=bun-linux-arm64 --outfile dist/openpalm-linux-arm64",
35
+ "build:darwin-x64": "bun build src/main.ts --compile --target=bun-darwin-x64 --outfile dist/openpalm-darwin-x64",
36
+ "build:darwin-arm64": "bun build src/main.ts --compile --target=bun-darwin-arm64 --outfile dist/openpalm-darwin-arm64",
37
+ "build:windows-x64": "bun build src/main.ts --compile --target=bun-windows-x64 --outfile dist/openpalm-windows-x64.exe",
38
+ "build:windows-arm64": "bun build src/main.ts --compile --target=bun-windows-arm64 --outfile dist/openpalm-windows-arm64.exe"
39
+ },
40
+ "dependencies": {
41
+ "@openpalm/lib": "workspace:*"
42
+ }
43
+ }
@@ -0,0 +1,262 @@
1
+ import { mkdirSync, writeFileSync, existsSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+
4
+ function usage(): never {
5
+ throw new Error("Usage: openpalm dev create-channel <channel-name> [--port <number>]");
6
+ }
7
+
8
+ export function createChannel(args: string[]): void {
9
+ const nameArg = args.find((arg) => !arg.startsWith("--"));
10
+ if (!nameArg) usage();
11
+
12
+ const name = nameArg.toLowerCase();
13
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
14
+ throw new Error(`Error: channel name must be lowercase alphanumeric with hyphens, got \"${name}\"`);
15
+ }
16
+ if (name.length > 32) throw new Error("Error: channel name must be 32 characters or fewer");
17
+
18
+ const reserved = new Set(["chat", "discord", "voice", "telegram", "webhook"]);
19
+ if (reserved.has(name)) {
20
+ throw new Error(`Error: \"${name}\" is an existing built-in channel. Choose a different name.`);
21
+ }
22
+
23
+ const portIndex = args.indexOf("--port");
24
+ const defaultPort = portIndex !== -1 && args[portIndex + 1] ? Number(args[portIndex + 1]) : 8190;
25
+ if (Number.isNaN(defaultPort) || defaultPort < 1024 || defaultPort > 65535) {
26
+ throw new Error("Error: --port must be a number between 1024 and 65535");
27
+ }
28
+
29
+ const root = resolve(import.meta.dir, "../../../..");
30
+ const channelDir = join(root, "channels", name);
31
+ if (existsSync(channelDir)) throw new Error(`Error: channels/${name}/ already exists.`);
32
+
33
+ const envPrefix = name.replace(/-/g, "_").toUpperCase();
34
+ const secretVar = `CHANNEL_${envPrefix}_SECRET`;
35
+ const inboundTokenVar = `${envPrefix}_INBOUND_TOKEN`;
36
+ const serviceName = `channel-${name}`;
37
+ const camel = name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
38
+ const pascal = camel.charAt(0).toUpperCase() + camel.slice(1);
39
+ const createFn = `create${pascal}Channel`;
40
+
41
+ const channelTs = `import type { ChannelAdapter, InboundResult } from "@openpalm/lib/channel.ts";
42
+
43
+ const INBOUND_TOKEN = Bun.env.${inboundTokenVar} ?? "";
44
+
45
+ export function ${createFn}(): ChannelAdapter {
46
+ return {
47
+ name: "${name}",
48
+ routes: [
49
+ {
50
+ method: "POST",
51
+ path: "/${name}/inbound",
52
+ handler: async (req: Request): Promise<InboundResult> => {
53
+ if (INBOUND_TOKEN && req.headers.get("x-${name}-token") !== INBOUND_TOKEN) {
54
+ return { ok: false, status: 401, body: { error: "unauthorized" } };
55
+ }
56
+
57
+ const body = (await req.json()) as {
58
+ userId?: string;
59
+ text?: string;
60
+ metadata?: Record<string, unknown>;
61
+ };
62
+
63
+ if (!body.text) {
64
+ return { ok: false, status: 400, body: { error: "text_required" } };
65
+ }
66
+
67
+ return {
68
+ ok: true,
69
+ payload: {
70
+ userId: body.userId ?? "${name}-user",
71
+ channel: "${name}",
72
+ text: body.text,
73
+ metadata: body.metadata ?? {},
74
+ },
75
+ };
76
+ },
77
+ },
78
+ ],
79
+
80
+ health: () => ({ ok: true, service: "${serviceName}" }),
81
+ };
82
+ }
83
+ `;
84
+
85
+ const serverTs = `import { createHmac } from "node:crypto";
86
+ import { ${createFn} } from "./channel.ts";
87
+ import type { ChannelAdapter } from "@openpalm/lib/channel.ts";
88
+
89
+ const PORT = Number(Bun.env.PORT ?? ${defaultPort});
90
+ const GATEWAY_URL = Bun.env.GATEWAY_URL ?? "http://gateway:8080";
91
+ const SHARED_SECRET = Bun.env.${secretVar} ?? "";
92
+
93
+ export function signPayload(secret: string, body: string) {
94
+ return createHmac("sha256", secret).update(body).digest("hex");
95
+ }
96
+
97
+ function json(status: number, data: unknown) {
98
+ return new Response(JSON.stringify(data, null, 2), {
99
+ status,
100
+ headers: { "content-type": "application/json" },
101
+ });
102
+ }
103
+
104
+ export function createFetch(
105
+ adapter: ChannelAdapter,
106
+ gatewayUrl: string,
107
+ sharedSecret: string,
108
+ forwardFetch: typeof fetch = fetch,
109
+ ) {
110
+ const routeMap = new Map(adapter.routes.map((route) => [route.method + " " + route.path, route.handler]));
111
+
112
+ return async function handle(req: Request): Promise<Response> {
113
+ const url = new URL(req.url);
114
+ if (url.pathname === "/health") return json(200, adapter.health());
115
+
116
+ const handler = routeMap.get(req.method + " " + url.pathname);
117
+ if (!handler) return json(404, { error: "not_found" });
118
+
119
+ const result = await handler(req);
120
+ if (!result.ok) return json(result.status, result.body);
121
+
122
+ const gatewayPayload = {
123
+ ...result.payload,
124
+ nonce: crypto.randomUUID(),
125
+ timestamp: Date.now(),
126
+ };
127
+
128
+ const serialized = JSON.stringify(gatewayPayload);
129
+ const sig = signPayload(sharedSecret, serialized);
130
+
131
+ const resp = await forwardFetch(gatewayUrl + "/channel/inbound", {
132
+ method: "POST",
133
+ headers: {
134
+ "content-type": "application/json",
135
+ "x-channel-signature": sig,
136
+ },
137
+ body: serialized,
138
+ });
139
+
140
+ return new Response(await resp.text(), {
141
+ status: resp.status,
142
+ headers: { "content-type": "application/json" },
143
+ });
144
+ };
145
+ }
146
+
147
+ if (import.meta.main) {
148
+ const adapter = ${createFn}();
149
+ Bun.serve({ port: PORT, fetch: createFetch(adapter, GATEWAY_URL, SHARED_SECRET) });
150
+ console.log("${name} channel listening on " + PORT);
151
+ }
152
+ `;
153
+
154
+ const testTs = `import { describe, expect, it } from "bun:test";
155
+ import { ${createFn} } from "./channel.ts";
156
+ import { createFetch, signPayload } from "./server.ts";
157
+
158
+ describe("${name} adapter", () => {
159
+ const adapter = ${createFn}();
160
+
161
+ it("returns health status", async () => {
162
+ const handler = createFetch(adapter, "http://gateway", "secret");
163
+ const resp = await handler(new Request("http://test/health"));
164
+ expect(resp.status).toBe(200);
165
+ const data = (await resp.json()) as { ok: boolean; service: string };
166
+ expect(data.ok).toBe(true);
167
+ expect(data.service).toBe("${serviceName}");
168
+ });
169
+
170
+ it("returns 404 for unknown routes", async () => {
171
+ const handler = createFetch(adapter, "http://gateway", "secret");
172
+ const resp = await handler(new Request("http://test/unknown"));
173
+ expect(resp.status).toBe(404);
174
+ });
175
+
176
+ it("returns 400 when text is missing", async () => {
177
+ const handler = createFetch(adapter, "http://gateway", "secret");
178
+ const resp = await handler(new Request("http://test/${name}/inbound", {
179
+ method: "POST",
180
+ body: JSON.stringify({ userId: "u1" }),
181
+ headers: { "content-type": "application/json" },
182
+ }));
183
+ expect(resp.status).toBe(400);
184
+ });
185
+
186
+ it("normalizes payload and forwards with valid HMAC", async () => {
187
+ let capturedUrl = "";
188
+ let capturedSig = "";
189
+ let capturedBody = "";
190
+
191
+ const mockFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
192
+ capturedUrl = String(input);
193
+ capturedSig = String((init?.headers as Record<string, string>)["x-channel-signature"]);
194
+ capturedBody = String(init?.body);
195
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
196
+ };
197
+
198
+ const handler = createFetch(adapter, "http://gateway", "test-secret", mockFetch as typeof fetch);
199
+
200
+ const resp = await handler(new Request("http://test/${name}/inbound", {
201
+ method: "POST",
202
+ body: JSON.stringify({ userId: "u1", text: "hello" }),
203
+ headers: { "content-type": "application/json" },
204
+ }));
205
+
206
+ expect(resp.status).toBe(200);
207
+ expect(capturedUrl).toBe("http://gateway/channel/inbound");
208
+
209
+ const parsed = JSON.parse(capturedBody) as Record<string, unknown>;
210
+ expect(parsed.channel).toBe("${name}");
211
+ expect(parsed.text).toBe("hello");
212
+ expect(parsed.userId).toBe("u1");
213
+ expect(typeof parsed.nonce).toBe("string");
214
+ expect(typeof parsed.timestamp).toBe("number");
215
+ expect(capturedSig).toBe(signPayload("test-secret", capturedBody));
216
+ });
217
+ });
218
+ `;
219
+
220
+ const packageJson = `{
221
+ "name": "@openpalm/channel-${name}",
222
+ "private": true,
223
+ "type": "module",
224
+ "scripts": {
225
+ "start": "bun run server.ts",
226
+ "test": "bun test"
227
+ }
228
+ }
229
+ `;
230
+
231
+ const dockerfile = `FROM oven/bun:1.1.42
232
+ WORKDIR /app
233
+ COPY package.json ./
234
+ COPY server.ts ./server.ts
235
+ COPY channel.ts ./channel.ts
236
+ RUN bun install --production
237
+ CMD ["bun", "run", "server.ts"]
238
+ `;
239
+
240
+ const envFile = `# Channel-specific overrides managed by admin UI\n${inboundTokenVar}=\n`;
241
+
242
+ mkdirSync(channelDir, { recursive: true });
243
+
244
+ const files: Array<[string, string]> = [
245
+ ["channel.ts", channelTs],
246
+ ["server.ts", serverTs],
247
+ ["server.test.ts", testTs],
248
+ ["package.json", packageJson],
249
+ ["Dockerfile", dockerfile],
250
+ ];
251
+
252
+ for (const [fileName, content] of files) {
253
+ writeFileSync(join(channelDir, fileName), content, "utf8");
254
+ }
255
+
256
+ const channelEnvDir = join(root, "assets", "config", "channels");
257
+ if (existsSync(channelEnvDir)) {
258
+ writeFileSync(join(channelEnvDir, `${name}.env`), envFile, "utf8");
259
+ }
260
+
261
+ console.log(`✔ Channel scaffolded: channels/${name}/`);
262
+ }
@@ -0,0 +1,122 @@
1
+ import { join } from "node:path";
2
+ import { readEnvFile } from "@openpalm/lib/env.ts";
3
+ import { resolveXDGPaths } from "@openpalm/lib/paths.ts";
4
+ import { error, info } from "@openpalm/lib/ui.ts";
5
+
6
+ /**
7
+ * Implements the extensions command for managing OpenPalm extensions.
8
+ * @param subcommand - The subcommand to execute: "install", "uninstall", or "list"
9
+ * @param args - Remaining CLI arguments (may contain --plugin <id>)
10
+ */
11
+ export async function extensions(
12
+ subcommand: string,
13
+ args: string[]
14
+ ): Promise<void> {
15
+ // Helper function to find argument value
16
+ function getArg(name: string): string | undefined {
17
+ const index = args.indexOf(`--${name}`);
18
+ return index >= 0 && index + 1 < args.length ? args[index + 1] : undefined;
19
+ }
20
+
21
+ // Get admin token from environment or state .env file
22
+ let adminToken = Bun.env.ADMIN_TOKEN;
23
+ if (!adminToken) {
24
+ try {
25
+ const stateEnvPath = join(resolveXDGPaths().state, ".env");
26
+ const envVars = await readEnvFile(stateEnvPath);
27
+ adminToken = envVars.ADMIN_TOKEN;
28
+ } catch {
29
+ // Ignore errors reading env file
30
+ }
31
+ }
32
+
33
+ if (!adminToken) {
34
+ error("ADMIN_TOKEN not found in environment or state .env file");
35
+ process.exit(1);
36
+ }
37
+
38
+ // Determine base URL
39
+ const base =
40
+ Bun.env.ADMIN_APP_URL ??
41
+ Bun.env.GATEWAY_URL ??
42
+ "http://localhost/admin";
43
+
44
+ // Build headers
45
+ const headers = {
46
+ "content-type": "application/json",
47
+ "x-admin-token": adminToken,
48
+ };
49
+
50
+ /** Check HTTP response status and throw on failure. */
51
+ function checkResponse(response: Response, action: string): void {
52
+ if (!response.ok) {
53
+ throw new Error(
54
+ `${action} failed: HTTP ${response.status} ${response.statusText}`
55
+ );
56
+ }
57
+ }
58
+
59
+ try {
60
+ switch (subcommand) {
61
+ case "install": {
62
+ const pluginId = getArg("plugin");
63
+ if (!pluginId) {
64
+ error("--plugin <id> is required for install");
65
+ info("Usage: openpalm extensions install --plugin <id>");
66
+ process.exit(1);
67
+ }
68
+
69
+ const response = await fetch(`${base}/admin/plugins/install`, {
70
+ method: "POST",
71
+ headers,
72
+ body: JSON.stringify({ pluginId }),
73
+ });
74
+
75
+ checkResponse(response, "Extension install");
76
+ const text = await response.text();
77
+ info(text);
78
+ break;
79
+ }
80
+
81
+ case "uninstall": {
82
+ const pluginId = getArg("plugin");
83
+ if (!pluginId) {
84
+ error("--plugin <id> is required for uninstall");
85
+ info("Usage: openpalm extensions uninstall --plugin <id>");
86
+ process.exit(1);
87
+ }
88
+
89
+ const response = await fetch(`${base}/admin/plugins/uninstall`, {
90
+ method: "POST",
91
+ headers,
92
+ body: JSON.stringify({ pluginId }),
93
+ });
94
+
95
+ checkResponse(response, "Extension uninstall");
96
+ const text = await response.text();
97
+ info(text);
98
+ break;
99
+ }
100
+
101
+ case "list": {
102
+ const response = await fetch(`${base}/admin/installed`, {
103
+ method: "GET",
104
+ headers,
105
+ });
106
+
107
+ checkResponse(response, "Extension list");
108
+ const text = await response.text();
109
+ info(text);
110
+ break;
111
+ }
112
+
113
+ default:
114
+ error(`Unknown subcommand: ${subcommand}`);
115
+ info("Usage: openpalm extensions <install|uninstall|list> [--plugin <id>]");
116
+ process.exit(1);
117
+ }
118
+ } catch (err) {
119
+ error(`Failed to execute extensions command: ${err}`);
120
+ process.exit(1);
121
+ }
122
+ }
@@ -0,0 +1,326 @@
1
+ import { join } from "node:path";
2
+ import { chmod, writeFile, rm } from "node:fs/promises";
3
+ import type { InstallOptions } from "../types.ts";
4
+ import type { ComposeConfig } from "@openpalm/lib/types.ts";
5
+ import { detectOS, detectArch, detectRuntime, resolveSocketPath, resolveComposeBin, validateRuntime } from "@openpalm/lib/runtime.ts";
6
+ import { resolveXDGPaths, createDirectoryTree } from "@openpalm/lib/paths.ts";
7
+ import { upsertEnvVars } from "@openpalm/lib/env.ts";
8
+ import { generateToken } from "@openpalm/lib/tokens.ts";
9
+ import { composePull, composeUp } from "@openpalm/lib/compose.ts";
10
+ import { seedConfigFiles } from "@openpalm/lib/assets.ts";
11
+ import { runPreflightChecks, noRuntimeGuidance, noComposeGuidance } from "@openpalm/lib/preflight.ts";
12
+ import { log, info, warn, error, bold, green, cyan, yellow, dim, spinner } from "@openpalm/lib/ui.ts";
13
+
14
+ export async function install(options: InstallOptions): Promise<void> {
15
+ // ============================================================================
16
+ // Phase 1: Setup infrastructure
17
+ // ============================================================================
18
+
19
+ log(bold("\nOpenPalm Installation\n"));
20
+
21
+ // 1. Detect OS
22
+ const os = detectOS();
23
+ if (os === "unknown") {
24
+ error("Unable to detect operating system. Installation aborted.");
25
+ process.exit(1);
26
+ }
27
+
28
+ // 2. Detect arch
29
+ const arch = detectArch();
30
+
31
+ // 3. Detect or use overridden container runtime
32
+ const platform = options.runtime ?? await detectRuntime(os);
33
+ if (!platform) {
34
+ error(noRuntimeGuidance(os));
35
+ process.exit(1);
36
+ }
37
+
38
+ // 4. Resolve compose bin/subcommand
39
+ const { bin, subcommand } = resolveComposeBin(platform);
40
+
41
+ // 5. Run pre-flight checks (daemon running, disk space, port 80)
42
+ const preflightWarnings = await runPreflightChecks(bin, platform);
43
+ for (const w of preflightWarnings) {
44
+ warn(w.message);
45
+ if (w.detail) {
46
+ for (const line of w.detail.split("\n")) {
47
+ info(` ${line}`);
48
+ }
49
+ }
50
+ log("");
51
+ }
52
+
53
+ // Daemon not running is fatal — we can't proceed
54
+ const daemonWarning = preflightWarnings.find((w) =>
55
+ w.message.includes("daemon is not running")
56
+ );
57
+ if (daemonWarning) {
58
+ process.exit(1);
59
+ }
60
+
61
+ // 6. Validate compose works
62
+ const isValid = await validateRuntime(bin, subcommand);
63
+ if (!isValid) {
64
+ error(noComposeGuidance(platform));
65
+ process.exit(1);
66
+ }
67
+
68
+ // 7. Print detected info
69
+ log(bold("Detected environment:"));
70
+ info(` OS: ${cyan(os)}`);
71
+ info(` Architecture: ${cyan(arch)}`);
72
+ info(` Container runtime: ${cyan(platform)}`);
73
+ info(` Compose command: ${cyan(`${bin} ${subcommand}`)}\n`);
74
+
75
+ // 8. Resolve XDG paths, print them
76
+ const xdg = resolveXDGPaths();
77
+ log(bold("\nXDG paths:"));
78
+ info(` Data: ${dim(xdg.data)}`);
79
+ info(` Config: ${dim(xdg.config)}`);
80
+ info(` State: ${dim(xdg.state)}\n`);
81
+
82
+ // 9. Check if .env exists in CWD, generate if not
83
+ const envPath = join(process.cwd(), ".env");
84
+ const envExists = await Bun.file(envPath).exists();
85
+
86
+ let generatedAdminToken = "";
87
+ if (!envExists) {
88
+ const spin2 = spinner("Generating .env file...");
89
+ generatedAdminToken = generateToken();
90
+ const overrides: Record<string, string> = {
91
+ ADMIN_TOKEN: generatedAdminToken,
92
+ POSTGRES_PASSWORD: generateToken(),
93
+ CHANNEL_CHAT_SECRET: generateToken(),
94
+ CHANNEL_DISCORD_SECRET: generateToken(),
95
+ CHANNEL_VOICE_SECRET: generateToken(),
96
+ CHANNEL_TELEGRAM_SECRET: generateToken(),
97
+ };
98
+ const envSeed = Object.entries(overrides).map(([key, value]) => `${key}=${value}`).join("\n") + "\n";
99
+ await writeFile(envPath, envSeed, "utf8");
100
+ spin2.stop(green(".env file created"));
101
+
102
+ // Display admin token prominently
103
+ log("");
104
+ log(bold(green(" YOUR ADMIN PASSWORD (save this!)")));
105
+ log("");
106
+ log(` ${yellow(generatedAdminToken)}`);
107
+ log("");
108
+ info(" You will need this password to log in to the admin dashboard.");
109
+ info(` It is also saved in: ${dim(envPath)}`);
110
+ log("");
111
+ } else {
112
+ info("Using existing .env file");
113
+ }
114
+
115
+ // 10. Upsert runtime config vars into .env (single read-write cycle)
116
+ const socketPath = resolveSocketPath(platform, os);
117
+ await upsertEnvVars(envPath, [
118
+ ["OPENPALM_DATA_HOME", xdg.data],
119
+ ["OPENPALM_CONFIG_HOME", xdg.config],
120
+ ["OPENPALM_STATE_HOME", xdg.state],
121
+ ["OPENPALM_CONTAINER_PLATFORM", platform],
122
+ ["OPENPALM_COMPOSE_BIN", bin],
123
+ ["OPENPALM_COMPOSE_SUBCOMMAND", subcommand],
124
+ ["OPENPALM_CONTAINER_SOCKET_PATH", socketPath],
125
+ ["OPENPALM_CONTAINER_SOCKET_IN_CONTAINER", "/var/run/docker.sock"],
126
+ ["OPENPALM_CONTAINER_SOCKET_URI", "unix:///var/run/docker.sock"],
127
+ ["OPENPALM_IMAGE_TAG", `latest-${arch}`],
128
+ ["OPENPALM_ENABLED_CHANNELS", ""],
129
+ ]);
130
+
131
+ // 11. Create XDG directory tree
132
+ const spin3 = spinner("Creating directory structure...");
133
+ await createDirectoryTree(xdg);
134
+ spin3.stop(green("Directory structure created"));
135
+
136
+ // 12. Copy .env to state home
137
+ const stateEnvFile = join(xdg.state, ".env");
138
+ await Bun.write(stateEnvFile, Bun.file(envPath));
139
+
140
+ // 13. Seed config files (embedded templates — no network needed)
141
+ const spin4 = spinner("Seeding configuration files...");
142
+ await seedConfigFiles(xdg.config);
143
+ spin4.stop(green("Configuration files seeded"));
144
+
145
+ // 14. Reset setup wizard state so every install/reinstall starts from first boot
146
+ await rm(join(xdg.data, "admin", "setup-state.json"), { force: true });
147
+
148
+ // 15. Write uninstall script to state home
149
+ const uninstallDst = join(xdg.state, "uninstall.sh");
150
+ await writeFile(uninstallDst, "#!/usr/bin/env bash\nopenpalm uninstall\n", "utf8");
151
+ try {
152
+ await chmod(uninstallDst, 0o755);
153
+ } catch {
154
+ // chmod may fail on Windows — non-critical
155
+ }
156
+
157
+ // 16. Write minimal setup-only Caddy JSON config (admin routes only)
158
+ const minimalCaddyJson = JSON.stringify({
159
+ admin: { disabled: true },
160
+ apps: {
161
+ http: {
162
+ servers: {
163
+ main: {
164
+ listen: [":80"],
165
+ routes: [
166
+ {
167
+ match: [{ path: ["/admin*"] }],
168
+ handle: [{
169
+ handler: "subroute",
170
+ routes: [
171
+ {
172
+ match: [{ path: ["/admin/api*"] }],
173
+ handle: [
174
+ { handler: "rewrite", uri_substring: [{ find: "/admin/api", replace: "/admin" }] },
175
+ { handler: "reverse_proxy", upstreams: [{ dial: "admin:8100" }] },
176
+ ],
177
+ terminal: true,
178
+ },
179
+ {
180
+ handle: [
181
+ { handler: "rewrite", strip_path_prefix: "/admin" },
182
+ { handler: "reverse_proxy", upstreams: [{ dial: "admin:8100" }] },
183
+ ],
184
+ },
185
+ ],
186
+ }],
187
+ terminal: true,
188
+ },
189
+ {
190
+ handle: [{
191
+ handler: "static_response",
192
+ body: "OpenPalm is starting... Please visit /admin/ to complete setup.",
193
+ status_code: "503",
194
+ }],
195
+ },
196
+ ],
197
+ },
198
+ },
199
+ },
200
+ },
201
+ }, null, 2) + "\n";
202
+ const caddyJsonPath = join(xdg.state, "rendered", "caddy", "caddy.json");
203
+ await writeFile(caddyJsonPath, minimalCaddyJson, "utf8");
204
+
205
+ // ============================================================================
206
+ // Phase 2: Early UI access
207
+ // ============================================================================
208
+
209
+ log(bold("\nDownloading OpenPalm services (this may take a few minutes on first install)...\n"));
210
+
211
+ // The compose file is generated by the admin on first stack apply.
212
+ // For initial install, we need a minimal compose file to start core services.
213
+ // The admin server will generate the full compose file when the stack is applied.
214
+ const stateComposeFile = join(xdg.state, "docker-compose.yml");
215
+
216
+ const composeConfig: ComposeConfig = {
217
+ bin,
218
+ subcommand,
219
+ composeFile: stateComposeFile,
220
+ envFile: stateEnvFile,
221
+ };
222
+
223
+ const coreServices = ["caddy", "admin"];
224
+
225
+ const spin6 = spinner("Pulling core service images...");
226
+ await composePull(composeConfig, coreServices);
227
+ spin6.stop(green("Core images pulled"));
228
+
229
+ const spin7 = spinner("Starting core services...");
230
+ await composeUp(composeConfig, coreServices, { detach: true });
231
+ spin7.stop(green("Core services started"));
232
+
233
+ // Wait for admin health check
234
+ const adminUrl = "http://localhost/admin";
235
+ const healthUrl = `${adminUrl}/api/setup/status`;
236
+ const spin8 = spinner("Waiting for admin interface...");
237
+
238
+ let healthy = false;
239
+ for (let i = 0; i < 90; i++) {
240
+ try {
241
+ const response = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) });
242
+ if (response.ok) {
243
+ healthy = true;
244
+ break;
245
+ }
246
+ } catch {
247
+ // Service not ready yet
248
+ }
249
+ await Bun.sleep(2000);
250
+ }
251
+
252
+ if (!healthy) {
253
+ spin8.stop(yellow("Admin interface did not become healthy in time"));
254
+ } else {
255
+ spin8.stop(green("Admin interface ready"));
256
+ }
257
+
258
+ // Open browser
259
+ if (!options.noOpen && healthy) {
260
+ try {
261
+ if (os === "macos") {
262
+ Bun.spawn(["open", adminUrl]);
263
+ } else if (os === "linux") {
264
+ Bun.spawn(["xdg-open", adminUrl]);
265
+ } else {
266
+ // Windows — use cmd /c start
267
+ Bun.spawn(["cmd", "/c", "start", adminUrl]);
268
+ }
269
+ } catch {
270
+ // Ignore — we print the URL below
271
+ }
272
+ }
273
+
274
+ // ============================================================================
275
+ // Final output
276
+ // ============================================================================
277
+
278
+ if (healthy) {
279
+ log("");
280
+ log(bold(green(" OpenPalm setup wizard is ready!")));
281
+ log("");
282
+ info(` Setup wizard: ${cyan(adminUrl)}`);
283
+ log("");
284
+ if (generatedAdminToken) {
285
+ info(` Admin password: ${yellow(generatedAdminToken)}`);
286
+ log("");
287
+ }
288
+ log(bold(" What happens next:"));
289
+ info(" 1. The setup wizard opens in your browser");
290
+ info(" 2. Enter your AI provider API key (e.g. from console.anthropic.com)");
291
+ info(" 3. The wizard will download and start remaining services automatically");
292
+ info(" 4. Pick which channels to enable (chat, Discord, etc.)");
293
+ info(" 5. Done! Start chatting with your assistant");
294
+ log("");
295
+ if (!options.noOpen) {
296
+ info(" Opening setup wizard in your browser...");
297
+ } else {
298
+ info(` Open this URL in your browser to continue: ${adminUrl}`);
299
+ }
300
+ } else {
301
+ log("");
302
+ log(bold(yellow(" Setup did not come online within 90 seconds")));
303
+ log("");
304
+ info(" This usually means containers are still starting. Try these steps:");
305
+ log("");
306
+ info(` 1. Wait a minute, then open: ${adminUrl}`);
307
+ log("");
308
+ info(" 2. Check if containers are running:");
309
+ info(" openpalm status");
310
+ log("");
311
+ info(" 3. Check logs for errors:");
312
+ info(" openpalm logs");
313
+ log("");
314
+ info(" 4. Common fixes:");
315
+ info(" - Make sure port 80 is not used by another service");
316
+ info(" - Restart Docker/Podman and try again");
317
+ info(" - Check that you have internet access (images need to download)");
318
+ }
319
+
320
+ log("");
321
+ log(bold(" Useful commands:"));
322
+ info(" View logs: openpalm logs");
323
+ info(" Stop: openpalm stop");
324
+ info(" Uninstall: openpalm uninstall");
325
+ log("");
326
+ }
@@ -0,0 +1,7 @@
1
+ import { composeLogs } from "@openpalm/lib/compose.ts";
2
+ import { loadComposeConfig } from "@openpalm/lib/config.ts";
3
+
4
+ export async function logs(services?: string[]): Promise<void> {
5
+ const config = await loadComposeConfig();
6
+ await composeLogs(config, services?.length ? services : undefined, { follow: true, tail: 50 });
7
+ }
@@ -0,0 +1,49 @@
1
+ import { statSync } from "node:fs";
2
+ import { info } from "@openpalm/lib/ui.ts";
3
+
4
+ const devDir = ".dev";
5
+ const envFile = ".env";
6
+
7
+ const requiredDirs = [
8
+ "config",
9
+ "data/postgres",
10
+ "data/qdrant",
11
+ "data/openmemory",
12
+ "data/assistant",
13
+ "state/gateway",
14
+ "state/caddy",
15
+ "state/rendered/caddy",
16
+ ];
17
+
18
+ export function preflight(): void {
19
+ const issues: string[] = [];
20
+
21
+ try {
22
+ statSync(envFile);
23
+ } catch {
24
+ issues.push(`Missing ${envFile}. Run: bun run dev:setup`);
25
+ }
26
+
27
+ try {
28
+ statSync(devDir);
29
+ } catch {
30
+ issues.push(`Missing ${devDir}/ directory. Run: bun run dev:setup`);
31
+ }
32
+
33
+ if (issues.length === 0) {
34
+ for (const dir of requiredDirs) {
35
+ try {
36
+ statSync(`${devDir}/${dir}`);
37
+ } catch {
38
+ issues.push(`Missing ${devDir}/${dir}. Run: bun run dev:setup`);
39
+ break;
40
+ }
41
+ }
42
+ }
43
+
44
+ if (issues.length > 0) {
45
+ throw new Error(`Pre-flight check failed:\n\n${issues.map((issue) => ` - ${issue}`).join("\n")}\n\nRun 'bun run dev:setup' first, then try again.`);
46
+ }
47
+
48
+ info("Pre-flight check passed.");
49
+ }
@@ -0,0 +1,10 @@
1
+ import { composeRestart } from "@openpalm/lib/compose.ts";
2
+ import { loadComposeConfig } from "@openpalm/lib/config.ts";
3
+ import { info, green } from "@openpalm/lib/ui.ts";
4
+
5
+ export async function restart(services?: string[]): Promise<void> {
6
+ const config = await loadComposeConfig();
7
+ info("Restarting services...");
8
+ await composeRestart(config, services);
9
+ info(green("Services restarted."));
10
+ }
@@ -0,0 +1,10 @@
1
+ import { composeUp } from "@openpalm/lib/compose.ts";
2
+ import { loadComposeConfig } from "@openpalm/lib/config.ts";
3
+ import { info, green } from "@openpalm/lib/ui.ts";
4
+
5
+ export async function start(services?: string[]): Promise<void> {
6
+ const config = await loadComposeConfig();
7
+ info("Starting services...");
8
+ await composeUp(config, services);
9
+ info(green("Services started."));
10
+ }
@@ -0,0 +1,9 @@
1
+ import { composePs } from "@openpalm/lib/compose.ts";
2
+ import { loadComposeConfig } from "@openpalm/lib/config.ts";
3
+ import { log } from "@openpalm/lib/ui.ts";
4
+
5
+ export async function status(): Promise<void> {
6
+ const config = await loadComposeConfig();
7
+ const output = await composePs(config);
8
+ log(output);
9
+ }
@@ -0,0 +1,10 @@
1
+ import { composeStop } from "@openpalm/lib/compose.ts";
2
+ import { loadComposeConfig } from "@openpalm/lib/config.ts";
3
+ import { info, green } from "@openpalm/lib/ui.ts";
4
+
5
+ export async function stop(services?: string[]): Promise<void> {
6
+ const config = await loadComposeConfig();
7
+ info("Stopping services...");
8
+ await composeStop(config, services);
9
+ info(green("Services stopped."));
10
+ }
@@ -0,0 +1,124 @@
1
+ import { rm, unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { UninstallOptions, ContainerPlatform } from "../types.ts";
4
+ import type { ComposeConfig } from "@openpalm/lib/types.ts";
5
+ import { composeDown } from "@openpalm/lib/compose.ts";
6
+ import { readEnvFile } from "@openpalm/lib/env.ts";
7
+ import { resolveXDGPaths } from "@openpalm/lib/paths.ts";
8
+ import { resolveComposeBin, detectRuntime, detectOS } from "@openpalm/lib/runtime.ts";
9
+ import { log, info, warn, error, bold, green, red, yellow, confirm } from "@openpalm/lib/ui.ts";
10
+
11
+ export async function uninstall(options: UninstallOptions): Promise<void> {
12
+ // 1. Resolve XDG paths
13
+ const xdg = resolveXDGPaths();
14
+
15
+ // 2. Try to read .env from state home, falling back to CWD .env
16
+ let env: Record<string, string> = {};
17
+ const stateEnvPath = join(xdg.state, ".env");
18
+ try {
19
+ env = await readEnvFile(stateEnvPath);
20
+ } catch {
21
+ try {
22
+ env = await readEnvFile(".env");
23
+ } catch {
24
+ // No env file found, continue with empty env
25
+ }
26
+ }
27
+
28
+ // 3. Determine container platform
29
+ let platform: ContainerPlatform | null = null;
30
+ if (options.runtime) {
31
+ platform = options.runtime;
32
+ } else if (env.OPENPALM_CONTAINER_PLATFORM) {
33
+ platform = env.OPENPALM_CONTAINER_PLATFORM as ContainerPlatform;
34
+ } else {
35
+ platform = await detectRuntime(detectOS());
36
+ }
37
+
38
+ // 4. Resolve compose bin/subcommand if platform found
39
+ let composeBin: { bin: string; subcommand: string } | null = null;
40
+ if (platform) {
41
+ composeBin = resolveComposeBin(platform);
42
+ }
43
+
44
+ // 5. Print planned actions summary
45
+ log("");
46
+ log(bold("Uninstall Summary:"));
47
+ log(`Runtime platform: ${platform || "not detected"}`);
48
+ log("Stop/remove containers: yes");
49
+ log(`Remove images: ${options.removeImages ? "yes" : "no"}`);
50
+ log(`Remove all data/config/state: ${options.removeAll ? "yes" : "no"}`);
51
+ log("");
52
+ log(`Data directory: ${xdg.data}`);
53
+ log(`Config directory: ${xdg.config}`);
54
+ log(`State directory: ${xdg.state}`);
55
+ log("");
56
+
57
+ // 6. Prompt for confirmation if not --yes
58
+ if (!options.yes) {
59
+ const shouldContinue = await confirm("Continue?");
60
+ if (!shouldContinue) {
61
+ log("Aborted.");
62
+ return;
63
+ }
64
+ }
65
+
66
+ // 7. Stop and remove containers if compose is available
67
+ const composeFilePath = join(xdg.state, "docker-compose.yml");
68
+ if (composeBin && platform) {
69
+ try {
70
+ // Check if compose file exists by attempting to read env (simple existence check)
71
+ await Bun.file(composeFilePath).text();
72
+
73
+ const config: ComposeConfig = {
74
+ bin: composeBin.bin,
75
+ subcommand: composeBin.subcommand,
76
+ envFile: stateEnvPath,
77
+ composeFile: composeFilePath,
78
+ };
79
+
80
+ await composeDown(config, {
81
+ removeOrphans: true,
82
+ removeImages: options.removeImages,
83
+ });
84
+ } catch {
85
+ // 8. Compose file not found or other error
86
+ warn("Compose runtime or file not found; skipping container shutdown.");
87
+ }
88
+ } else {
89
+ // 8. No platform/compose bin detected
90
+ warn("Compose runtime or file not found; skipping container shutdown.");
91
+ }
92
+
93
+ // 9. Remove all data/config/state if requested
94
+ if (options.removeAll) {
95
+ try {
96
+ await rm(xdg.data, { recursive: true, force: true });
97
+ } catch {
98
+ // Directory may not exist, continue
99
+ }
100
+
101
+ try {
102
+ await rm(xdg.config, { recursive: true, force: true });
103
+ } catch {
104
+ // Directory may not exist, continue
105
+ }
106
+
107
+ try {
108
+ await rm(xdg.state, { recursive: true, force: true });
109
+ } catch {
110
+ // Directory may not exist, continue
111
+ }
112
+
113
+ try {
114
+ await unlink(".env");
115
+ } catch {
116
+ // .env may not exist in CWD, continue
117
+ }
118
+
119
+ info("Removed OpenPalm data/config/state and local .env.");
120
+ }
121
+
122
+ // 10. Success message
123
+ info(green("Uninstall complete."));
124
+ }
@@ -0,0 +1,12 @@
1
+ import { composePull, composeUp } from "@openpalm/lib/compose.ts";
2
+ import { loadComposeConfig } from "@openpalm/lib/config.ts";
3
+ import { info, green } from "@openpalm/lib/ui.ts";
4
+
5
+ export async function update(): Promise<void> {
6
+ const config = await loadComposeConfig();
7
+ info("Pulling latest images...");
8
+ await composePull(config);
9
+ info("Recreating containers with updated images...");
10
+ await composeUp(config, undefined, { pull: "always" });
11
+ info(green("Update complete."));
12
+ }
package/src/main.ts ADDED
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env bun
2
+ import type { ContainerPlatform, InstallOptions, UninstallOptions } from "./types.ts";
3
+ import { install } from "./commands/install.ts";
4
+ import { uninstall } from "./commands/uninstall.ts";
5
+ import { update } from "./commands/update.ts";
6
+ import { start } from "./commands/start.ts";
7
+ import { stop } from "./commands/stop.ts";
8
+ import { restart } from "./commands/restart.ts";
9
+ import { logs } from "./commands/logs.ts";
10
+ import { status } from "./commands/status.ts";
11
+ import { extensions } from "./commands/extensions.ts";
12
+ import { preflight } from "./commands/preflight.ts";
13
+ import { createChannel } from "./commands/create-channel.ts";
14
+ import { log, error, bold, dim } from "@openpalm/lib/ui.ts";
15
+ import pkg from "../package.json";
16
+
17
+ const VERSION = pkg.version;
18
+
19
+ function printHelp(): void {
20
+ log(bold("openpalm") + dim(` v${VERSION}`));
21
+ log("");
22
+ log(bold("Usage:"));
23
+ log(" openpalm <command> [options]");
24
+ log("");
25
+ log(bold("Commands:"));
26
+ log(" install Install and start OpenPalm");
27
+ log(" uninstall Stop and remove OpenPalm");
28
+ log(" update Pull latest images and recreate containers");
29
+ log(" start Start services");
30
+ log(" stop Stop services");
31
+ log(" restart Restart services");
32
+ log(" logs View container logs");
33
+ log(" status Show container status");
34
+ log(" extensions Manage extensions (install, uninstall, list)");
35
+ log(" dev Development helpers (preflight, create-channel)");
36
+ log(" version Print version");
37
+ log(" help Show this help");
38
+ log("");
39
+ log(bold("Install options:"));
40
+ log(" --runtime <docker|podman|orbstack> Force container runtime");
41
+ log(" --no-open Don't auto-open browser");
42
+ log(" --ref <branch|tag> Git ref for asset download");
43
+ log("");
44
+ log(bold("Uninstall options:"));
45
+ log(" --runtime <docker|podman|orbstack> Force container runtime");
46
+ log(" --remove-all Remove all data/config/state");
47
+ log(" --remove-images Remove container images");
48
+ log(" --yes Skip confirmation prompts");
49
+ log("");
50
+ log(bold("Management commands accept optional service names:"));
51
+ log(" openpalm start [service...]");
52
+ log(" openpalm stop [service...]");
53
+ log(" openpalm restart [service...]");
54
+ log(" openpalm logs [service...]");
55
+ log("");
56
+ log(bold("Extensions:"));
57
+ log(" openpalm extensions install --plugin <id>");
58
+ log(" openpalm extensions uninstall --plugin <id>");
59
+ log(" openpalm extensions list");
60
+ }
61
+
62
+ function parseArg(args: string[], name: string): string | undefined {
63
+ const index = args.indexOf(`--${name}`);
64
+ if (index >= 0 && index + 1 < args.length) {
65
+ return args[index + 1];
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ function hasFlag(args: string[], name: string): boolean {
71
+ return args.includes(`--${name}`);
72
+ }
73
+
74
+ function getPositionalArgs(args: string[]): string[] {
75
+ const result: string[] = [];
76
+ let i = 0;
77
+ while (i < args.length) {
78
+ if (args[i].startsWith("--")) {
79
+ // Skip flag and its value if it has one
80
+ const flagName = args[i].slice(2);
81
+ if (["runtime", "ref", "plugin"].includes(flagName)) {
82
+ i += 2; // skip flag + value
83
+ } else {
84
+ i += 1; // skip boolean flag
85
+ }
86
+ } else {
87
+ result.push(args[i]);
88
+ i += 1;
89
+ }
90
+ }
91
+ return result;
92
+ }
93
+
94
+ async function main(): Promise<void> {
95
+ const [command, ...args] = process.argv.slice(2);
96
+
97
+ if (!command || command === "help" || command === "--help" || command === "-h") {
98
+ printHelp();
99
+ return;
100
+ }
101
+
102
+ if (command === "version" || command === "--version" || command === "-v") {
103
+ log(`openpalm v${VERSION}`);
104
+ return;
105
+ }
106
+
107
+ try {
108
+ switch (command) {
109
+ case "install": {
110
+ const options: InstallOptions = {
111
+ runtime: parseArg(args, "runtime") as ContainerPlatform | undefined,
112
+ noOpen: hasFlag(args, "no-open"),
113
+ ref: parseArg(args, "ref"),
114
+ };
115
+ await install(options);
116
+ break;
117
+ }
118
+
119
+ case "uninstall": {
120
+ const options: UninstallOptions = {
121
+ runtime: parseArg(args, "runtime") as ContainerPlatform | undefined,
122
+ removeAll: hasFlag(args, "remove-all"),
123
+ removeImages: hasFlag(args, "remove-images"),
124
+ yes: hasFlag(args, "yes"),
125
+ };
126
+ await uninstall(options);
127
+ break;
128
+ }
129
+
130
+ case "update": {
131
+ await update();
132
+ break;
133
+ }
134
+
135
+ case "start": {
136
+ const services = getPositionalArgs(args);
137
+ await start(services.length > 0 ? services : undefined);
138
+ break;
139
+ }
140
+
141
+ case "stop": {
142
+ const services = getPositionalArgs(args);
143
+ await stop(services.length > 0 ? services : undefined);
144
+ break;
145
+ }
146
+
147
+ case "restart": {
148
+ const services = getPositionalArgs(args);
149
+ await restart(services.length > 0 ? services : undefined);
150
+ break;
151
+ }
152
+
153
+ case "logs": {
154
+ const services = getPositionalArgs(args);
155
+ await logs(services.length > 0 ? services : undefined);
156
+ break;
157
+ }
158
+
159
+ case "status":
160
+ case "ps": {
161
+ await status();
162
+ break;
163
+ }
164
+
165
+ case "extensions":
166
+ case "ext": {
167
+ const [subcommand, ...extArgs] = args;
168
+ if (!subcommand) {
169
+ error("Missing subcommand. Usage: openpalm extensions <install|uninstall|list>");
170
+ process.exit(1);
171
+ }
172
+ await extensions(subcommand, extArgs);
173
+ break;
174
+ }
175
+
176
+ case "dev": {
177
+ const [subcommand, ...devArgs] = args;
178
+ if (!subcommand) {
179
+ error("Missing subcommand. Usage: openpalm dev <preflight|create-channel>");
180
+ process.exit(1);
181
+ }
182
+ if (subcommand === "preflight") {
183
+ preflight();
184
+ break;
185
+ }
186
+ if (subcommand === "create-channel") {
187
+ createChannel(devArgs);
188
+ break;
189
+ }
190
+ error(`Unknown dev subcommand: ${subcommand}`);
191
+ process.exit(1);
192
+ }
193
+
194
+ default: {
195
+ error(`Unknown command: ${command}`);
196
+ log("Run 'openpalm help' for usage information.");
197
+ process.exit(1);
198
+ }
199
+ }
200
+ } catch (err) {
201
+ if (err instanceof Error) {
202
+ error(err.message);
203
+ } else {
204
+ error(String(err));
205
+ }
206
+ process.exit(1);
207
+ }
208
+ }
209
+
210
+ main();
package/src/types.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { ContainerPlatform } from "@openpalm/lib/types.ts";
2
+
3
+ /** Options for the install command. */
4
+ export type InstallOptions = {
5
+ runtime?: ContainerPlatform;
6
+ noOpen?: boolean;
7
+ ref?: string;
8
+ };
9
+
10
+ /** Options for the uninstall command. */
11
+ export type UninstallOptions = {
12
+ runtime?: ContainerPlatform;
13
+ removeAll?: boolean;
14
+ removeImages?: boolean;
15
+ yes?: boolean;
16
+ };
17
+
18
+ export type { ContainerPlatform };