openpalm 0.2.0 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +121 -0
- package/dist/openpalm.js +1847 -0
- package/package.json +6 -18
- package/src/commands/create-channel.ts +0 -262
- package/src/commands/extensions.ts +0 -122
- package/src/commands/install.ts +0 -326
- package/src/commands/logs.ts +0 -7
- package/src/commands/preflight.ts +0 -49
- package/src/commands/restart.ts +0 -10
- package/src/commands/start.ts +0 -10
- package/src/commands/status.ts +0 -9
- package/src/commands/stop.ts +0 -10
- package/src/commands/uninstall.ts +0 -124
- package/src/commands/update.ts +0 -12
- package/src/main.ts +0 -210
- package/src/types.ts +0 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openpalm",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "CLI tool for installing and managing an OpenPalm stack",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,26 +18,14 @@
|
|
|
18
18
|
"installer"
|
|
19
19
|
],
|
|
20
20
|
"bin": {
|
|
21
|
-
"openpalm": "./
|
|
21
|
+
"openpalm": "./dist/openpalm.js"
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
|
-
"
|
|
24
|
+
"dist/**",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
25
27
|
],
|
|
26
28
|
"engines": {
|
|
27
|
-
"bun": ">=1.
|
|
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:*"
|
|
29
|
+
"bun": ">=1.2.0"
|
|
42
30
|
}
|
|
43
31
|
}
|
|
@@ -1,262 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,122 +0,0 @@
|
|
|
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
|
-
}
|