openpalm 0.9.8 → 0.9.10
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/package.json +4 -2
- package/playwright.config.ts +16 -0
- package/src/commands/install-file.test.ts +306 -0
- package/src/commands/install-services.test.ts +12 -7
- package/src/commands/install-services.ts +1 -1
- package/src/commands/install.ts +142 -34
- package/src/commands/restart.ts +2 -35
- package/src/commands/service.ts +0 -17
- package/src/commands/start.ts +5 -43
- package/src/commands/status.ts +0 -9
- package/src/commands/stop.ts +4 -36
- package/src/commands/uninstall.ts +23 -14
- package/src/commands/update.ts +0 -9
- package/src/lib/docker.ts +25 -7
- package/src/lib/env.ts +13 -60
- package/src/lib/paths.ts +11 -1
- package/src/lib/staging.ts +3 -3
- package/src/main.test.ts +118 -80
- package/src/setup-wizard/index.html +114 -180
- package/src/setup-wizard/server-errors.test.ts +429 -0
- package/src/setup-wizard/server-integration.test.ts +511 -0
- package/src/setup-wizard/server.test.ts +6 -6
- package/src/setup-wizard/server.ts +17 -5
- package/src/setup-wizard/standalone.ts +166 -0
- package/src/setup-wizard/wizard.css +892 -299
- package/src/setup-wizard/wizard.js +1172 -559
- package/src/lib/admin.ts +0 -107
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openpalm",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
6
|
"description": "OpenPalm CLI — install and manage a self-hosted OpenPalm stack",
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"scripts": {
|
|
16
16
|
"start": "bun run src/main.ts",
|
|
17
17
|
"test": "bun test",
|
|
18
|
+
"test:e2e": "npx playwright test",
|
|
19
|
+
"wizard:dev": "bun run src/setup-wizard/standalone.ts",
|
|
18
20
|
"build": "bun build src/main.ts --compile --outfile dist/openpalm-cli",
|
|
19
21
|
"build:linux-x64": "bun build src/main.ts --compile --target=bun-linux-x64 --outfile dist/openpalm-cli-linux-x64",
|
|
20
22
|
"build:linux-arm64": "bun build src/main.ts --compile --target=bun-linux-arm64 --outfile dist/openpalm-cli-linux-arm64",
|
|
@@ -24,7 +26,7 @@
|
|
|
24
26
|
"build:windows-arm64": "bun build src/main.ts --compile --target=bun-windows-arm64 --outfile dist/openpalm-cli-windows-arm64.exe"
|
|
25
27
|
},
|
|
26
28
|
"dependencies": {
|
|
27
|
-
"@openpalm/lib": "0.9.
|
|
29
|
+
"@openpalm/lib": ">=0.9.8 <1.0.0",
|
|
28
30
|
"citty": "^0.2.1"
|
|
29
31
|
}
|
|
30
32
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineConfig } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
const WIZARD_PORT = 18200;
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
testDir: 'e2e',
|
|
7
|
+
webServer: {
|
|
8
|
+
command: `bun run e2e/start-wizard-server.ts ${WIZARD_PORT}`,
|
|
9
|
+
port: WIZARD_PORT,
|
|
10
|
+
stdout: 'pipe',
|
|
11
|
+
reuseExistingServer: !process.env.CI,
|
|
12
|
+
},
|
|
13
|
+
use: {
|
|
14
|
+
baseURL: `http://localhost:${WIZARD_PORT}`,
|
|
15
|
+
},
|
|
16
|
+
});
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the --file install path in the install command.
|
|
3
|
+
*
|
|
4
|
+
* Mocks performSetupFromConfig and performSetup to avoid filesystem
|
|
5
|
+
* side effects, and verifies that the file-based install flow correctly
|
|
6
|
+
* reads, parses, and dispatches JSON/YAML config files.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it, mock, afterEach, beforeEach } from 'bun:test';
|
|
9
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const originalBunSpawn = Bun.spawn;
|
|
16
|
+
const originalBunWhich = Bun.which;
|
|
17
|
+
|
|
18
|
+
function mockDockerCli(): void {
|
|
19
|
+
Bun.which = mock((_cmd: string) => '/usr/bin/docker') as typeof Bun.which;
|
|
20
|
+
Bun.spawn = mock((_cmd: string[] | readonly string[], _opts?: unknown) => ({
|
|
21
|
+
pid: 0,
|
|
22
|
+
exited: Promise.resolve(0),
|
|
23
|
+
exitCode: null,
|
|
24
|
+
signalCode: null,
|
|
25
|
+
killed: false,
|
|
26
|
+
stdin: null,
|
|
27
|
+
stdout: null,
|
|
28
|
+
stderr: null,
|
|
29
|
+
kill: () => {},
|
|
30
|
+
ref: () => {},
|
|
31
|
+
unref: () => {},
|
|
32
|
+
[Symbol.asyncDispose]: async () => {},
|
|
33
|
+
resourceUsage: () => undefined,
|
|
34
|
+
})) as unknown as typeof Bun.spawn;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function restoreDockerCli(): void {
|
|
38
|
+
Bun.spawn = originalBunSpawn;
|
|
39
|
+
Bun.which = originalBunWhich;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeValidSetupConfig(): Record<string, unknown> {
|
|
43
|
+
return {
|
|
44
|
+
version: 1,
|
|
45
|
+
owner: { name: 'Test User', email: 'test@example.com' },
|
|
46
|
+
security: { adminToken: 'test-admin-token-12345' },
|
|
47
|
+
connections: [
|
|
48
|
+
{
|
|
49
|
+
id: 'openai-main',
|
|
50
|
+
name: 'OpenAI',
|
|
51
|
+
provider: 'openai',
|
|
52
|
+
baseUrl: 'https://api.openai.com',
|
|
53
|
+
apiKey: 'sk-test-key-123',
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
assignments: {
|
|
57
|
+
llm: { connectionId: 'openai-main', model: 'gpt-4o' },
|
|
58
|
+
embeddings: { connectionId: 'openai-main', model: 'text-embedding-3-small' },
|
|
59
|
+
},
|
|
60
|
+
memory: { userId: 'test_user' },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeLegacySetupInput(): Record<string, unknown> {
|
|
65
|
+
return {
|
|
66
|
+
adminToken: 'test-admin-token-12345',
|
|
67
|
+
ownerName: 'Test User',
|
|
68
|
+
ownerEmail: 'test@example.com',
|
|
69
|
+
memoryUserId: 'test_user',
|
|
70
|
+
ollamaEnabled: false,
|
|
71
|
+
connections: [
|
|
72
|
+
{
|
|
73
|
+
id: 'openai-main',
|
|
74
|
+
name: 'OpenAI',
|
|
75
|
+
provider: 'openai',
|
|
76
|
+
baseUrl: 'https://api.openai.com',
|
|
77
|
+
apiKey: 'sk-test-key-123',
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
assignments: {
|
|
81
|
+
llm: { connectionId: 'openai-main', model: 'gpt-4o' },
|
|
82
|
+
embeddings: { connectionId: 'openai-main', model: 'text-embedding-3-small' },
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Test Suite ────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
describe('install --file', () => {
|
|
90
|
+
let tempBase: string;
|
|
91
|
+
let configDir: string;
|
|
92
|
+
let dataDir: string;
|
|
93
|
+
let stateDir: string;
|
|
94
|
+
let workDir: string;
|
|
95
|
+
|
|
96
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
97
|
+
const originalFetch = globalThis.fetch;
|
|
98
|
+
const originalLog = console.log;
|
|
99
|
+
const originalWarn = console.warn;
|
|
100
|
+
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
tempBase = mkdtempSync(join(tmpdir(), 'openpalm-install-file-'));
|
|
103
|
+
configDir = join(tempBase, 'config');
|
|
104
|
+
dataDir = join(tempBase, 'data');
|
|
105
|
+
stateDir = join(tempBase, 'state');
|
|
106
|
+
workDir = join(tempBase, 'work');
|
|
107
|
+
const binDir = join(stateDir, 'bin');
|
|
108
|
+
|
|
109
|
+
mkdirSync(binDir, { recursive: true });
|
|
110
|
+
writeFileSync(join(binDir, 'varlock'), '#!/bin/sh\nexit 0\n');
|
|
111
|
+
chmodSync(join(binDir, 'varlock'), 0o755);
|
|
112
|
+
|
|
113
|
+
savedEnv.OPENPALM_CONFIG_HOME = process.env.OPENPALM_CONFIG_HOME;
|
|
114
|
+
savedEnv.OPENPALM_DATA_HOME = process.env.OPENPALM_DATA_HOME;
|
|
115
|
+
savedEnv.OPENPALM_STATE_HOME = process.env.OPENPALM_STATE_HOME;
|
|
116
|
+
savedEnv.OPENPALM_WORK_DIR = process.env.OPENPALM_WORK_DIR;
|
|
117
|
+
savedEnv.ADMIN_TOKEN = process.env.ADMIN_TOKEN;
|
|
118
|
+
savedEnv.OPENPALM_ADMIN_TOKEN = process.env.OPENPALM_ADMIN_TOKEN;
|
|
119
|
+
|
|
120
|
+
process.env.OPENPALM_CONFIG_HOME = configDir;
|
|
121
|
+
process.env.OPENPALM_DATA_HOME = dataDir;
|
|
122
|
+
process.env.OPENPALM_STATE_HOME = stateDir;
|
|
123
|
+
process.env.OPENPALM_WORK_DIR = workDir;
|
|
124
|
+
delete process.env.ADMIN_TOKEN;
|
|
125
|
+
delete process.env.OPENPALM_ADMIN_TOKEN;
|
|
126
|
+
|
|
127
|
+
mockDockerCli();
|
|
128
|
+
globalThis.fetch = mock(async (input: string | URL) => {
|
|
129
|
+
const url = String(input);
|
|
130
|
+
if (url.includes('/docker-compose.yml')) return new Response('services: {}\n', { status: 200 });
|
|
131
|
+
if (url.includes('/Caddyfile')) return new Response(':80 {\n}\n', { status: 200 });
|
|
132
|
+
if (url.endsWith('.schema') || url.endsWith('.schema.json')) return new Response('KEY=string\n', { status: 200 });
|
|
133
|
+
// Return valid content for asset files needed by FilesystemAssetProvider
|
|
134
|
+
if (url.includes('/ollama.yml')) return new Response('services:\n ollama:\n image: ollama/ollama\n', { status: 200 });
|
|
135
|
+
if (url.includes('/AGENTS.md')) return new Response('# Agents\n', { status: 200 });
|
|
136
|
+
if (url.includes('/opencode.jsonc') || url.includes('/admin-opencode.jsonc'))
|
|
137
|
+
return new Response('{"$schema":"https://opencode.ai/config.json"}\n', { status: 200 });
|
|
138
|
+
if (url.includes('/cleanup-logs.yml')) return new Response('name: cleanup-logs\nschedule: daily\n', { status: 200 });
|
|
139
|
+
if (url.includes('/cleanup-data.yml')) return new Response('name: cleanup-data\nschedule: weekly\n', { status: 200 });
|
|
140
|
+
if (url.includes('/validate-config.yml')) return new Response('name: validate-config\nschedule: hourly\n', { status: 200 });
|
|
141
|
+
return new Response('', { status: 503 });
|
|
142
|
+
}) as typeof fetch;
|
|
143
|
+
console.log = mock(() => {}) as typeof console.log;
|
|
144
|
+
console.warn = mock(() => {}) as typeof console.warn;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
afterEach(() => {
|
|
148
|
+
globalThis.fetch = originalFetch;
|
|
149
|
+
console.log = originalLog;
|
|
150
|
+
console.warn = originalWarn;
|
|
151
|
+
restoreDockerCli();
|
|
152
|
+
process.env.OPENPALM_CONFIG_HOME = savedEnv.OPENPALM_CONFIG_HOME;
|
|
153
|
+
process.env.OPENPALM_DATA_HOME = savedEnv.OPENPALM_DATA_HOME;
|
|
154
|
+
process.env.OPENPALM_STATE_HOME = savedEnv.OPENPALM_STATE_HOME;
|
|
155
|
+
process.env.OPENPALM_WORK_DIR = savedEnv.OPENPALM_WORK_DIR;
|
|
156
|
+
process.env.ADMIN_TOKEN = savedEnv.ADMIN_TOKEN;
|
|
157
|
+
process.env.OPENPALM_ADMIN_TOKEN = savedEnv.OPENPALM_ADMIN_TOKEN;
|
|
158
|
+
rmSync(tempBase, { recursive: true, force: true });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('--file missing.json throws "Setup config file not found"', async () => {
|
|
162
|
+
const { bootstrapInstall } = await import('./install.ts');
|
|
163
|
+
const missingPath = join(tempBase, 'missing.json');
|
|
164
|
+
|
|
165
|
+
const err = await bootstrapInstall({
|
|
166
|
+
force: true,
|
|
167
|
+
version: 'main',
|
|
168
|
+
noStart: false,
|
|
169
|
+
noOpen: true,
|
|
170
|
+
file: missingPath,
|
|
171
|
+
}).catch((e: unknown) => e);
|
|
172
|
+
|
|
173
|
+
expect(err).toBeInstanceOf(Error);
|
|
174
|
+
expect((err as Error).message).toContain('Setup config file not found');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('--file config.txt throws "Unsupported config file format"', async () => {
|
|
178
|
+
const { bootstrapInstall } = await import('./install.ts');
|
|
179
|
+
const txtPath = join(tempBase, 'config.txt');
|
|
180
|
+
writeFileSync(txtPath, 'some text content');
|
|
181
|
+
|
|
182
|
+
const err = await bootstrapInstall({
|
|
183
|
+
force: true,
|
|
184
|
+
version: 'main',
|
|
185
|
+
noStart: false,
|
|
186
|
+
noOpen: true,
|
|
187
|
+
file: txtPath,
|
|
188
|
+
}).catch((e: unknown) => e);
|
|
189
|
+
|
|
190
|
+
expect(err).toBeInstanceOf(Error);
|
|
191
|
+
expect((err as Error).message).toContain('Unsupported config file format');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('--file broken.json with invalid JSON throws "Failed to parse setup config"', async () => {
|
|
195
|
+
const { bootstrapInstall } = await import('./install.ts');
|
|
196
|
+
const brokenPath = join(tempBase, 'broken.json');
|
|
197
|
+
writeFileSync(brokenPath, '{ invalid json !!!');
|
|
198
|
+
|
|
199
|
+
const err = await bootstrapInstall({
|
|
200
|
+
force: true,
|
|
201
|
+
version: 'main',
|
|
202
|
+
noStart: false,
|
|
203
|
+
noOpen: true,
|
|
204
|
+
file: brokenPath,
|
|
205
|
+
}).catch((e: unknown) => e);
|
|
206
|
+
|
|
207
|
+
expect(err).toBeInstanceOf(Error);
|
|
208
|
+
expect((err as Error).message).toContain('Failed to parse setup config');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('--file config.json with version: 1 calls performSetupFromConfig path', async () => {
|
|
212
|
+
const { bootstrapInstall } = await import('./install.ts');
|
|
213
|
+
const configPath = join(tempBase, 'config.json');
|
|
214
|
+
const config = makeValidSetupConfig();
|
|
215
|
+
writeFileSync(configPath, JSON.stringify(config));
|
|
216
|
+
|
|
217
|
+
// This will call performSetupFromConfig which needs filesystem dirs.
|
|
218
|
+
// The function will fail at staging since we don't have the full
|
|
219
|
+
// filesystem setup, but the important thing is it reaches the right
|
|
220
|
+
// code path (version 1 -> performSetupFromConfig).
|
|
221
|
+
const err = await bootstrapInstall({
|
|
222
|
+
force: true,
|
|
223
|
+
version: 'main',
|
|
224
|
+
noStart: true,
|
|
225
|
+
noOpen: true,
|
|
226
|
+
file: configPath,
|
|
227
|
+
}).catch((e: unknown) => e);
|
|
228
|
+
|
|
229
|
+
// If setup fails it will throw "Setup failed: ..." — but NOT
|
|
230
|
+
// "Unsupported config file format" or "Failed to parse" which
|
|
231
|
+
// confirms the JSON was parsed and routed to performSetupFromConfig.
|
|
232
|
+
if (err) {
|
|
233
|
+
expect((err as Error).message).not.toContain('Unsupported config file format');
|
|
234
|
+
expect((err as Error).message).not.toContain('Failed to parse');
|
|
235
|
+
// It may fail with "Setup failed" due to missing filesystem state
|
|
236
|
+
// or it may succeed; either is acceptable for this routing test.
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('--file setup.yaml with valid YAML is parsed correctly', async () => {
|
|
241
|
+
const { bootstrapInstall } = await import('./install.ts');
|
|
242
|
+
const yamlPath = join(tempBase, 'setup.yaml');
|
|
243
|
+
const yamlContent = [
|
|
244
|
+
'version: 1',
|
|
245
|
+
'security:',
|
|
246
|
+
' adminToken: test-admin-token-12345',
|
|
247
|
+
'connections:',
|
|
248
|
+
' - id: openai-main',
|
|
249
|
+
' name: OpenAI',
|
|
250
|
+
' provider: openai',
|
|
251
|
+
' baseUrl: https://api.openai.com',
|
|
252
|
+
' apiKey: sk-test-key-123',
|
|
253
|
+
'assignments:',
|
|
254
|
+
' llm:',
|
|
255
|
+
' connectionId: openai-main',
|
|
256
|
+
' model: gpt-4o',
|
|
257
|
+
' embeddings:',
|
|
258
|
+
' connectionId: openai-main',
|
|
259
|
+
' model: text-embedding-3-small',
|
|
260
|
+
].join('\n');
|
|
261
|
+
writeFileSync(yamlPath, yamlContent);
|
|
262
|
+
|
|
263
|
+
const err = await bootstrapInstall({
|
|
264
|
+
force: true,
|
|
265
|
+
version: 'main',
|
|
266
|
+
noStart: true,
|
|
267
|
+
noOpen: true,
|
|
268
|
+
file: yamlPath,
|
|
269
|
+
}).catch((e: unknown) => e);
|
|
270
|
+
|
|
271
|
+
// If it gets past parsing, it will NOT throw a parse error.
|
|
272
|
+
if (err) {
|
|
273
|
+
expect((err as Error).message).not.toContain('Unsupported config file format');
|
|
274
|
+
expect((err as Error).message).not.toContain('Failed to parse');
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('--file config.json --no-start exits after setup without compose up', async () => {
|
|
279
|
+
const { bootstrapInstall } = await import('./install.ts');
|
|
280
|
+
const configPath = join(tempBase, 'config.json');
|
|
281
|
+
const config = makeValidSetupConfig();
|
|
282
|
+
writeFileSync(configPath, JSON.stringify(config));
|
|
283
|
+
|
|
284
|
+
const err = await bootstrapInstall({
|
|
285
|
+
force: true,
|
|
286
|
+
version: 'main',
|
|
287
|
+
noStart: true,
|
|
288
|
+
noOpen: true,
|
|
289
|
+
file: configPath,
|
|
290
|
+
}).catch((e: unknown) => e);
|
|
291
|
+
|
|
292
|
+
// Check Bun.spawn calls — docker compose up should NOT have been called
|
|
293
|
+
const spawnCalls = (Bun.spawn as ReturnType<typeof mock>).mock.calls;
|
|
294
|
+
const composeUpCalls = spawnCalls.filter((call: unknown[]) => {
|
|
295
|
+
const args = call[0] as string[];
|
|
296
|
+
return Array.isArray(args) && args.includes('compose') && args.includes('up');
|
|
297
|
+
});
|
|
298
|
+
expect(composeUpCalls).toHaveLength(0);
|
|
299
|
+
|
|
300
|
+
// With --no-start, should print "Config written" message (if setup succeeds)
|
|
301
|
+
// or throw a setup error. Either way, no docker compose up.
|
|
302
|
+
if (err) {
|
|
303
|
+
expect((err as Error).message).not.toContain('Unsupported config file format');
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -2,23 +2,28 @@ import { describe, expect, it } from 'bun:test';
|
|
|
2
2
|
import { buildDeployStatusEntries, buildInstallServiceNames } from './install-services.ts';
|
|
3
3
|
|
|
4
4
|
describe('install service helpers', () => {
|
|
5
|
-
it('
|
|
6
|
-
expect(buildInstallServiceNames(['
|
|
7
|
-
'
|
|
5
|
+
it('passes managed services through unchanged', () => {
|
|
6
|
+
expect(buildInstallServiceNames(['memory', 'assistant'])).toEqual([
|
|
7
|
+
'memory',
|
|
8
|
+
'assistant',
|
|
9
|
+
]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('includes admin services when they are in managed list', () => {
|
|
13
|
+
expect(buildInstallServiceNames(['memory', 'caddy', 'admin', 'docker-socket-proxy'])).toEqual([
|
|
8
14
|
'memory',
|
|
15
|
+
'caddy',
|
|
9
16
|
'admin',
|
|
10
17
|
'docker-socket-proxy',
|
|
11
18
|
]);
|
|
12
19
|
});
|
|
13
20
|
|
|
14
21
|
it('builds deploy status entries for the full install service list', () => {
|
|
15
|
-
const services = buildInstallServiceNames(['
|
|
22
|
+
const services = buildInstallServiceNames(['memory', 'assistant']);
|
|
16
23
|
|
|
17
24
|
expect(buildDeployStatusEntries(services, 'pending', 'Waiting...')).toEqual([
|
|
18
|
-
{ service: 'caddy', status: 'pending', label: 'Waiting...' },
|
|
19
25
|
{ service: 'memory', status: 'pending', label: 'Waiting...' },
|
|
20
|
-
{ service: '
|
|
21
|
-
{ service: 'docker-socket-proxy', status: 'pending', label: 'Waiting...' },
|
|
26
|
+
{ service: 'assistant', status: 'pending', label: 'Waiting...' },
|
|
22
27
|
]);
|
|
23
28
|
});
|
|
24
29
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export type DeployStatusState = 'pending' | 'pulling';
|
|
2
2
|
|
|
3
3
|
export function buildInstallServiceNames(managedServices: string[]): string[] {
|
|
4
|
-
return [...managedServices
|
|
4
|
+
return [...managedServices];
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
export function buildDeployStatusEntries(
|
package/src/commands/install.ts
CHANGED
|
@@ -3,19 +3,43 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { rm } from 'node:fs/promises';
|
|
4
4
|
import cliPkg from '../../package.json' with { type: 'json' };
|
|
5
5
|
import { defaultConfigHome, defaultDataHome, defaultStateHome, defaultWorkDir } from '../lib/paths.ts';
|
|
6
|
-
import { ensureSecrets, ensureStackEnv } from '../lib/env.ts';
|
|
7
|
-
import { isAdminReachable, adminRequest } from '../lib/admin.ts';
|
|
6
|
+
import { ensureSecrets, ensureStackEnv, resolveRequestedImageTag } from '../lib/env.ts';
|
|
8
7
|
import { ensureDirectoryTree, fetchAsset, runDockerCompose, openBrowser } from '../lib/docker.ts';
|
|
9
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
ensureOpenCodeConfig, ensureOpenCodeSystemConfig, ensureAdminOpenCodeConfig, FilesystemAssetProvider,
|
|
10
|
+
performSetupFromConfig,
|
|
11
|
+
type SetupConfig, type SetupResult,
|
|
12
|
+
} from '@openpalm/lib';
|
|
10
13
|
import { ensureVarlock, prepareVarlockDir } from '../lib/varlock.ts';
|
|
11
14
|
import { detectHostInfo } from '../lib/host-info.ts';
|
|
12
|
-
import { loadAdminToken } from '../lib/env.ts';
|
|
13
15
|
import { ensureStagedState, fullComposeArgs, buildManagedServiceNames } from '../lib/staging.ts';
|
|
14
16
|
import { createSetupServer } from '../setup-wizard/server.ts';
|
|
15
17
|
import { buildInstallServiceNames, buildDeployStatusEntries } from './install-services.ts';
|
|
16
18
|
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
+
const SETUP_WIZARD_PORT = Number(process.env.OPENPALM_SETUP_PORT) || 8100;
|
|
20
|
+
|
|
21
|
+
const REPO_OWNER = 'itlackey';
|
|
22
|
+
const REPO_NAME = 'openpalm';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolves the latest release tag from GitHub. Falls back to the CLI package
|
|
26
|
+
* version (prefixed with 'v') so the install never silently defaults to 'main'
|
|
27
|
+
* which produces an un-pinned 'latest' image tag.
|
|
28
|
+
*/
|
|
29
|
+
async function resolveDefaultInstallRef(): Promise<string> {
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(
|
|
32
|
+
`https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
|
|
33
|
+
{ redirect: 'manual', signal: AbortSignal.timeout(10000) },
|
|
34
|
+
);
|
|
35
|
+
const location = res.headers.get('location') ?? '';
|
|
36
|
+
const match = location.match(/\/tag\/(v[0-9]+\.[0-9]+\.[0-9]+[^\s]*)$/);
|
|
37
|
+
if (match?.[1]) return match[1];
|
|
38
|
+
} catch {
|
|
39
|
+
// Network error — fall through to package version
|
|
40
|
+
}
|
|
41
|
+
return cliPkg.version ? `v${cliPkg.version}` : 'main';
|
|
42
|
+
}
|
|
19
43
|
|
|
20
44
|
export default defineCommand({
|
|
21
45
|
meta: {
|
|
@@ -30,8 +54,7 @@ export default defineCommand({
|
|
|
30
54
|
},
|
|
31
55
|
version: {
|
|
32
56
|
type: 'string',
|
|
33
|
-
description: 'Install specific
|
|
34
|
-
default: DEFAULT_INSTALL_REF,
|
|
57
|
+
description: 'Install specific repository ref (default: latest release)',
|
|
35
58
|
},
|
|
36
59
|
start: {
|
|
37
60
|
type: 'boolean',
|
|
@@ -43,25 +66,20 @@ export default defineCommand({
|
|
|
43
66
|
description: 'Open browser after install (use --no-open to skip)',
|
|
44
67
|
default: true,
|
|
45
68
|
},
|
|
69
|
+
file: {
|
|
70
|
+
type: 'string',
|
|
71
|
+
alias: 'f',
|
|
72
|
+
description: 'Path to setup config file (JSON or YAML) — skips wizard',
|
|
73
|
+
},
|
|
46
74
|
},
|
|
47
75
|
async run({ args }) {
|
|
48
|
-
|
|
49
|
-
// delegate to the admin API. Otherwise fall through to bootstrap.
|
|
50
|
-
if (await isAdminReachable()) {
|
|
51
|
-
const token = await loadAdminToken();
|
|
52
|
-
if (token) {
|
|
53
|
-
console.log(JSON.stringify(await adminRequest('/admin/install', { method: 'POST' }), null, 2));
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
// No token available — fall through to bootstrap install which doesn't need auth.
|
|
57
|
-
console.warn('Stack is running but no admin token is configured. Proceeding with bootstrap install.');
|
|
58
|
-
}
|
|
59
|
-
|
|
76
|
+
const version = args.version || await resolveDefaultInstallRef();
|
|
60
77
|
await bootstrapInstall({
|
|
61
78
|
force: args.force,
|
|
62
|
-
version
|
|
79
|
+
version,
|
|
63
80
|
noStart: !args.start,
|
|
64
81
|
noOpen: !args.open,
|
|
82
|
+
file: args.file,
|
|
65
83
|
});
|
|
66
84
|
},
|
|
67
85
|
});
|
|
@@ -71,6 +89,7 @@ type InstallOptions = {
|
|
|
71
89
|
version: string;
|
|
72
90
|
noStart: boolean;
|
|
73
91
|
noOpen: boolean;
|
|
92
|
+
file?: string;
|
|
74
93
|
};
|
|
75
94
|
|
|
76
95
|
export async function bootstrapInstall(options: InstallOptions): Promise<void> {
|
|
@@ -117,16 +136,20 @@ export async function bootstrapInstall(options: InstallOptions): Promise<void> {
|
|
|
117
136
|
await Bun.write(join(stateHome, 'artifacts', 'Caddyfile'), caddyContent);
|
|
118
137
|
|
|
119
138
|
// Download schemas to both DATA_HOME (for FilesystemAssetProvider) and STATE_HOME (for varlock validation)
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
139
|
+
for (const schemaFile of ['secrets.env.schema', 'stack.env.schema', 'setup-config.schema.json']) {
|
|
140
|
+
try {
|
|
141
|
+
const content = await fetchAsset(options.version, schemaFile);
|
|
142
|
+
await Bun.write(join(dataHome, schemaFile), content);
|
|
143
|
+
await Bun.write(join(stateHome, 'artifacts', schemaFile), content);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.warn(`Warning: could not download schema '${schemaFile}': ${err instanceof Error ? err.message : String(err)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
126
148
|
|
|
127
149
|
// Download remaining assets needed by FilesystemAssetProvider
|
|
128
150
|
const assetFiles: Array<{ remote: string; localPath: string }> = [
|
|
129
151
|
{ remote: 'ollama.yml', localPath: join(dataHome, 'ollama.yml') },
|
|
152
|
+
{ remote: 'admin.yml', localPath: join(dataHome, 'admin.yml') },
|
|
130
153
|
{ remote: 'AGENTS.md', localPath: join(dataHome, 'assistant', 'AGENTS.md') },
|
|
131
154
|
{ remote: 'opencode.jsonc', localPath: join(dataHome, 'assistant', 'opencode.jsonc') },
|
|
132
155
|
{ remote: 'admin-opencode.jsonc', localPath: join(dataHome, 'admin', 'opencode.jsonc') },
|
|
@@ -146,7 +169,10 @@ export async function bootstrapInstall(options: InstallOptions): Promise<void> {
|
|
|
146
169
|
);
|
|
147
170
|
|
|
148
171
|
await ensureSecrets(configHome);
|
|
149
|
-
|
|
172
|
+
// Derive the image tag from the resolved version so that stale or
|
|
173
|
+
// architecture-suffixed OPENPALM_IMAGE_TAG env vars don't leak in.
|
|
174
|
+
const imageTag = resolveRequestedImageTag(options.version) ?? undefined;
|
|
175
|
+
await ensureStackEnv(configHome, dataHome, stateHome, workDir, options.version, imageTag);
|
|
150
176
|
// Seed OpenCode config — non-fatal since performSetup() also seeds these
|
|
151
177
|
try {
|
|
152
178
|
const fsAssets = new FilesystemAssetProvider(dataHome);
|
|
@@ -183,11 +209,84 @@ export async function bootstrapInstall(options: InstallOptions): Promise<void> {
|
|
|
183
209
|
// Varlock install/execution failures are non-fatal during install
|
|
184
210
|
}
|
|
185
211
|
|
|
186
|
-
if (options.noStart) {
|
|
212
|
+
if (options.noStart && !options.file) {
|
|
187
213
|
console.log('OpenPalm files prepared. Run `openpalm start` to start services.');
|
|
188
214
|
return;
|
|
189
215
|
}
|
|
190
216
|
|
|
217
|
+
// ── File-based install (--file / -f) ──────────────────────────────────
|
|
218
|
+
// Read a JSON or YAML setup config file and call performSetup() or
|
|
219
|
+
// performSetupFromConfig() directly — no wizard needed.
|
|
220
|
+
|
|
221
|
+
if (options.file) {
|
|
222
|
+
console.log(`Reading setup config from ${options.file}...`);
|
|
223
|
+
|
|
224
|
+
if (!(await Bun.file(options.file).exists())) {
|
|
225
|
+
throw new Error(`Setup config file not found: ${options.file}. Check the --file path and try again.`);
|
|
226
|
+
}
|
|
227
|
+
let raw: string;
|
|
228
|
+
try {
|
|
229
|
+
raw = await Bun.file(options.file).text();
|
|
230
|
+
} catch (err) {
|
|
231
|
+
throw new Error(`Failed to read setup config file '${options.file}': ${err instanceof Error ? err.message : String(err)}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const ext = options.file.toLowerCase();
|
|
235
|
+
let parsed: unknown;
|
|
236
|
+
try {
|
|
237
|
+
if (ext.endsWith('.yaml') || ext.endsWith('.yml')) {
|
|
238
|
+
const { parse } = await import('yaml');
|
|
239
|
+
parsed = parse(raw);
|
|
240
|
+
} else if (ext.endsWith('.json')) {
|
|
241
|
+
parsed = JSON.parse(raw);
|
|
242
|
+
} else {
|
|
243
|
+
throw new Error(`Unsupported config file format: ${options.file}. Use .json or .yaml.`);
|
|
244
|
+
}
|
|
245
|
+
} catch (err) {
|
|
246
|
+
if (err instanceof Error && err.message.startsWith('Unsupported config file format:')) {
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
throw new Error(`Failed to parse setup config '${options.file}': ${err instanceof Error ? err.message : String(err)}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const fsAssets = new FilesystemAssetProvider(dataHome);
|
|
253
|
+
const config = parsed as Record<string, unknown>;
|
|
254
|
+
let result: SetupResult;
|
|
255
|
+
|
|
256
|
+
if (typeof config.version !== "number") {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`Setup config file is missing a 'version' field. Use 'version: 1' for the current format.`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
if (config.version === 1) {
|
|
262
|
+
result = await performSetupFromConfig(config as SetupConfig, fsAssets);
|
|
263
|
+
} else {
|
|
264
|
+
throw new Error(`Unsupported setup config version: ${config.version}. Only version 1 is supported.`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!result.ok) throw new Error(`Setup failed: ${result.error}`);
|
|
268
|
+
console.log('Setup complete.');
|
|
269
|
+
|
|
270
|
+
if (options.noStart) {
|
|
271
|
+
console.log('Config written. Run `openpalm start` to start services.');
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Deploy (same as existing update-mode code)
|
|
276
|
+
console.log('Starting services...');
|
|
277
|
+
const state = await ensureStagedState();
|
|
278
|
+
const composeArgs = fullComposeArgs(state);
|
|
279
|
+
const managedServices = buildManagedServiceNames(state);
|
|
280
|
+
const allServices = buildInstallServiceNames(managedServices);
|
|
281
|
+
|
|
282
|
+
await runDockerCompose([...composeArgs, 'pull', ...allServices]).catch(() => {
|
|
283
|
+
console.warn('Warning: image pull failed.');
|
|
284
|
+
});
|
|
285
|
+
await runDockerCompose([...composeArgs, 'up', '-d', ...allServices]);
|
|
286
|
+
console.log(JSON.stringify({ ok: true, mode: 'install', services: allServices }, null, 2));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
191
290
|
// ── Setup Wizard ──────────────────────────────────────────────────────
|
|
192
291
|
// First-time install: serve the setup wizard locally and wait for user
|
|
193
292
|
// to complete it. The wizard calls performSetup() from @openpalm/lib
|
|
@@ -197,7 +296,16 @@ export async function bootstrapInstall(options: InstallOptions): Promise<void> {
|
|
|
197
296
|
if (!updateMode) {
|
|
198
297
|
console.log('Starting setup wizard...');
|
|
199
298
|
|
|
200
|
-
|
|
299
|
+
let wizard;
|
|
300
|
+
try {
|
|
301
|
+
wizard = createSetupServer(SETUP_WIZARD_PORT, { configDir: configHome });
|
|
302
|
+
} catch (err) {
|
|
303
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
304
|
+
if (msg.includes('EADDRINUSE') || msg.includes('address already in use') || msg.includes('Failed to start')) {
|
|
305
|
+
throw new Error(`Port ${SETUP_WIZARD_PORT} is in use. Stop the conflicting process or set OPENPALM_SETUP_PORT=<port>.`);
|
|
306
|
+
}
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
201
309
|
const wizardUrl = `http://localhost:${wizard.server.port}/setup`;
|
|
202
310
|
console.log(`Setup wizard running at ${wizardUrl}`);
|
|
203
311
|
|
|
@@ -225,13 +333,13 @@ export async function bootstrapInstall(options: InstallOptions): Promise<void> {
|
|
|
225
333
|
|
|
226
334
|
wizard.updateDeployStatus(buildDeployStatusEntries(allServices, 'pending', 'Waiting...'));
|
|
227
335
|
|
|
228
|
-
await runDockerCompose([...composeArgs, '
|
|
229
|
-
|
|
336
|
+
await runDockerCompose([...composeArgs, 'pull', ...allServices]).catch(() => {
|
|
337
|
+
console.warn('Warning: image pull failed — if this is your first install, check your network connection.');
|
|
230
338
|
});
|
|
231
339
|
|
|
232
340
|
wizard.updateDeployStatus(buildDeployStatusEntries(allServices, 'pulling', 'Starting...'));
|
|
233
341
|
|
|
234
|
-
await runDockerCompose([...composeArgs, '
|
|
342
|
+
await runDockerCompose([...composeArgs, 'up', '-d', ...allServices]);
|
|
235
343
|
|
|
236
344
|
wizard.markAllRunning();
|
|
237
345
|
|
|
@@ -264,7 +372,7 @@ export async function bootstrapInstall(options: InstallOptions): Promise<void> {
|
|
|
264
372
|
const managedServices = buildManagedServiceNames(state);
|
|
265
373
|
const allServices = buildInstallServiceNames(managedServices);
|
|
266
374
|
|
|
267
|
-
await runDockerCompose([...composeArgs, '
|
|
375
|
+
await runDockerCompose([...composeArgs, 'up', '-d', ...allServices]);
|
|
268
376
|
|
|
269
377
|
console.log(JSON.stringify({
|
|
270
378
|
ok: true,
|