openpalm 0.4.0 → 0.9.2-rc2
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 +58 -72
- package/e2e/start-wizard-server.ts +64 -0
- package/package.json +18 -19
- package/src/commands/install.ts +275 -0
- package/src/commands/logs.ts +25 -0
- package/src/commands/restart.ts +71 -0
- package/src/commands/scan.ts +48 -0
- package/src/commands/service.ts +93 -0
- package/src/commands/start.ts +79 -0
- package/src/commands/status.ts +23 -0
- package/src/commands/stop.ts +70 -0
- package/src/commands/uninstall.ts +37 -0
- package/src/commands/update.ts +32 -0
- package/src/commands/validate.ts +47 -0
- package/src/lib/admin.ts +107 -0
- package/src/lib/docker.ts +107 -0
- package/src/lib/env.ts +196 -0
- package/src/lib/host-info.ts +47 -0
- package/src/lib/paths.ts +27 -0
- package/src/lib/staging.ts +72 -0
- package/src/lib/varlock.ts +132 -0
- package/src/main.test.ts +465 -0
- package/src/main.ts +44 -0
- package/src/setup-wizard/index.html +349 -0
- package/src/setup-wizard/server.test.ts +347 -0
- package/src/setup-wizard/server.ts +297 -0
- package/src/setup-wizard/wizard.css +952 -0
- package/src/setup-wizard/wizard.js +1104 -0
- package/LICENSE +0 -121
- package/dist/openpalm.js +0 -9479
package/src/main.test.ts
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, mock } from 'bun:test';
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync, chmodSync, mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { detectHostInfo, main, reconcileStackEnvImageTag, resolveRequestedImageTag, upsertEnvValue } from './main.ts';
|
|
6
|
+
|
|
7
|
+
// Helpers to mock Bun.spawn and Bun.which for tests that would otherwise
|
|
8
|
+
// shell out to `docker info` / `docker compose version` and block in CI.
|
|
9
|
+
const originalBunSpawn = Bun.spawn;
|
|
10
|
+
const originalBunWhich = Bun.which;
|
|
11
|
+
|
|
12
|
+
function mockDockerCli(): void {
|
|
13
|
+
Bun.which = mock((_cmd: string) => '/usr/bin/docker') as typeof Bun.which;
|
|
14
|
+
Bun.spawn = mock((_cmd: string[] | readonly string[], _opts?: unknown) => ({
|
|
15
|
+
pid: 0,
|
|
16
|
+
exited: Promise.resolve(0),
|
|
17
|
+
exitCode: null,
|
|
18
|
+
signalCode: null,
|
|
19
|
+
killed: false,
|
|
20
|
+
stdin: null,
|
|
21
|
+
stdout: null,
|
|
22
|
+
stderr: null,
|
|
23
|
+
kill: () => {},
|
|
24
|
+
ref: () => {},
|
|
25
|
+
unref: () => {},
|
|
26
|
+
[Symbol.asyncDispose]: async () => {},
|
|
27
|
+
resourceUsage: () => undefined,
|
|
28
|
+
})) as unknown as typeof Bun.spawn;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function restoreDockerCli(): void {
|
|
32
|
+
Bun.spawn = originalBunSpawn;
|
|
33
|
+
Bun.which = originalBunWhich;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('cli main', () => {
|
|
37
|
+
const originalFetch = globalThis.fetch;
|
|
38
|
+
const originalLog = console.log;
|
|
39
|
+
const originalWarn = console.warn;
|
|
40
|
+
const originalConfigHome = process.env.OPENPALM_CONFIG_HOME;
|
|
41
|
+
const originalDataHome = process.env.OPENPALM_DATA_HOME;
|
|
42
|
+
const originalStateHome = process.env.OPENPALM_STATE_HOME;
|
|
43
|
+
const originalWorkDir = process.env.OPENPALM_WORK_DIR;
|
|
44
|
+
const originalAdminToken = process.env.ADMIN_TOKEN;
|
|
45
|
+
const originalOpenPalmAdminToken = process.env.OPENPALM_ADMIN_TOKEN;
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
globalThis.fetch = originalFetch;
|
|
49
|
+
console.log = originalLog;
|
|
50
|
+
console.warn = originalWarn;
|
|
51
|
+
restoreDockerCli();
|
|
52
|
+
process.env.OPENPALM_CONFIG_HOME = originalConfigHome;
|
|
53
|
+
process.env.OPENPALM_DATA_HOME = originalDataHome;
|
|
54
|
+
process.env.OPENPALM_STATE_HOME = originalStateHome;
|
|
55
|
+
process.env.OPENPALM_WORK_DIR = originalWorkDir;
|
|
56
|
+
process.env.ADMIN_TOKEN = originalAdminToken;
|
|
57
|
+
process.env.OPENPALM_ADMIN_TOKEN = originalOpenPalmAdminToken;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('calls containers pull for update (via admin when reachable)', async () => {
|
|
61
|
+
const calls: string[] = [];
|
|
62
|
+
process.env.OPENPALM_ADMIN_TOKEN = 'test-token';
|
|
63
|
+
globalThis.fetch = mock(async (input: string | URL) => {
|
|
64
|
+
calls.push(String(input));
|
|
65
|
+
return new Response('{"ok":true}', { status: 200 });
|
|
66
|
+
}) as typeof fetch;
|
|
67
|
+
console.log = mock(() => {}) as typeof console.log;
|
|
68
|
+
|
|
69
|
+
await main(['update']);
|
|
70
|
+
|
|
71
|
+
// tryAdminRequest attempts directly — no separate health check
|
|
72
|
+
expect(calls).toEqual([
|
|
73
|
+
'http://localhost:8100/admin/containers/pull',
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('uses ADMIN_TOKEN from the environment for admin requests', async () => {
|
|
78
|
+
const adminTokens: string[] = [];
|
|
79
|
+
process.env.ADMIN_TOKEN = 'env-admin-token';
|
|
80
|
+
delete process.env.OPENPALM_ADMIN_TOKEN;
|
|
81
|
+
|
|
82
|
+
globalThis.fetch = mock(async (_input: string | URL, init?: RequestInit) => {
|
|
83
|
+
const headers = new Headers(init?.headers);
|
|
84
|
+
adminTokens.push(headers.get('X-Admin-Token') ?? '');
|
|
85
|
+
return new Response('{"ok":true}', { status: 200 });
|
|
86
|
+
}) as typeof fetch;
|
|
87
|
+
console.log = mock(() => {}) as typeof console.log;
|
|
88
|
+
|
|
89
|
+
await main(['update']);
|
|
90
|
+
|
|
91
|
+
// Direct request — single call with token header
|
|
92
|
+
expect(adminTokens).toEqual(['env-admin-token']);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('falls back to the legacy parent secrets.env for admin requests', async () => {
|
|
96
|
+
const base = mkdtempSync(join(tmpdir(), 'openpalm-config-'));
|
|
97
|
+
const configHome = join(base, 'openpalm');
|
|
98
|
+
const adminTokens: string[] = [];
|
|
99
|
+
|
|
100
|
+
mkdirSync(configHome, { recursive: true });
|
|
101
|
+
writeFileSync(join(configHome, 'secrets.env'), 'OPENPALM_ADMIN_TOKEN=\nADMIN_TOKEN=\n');
|
|
102
|
+
writeFileSync(join(base, 'secrets.env'), 'export ADMIN_TOKEN="legacy-admin-token"\n');
|
|
103
|
+
|
|
104
|
+
process.env.OPENPALM_CONFIG_HOME = configHome;
|
|
105
|
+
delete process.env.ADMIN_TOKEN;
|
|
106
|
+
delete process.env.OPENPALM_ADMIN_TOKEN;
|
|
107
|
+
|
|
108
|
+
globalThis.fetch = mock(async (_input: string | URL, init?: RequestInit) => {
|
|
109
|
+
const headers = new Headers(init?.headers);
|
|
110
|
+
adminTokens.push(headers.get('X-Admin-Token') ?? '');
|
|
111
|
+
return new Response('{"ok":true}', { status: 200 });
|
|
112
|
+
}) as typeof fetch;
|
|
113
|
+
console.log = mock(() => {}) as typeof console.log;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await main(['update']);
|
|
117
|
+
// Direct request — single call with legacy token
|
|
118
|
+
expect(adminTokens).toEqual(['legacy-admin-token']);
|
|
119
|
+
} finally {
|
|
120
|
+
rmSync(base, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('calls admin install when stack is already running and token exists', async () => {
|
|
125
|
+
const calls: string[] = [];
|
|
126
|
+
process.env.OPENPALM_ADMIN_TOKEN = 'test-token';
|
|
127
|
+
globalThis.fetch = mock(async (input: string | URL) => {
|
|
128
|
+
const url = String(input);
|
|
129
|
+
calls.push(url);
|
|
130
|
+
if (url.endsWith('/health')) {
|
|
131
|
+
return new Response('ok', { status: 200 });
|
|
132
|
+
}
|
|
133
|
+
return new Response('{\"ok\":true}', { status: 200 });
|
|
134
|
+
}) as typeof fetch;
|
|
135
|
+
console.log = mock(() => {}) as typeof console.log;
|
|
136
|
+
|
|
137
|
+
await main(['install']);
|
|
138
|
+
|
|
139
|
+
expect(calls).toEqual([
|
|
140
|
+
'http://localhost:8100/health',
|
|
141
|
+
'http://localhost:8100/admin/install',
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('falls back to bootstrap when stack is running but no token exists', async () => {
|
|
146
|
+
const base = mkdtempSync(join(tmpdir(), 'openpalm-install-'));
|
|
147
|
+
const configHome = join(base, 'config');
|
|
148
|
+
const dataHome = join(base, 'data');
|
|
149
|
+
const stateHome = join(base, 'state');
|
|
150
|
+
const workDir = join(base, 'work');
|
|
151
|
+
const binDir = join(stateHome, 'bin');
|
|
152
|
+
|
|
153
|
+
mkdirSync(binDir, { recursive: true });
|
|
154
|
+
writeFileSync(join(binDir, 'varlock'), '#!/bin/sh\nexit 0\n');
|
|
155
|
+
chmodSync(join(binDir, 'varlock'), 0o755);
|
|
156
|
+
|
|
157
|
+
process.env.OPENPALM_CONFIG_HOME = configHome;
|
|
158
|
+
process.env.OPENPALM_DATA_HOME = dataHome;
|
|
159
|
+
process.env.OPENPALM_STATE_HOME = stateHome;
|
|
160
|
+
process.env.OPENPALM_WORK_DIR = workDir;
|
|
161
|
+
delete process.env.ADMIN_TOKEN;
|
|
162
|
+
delete process.env.OPENPALM_ADMIN_TOKEN;
|
|
163
|
+
|
|
164
|
+
mockDockerCli();
|
|
165
|
+
const fetchedUrls: string[] = [];
|
|
166
|
+
globalThis.fetch = mock(async (input: string | URL) => {
|
|
167
|
+
const url = String(input);
|
|
168
|
+
fetchedUrls.push(url);
|
|
169
|
+
if (url.endsWith('/health')) {
|
|
170
|
+
return new Response('ok', { status: 200 });
|
|
171
|
+
}
|
|
172
|
+
if (url.includes('/docker-compose.yml')) {
|
|
173
|
+
return new Response('services: {}\n', { status: 200 });
|
|
174
|
+
}
|
|
175
|
+
if (url.includes('/Caddyfile')) {
|
|
176
|
+
return new Response(':80 {\n}\n', { status: 200 });
|
|
177
|
+
}
|
|
178
|
+
if (url.includes('/secrets.env.schema') || url.includes('/stack.env.schema')) {
|
|
179
|
+
return new Response('KEY=string\n', { status: 200 });
|
|
180
|
+
}
|
|
181
|
+
return new Response('', { status: 503 });
|
|
182
|
+
}) as typeof fetch;
|
|
183
|
+
console.log = mock(() => {}) as typeof console.log;
|
|
184
|
+
console.warn = mock(() => {}) as typeof console.warn;
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
await main(['install', '--no-start', '--force', '--no-open']);
|
|
188
|
+
// Should have fallen through to bootstrap, creating directories
|
|
189
|
+
expect(existsSync(join(dataHome, 'admin'))).toBe(true);
|
|
190
|
+
} finally {
|
|
191
|
+
rmSync(base, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('creates the admin data directory during bootstrap install', async () => {
|
|
196
|
+
const base = mkdtempSync(join(tmpdir(), 'openpalm-install-'));
|
|
197
|
+
const configHome = join(base, 'config');
|
|
198
|
+
const dataHome = join(base, 'data');
|
|
199
|
+
const stateHome = join(base, 'state');
|
|
200
|
+
const workDir = join(base, 'work');
|
|
201
|
+
const binDir = join(stateHome, 'bin');
|
|
202
|
+
|
|
203
|
+
mkdirSync(binDir, { recursive: true });
|
|
204
|
+
writeFileSync(join(binDir, 'varlock'), '#!/bin/sh\nexit 0\n');
|
|
205
|
+
chmodSync(join(binDir, 'varlock'), 0o755);
|
|
206
|
+
|
|
207
|
+
process.env.OPENPALM_CONFIG_HOME = configHome;
|
|
208
|
+
process.env.OPENPALM_DATA_HOME = dataHome;
|
|
209
|
+
process.env.OPENPALM_STATE_HOME = stateHome;
|
|
210
|
+
process.env.OPENPALM_WORK_DIR = workDir;
|
|
211
|
+
|
|
212
|
+
mockDockerCli();
|
|
213
|
+
globalThis.fetch = mock(async (input: string | URL) => {
|
|
214
|
+
const url = String(input);
|
|
215
|
+
if (url.endsWith('/health')) {
|
|
216
|
+
throw new TypeError('fetch failed');
|
|
217
|
+
}
|
|
218
|
+
if (url.includes('/docker-compose.yml')) {
|
|
219
|
+
return new Response('services: {}\n', { status: 200 });
|
|
220
|
+
}
|
|
221
|
+
if (url.includes('/Caddyfile')) {
|
|
222
|
+
return new Response(':80 {\n}\n', { status: 200 });
|
|
223
|
+
}
|
|
224
|
+
if (url.includes('/secrets.env.schema') || url.includes('/stack.env.schema')) {
|
|
225
|
+
return new Response('KEY=string\n', { status: 200 });
|
|
226
|
+
}
|
|
227
|
+
return new Response('', { status: 503 });
|
|
228
|
+
}) as typeof fetch;
|
|
229
|
+
console.log = mock(() => {}) as typeof console.log;
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
await main(['install', '--no-start', '--force', '--no-open']);
|
|
233
|
+
expect(existsSync(join(dataHome, 'admin'))).toBe(true);
|
|
234
|
+
} finally {
|
|
235
|
+
rmSync(base, { recursive: true, force: true });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('validate command', () => {
|
|
241
|
+
it('is a recognized command (does not throw Unknown command)', async () => {
|
|
242
|
+
const tempStateHome = mkdtempSync(join(tmpdir(), 'openpalm-test-'));
|
|
243
|
+
const binDir = join(tempStateHome, 'bin');
|
|
244
|
+
const artifactsDir = join(tempStateHome, 'artifacts');
|
|
245
|
+
mkdirSync(binDir, { recursive: true });
|
|
246
|
+
mkdirSync(artifactsDir, { recursive: true });
|
|
247
|
+
|
|
248
|
+
const fakeVarlock = join(binDir, 'varlock');
|
|
249
|
+
writeFileSync(fakeVarlock, '#!/bin/sh\nexit 1\n');
|
|
250
|
+
chmodSync(fakeVarlock, 0o755);
|
|
251
|
+
|
|
252
|
+
const originalStateHome = process.env.OPENPALM_STATE_HOME;
|
|
253
|
+
const originalExit = process.exit;
|
|
254
|
+
process.env.OPENPALM_STATE_HOME = tempStateHome;
|
|
255
|
+
process.exit = mock((_code?: number) => { throw new Error(`process.exit(${_code})`); }) as typeof process.exit;
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const err = await main(['validate']).catch((e: unknown) => e);
|
|
259
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
260
|
+
expect(message).not.toContain('Unknown command');
|
|
261
|
+
} finally {
|
|
262
|
+
process.exit = originalExit;
|
|
263
|
+
process.env.OPENPALM_STATE_HOME = originalStateHome;
|
|
264
|
+
rmSync(tempStateHome, { recursive: true, force: true });
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('scan command', () => {
|
|
270
|
+
it('is a recognized command (does not throw Unknown command)', async () => {
|
|
271
|
+
const tempStateHome = mkdtempSync(join(tmpdir(), 'openpalm-test-'));
|
|
272
|
+
const tempConfigHome = mkdtempSync(join(tmpdir(), 'openpalm-test-'));
|
|
273
|
+
const binDir = join(tempStateHome, 'bin');
|
|
274
|
+
const artifactsDir = join(tempStateHome, 'artifacts');
|
|
275
|
+
mkdirSync(binDir, { recursive: true });
|
|
276
|
+
mkdirSync(artifactsDir, { recursive: true });
|
|
277
|
+
|
|
278
|
+
const fakeVarlock = join(binDir, 'varlock');
|
|
279
|
+
writeFileSync(fakeVarlock, '#!/bin/sh\nexit 0\n');
|
|
280
|
+
chmodSync(fakeVarlock, 0o755);
|
|
281
|
+
|
|
282
|
+
writeFileSync(join(artifactsDir, 'secrets.env.schema'), 'ADMIN_TOKEN\n');
|
|
283
|
+
writeFileSync(join(tempConfigHome, 'secrets.env'), 'ADMIN_TOKEN=testtoken\n');
|
|
284
|
+
|
|
285
|
+
const originalStateHome = process.env.OPENPALM_STATE_HOME;
|
|
286
|
+
const originalConfigHome = process.env.OPENPALM_CONFIG_HOME;
|
|
287
|
+
const originalExit = process.exit;
|
|
288
|
+
process.env.OPENPALM_STATE_HOME = tempStateHome;
|
|
289
|
+
process.env.OPENPALM_CONFIG_HOME = tempConfigHome;
|
|
290
|
+
process.exit = mock((_code?: number) => { throw new Error(`process.exit(${_code})`); }) as typeof process.exit;
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
const err = await main(['scan']).catch((e: unknown) => e);
|
|
294
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
295
|
+
expect(message).not.toContain('Unknown command');
|
|
296
|
+
expect(message).toBe('process.exit(0)');
|
|
297
|
+
} finally {
|
|
298
|
+
process.exit = originalExit;
|
|
299
|
+
process.env.OPENPALM_STATE_HOME = originalStateHome;
|
|
300
|
+
process.env.OPENPALM_CONFIG_HOME = originalConfigHome;
|
|
301
|
+
rmSync(tempStateHome, { recursive: true, force: true });
|
|
302
|
+
rmSync(tempConfigHome, { recursive: true, force: true });
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('errors when secrets.env.schema is missing', async () => {
|
|
307
|
+
const tempStateHome = mkdtempSync(join(tmpdir(), 'openpalm-test-'));
|
|
308
|
+
const tempConfigHome = mkdtempSync(join(tmpdir(), 'openpalm-test-'));
|
|
309
|
+
const artifactsDir = join(tempStateHome, 'artifacts');
|
|
310
|
+
mkdirSync(artifactsDir, { recursive: true });
|
|
311
|
+
|
|
312
|
+
writeFileSync(join(tempConfigHome, 'secrets.env'), 'ADMIN_TOKEN=testtoken\n');
|
|
313
|
+
|
|
314
|
+
const originalStateHome = process.env.OPENPALM_STATE_HOME;
|
|
315
|
+
const originalConfigHome = process.env.OPENPALM_CONFIG_HOME;
|
|
316
|
+
const originalExit = process.exit;
|
|
317
|
+
const originalError = console.error;
|
|
318
|
+
const errorCalls: string[] = [];
|
|
319
|
+
process.env.OPENPALM_STATE_HOME = tempStateHome;
|
|
320
|
+
process.env.OPENPALM_CONFIG_HOME = tempConfigHome;
|
|
321
|
+
process.exit = mock((_code?: number) => { throw new Error(`process.exit(${_code})`); }) as typeof process.exit;
|
|
322
|
+
console.error = mock((...args: unknown[]) => { errorCalls.push(args.join(' ')); }) as typeof console.error;
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const err = await main(['scan']).catch((e: unknown) => e);
|
|
326
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
327
|
+
expect(message).toBe('process.exit(1)');
|
|
328
|
+
expect(errorCalls.some(msg => msg.includes('secrets.env.schema not found'))).toBe(true);
|
|
329
|
+
expect(errorCalls.some(msg => msg.includes('openpalm install'))).toBe(true);
|
|
330
|
+
} finally {
|
|
331
|
+
process.exit = originalExit;
|
|
332
|
+
console.error = originalError;
|
|
333
|
+
process.env.OPENPALM_STATE_HOME = originalStateHome;
|
|
334
|
+
process.env.OPENPALM_CONFIG_HOME = originalConfigHome;
|
|
335
|
+
rmSync(tempStateHome, { recursive: true, force: true });
|
|
336
|
+
rmSync(tempConfigHome, { recursive: true, force: true });
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe('detectHostInfo', () => {
|
|
342
|
+
const originalFetch = globalThis.fetch;
|
|
343
|
+
|
|
344
|
+
afterEach(() => {
|
|
345
|
+
globalThis.fetch = originalFetch;
|
|
346
|
+
restoreDockerCli();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('returns valid HostInfo structure', async () => {
|
|
350
|
+
mockDockerCli();
|
|
351
|
+
globalThis.fetch = mock(async () => new Response('', { status: 503 })) as typeof fetch;
|
|
352
|
+
const info = await detectHostInfo();
|
|
353
|
+
expect(info).toHaveProperty('platform');
|
|
354
|
+
expect(info).toHaveProperty('arch');
|
|
355
|
+
expect(info).toHaveProperty('docker');
|
|
356
|
+
expect(info).toHaveProperty('ollama');
|
|
357
|
+
expect(info).toHaveProperty('lmstudio');
|
|
358
|
+
expect(info).toHaveProperty('llamacpp');
|
|
359
|
+
expect(info).toHaveProperty('timestamp');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('platform and arch match process values', async () => {
|
|
363
|
+
mockDockerCli();
|
|
364
|
+
globalThis.fetch = mock(async () => new Response('', { status: 503 })) as typeof fetch;
|
|
365
|
+
const info = await detectHostInfo();
|
|
366
|
+
expect(info.platform).toBe(process.platform);
|
|
367
|
+
expect(info.arch).toBe(process.arch);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('HTTP probes handle connection refused gracefully', async () => {
|
|
371
|
+
mockDockerCli();
|
|
372
|
+
globalThis.fetch = mock(async () => { throw new TypeError('fetch failed'); }) as typeof fetch;
|
|
373
|
+
const info = await detectHostInfo();
|
|
374
|
+
expect(info.ollama.running).toBe(false);
|
|
375
|
+
expect(info.lmstudio.running).toBe(false);
|
|
376
|
+
expect(info.llamacpp.running).toBe(false);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe('install image tag pinning', () => {
|
|
381
|
+
it('normalizes semver refs to image tags', () => {
|
|
382
|
+
expect(resolveRequestedImageTag('0.9.0-rc10')).toBe('v0.9.0-rc10');
|
|
383
|
+
expect(resolveRequestedImageTag('v0.9.0-rc10')).toBe('v0.9.0-rc10');
|
|
384
|
+
expect(resolveRequestedImageTag('main')).toBeNull();
|
|
385
|
+
expect(resolveRequestedImageTag(' ')).toBeNull();
|
|
386
|
+
expect(resolveRequestedImageTag('1.2')).toBeNull();
|
|
387
|
+
expect(resolveRequestedImageTag('v1.x.y')).toBeNull();
|
|
388
|
+
expect(resolveRequestedImageTag('invalid')).toBeNull();
|
|
389
|
+
expect(resolveRequestedImageTag('v1.0.0-rc..10')).toBeNull();
|
|
390
|
+
expect(resolveRequestedImageTag('v1.0.0..1')).toBeNull();
|
|
391
|
+
expect(resolveRequestedImageTag('v1.0.0-rc_10')).toBeNull();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('pins existing stack.env image tag to the requested release tag', () => {
|
|
395
|
+
const original = 'OPENPALM_IMAGE_NAMESPACE=openpalm\nOPENPALM_IMAGE_TAG=latest\n';
|
|
396
|
+
expect(reconcileStackEnvImageTag(original, 'v0.9.0-rc10')).toBe(
|
|
397
|
+
'OPENPALM_IMAGE_NAMESPACE=openpalm\nOPENPALM_IMAGE_TAG=v0.9.0-rc10\n',
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('does not overwrite existing stack.env image tag for main installs', () => {
|
|
402
|
+
const original = 'OPENPALM_IMAGE_NAMESPACE=openpalm\nOPENPALM_IMAGE_TAG=latest\n';
|
|
403
|
+
expect(reconcileStackEnvImageTag(original, 'main')).toBe(original);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('prefers an explicit image tag over the requested release ref', () => {
|
|
407
|
+
const original = 'OPENPALM_IMAGE_NAMESPACE=openpalm\nOPENPALM_IMAGE_TAG=latest\n';
|
|
408
|
+
expect(reconcileStackEnvImageTag(original, 'v0.9.0-rc10', 'v9.9.9-test')).toBe(
|
|
409
|
+
'OPENPALM_IMAGE_NAMESPACE=openpalm\nOPENPALM_IMAGE_TAG=v9.9.9-test\n',
|
|
410
|
+
);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('updates an existing key in env content', () => {
|
|
414
|
+
expect(upsertEnvValue('OPENPALM_IMAGE_TAG=latest\n', 'OPENPALM_IMAGE_TAG', 'v0.9.0-rc10')).toBe(
|
|
415
|
+
'OPENPALM_IMAGE_TAG=v0.9.0-rc10\n',
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('inserts a new key into empty env content', () => {
|
|
420
|
+
expect(upsertEnvValue('', 'OPENPALM_IMAGE_TAG', 'v0.9.0-rc10')).toBe(
|
|
421
|
+
'OPENPALM_IMAGE_TAG=v0.9.0-rc10\n',
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('inserts a new key when the original content lacks a trailing newline', () => {
|
|
426
|
+
expect(upsertEnvValue('OPENPALM_IMAGE_NAMESPACE=openpalm', 'OPENPALM_IMAGE_TAG', 'v0.9.0-rc10')).toBe(
|
|
427
|
+
'OPENPALM_IMAGE_NAMESPACE=openpalm\nOPENPALM_IMAGE_TAG=v0.9.0-rc10\n',
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('treats regex characters in keys literally when updating env content', () => {
|
|
432
|
+
expect(upsertEnvValue('KEY.WITH-HYPHEN=old\n', 'KEY.WITH-HYPHEN', 'new')).toBe(
|
|
433
|
+
'KEY.WITH-HYPHEN=new\n',
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('preserves export prefix when upserting a key', () => {
|
|
438
|
+
expect(upsertEnvValue('export OPENPALM_ADMIN_TOKEN=old\n', 'OPENPALM_ADMIN_TOKEN', 'new')).toBe(
|
|
439
|
+
'export OPENPALM_ADMIN_TOKEN=new\n',
|
|
440
|
+
);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('upserts without export prefix when original has none', () => {
|
|
444
|
+
expect(upsertEnvValue('OPENPALM_IMAGE_TAG=latest\n', 'OPENPALM_IMAGE_TAG', 'v1.0.0')).toBe(
|
|
445
|
+
'OPENPALM_IMAGE_TAG=v1.0.0\n',
|
|
446
|
+
);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
describe('secrets.env generation', () => {
|
|
451
|
+
it('generates secrets.env with export prefix and OPENPALM_ADMIN_TOKEN', async () => {
|
|
452
|
+
const { ensureSecrets } = await import('./lib/env.ts');
|
|
453
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'openpalm-secrets-'));
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
await ensureSecrets(tempDir);
|
|
457
|
+
const content = await Bun.file(join(tempDir, 'secrets.env')).text();
|
|
458
|
+
expect(content).toContain('export OPENPALM_ADMIN_TOKEN=');
|
|
459
|
+
expect(content).toContain('export OPENAI_API_KEY=');
|
|
460
|
+
expect(content).toContain('export MEMORY_USER_ID=');
|
|
461
|
+
} finally {
|
|
462
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
});
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { defineCommand, runCommand, runMain } from 'citty';
|
|
3
|
+
import cliPkg from '../package.json' with { type: 'json' };
|
|
4
|
+
|
|
5
|
+
// Re-export public API used by tests and external consumers
|
|
6
|
+
export { detectHostInfo } from './lib/host-info.ts';
|
|
7
|
+
export type { HostInfo } from './lib/host-info.ts';
|
|
8
|
+
export { upsertEnvValue, resolveRequestedImageTag, reconcileStackEnvImageTag } from './lib/env.ts';
|
|
9
|
+
export { bootstrapInstall } from './commands/install.ts';
|
|
10
|
+
|
|
11
|
+
const mainCommand = defineCommand({
|
|
12
|
+
meta: {
|
|
13
|
+
name: 'openpalm',
|
|
14
|
+
version: cliPkg.version,
|
|
15
|
+
description: 'OpenPalm CLI — install and manage a self-hosted OpenPalm stack',
|
|
16
|
+
},
|
|
17
|
+
subCommands: {
|
|
18
|
+
install: () => import('./commands/install.ts').then((m) => m.default),
|
|
19
|
+
uninstall: () => import('./commands/uninstall.ts').then((m) => m.default),
|
|
20
|
+
update: () => import('./commands/update.ts').then((m) => m.default),
|
|
21
|
+
start: () => import('./commands/start.ts').then((m) => m.default),
|
|
22
|
+
stop: () => import('./commands/stop.ts').then((m) => m.default),
|
|
23
|
+
restart: () => import('./commands/restart.ts').then((m) => m.default),
|
|
24
|
+
logs: () => import('./commands/logs.ts').then((m) => m.default),
|
|
25
|
+
status: () => import('./commands/status.ts').then((m) => m.default),
|
|
26
|
+
service: () => import('./commands/service.ts').then((m) => m.default),
|
|
27
|
+
validate: () => import('./commands/validate.ts').then((m) => m.default),
|
|
28
|
+
scan: () => import('./commands/scan.ts').then((m) => m.default),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Programmatic entry point for tests and embedding.
|
|
34
|
+
* Uses runCommand directly (not runMain) to avoid the process.exit(1) wrapper
|
|
35
|
+
* and process.argv manipulation.
|
|
36
|
+
*/
|
|
37
|
+
export async function main(argv = process.argv.slice(2)): Promise<void> {
|
|
38
|
+
await runCommand(mainCommand, { rawArgs: argv });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (import.meta.main) {
|
|
42
|
+
// Let citty handle process.argv natively
|
|
43
|
+
runMain(mainCommand);
|
|
44
|
+
}
|