openpalm 0.9.7 → 0.9.9
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/bin/openpalm.js +5 -0
- 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 +113 -30
- 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 +6 -59
- package/src/lib/paths.ts +11 -1
- package/src/lib/staging.ts +3 -3
- package/src/main.test.ts +67 -83
- package/src/main.ts +1 -1
- 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/bin/openpalm.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openpalm",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
6
|
"description": "OpenPalm CLI — install and manage a self-hosted OpenPalm stack",
|
|
@@ -10,11 +10,13 @@
|
|
|
10
10
|
"directory": "packages/cli"
|
|
11
11
|
},
|
|
12
12
|
"bin": {
|
|
13
|
-
"openpalm": "./
|
|
13
|
+
"openpalm": "./bin/openpalm.js"
|
|
14
14
|
},
|
|
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",
|
|
@@ -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
|
@@ -4,18 +4,20 @@ 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
6
|
import { ensureSecrets, ensureStackEnv } from '../lib/env.ts';
|
|
7
|
-
import { isAdminReachable, adminRequest } from '../lib/admin.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 DEFAULT_INSTALL_REF =
|
|
18
|
-
const SETUP_WIZARD_PORT = 8100;
|
|
19
|
+
const DEFAULT_INSTALL_REF = 'main';
|
|
20
|
+
const SETUP_WIZARD_PORT = Number(process.env.OPENPALM_SETUP_PORT) || 8100;
|
|
19
21
|
|
|
20
22
|
export default defineCommand({
|
|
21
23
|
meta: {
|
|
@@ -30,7 +32,7 @@ export default defineCommand({
|
|
|
30
32
|
},
|
|
31
33
|
version: {
|
|
32
34
|
type: 'string',
|
|
33
|
-
description: 'Install specific
|
|
35
|
+
description: 'Install specific repository ref (default: main)',
|
|
34
36
|
default: DEFAULT_INSTALL_REF,
|
|
35
37
|
},
|
|
36
38
|
start: {
|
|
@@ -43,25 +45,19 @@ export default defineCommand({
|
|
|
43
45
|
description: 'Open browser after install (use --no-open to skip)',
|
|
44
46
|
default: true,
|
|
45
47
|
},
|
|
48
|
+
file: {
|
|
49
|
+
type: 'string',
|
|
50
|
+
alias: 'f',
|
|
51
|
+
description: 'Path to setup config file (JSON or YAML) — skips wizard',
|
|
52
|
+
},
|
|
46
53
|
},
|
|
47
54
|
async run({ args }) {
|
|
48
|
-
// If the stack is already running AND we have a valid admin token,
|
|
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
|
-
|
|
60
55
|
await bootstrapInstall({
|
|
61
56
|
force: args.force,
|
|
62
57
|
version: args.version,
|
|
63
58
|
noStart: !args.start,
|
|
64
59
|
noOpen: !args.open,
|
|
60
|
+
file: args.file,
|
|
65
61
|
});
|
|
66
62
|
},
|
|
67
63
|
});
|
|
@@ -71,6 +67,7 @@ type InstallOptions = {
|
|
|
71
67
|
version: string;
|
|
72
68
|
noStart: boolean;
|
|
73
69
|
noOpen: boolean;
|
|
70
|
+
file?: string;
|
|
74
71
|
};
|
|
75
72
|
|
|
76
73
|
export async function bootstrapInstall(options: InstallOptions): Promise<void> {
|
|
@@ -117,16 +114,20 @@ export async function bootstrapInstall(options: InstallOptions): Promise<void> {
|
|
|
117
114
|
await Bun.write(join(stateHome, 'artifacts', 'Caddyfile'), caddyContent);
|
|
118
115
|
|
|
119
116
|
// Download schemas to both DATA_HOME (for FilesystemAssetProvider) and STATE_HOME (for varlock validation)
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
117
|
+
for (const schemaFile of ['secrets.env.schema', 'stack.env.schema', 'setup-config.schema.json']) {
|
|
118
|
+
try {
|
|
119
|
+
const content = await fetchAsset(options.version, schemaFile);
|
|
120
|
+
await Bun.write(join(dataHome, schemaFile), content);
|
|
121
|
+
await Bun.write(join(stateHome, 'artifacts', schemaFile), content);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.warn(`Warning: could not download schema '${schemaFile}': ${err instanceof Error ? err.message : String(err)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
126
|
|
|
127
127
|
// Download remaining assets needed by FilesystemAssetProvider
|
|
128
128
|
const assetFiles: Array<{ remote: string; localPath: string }> = [
|
|
129
129
|
{ remote: 'ollama.yml', localPath: join(dataHome, 'ollama.yml') },
|
|
130
|
+
{ remote: 'admin.yml', localPath: join(dataHome, 'admin.yml') },
|
|
130
131
|
{ remote: 'AGENTS.md', localPath: join(dataHome, 'assistant', 'AGENTS.md') },
|
|
131
132
|
{ remote: 'opencode.jsonc', localPath: join(dataHome, 'assistant', 'opencode.jsonc') },
|
|
132
133
|
{ remote: 'admin-opencode.jsonc', localPath: join(dataHome, 'admin', 'opencode.jsonc') },
|
|
@@ -183,11 +184,84 @@ export async function bootstrapInstall(options: InstallOptions): Promise<void> {
|
|
|
183
184
|
// Varlock install/execution failures are non-fatal during install
|
|
184
185
|
}
|
|
185
186
|
|
|
186
|
-
if (options.noStart) {
|
|
187
|
+
if (options.noStart && !options.file) {
|
|
187
188
|
console.log('OpenPalm files prepared. Run `openpalm start` to start services.');
|
|
188
189
|
return;
|
|
189
190
|
}
|
|
190
191
|
|
|
192
|
+
// ── File-based install (--file / -f) ──────────────────────────────────
|
|
193
|
+
// Read a JSON or YAML setup config file and call performSetup() or
|
|
194
|
+
// performSetupFromConfig() directly — no wizard needed.
|
|
195
|
+
|
|
196
|
+
if (options.file) {
|
|
197
|
+
console.log(`Reading setup config from ${options.file}...`);
|
|
198
|
+
|
|
199
|
+
if (!(await Bun.file(options.file).exists())) {
|
|
200
|
+
throw new Error(`Setup config file not found: ${options.file}. Check the --file path and try again.`);
|
|
201
|
+
}
|
|
202
|
+
let raw: string;
|
|
203
|
+
try {
|
|
204
|
+
raw = await Bun.file(options.file).text();
|
|
205
|
+
} catch (err) {
|
|
206
|
+
throw new Error(`Failed to read setup config file '${options.file}': ${err instanceof Error ? err.message : String(err)}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const ext = options.file.toLowerCase();
|
|
210
|
+
let parsed: unknown;
|
|
211
|
+
try {
|
|
212
|
+
if (ext.endsWith('.yaml') || ext.endsWith('.yml')) {
|
|
213
|
+
const { parse } = await import('yaml');
|
|
214
|
+
parsed = parse(raw);
|
|
215
|
+
} else if (ext.endsWith('.json')) {
|
|
216
|
+
parsed = JSON.parse(raw);
|
|
217
|
+
} else {
|
|
218
|
+
throw new Error(`Unsupported config file format: ${options.file}. Use .json or .yaml.`);
|
|
219
|
+
}
|
|
220
|
+
} catch (err) {
|
|
221
|
+
if (err instanceof Error && err.message.startsWith('Unsupported config file format:')) {
|
|
222
|
+
throw err;
|
|
223
|
+
}
|
|
224
|
+
throw new Error(`Failed to parse setup config '${options.file}': ${err instanceof Error ? err.message : String(err)}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const fsAssets = new FilesystemAssetProvider(dataHome);
|
|
228
|
+
const config = parsed as Record<string, unknown>;
|
|
229
|
+
let result: SetupResult;
|
|
230
|
+
|
|
231
|
+
if (typeof config.version !== "number") {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`Setup config file is missing a 'version' field. Use 'version: 1' for the current format.`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (config.version === 1) {
|
|
237
|
+
result = await performSetupFromConfig(config as SetupConfig, fsAssets);
|
|
238
|
+
} else {
|
|
239
|
+
throw new Error(`Unsupported setup config version: ${config.version}. Only version 1 is supported.`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!result.ok) throw new Error(`Setup failed: ${result.error}`);
|
|
243
|
+
console.log('Setup complete.');
|
|
244
|
+
|
|
245
|
+
if (options.noStart) {
|
|
246
|
+
console.log('Config written. Run `openpalm start` to start services.');
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Deploy (same as existing update-mode code)
|
|
251
|
+
console.log('Starting services...');
|
|
252
|
+
const state = await ensureStagedState();
|
|
253
|
+
const composeArgs = fullComposeArgs(state);
|
|
254
|
+
const managedServices = buildManagedServiceNames(state);
|
|
255
|
+
const allServices = buildInstallServiceNames(managedServices);
|
|
256
|
+
|
|
257
|
+
await runDockerCompose([...composeArgs, 'pull', ...allServices]).catch(() => {
|
|
258
|
+
console.warn('Warning: image pull failed.');
|
|
259
|
+
});
|
|
260
|
+
await runDockerCompose([...composeArgs, 'up', '-d', ...allServices]);
|
|
261
|
+
console.log(JSON.stringify({ ok: true, mode: 'install', services: allServices }, null, 2));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
191
265
|
// ── Setup Wizard ──────────────────────────────────────────────────────
|
|
192
266
|
// First-time install: serve the setup wizard locally and wait for user
|
|
193
267
|
// to complete it. The wizard calls performSetup() from @openpalm/lib
|
|
@@ -197,7 +271,16 @@ export async function bootstrapInstall(options: InstallOptions): Promise<void> {
|
|
|
197
271
|
if (!updateMode) {
|
|
198
272
|
console.log('Starting setup wizard...');
|
|
199
273
|
|
|
200
|
-
|
|
274
|
+
let wizard;
|
|
275
|
+
try {
|
|
276
|
+
wizard = createSetupServer(SETUP_WIZARD_PORT, { configDir: configHome });
|
|
277
|
+
} catch (err) {
|
|
278
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
279
|
+
if (msg.includes('EADDRINUSE') || msg.includes('address already in use') || msg.includes('Failed to start')) {
|
|
280
|
+
throw new Error(`Port ${SETUP_WIZARD_PORT} is in use. Stop the conflicting process or set OPENPALM_SETUP_PORT=<port>.`);
|
|
281
|
+
}
|
|
282
|
+
throw err;
|
|
283
|
+
}
|
|
201
284
|
const wizardUrl = `http://localhost:${wizard.server.port}/setup`;
|
|
202
285
|
console.log(`Setup wizard running at ${wizardUrl}`);
|
|
203
286
|
|
|
@@ -225,13 +308,13 @@ export async function bootstrapInstall(options: InstallOptions): Promise<void> {
|
|
|
225
308
|
|
|
226
309
|
wizard.updateDeployStatus(buildDeployStatusEntries(allServices, 'pending', 'Waiting...'));
|
|
227
310
|
|
|
228
|
-
await runDockerCompose([...composeArgs, '
|
|
229
|
-
|
|
311
|
+
await runDockerCompose([...composeArgs, 'pull', ...allServices]).catch(() => {
|
|
312
|
+
console.warn('Warning: image pull failed — if this is your first install, check your network connection.');
|
|
230
313
|
});
|
|
231
314
|
|
|
232
315
|
wizard.updateDeployStatus(buildDeployStatusEntries(allServices, 'pulling', 'Starting...'));
|
|
233
316
|
|
|
234
|
-
await runDockerCompose([...composeArgs, '
|
|
317
|
+
await runDockerCompose([...composeArgs, 'up', '-d', ...allServices]);
|
|
235
318
|
|
|
236
319
|
wizard.markAllRunning();
|
|
237
320
|
|
|
@@ -264,7 +347,7 @@ export async function bootstrapInstall(options: InstallOptions): Promise<void> {
|
|
|
264
347
|
const managedServices = buildManagedServiceNames(state);
|
|
265
348
|
const allServices = buildInstallServiceNames(managedServices);
|
|
266
349
|
|
|
267
|
-
await runDockerCompose([...composeArgs, '
|
|
350
|
+
await runDockerCompose([...composeArgs, 'up', '-d', ...allServices]);
|
|
268
351
|
|
|
269
352
|
console.log(JSON.stringify({
|
|
270
353
|
ok: true,
|