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 +63 -0
- package/package.json +43 -0
- package/src/commands/create-channel.ts +262 -0
- package/src/commands/extensions.ts +122 -0
- package/src/commands/install.ts +326 -0
- package/src/commands/logs.ts +7 -0
- package/src/commands/preflight.ts +49 -0
- package/src/commands/restart.ts +10 -0
- package/src/commands/start.ts +10 -0
- package/src/commands/status.ts +9 -0
- package/src/commands/stop.ts +10 -0
- package/src/commands/uninstall.ts +124 -0
- package/src/commands/update.ts +12 -0
- package/src/main.ts +210 -0
- package/src/types.ts +18 -0
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 };
|