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.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bun
2
+ import { runMain } from 'citty';
3
+ import { mainCommand } from '../src/main.ts';
4
+
5
+ runMain(mainCommand);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openpalm",
3
- "version": "0.9.7",
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": "./src/main.ts"
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('appends admin services to managed services', () => {
6
- expect(buildInstallServiceNames(['caddy', 'memory'])).toEqual([
7
- 'caddy',
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(['caddy', 'memory']);
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: 'admin', status: 'pending', label: 'Waiting...' },
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, 'admin', 'docker-socket-proxy'];
4
+ return [...managedServices];
5
5
  }
6
6
 
7
7
  export function buildDeployStatusEntries(
@@ -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 { ensureOpenCodeConfig, ensureOpenCodeSystemConfig, ensureAdminOpenCodeConfig, FilesystemAssetProvider } from '@openpalm/lib';
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 = cliPkg.version ? `v${cliPkg.version}` : 'main';
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 release ref (default: current CLI version)',
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 secretsSchemaContent = await fetchAsset(options.version, 'secrets.env.schema');
121
- const stackSchemaContent = await fetchAsset(options.version, 'stack.env.schema');
122
- await Bun.write(join(dataHome, 'secrets.env.schema'), secretsSchemaContent);
123
- await Bun.write(join(dataHome, 'stack.env.schema'), stackSchemaContent);
124
- await Bun.write(join(stateHome, 'artifacts', 'secrets.env.schema'), secretsSchemaContent);
125
- await Bun.write(join(stateHome, 'artifacts', 'stack.env.schema'), stackSchemaContent);
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
- const wizard = createSetupServer(SETUP_WIZARD_PORT, { configDir: configHome });
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, '--profile', 'admin', 'pull', ...allServices]).catch(() => {
229
- // Pull failure is non-fatal images may already be cached
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, '--profile', 'admin', 'up', '-d', ...allServices]);
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, '--profile', 'admin', 'up', '-d', ...allServices]);
350
+ await runDockerCompose([...composeArgs, 'up', '-d', ...allServices]);
268
351
 
269
352
  console.log(JSON.stringify({
270
353
  ok: true,