openpalm 0.10.2 → 0.11.0-beta.2

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.
Files changed (47) hide show
  1. package/README.md +11 -19
  2. package/package.json +4 -2
  3. package/src/commands/addon.ts +5 -4
  4. package/src/commands/automations.ts +63 -0
  5. package/src/commands/install.ts +98 -280
  6. package/src/commands/logs.ts +1 -1
  7. package/src/commands/restart.ts +5 -4
  8. package/src/commands/rollback.ts +4 -3
  9. package/src/commands/scan.ts +66 -32
  10. package/src/commands/service.ts +5 -4
  11. package/src/commands/start.ts +5 -4
  12. package/src/commands/status.ts +1 -1
  13. package/src/commands/stop.ts +2 -4
  14. package/src/commands/uninstall.ts +3 -5
  15. package/src/commands/update.ts +19 -2
  16. package/src/commands/validate.ts +16 -34
  17. package/src/install-flow.test.ts +153 -154
  18. package/src/lib/admin-skills/index.test.ts +70 -0
  19. package/src/lib/admin-skills/index.ts +113 -0
  20. package/src/lib/browser.ts +20 -0
  21. package/src/lib/cli-compose.ts +2 -20
  22. package/src/lib/cli-state.ts +1 -1
  23. package/src/lib/docker.ts +8 -214
  24. package/src/lib/env.ts +12 -83
  25. package/src/lib/io.ts +130 -0
  26. package/src/lib/opencode-subprocess.ts +14 -6
  27. package/src/lib/paths.ts +2 -2
  28. package/src/lib/ui-server.ts +150 -0
  29. package/src/main.test.ts +76 -173
  30. package/src/main.ts +131 -7
  31. package/e2e/start-wizard-server.ts +0 -59
  32. package/src/commands/admin.ts +0 -43
  33. package/src/commands/install-services.test.ts +0 -13
  34. package/src/commands/install-services.ts +0 -9
  35. package/src/commands/upgrade.ts +0 -12
  36. package/src/lib/embedded-assets.ts +0 -115
  37. package/src/lib/varlock.ts +0 -126
  38. package/src/setup-wizard/index.html +0 -321
  39. package/src/setup-wizard/server-errors.test.ts +0 -418
  40. package/src/setup-wizard/server-integration.test.ts +0 -511
  41. package/src/setup-wizard/server.test.ts +0 -508
  42. package/src/setup-wizard/server.ts +0 -342
  43. package/src/setup-wizard/wizard-renderers.js +0 -1294
  44. package/src/setup-wizard/wizard-state.js +0 -346
  45. package/src/setup-wizard/wizard-validators.js +0 -81
  46. package/src/setup-wizard/wizard.css +0 -1611
  47. package/src/setup-wizard/wizard.js +0 -613
@@ -1,32 +1,28 @@
1
1
  import { defineCommand } from 'citty';
2
2
  import { join } from 'node:path';
3
- import { rm } from 'node:fs/promises';
3
+ import { createInterface } from 'node:readline';
4
4
  import cliPkg from '../../package.json' with { type: 'json' };
5
5
  import { defaultWorkDir } from '../lib/paths.ts';
6
- import { resolveOpenPalmHome, resolveConfigDir, resolveVaultDir, resolveDataDir } from '@openpalm/lib';
7
- import { ensureSecrets, ensureStackEnv, resolveRequestedImageTag } from '../lib/env.ts';
8
- import { ensureDirectoryTree, seedOpenPalmDir, openBrowser, runDockerCompose, runDockerComposeCapture } from '../lib/docker.ts';
6
+ import { resolveOpenPalmHome, resolveConfigDir } from '@openpalm/lib';
7
+ import { ensureSecrets, ensureStackEnv } from '../lib/env.ts';
8
+ import { ensureDirectoryTree, seedOpenPalmDir, seedUiBuild } from '../lib/io.ts';
9
+ import { openBrowser } from '../lib/browser.ts';
10
+ import { runDockerCompose } from '../lib/docker.ts';
9
11
  import {
10
12
  backupOpenPalmHome,
13
+ buildComposeCliArgs,
11
14
  ensureOpenCodeConfig, ensureOpenCodeSystemConfig,
12
15
  performSetup,
13
16
  applyInstall,
14
17
  buildManagedServices,
15
- createOpenCodeClient,
16
18
  createLogger,
19
+ resolveRequestedImageTag,
17
20
  type SetupSpec,
18
21
  } from '@openpalm/lib';
19
- import { seedEmbeddedAssets } from '../lib/embedded-assets.ts';
20
- import { ensureVarlock, prepareVarlockDir } from '../lib/varlock.ts';
21
22
  import { detectHostInfo } from '../lib/host-info.ts';
22
23
  import { ensureValidState } from '../lib/cli-state.ts';
23
- import { fullComposeArgs } from '../lib/cli-compose.ts';
24
- import { createSetupServer } from '../setup-wizard/server.ts';
25
- import { buildDeployStatusEntries } from './install-services.ts';
26
- import { startOpenCodeSubprocess, type OpenCodeSubprocess } from '../lib/opencode-subprocess.ts';
27
24
 
28
25
  const logger = createLogger('cli:install');
29
- const SETUP_WIZARD_PORT = Number(process.env.OP_SETUP_PORT) || 0; // 0 = random available port
30
26
 
31
27
  async function resolveDefaultInstallRef(): Promise<string> {
32
28
  try {
@@ -67,6 +63,12 @@ export default defineCommand({
67
63
  alias: 'f',
68
64
  description: 'Path to setup config file (JSON or YAML) — skips wizard',
69
65
  },
66
+ yes: {
67
+ type: 'boolean',
68
+ alias: 'y',
69
+ description: 'Auto-confirm destructive prompts (e.g. --force backup of existing OP_HOME)',
70
+ default: false,
71
+ },
70
72
  },
71
73
  async run({ args }) {
72
74
  try {
@@ -77,6 +79,7 @@ export default defineCommand({
77
79
  noStart: !args.start,
78
80
  noOpen: !args.open,
79
81
  file: args.file,
82
+ assumeYes: args.yes,
80
83
  });
81
84
  } catch (err) {
82
85
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
@@ -91,8 +94,26 @@ type InstallOptions = {
91
94
  noStart: boolean;
92
95
  noOpen: boolean;
93
96
  file?: string;
97
+ assumeYes: boolean;
94
98
  };
95
99
 
100
+ /**
101
+ * Prompt the user for a y/N confirmation on stdin/stdout. Returns false in
102
+ * any non-interactive context (no TTY) so CI runs do not hang waiting on
103
+ * input — callers must pair this with an explicit `--yes` flag for
104
+ * unattended invocations.
105
+ */
106
+ async function promptYesNo(question: string): Promise<boolean> {
107
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
108
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
109
+ try {
110
+ const answer = await new Promise<string>((resolve) => rl.question(`${question} `, resolve));
111
+ return /^y(es)?$/i.test(answer.trim());
112
+ } finally {
113
+ rl.close();
114
+ }
115
+ }
116
+
96
117
  async function requireCmd(cmd: string[], msg: string): Promise<void> {
97
118
  if ((await Bun.spawn(cmd, { stdout: 'ignore', stderr: 'ignore' }).exited) !== 0) throw new Error(msg);
98
119
  }
@@ -104,10 +125,10 @@ async function requireDocker(): Promise<void> {
104
125
  }
105
126
 
106
127
  async function deployServices(mode: string, pull = true): Promise<string[]> {
107
- const state = await ensureValidState();
128
+ const state = ensureValidState();
108
129
  await applyInstall(state);
109
130
  const managedServices = await buildManagedServices(state);
110
- const composeArgs = fullComposeArgs(state);
131
+ const composeArgs = buildComposeCliArgs(state);
111
132
  if (pull) await runDockerCompose([...composeArgs, 'pull', ...managedServices]).catch(() => console.warn('Warning: image pull failed.'));
112
133
  await runDockerCompose([...composeArgs, 'up', '-d', ...managedServices]);
113
134
  console.log(JSON.stringify({ ok: true, mode, services: managedServices }, null, 2));
@@ -128,16 +149,39 @@ async function parseConfigFile(filePath: string, raw: string): Promise<Record<st
128
149
  export async function bootstrapInstall(options: InstallOptions): Promise<void> {
129
150
  const homeDir = resolveOpenPalmHome();
130
151
  const configDir = resolveConfigDir();
131
- const vaultDir = resolveVaultDir();
132
- const dataDir = resolveDataDir();
152
+ const stateDir = `${homeDir}/state`;
133
153
  const workDir = defaultWorkDir();
134
154
 
135
- const alreadyInstalled = await Bun.file(join(vaultDir, 'user', 'user.env')).exists();
155
+ // Use config/stack/stack.env (always present after a successful install) as the
156
+ // canonical "already installed" indicator.
157
+ const alreadyInstalled = await Bun.file(join(configDir, 'stack', 'stack.env')).exists();
136
158
  if (alreadyInstalled && !options.force) {
137
159
  throw new Error('OpenPalm appears to already be installed. Re-run install with --force to continue.');
138
160
  }
139
161
 
140
162
  if (alreadyInstalled && options.force) {
163
+ // Use the helper's own backup-path convention so the prompt is honest about
164
+ // where the existing install will land. Match backupOpenPalmHome():
165
+ // `${homeDir}.backup.${YYYYMMDD-HHMMSS}`.
166
+ const now = new Date();
167
+ const stamp =
168
+ `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}` +
169
+ `-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
170
+ const plannedBackup = `${homeDir}.backup.${stamp}`;
171
+
172
+ // Skip the prompt when --yes was passed OR when there's no TTY (CI/scripts).
173
+ // Without the TTY exemption we would silently hang a non-interactive
174
+ // pipeline waiting for stdin, which is worse than auto-confirming.
175
+ const interactive = process.stdin.isTTY && process.stdout.isTTY;
176
+ if (!options.assumeYes && interactive) {
177
+ const proceed = await promptYesNo(
178
+ `--force will move the existing OpenPalm install at ${homeDir} to ${plannedBackup}. Continue? [y/N]`,
179
+ );
180
+ if (!proceed) {
181
+ console.log('Install aborted. Re-run with --yes (or -y) to skip this confirmation in non-interactive use.');
182
+ return;
183
+ }
184
+ }
141
185
  const backupDir = backupOpenPalmHome(homeDir);
142
186
  if (backupDir) {
143
187
  console.log(`Backed up existing OP_HOME to ${backupDir}`);
@@ -145,7 +189,7 @@ export async function bootstrapInstall(options: InstallOptions): Promise<void> {
145
189
  }
146
190
 
147
191
  // ── Bootstrap files ────────────────────────────────────────────────────
148
- await prepareInstallFiles(homeDir, configDir, vaultDir, dataDir, workDir, options.version);
192
+ await prepareInstallFiles(homeDir, configDir, stateDir, workDir, options.version);
149
193
 
150
194
  // ── Configure ──────────────────────────────────────────────────────────
151
195
  // File-based install: read config, run performSetup, optionally deploy
@@ -154,10 +198,10 @@ export async function bootstrapInstall(options: InstallOptions): Promise<void> {
154
198
  return;
155
199
  }
156
200
 
157
- // Interactive wizard: --force always runs wizard, otherwise only on first install
201
+ // Interactive wizard: start the admin UI which serves the setup wizard
158
202
  const needsWizard = !alreadyInstalled || options.force;
159
203
  if (needsWizard) {
160
- await runWizardInstall(configDir, options.noOpen, options.noStart);
204
+ await runWizardInstall(options.noOpen);
161
205
  return;
162
206
  }
163
207
 
@@ -171,139 +215,51 @@ export async function bootstrapInstall(options: InstallOptions): Promise<void> {
171
215
  }
172
216
 
173
217
  async function prepareInstallFiles(
174
- homeDir: string, configDir: string, vaultDir: string, dataDir: string, workDir: string, version: string,
218
+ homeDir: string, configDir: string, stateDir: string, workDir: string, version: string,
175
219
  ): Promise<void> {
176
220
  console.log('Preparing directories...');
177
- await ensureDirectoryTree(homeDir, configDir, vaultDir, dataDir, workDir);
221
+ await ensureDirectoryTree(homeDir, configDir, '', '', workDir);
178
222
 
179
- try { await Bun.write(join(dataDir, 'host.json'), JSON.stringify(await detectHostInfo(), null, 2) + '\n'); }
223
+ try { await Bun.write(join(stateDir, 'host.json'), JSON.stringify(await detectHostInfo(), null, 2) + '\n'); }
180
224
  catch (err) { logger.debug('failed to write host.json', { error: String(err) }); }
181
225
 
182
- // Seed core files from embedded assets (always available, even offline)
183
- seedEmbeddedAssets(homeDir);
184
-
185
- // Try to fetch latest assets from GitHub (non-fatal — embedded assets are sufficient)
186
- try {
187
- await seedOpenPalmDir(version, homeDir, configDir, vaultDir, dataDir);
188
- } catch (err) {
189
- logger.debug('seedOpenPalmDir failed (embedded assets already seeded)', { error: String(err) });
190
- }
226
+ // Seed OP_HOME from .openpalm/ (local source if available, else GitHub tarball)
227
+ await seedOpenPalmDir(version, homeDir, configDir, stateDir);
228
+ // Install UI build to state/ui/ (local build if available, else GitHub release asset)
229
+ await seedUiBuild(version, stateDir);
191
230
 
192
231
  console.log('Configuring secrets...');
193
- await ensureSecrets(vaultDir);
194
- await ensureStackEnv(homeDir, vaultDir, workDir, version, resolveRequestedImageTag(version) ?? undefined);
232
+ await ensureSecrets(stateDir);
233
+ await ensureStackEnv(homeDir, configDir, workDir, version, resolveRequestedImageTag(version) ?? undefined);
195
234
 
196
235
  for (const [path, content] of [
197
- [join(vaultDir, 'stack', 'guardian.env'), '# Guardian channel HMAC secrets — managed by openpalm\n'],
198
- [join(vaultDir, 'stack', 'auth.json'), '{}\n'],
236
+ [join(configDir, 'stack', 'guardian.env'), '# Guardian channel HMAC secrets — managed by openpalm\n'],
237
+ [join(configDir, 'stack', 'auth.json'), '{}\n'],
238
+ [join(homeDir, 'stash', 'vaults', 'user.env'), '# OpenPalm user vault — add LLM API keys and other secrets here\n'],
199
239
  ] as const) {
200
240
  if (!(await Bun.file(path).exists())) await Bun.write(path, content);
201
241
  }
202
242
 
203
243
  try { ensureOpenCodeConfig(); ensureOpenCodeSystemConfig(); } catch (err) { logger.debug('failed to ensure OpenCode config', { error: String(err) }); }
204
-
205
- try {
206
- // Download + validate wrapped in a single timeout. The download can be
207
- // slow on first install (binary fetch from GitHub) but must not block
208
- // the install indefinitely — 30s is generous enough for most connections.
209
- await Promise.race([
210
- (async () => {
211
- const varlockBin = await ensureVarlock();
212
- await runVarlockValidation(varlockBin, vaultDir);
213
- })(),
214
- new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 30_000)),
215
- ]);
216
- console.log('Configuration validated.');
217
- } catch (err) { logger.debug('varlock validation skipped', { error: String(err) }); }
218
244
  }
219
245
 
220
- async function runWizardInstall(configDir: string, noOpen: boolean, noStart = false): Promise<void> {
221
- console.log('Starting setup wizard...');
222
-
223
- // Start OpenCode subprocess for provider discovery (non-fatal if unavailable)
224
- let openCodeSub: OpenCodeSubprocess | null = null;
225
- let openCodeClient: ReturnType<typeof createOpenCodeClient> | undefined;
226
- try {
227
- console.log('Starting provider discovery...');
228
- openCodeSub = await startOpenCodeSubprocess({
229
- homeDir: resolveOpenPalmHome(),
230
- configDir: resolveConfigDir(),
231
- vaultDir: resolveVaultDir(),
232
- dataDir: resolveDataDir(),
233
- });
234
- const ready = await openCodeSub.waitForReady();
235
- if (ready) {
236
- openCodeClient = createOpenCodeClient({ baseUrl: openCodeSub.baseUrl });
237
- } else {
238
- console.log('Provider discovery unavailable. Using built-in provider list.');
239
- await openCodeSub.stop();
240
- openCodeSub = null;
241
- }
242
- } catch {
243
- console.log('Provider discovery unavailable. Using built-in provider list.');
244
- openCodeSub = null;
245
- }
246
-
247
- const wizard = createSetupServer(SETUP_WIZARD_PORT, { configDir, openCodeClient });
248
- const wizardUrl = `http://localhost:${wizard.server.port}/setup`;
249
- console.log(`Setup wizard running at ${wizardUrl}`);
250
- if (!noOpen) await openBrowser(wizardUrl);
251
-
252
- const result = await wizard.waitForComplete();
253
- if (!result.ok) { wizard.stop(); throw new Error(`Setup failed: ${result.error ?? 'unknown error'}`); }
254
-
255
- if (noStart) {
256
- console.log('Setup complete. Config written. Run `openpalm start` to start services.');
257
- wizard.stop();
258
- if (openCodeSub) await openCodeSub.stop().catch(() => {});
259
- return;
260
- }
261
-
262
- console.log('Setup complete. Checking Docker...');
263
- wizard.setDeploying(true);
246
+ /**
247
+ * Launch the UI host server to handle first-time setup.
248
+ *
249
+ * The SvelteKit UI detects that setup is not complete (via hooks.server.ts)
250
+ * and redirects to /setup where the wizard runs. Deploy is triggered from
251
+ * within the UI process after the user completes the wizard.
252
+ *
253
+ * Pre-flight: `requireDocker()` runs FIRST so users hit our friendly Docker
254
+ * error before the browser opens to a wizard that will fail at the end of
255
+ * a 10-step flow.
256
+ */
257
+ async function runWizardInstall(noOpen: boolean): Promise<void> {
264
258
  await requireDocker();
265
-
266
- console.log('Starting services...');
267
- const homeDir = resolveOpenPalmHome();
268
- const vaultDir = resolveVaultDir();
269
- await ensureVolumeMountTargets(homeDir, vaultDir);
270
- const state = await ensureValidState();
271
- await applyInstall(state);
272
- const allServices = await buildManagedServices(state);
273
- const composeArgs = fullComposeArgs(state);
274
- try {
275
- wizard.updateDeployStatus(buildDeployStatusEntries(allServices, 'pending', 'Pulling images...'));
276
- await runDockerCompose([...composeArgs, 'pull', ...allServices]).catch(() => {
277
- console.warn('Warning: image pull failed — if this is your first install, check your network connection.');
278
- });
279
- wizard.updateDeployStatus(buildDeployStatusEntries(allServices, 'pending', 'Starting...'));
280
- await runDockerCompose([...composeArgs, 'up', '-d', ...allServices]);
281
-
282
- // Poll container health so the wizard shows real progress per-service
283
- await pollContainerHealth(composeArgs, allServices, wizard);
284
-
285
- console.log('\n✓ All services are running:');
286
- for (const svc of allServices) {
287
- console.log(` • ${svc}`);
288
- }
289
- console.log(`\n Assistant: http://localhost:${3800}`);
290
- console.log(` Admin: http://localhost:${3880}`);
291
- console.log(` Memory API: http://localhost:${3898}`);
292
- console.log(` Guardian: http://localhost:${3899}`);
293
- console.log('');
294
- // pollContainerHealth returns as soon as all services are healthy, but
295
- // the frontend polls every 2.5s — keep the server alive long enough for
296
- // at least 2-3 polls to fetch the final "all running" state with URLs.
297
- await new Promise(resolve => setTimeout(resolve, 8000));
298
- } catch (err) {
299
- wizard.updateDeployStatus(buildDeployStatusEntries(allServices, 'error', String(err)));
300
- wizard.setDeployError(String(err));
301
- await new Promise(resolve => setTimeout(resolve, 10000));
302
- throw err;
303
- } finally {
304
- wizard.stop();
305
- if (openCodeSub) await openCodeSub.stop().catch(() => {});
306
- }
259
+ const port = Number(process.env.OP_HOST_UI_PORT) || 3880;
260
+ console.log(`Setup wizard: http://localhost:${port}/setup`);
261
+ const { startUIServer } = await import('../lib/ui-server.ts');
262
+ await startUIServer({ open: !noOpen, port });
307
263
  }
308
264
 
309
265
  async function runFileInstall(filePath: string, noStart: boolean): Promise<void> {
@@ -326,13 +282,15 @@ async function runFileInstall(filePath: string, noStart: boolean): Promise<void>
326
282
 
327
283
  if (config.version !== 2) throw new Error('Setup config must be version 2. See example.spec.yaml for the format.');
328
284
  if (!config.capabilities || typeof config.capabilities !== 'object' || Array.isArray(config.capabilities)) {
329
- throw new Error('Setup config must contain a "capabilities" object (llm, embeddings, memory).');
285
+ throw new Error('Setup config must contain a "capabilities" object (llm, embeddings).');
330
286
  }
331
287
 
332
- // Resolve security.adminToken from environment when not in spec
288
+ // Resolve security.uiLoginPassword from environment when not in spec.
289
+ // Phase 4 (auth/proxy refactor) renamed the env var to OP_UI_LOGIN_PASSWORD
290
+ // and the spec field to security.uiLoginPassword.
333
291
  const security = (config.security ?? {}) as Record<string, unknown>;
334
- if (!security.adminToken && process.env.OP_ADMIN_TOKEN) {
335
- security.adminToken = process.env.OP_ADMIN_TOKEN;
292
+ if (!security.uiLoginPassword && process.env.OP_UI_LOGIN_PASSWORD) {
293
+ security.uiLoginPassword = process.env.OP_UI_LOGIN_PASSWORD;
336
294
  config.security = security;
337
295
  }
338
296
 
@@ -341,147 +299,7 @@ async function runFileInstall(filePath: string, noStart: boolean): Promise<void>
341
299
  console.log('Setup complete.');
342
300
  if (noStart) { console.log('Config written. Run `openpalm start` to start services.'); return; }
343
301
  await requireDocker();
344
- await ensureVolumeMountTargets(resolveOpenPalmHome(), resolveVaultDir());
345
302
  await deployServices('install');
346
303
  }
347
304
 
348
- /**
349
- * Poll `docker compose ps` until all services are running/healthy (or timeout).
350
- * Updates the wizard deploy status per-service so the frontend shows real progress.
351
- */
352
- async function pollContainerHealth(
353
- composeArgs: string[],
354
- services: string[],
355
- wizard: ReturnType<typeof createSetupServer>,
356
- ): Promise<void> {
357
- const MAX_WAIT_MS = 120_000; // 2 minutes
358
- const POLL_INTERVAL = 3_000;
359
- const start = Date.now();
360
- const running = new Set<string>();
361
- const psArgs = [...composeArgs, 'ps', '--format', 'json'];
362
- let prevRunningCount = 0;
363
-
364
- while (Date.now() - start < MAX_WAIT_MS) {
365
- try {
366
- const output = await runDockerComposeCapture(psArgs);
367
- for (const line of output.trim().split('\n')) {
368
- if (!line.trim()) continue;
369
- try {
370
- const container = JSON.parse(line) as { Service?: string; State?: string; Health?: string };
371
- const svc = container.Service;
372
- if (!svc || !services.includes(svc)) continue;
373
- const isHealthy = container.Health === 'healthy' || (container.State === 'running' && !container.Health);
374
- if (isHealthy) running.add(svc);
375
- } catch { /* skip malformed JSON line */ }
376
- }
377
- } catch { /* compose ps failed — retry next tick */ }
378
-
379
- if (running.size !== prevRunningCount) {
380
- prevRunningCount = running.size;
381
- const entries = services.map(svc => ({
382
- service: svc,
383
- status: (running.has(svc) ? 'running' : 'pending') as 'running' | 'pending',
384
- label: running.has(svc) ? 'Running' : 'Starting...',
385
- }));
386
- wizard.updateDeployStatus(entries);
387
- }
388
-
389
- if (running.size >= services.length) return;
390
-
391
- await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL));
392
- }
393
-
394
- // Timeout: mark remaining as running so the UI completes, but warn
395
- const pending = services.filter(s => !running.has(s));
396
- console.warn(`Warning: health check timed out for: ${pending.join(', ')}. They may still be starting.`);
397
- wizard.markAllRunning();
398
- }
399
-
400
- /**
401
- * Parse all compose files under homeDir/stack/ and pre-create every host-side
402
- * volume mount target as the current user. This prevents Docker from creating
403
- * them as root-owned, which causes EACCES inside non-root containers.
404
- *
405
- * For file mounts (source path has an extension like .json, .env), creates
406
- * an empty file. For directory mounts, creates the directory.
407
- */
408
- async function ensureVolumeMountTargets(homeDir: string, vaultDir: string): Promise<void> {
409
- const { readFileSync, existsSync, mkdirSync, writeFileSync } = await import('node:fs');
410
- const { parse: yamlParse } = await import('yaml');
411
- const { dirname } = await import('node:path');
412
- const stackDir = join(homeDir, 'stack');
413
- const composeFiles: string[] = [];
414
-
415
- // Collect all compose files
416
- const coreYml = join(stackDir, 'core.compose.yml');
417
- if (existsSync(coreYml)) composeFiles.push(coreYml);
418
- const addonsDir = join(stackDir, 'addons');
419
- if (existsSync(addonsDir)) {
420
- for (const entry of (await import('node:fs')).readdirSync(addonsDir, { withFileTypes: true })) {
421
- if (entry.isDirectory()) {
422
- const addonYml = join(addonsDir, entry.name, 'compose.yml');
423
- if (existsSync(addonYml)) composeFiles.push(addonYml);
424
- }
425
- }
426
- }
427
-
428
- // Read env vars for variable substitution
429
- const envVars: Record<string, string> = { ...process.env };
430
- const stackEnv = join(vaultDir, 'stack', 'stack.env');
431
- if (existsSync(stackEnv)) {
432
- for (const line of readFileSync(stackEnv, 'utf-8').split('\n')) {
433
- const m = line.match(/^(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.*)$/);
434
- if (m) envVars[m[1]] = m[2];
435
- }
436
- }
437
-
438
- function resolveEnvVar(str: string): string {
439
- return str.replace(/\$\{([^}:]+)(?::-([^}]*))?\}/g, (_, name, def) => envVars[name] ?? def ?? '');
440
- }
441
-
442
- // Extract volume mount sources from all compose files
443
- for (const file of composeFiles) {
444
- let doc: Record<string, unknown>;
445
- try { doc = yamlParse(readFileSync(file, 'utf-8')) as Record<string, unknown>; } catch { continue; }
446
- const services = doc?.services;
447
- if (!services || typeof services !== 'object') continue;
448
-
449
- for (const svc of Object.values(services as Record<string, unknown>)) {
450
- if (!svc || typeof svc !== 'object') continue;
451
- const svcRecord = svc as Record<string, unknown>;
452
- if (!Array.isArray(svcRecord.volumes)) continue;
453
- for (const vol of svcRecord.volumes as unknown[]) {
454
- const volRecord = typeof vol === 'object' && vol !== null ? vol as Record<string, unknown> : null;
455
- const raw = typeof vol === 'string' ? vol : String(volRecord?.source ?? volRecord?.target ?? '');
456
- if (!raw || typeof raw !== 'string') continue;
457
305
 
458
- // Parse "source:target[:opts]" format
459
- const hostPath = resolveEnvVar(typeof vol === 'string' ? vol.split(':')[0] : String(volRecord?.source ?? ''));
460
- if (!hostPath || !hostPath.startsWith('/')) continue;
461
-
462
- // Determine if this is a file mount (has extension) or directory mount
463
- const basename = hostPath.split('/').pop() ?? '';
464
- const isFile = basename.includes('.') && !basename.startsWith('.');
465
-
466
- if (existsSync(hostPath)) continue;
467
-
468
- if (isFile) {
469
- mkdirSync(dirname(hostPath), { recursive: true });
470
- writeFileSync(hostPath, '');
471
- } else {
472
- mkdirSync(hostPath, { recursive: true });
473
- }
474
- }
475
- }
476
- }
477
- }
478
-
479
- async function runVarlockValidation(varlockBin: string, vaultDir: string): Promise<void> {
480
- const schemaPath = join(vaultDir, 'user', 'user.env.schema');
481
- if (!(await Bun.file(schemaPath).exists())) return;
482
- const tmpDir = await prepareVarlockDir(schemaPath, join(vaultDir, 'user', 'user.env'));
483
- try {
484
- const code = await Bun.spawn([varlockBin, 'load', '--path', `${tmpDir}/`], { stdout: 'ignore', stderr: 'ignore' }).exited;
485
- console.log(code === 0 ? 'Configuration validated.' : 'Configuration has validation warnings (non-fatal on first install).');
486
- } finally { await rm(tmpDir, { recursive: true, force: true }); }
487
- }
@@ -3,7 +3,7 @@ import { ensureValidState } from '../lib/cli-state.ts';
3
3
  import { runComposeReadOnly } from '../lib/cli-compose.ts';
4
4
 
5
5
  export async function runLogsAction(services: string[]): Promise<void> {
6
- const state = await ensureValidState();
6
+ const state = ensureValidState();
7
7
  await runComposeReadOnly(state, ['logs', '--tail', '100', ...services]);
8
8
  }
9
9
 
@@ -1,6 +1,7 @@
1
1
  import { defineCommand } from 'citty';
2
+ import { buildManagedServices } from '@openpalm/lib';
2
3
  import { ensureValidState } from '../lib/cli-state.ts';
3
- import { buildManagedServiceNames, runComposeWithPreflight } from '../lib/cli-compose.ts';
4
+ import { runComposeWithPreflight } from '../lib/cli-compose.ts';
4
5
 
5
6
  export default defineCommand({
6
7
  meta: {
@@ -23,13 +24,13 @@ export default defineCommand({
23
24
  export async function runRestartAction(services: string[]): Promise<void> {
24
25
  if (services.length === 0) {
25
26
  // Restart all managed services (admin included if enabled)
26
- const state = await ensureValidState();
27
- const managedServices = await buildManagedServiceNames(state);
27
+ const state = ensureValidState();
28
+ const managedServices = await buildManagedServices(state);
28
29
  await runComposeWithPreflight(state, ['restart', ...managedServices]);
29
30
  return;
30
31
  }
31
32
 
32
- const state = await ensureValidState();
33
+ const state = ensureValidState();
33
34
  for (const service of services) {
34
35
  await runComposeWithPreflight(state, ['restart', service]);
35
36
  }
@@ -1,7 +1,8 @@
1
1
  import { defineCommand } from 'citty';
2
2
  import { ensureValidState } from '../lib/cli-state.ts';
3
- import { buildManagedServiceNames, runComposeWithPreflight } from '../lib/cli-compose.ts';
3
+ import { runComposeWithPreflight } from '../lib/cli-compose.ts';
4
4
  import {
5
+ buildManagedServices,
5
6
  createState,
6
7
  restoreSnapshot,
7
8
  hasSnapshot,
@@ -30,9 +31,9 @@ export default defineCommand({
30
31
  console.log('Snapshot restored. Rebuilding configuration...');
31
32
 
32
33
  // Now validate and persist with the restored files in place
33
- const state = await ensureValidState();
34
+ const state = ensureValidState();
34
35
 
35
- const managedServices = await buildManagedServiceNames(state);
36
+ const managedServices = await buildManagedServices(state);
36
37
 
37
38
  await runComposeWithPreflight(state, [
38
39
  'up', '-d', '--remove-orphans', ...managedServices,