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.
- package/README.md +11 -19
- package/package.json +4 -2
- package/src/commands/addon.ts +5 -4
- package/src/commands/automations.ts +63 -0
- package/src/commands/install.ts +98 -280
- package/src/commands/logs.ts +1 -1
- package/src/commands/restart.ts +5 -4
- package/src/commands/rollback.ts +4 -3
- package/src/commands/scan.ts +66 -32
- package/src/commands/service.ts +5 -4
- package/src/commands/start.ts +5 -4
- package/src/commands/status.ts +1 -1
- package/src/commands/stop.ts +2 -4
- package/src/commands/uninstall.ts +3 -5
- package/src/commands/update.ts +19 -2
- package/src/commands/validate.ts +16 -34
- package/src/install-flow.test.ts +153 -154
- package/src/lib/admin-skills/index.test.ts +70 -0
- package/src/lib/admin-skills/index.ts +113 -0
- package/src/lib/browser.ts +20 -0
- package/src/lib/cli-compose.ts +2 -20
- package/src/lib/cli-state.ts +1 -1
- package/src/lib/docker.ts +8 -214
- package/src/lib/env.ts +12 -83
- package/src/lib/io.ts +130 -0
- package/src/lib/opencode-subprocess.ts +14 -6
- package/src/lib/paths.ts +2 -2
- package/src/lib/ui-server.ts +150 -0
- package/src/main.test.ts +76 -173
- package/src/main.ts +131 -7
- package/e2e/start-wizard-server.ts +0 -59
- package/src/commands/admin.ts +0 -43
- package/src/commands/install-services.test.ts +0 -13
- package/src/commands/install-services.ts +0 -9
- package/src/commands/upgrade.ts +0 -12
- package/src/lib/embedded-assets.ts +0 -115
- package/src/lib/varlock.ts +0 -126
- package/src/setup-wizard/index.html +0 -321
- package/src/setup-wizard/server-errors.test.ts +0 -418
- package/src/setup-wizard/server-integration.test.ts +0 -511
- package/src/setup-wizard/server.test.ts +0 -508
- package/src/setup-wizard/server.ts +0 -342
- package/src/setup-wizard/wizard-renderers.js +0 -1294
- package/src/setup-wizard/wizard-state.js +0 -346
- package/src/setup-wizard/wizard-validators.js +0 -81
- package/src/setup-wizard/wizard.css +0 -1611
- package/src/setup-wizard/wizard.js +0 -613
package/src/commands/install.ts
CHANGED
|
@@ -1,32 +1,28 @@
|
|
|
1
1
|
import { defineCommand } from 'citty';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
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
|
|
7
|
-
import { ensureSecrets, ensureStackEnv
|
|
8
|
-
import { ensureDirectoryTree, seedOpenPalmDir,
|
|
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 =
|
|
128
|
+
const state = ensureValidState();
|
|
108
129
|
await applyInstall(state);
|
|
109
130
|
const managedServices = await buildManagedServices(state);
|
|
110
|
-
const composeArgs =
|
|
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
|
|
132
|
-
const dataDir = resolveDataDir();
|
|
152
|
+
const stateDir = `${homeDir}/state`;
|
|
133
153
|
const workDir = defaultWorkDir();
|
|
134
154
|
|
|
135
|
-
|
|
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,
|
|
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:
|
|
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(
|
|
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,
|
|
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,
|
|
221
|
+
await ensureDirectoryTree(homeDir, configDir, '', '', workDir);
|
|
178
222
|
|
|
179
|
-
try { await Bun.write(join(
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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(
|
|
194
|
-
await ensureStackEnv(homeDir,
|
|
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(
|
|
198
|
-
[join(
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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(
|
|
267
|
-
const
|
|
268
|
-
|
|
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
|
|
285
|
+
throw new Error('Setup config must contain a "capabilities" object (llm, embeddings).');
|
|
330
286
|
}
|
|
331
287
|
|
|
332
|
-
// Resolve security.
|
|
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.
|
|
335
|
-
security.
|
|
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
|
-
}
|
package/src/commands/logs.ts
CHANGED
|
@@ -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 =
|
|
6
|
+
const state = ensureValidState();
|
|
7
7
|
await runComposeReadOnly(state, ['logs', '--tail', '100', ...services]);
|
|
8
8
|
}
|
|
9
9
|
|
package/src/commands/restart.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
27
|
-
const managedServices = await
|
|
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 =
|
|
33
|
+
const state = ensureValidState();
|
|
33
34
|
for (const service of services) {
|
|
34
35
|
await runComposeWithPreflight(state, ['restart', service]);
|
|
35
36
|
}
|
package/src/commands/rollback.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { defineCommand } from 'citty';
|
|
2
2
|
import { ensureValidState } from '../lib/cli-state.ts';
|
|
3
|
-
import {
|
|
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 =
|
|
34
|
+
const state = ensureValidState();
|
|
34
35
|
|
|
35
|
-
const managedServices = await
|
|
36
|
+
const managedServices = await buildManagedServices(state);
|
|
36
37
|
|
|
37
38
|
await runComposeWithPreflight(state, [
|
|
38
39
|
'up', '-d', '--remove-orphans', ...managedServices,
|