openpalm 0.9.1 → 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 +40 -12
- package/e2e/start-wizard-server.ts +64 -0
- package/package.json +6 -2
- 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 +142 -29
- package/src/main.ts +33 -954
- 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/src/main.test.ts
CHANGED
|
@@ -36,23 +36,30 @@ function restoreDockerCli(): void {
|
|
|
36
36
|
describe('cli main', () => {
|
|
37
37
|
const originalFetch = globalThis.fetch;
|
|
38
38
|
const originalLog = console.log;
|
|
39
|
+
const originalWarn = console.warn;
|
|
39
40
|
const originalConfigHome = process.env.OPENPALM_CONFIG_HOME;
|
|
40
41
|
const originalDataHome = process.env.OPENPALM_DATA_HOME;
|
|
41
42
|
const originalStateHome = process.env.OPENPALM_STATE_HOME;
|
|
42
43
|
const originalWorkDir = process.env.OPENPALM_WORK_DIR;
|
|
44
|
+
const originalAdminToken = process.env.ADMIN_TOKEN;
|
|
45
|
+
const originalOpenPalmAdminToken = process.env.OPENPALM_ADMIN_TOKEN;
|
|
43
46
|
|
|
44
47
|
afterEach(() => {
|
|
45
48
|
globalThis.fetch = originalFetch;
|
|
46
49
|
console.log = originalLog;
|
|
50
|
+
console.warn = originalWarn;
|
|
47
51
|
restoreDockerCli();
|
|
48
52
|
process.env.OPENPALM_CONFIG_HOME = originalConfigHome;
|
|
49
53
|
process.env.OPENPALM_DATA_HOME = originalDataHome;
|
|
50
54
|
process.env.OPENPALM_STATE_HOME = originalStateHome;
|
|
51
55
|
process.env.OPENPALM_WORK_DIR = originalWorkDir;
|
|
56
|
+
process.env.ADMIN_TOKEN = originalAdminToken;
|
|
57
|
+
process.env.OPENPALM_ADMIN_TOKEN = originalOpenPalmAdminToken;
|
|
52
58
|
});
|
|
53
59
|
|
|
54
|
-
it('calls containers pull for update', async () => {
|
|
60
|
+
it('calls containers pull for update (via admin when reachable)', async () => {
|
|
55
61
|
const calls: string[] = [];
|
|
62
|
+
process.env.OPENPALM_ADMIN_TOKEN = 'test-token';
|
|
56
63
|
globalThis.fetch = mock(async (input: string | URL) => {
|
|
57
64
|
calls.push(String(input));
|
|
58
65
|
return new Response('{"ok":true}', { status: 200 });
|
|
@@ -61,11 +68,62 @@ describe('cli main', () => {
|
|
|
61
68
|
|
|
62
69
|
await main(['update']);
|
|
63
70
|
|
|
64
|
-
|
|
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
|
+
}
|
|
65
122
|
});
|
|
66
123
|
|
|
67
|
-
it('calls admin install when stack is already running', async () => {
|
|
124
|
+
it('calls admin install when stack is already running and token exists', async () => {
|
|
68
125
|
const calls: string[] = [];
|
|
126
|
+
process.env.OPENPALM_ADMIN_TOKEN = 'test-token';
|
|
69
127
|
globalThis.fetch = mock(async (input: string | URL) => {
|
|
70
128
|
const url = String(input);
|
|
71
129
|
calls.push(url);
|
|
@@ -79,13 +137,59 @@ describe('cli main', () => {
|
|
|
79
137
|
await main(['install']);
|
|
80
138
|
|
|
81
139
|
expect(calls).toEqual([
|
|
82
|
-
'http://
|
|
140
|
+
'http://localhost:8100/health',
|
|
83
141
|
'http://localhost:8100/admin/install',
|
|
84
142
|
]);
|
|
85
143
|
});
|
|
86
144
|
|
|
87
|
-
it('
|
|
88
|
-
|
|
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
|
+
}
|
|
89
193
|
});
|
|
90
194
|
|
|
91
195
|
it('creates the admin data directory during bootstrap install', async () => {
|
|
@@ -134,18 +238,13 @@ describe('cli main', () => {
|
|
|
134
238
|
});
|
|
135
239
|
|
|
136
240
|
describe('validate command', () => {
|
|
137
|
-
it('
|
|
138
|
-
// validate is now a known command, so it won't throw Unknown command.
|
|
139
|
-
// It will fail because varlock exits non-zero on a missing env/schema, but that's a different error.
|
|
140
|
-
// We set up a temp state dir with a fake varlock binary that exits immediately to avoid a network download.
|
|
141
|
-
|
|
241
|
+
it('is a recognized command (does not throw Unknown command)', async () => {
|
|
142
242
|
const tempStateHome = mkdtempSync(join(tmpdir(), 'openpalm-test-'));
|
|
143
243
|
const binDir = join(tempStateHome, 'bin');
|
|
144
244
|
const artifactsDir = join(tempStateHome, 'artifacts');
|
|
145
245
|
mkdirSync(binDir, { recursive: true });
|
|
146
246
|
mkdirSync(artifactsDir, { recursive: true });
|
|
147
247
|
|
|
148
|
-
// Create a fake varlock script that exits 1 immediately
|
|
149
248
|
const fakeVarlock = join(binDir, 'varlock');
|
|
150
249
|
writeFileSync(fakeVarlock, '#!/bin/sh\nexit 1\n');
|
|
151
250
|
chmodSync(fakeVarlock, 0o755);
|
|
@@ -153,32 +252,22 @@ describe('validate command', () => {
|
|
|
153
252
|
const originalStateHome = process.env.OPENPALM_STATE_HOME;
|
|
154
253
|
const originalExit = process.exit;
|
|
155
254
|
process.env.OPENPALM_STATE_HOME = tempStateHome;
|
|
156
|
-
// Prevent process.exit from terminating the test runner
|
|
157
255
|
process.exit = mock((_code?: number) => { throw new Error(`process.exit(${_code})`); }) as typeof process.exit;
|
|
158
256
|
|
|
159
257
|
try {
|
|
160
258
|
const err = await main(['validate']).catch((e: unknown) => e);
|
|
161
259
|
const message = err instanceof Error ? err.message : String(err);
|
|
162
|
-
expect(message).not.toContain('Unknown command
|
|
260
|
+
expect(message).not.toContain('Unknown command');
|
|
163
261
|
} finally {
|
|
164
262
|
process.exit = originalExit;
|
|
165
263
|
process.env.OPENPALM_STATE_HOME = originalStateHome;
|
|
166
264
|
rmSync(tempStateHome, { recursive: true, force: true });
|
|
167
265
|
}
|
|
168
266
|
});
|
|
169
|
-
|
|
170
267
|
});
|
|
171
268
|
|
|
172
269
|
describe('scan command', () => {
|
|
173
270
|
it('is a recognized command (does not throw Unknown command)', async () => {
|
|
174
|
-
// Verifies 'scan' is in the COMMANDS list and dispatches correctly.
|
|
175
|
-
// Does NOT test full scan behavior (which requires a real varlock binary,
|
|
176
|
-
// secrets.env, and secrets.env.schema). A more complete test would:
|
|
177
|
-
// - Stage a secrets.env.schema + secrets.env in temp dirs
|
|
178
|
-
// - Provide a fake varlock binary that echoes its args
|
|
179
|
-
// - Assert varlock is invoked with 'scan --path <tmpDir>/'
|
|
180
|
-
// - Verify the temp dir is cleaned up after execution
|
|
181
|
-
|
|
182
271
|
const tempStateHome = mkdtempSync(join(tmpdir(), 'openpalm-test-'));
|
|
183
272
|
const tempConfigHome = mkdtempSync(join(tmpdir(), 'openpalm-test-'));
|
|
184
273
|
const binDir = join(tempStateHome, 'bin');
|
|
@@ -186,12 +275,10 @@ describe('scan command', () => {
|
|
|
186
275
|
mkdirSync(binDir, { recursive: true });
|
|
187
276
|
mkdirSync(artifactsDir, { recursive: true });
|
|
188
277
|
|
|
189
|
-
// Create a fake varlock script that exits 0 immediately
|
|
190
278
|
const fakeVarlock = join(binDir, 'varlock');
|
|
191
279
|
writeFileSync(fakeVarlock, '#!/bin/sh\nexit 0\n');
|
|
192
280
|
chmodSync(fakeVarlock, 0o755);
|
|
193
281
|
|
|
194
|
-
// Create required files so the command reaches the varlock invocation
|
|
195
282
|
writeFileSync(join(artifactsDir, 'secrets.env.schema'), 'ADMIN_TOKEN\n');
|
|
196
283
|
writeFileSync(join(tempConfigHome, 'secrets.env'), 'ADMIN_TOKEN=testtoken\n');
|
|
197
284
|
|
|
@@ -205,9 +292,7 @@ describe('scan command', () => {
|
|
|
205
292
|
try {
|
|
206
293
|
const err = await main(['scan']).catch((e: unknown) => e);
|
|
207
294
|
const message = err instanceof Error ? err.message : String(err);
|
|
208
|
-
|
|
209
|
-
expect(message).not.toContain('Unknown command: scan');
|
|
210
|
-
// The fake varlock exits 0, so process.exit(0) should be called
|
|
295
|
+
expect(message).not.toContain('Unknown command');
|
|
211
296
|
expect(message).toBe('process.exit(0)');
|
|
212
297
|
} finally {
|
|
213
298
|
process.exit = originalExit;
|
|
@@ -224,7 +309,6 @@ describe('scan command', () => {
|
|
|
224
309
|
const artifactsDir = join(tempStateHome, 'artifacts');
|
|
225
310
|
mkdirSync(artifactsDir, { recursive: true });
|
|
226
311
|
|
|
227
|
-
// secrets.env exists but secrets.env.schema does NOT
|
|
228
312
|
writeFileSync(join(tempConfigHome, 'secrets.env'), 'ADMIN_TOKEN=testtoken\n');
|
|
229
313
|
|
|
230
314
|
const originalStateHome = process.env.OPENPALM_STATE_HOME;
|
|
@@ -349,4 +433,33 @@ describe('install image tag pinning', () => {
|
|
|
349
433
|
'KEY.WITH-HYPHEN=new\n',
|
|
350
434
|
);
|
|
351
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
|
+
});
|
|
352
465
|
});
|