openpalm 0.10.0-rc9 → 0.10.1
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 +12 -5
- package/package.json +2 -2
- package/src/commands/addon.ts +130 -0
- package/src/commands/admin.ts +43 -0
- package/src/commands/install.ts +104 -10
- package/src/commands/self-update.ts +148 -0
- package/src/commands/update.ts +14 -10
- package/src/commands/upgrade.ts +12 -0
- package/src/install-flow.test.ts +12 -12
- package/src/lib/docker.ts +7 -3
- package/src/lib/env.ts +7 -15
- package/src/main.test.ts +127 -15
- package/src/main.ts +4 -0
- package/src/setup-wizard/server-errors.test.ts +31 -38
- package/src/setup-wizard/server-integration.test.ts +36 -42
- package/src/setup-wizard/server.test.ts +12 -14
- package/src/setup-wizard/server.ts +8 -0
- package/src/setup-wizard/wizard-state.js +3 -0
- package/src/setup-wizard/wizard.js +33 -21
package/README.md
CHANGED
|
@@ -19,6 +19,10 @@ The admin container is optional. Use `--with-admin` to enable the admin addon ov
|
|
|
19
19
|
| `openpalm install` | Bootstrap `~/.openpalm/`, download assets, run setup wizard, start core services |
|
|
20
20
|
| `openpalm uninstall` | Stop and remove the stack (preserves config and data) |
|
|
21
21
|
| `openpalm update` | Pull latest images and recreate containers |
|
|
22
|
+
| `openpalm upgrade` | Alias for `update` |
|
|
23
|
+
| `openpalm self-update` | Replace the installed CLI binary with the latest release build |
|
|
24
|
+
| `openpalm addon <enable|disable|list>` | Manage registry addons directly from the CLI |
|
|
25
|
+
| `openpalm admin <enable|disable|status>` | Manage the admin addon directly from the CLI |
|
|
22
26
|
| `openpalm start [svc...]` | Start all or named services |
|
|
23
27
|
| `openpalm start --with-admin` | Start all services including admin UI and docker-socket-proxy |
|
|
24
28
|
| `openpalm stop [svc...]` | Stop all or named services |
|
|
@@ -31,16 +35,19 @@ The admin container is optional. Use `--with-admin` to enable the admin addon ov
|
|
|
31
35
|
|
|
32
36
|
### Install options
|
|
33
37
|
|
|
34
|
-
`--force` skip "already installed" check
|
|
38
|
+
`--force` skip "already installed" check and create a backup of the current `OP_HOME`, `--version TAG` install a specific ref (default: current CLI version), `--no-start` prepare files only, `--no-open` skip browser launch.
|
|
35
39
|
|
|
36
40
|
### Admin addon
|
|
37
41
|
|
|
38
42
|
Admin and docker-socket-proxy start only when explicitly requested:
|
|
39
43
|
|
|
40
44
|
```bash
|
|
41
|
-
openpalm
|
|
42
|
-
openpalm
|
|
43
|
-
openpalm
|
|
45
|
+
openpalm admin enable # Enable the admin addon and start its services
|
|
46
|
+
openpalm admin disable # Stop and disable the admin addon
|
|
47
|
+
openpalm admin status # Show whether the admin addon is enabled
|
|
48
|
+
openpalm addon enable chat # Enable a registry addon and start its services
|
|
49
|
+
openpalm addon disable chat # Stop and disable a registry addon
|
|
50
|
+
openpalm addon list # Show available addons and whether they are enabled
|
|
44
51
|
```
|
|
45
52
|
|
|
46
53
|
## Setup Wizard
|
|
@@ -79,4 +86,4 @@ bun run start -- install --no-start
|
|
|
79
86
|
bun test
|
|
80
87
|
```
|
|
81
88
|
|
|
82
|
-
See also: [`scripts/setup.sh`](../../scripts/setup.sh) (
|
|
89
|
+
See also: [`scripts/setup.sh`](../../scripts/setup.sh) and [`scripts/setup.ps1`](../../scripts/setup.ps1). Both installers support `--cli-only` when you only want to install or refresh the CLI binary without touching the stack or `OP_HOME`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openpalm",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
6
|
"description": "OpenPalm CLI — install and manage a self-hosted OpenPalm stack",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"build:windows-arm64": "bun build src/main.ts --compile --target=bun-windows-arm64 --outfile dist/openpalm-cli-windows-arm64.exe"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@openpalm/lib": ">=0.
|
|
29
|
+
"@openpalm/lib": ">=0.10.0 <1.0.0",
|
|
30
30
|
"citty": "^0.2.1",
|
|
31
31
|
"yaml": "^2.8.0"
|
|
32
32
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import {
|
|
3
|
+
getAddonServiceNames,
|
|
4
|
+
listAvailableAddonIds,
|
|
5
|
+
listEnabledAddonIds,
|
|
6
|
+
setAddonEnabled,
|
|
7
|
+
} from '@openpalm/lib';
|
|
8
|
+
import { ensureValidState } from '../lib/cli-state.ts';
|
|
9
|
+
import { fullComposeArgs, runComposeWithPreflight } from '../lib/cli-compose.ts';
|
|
10
|
+
import { runDockerCompose } from '../lib/docker.ts';
|
|
11
|
+
|
|
12
|
+
function requireKnownAddon(name: string): void {
|
|
13
|
+
const available = listAvailableAddonIds();
|
|
14
|
+
const hint = available.length > 0 ? ` Run \`openpalm addon list\` to see the available addons.` : '';
|
|
15
|
+
if (!available.includes(name)) {
|
|
16
|
+
throw new Error(`Addon "${name}" is not available. Known addons: ${available.join(', ') || '(none)'}.${hint}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function runAddonListAction(): Promise<void> {
|
|
21
|
+
const state = ensureValidState();
|
|
22
|
+
const enabled = new Set(listEnabledAddonIds(state.homeDir));
|
|
23
|
+
const available = listAvailableAddonIds();
|
|
24
|
+
|
|
25
|
+
if (available.length === 0) {
|
|
26
|
+
console.log('No registry addons are available.');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const name of available) {
|
|
31
|
+
console.log(`${enabled.has(name) ? '[enabled]' : '[disabled]'} ${name}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function runAddonEnableAction(name: string): Promise<void> {
|
|
36
|
+
requireKnownAddon(name);
|
|
37
|
+
const state = ensureValidState();
|
|
38
|
+
const mutation = setAddonEnabled(state.homeDir, state.vaultDir, name, true);
|
|
39
|
+
if (!mutation.ok) throw new Error(mutation.error);
|
|
40
|
+
|
|
41
|
+
if (!mutation.changed) {
|
|
42
|
+
console.log(`Addon "${name}" is already enabled.`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
console.log(`Enabled addon "${name}".`);
|
|
47
|
+
|
|
48
|
+
if (mutation.services.length === 0) return;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const nextState = ensureValidState();
|
|
52
|
+
await runComposeWithPreflight(nextState, ['up', '-d', ...mutation.services]);
|
|
53
|
+
console.log(`Started services: ${mutation.services.join(', ')}`);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.warn(
|
|
56
|
+
`Warning: addon "${name}" was enabled but its services were not started automatically: ${err instanceof Error ? err.message : String(err)}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function runAddonDisableAction(name: string): Promise<void> {
|
|
62
|
+
requireKnownAddon(name);
|
|
63
|
+
const state = ensureValidState();
|
|
64
|
+
const services = getAddonServiceNames(state.homeDir, name);
|
|
65
|
+
const wasEnabled = listEnabledAddonIds(state.homeDir).includes(name);
|
|
66
|
+
|
|
67
|
+
if (wasEnabled && services.length > 0) {
|
|
68
|
+
try {
|
|
69
|
+
await runDockerCompose([...fullComposeArgs(state), 'stop', ...services]);
|
|
70
|
+
console.log(`Stopped services: ${services.join(', ')}`);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.warn(
|
|
73
|
+
`Warning: failed to stop services for addon "${name}" before disabling it: ${err instanceof Error ? err.message : String(err)}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const mutation = setAddonEnabled(state.homeDir, state.vaultDir, name, false);
|
|
79
|
+
if (!mutation.ok) throw new Error(mutation.error);
|
|
80
|
+
|
|
81
|
+
if (!mutation.changed) {
|
|
82
|
+
console.log(`Addon "${name}" is already disabled.`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log(`Disabled addon "${name}".`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const enableCmd = defineCommand({
|
|
90
|
+
meta: { name: 'enable', description: 'Enable a registry addon' },
|
|
91
|
+
args: {
|
|
92
|
+
name: { type: 'positional', description: 'Addon name', required: true },
|
|
93
|
+
},
|
|
94
|
+
async run({ args }) {
|
|
95
|
+
const name = String(args._?.[0] ?? '').trim();
|
|
96
|
+
if (!name) throw new Error('Addon name is required. Run `openpalm addon list` to see the available addons.');
|
|
97
|
+
await runAddonEnableAction(name);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const disableCmd = defineCommand({
|
|
102
|
+
meta: { name: 'disable', description: 'Disable a registry addon' },
|
|
103
|
+
args: {
|
|
104
|
+
name: { type: 'positional', description: 'Addon name', required: true },
|
|
105
|
+
},
|
|
106
|
+
async run({ args }) {
|
|
107
|
+
const name = String(args._?.[0] ?? '').trim();
|
|
108
|
+
if (!name) throw new Error('Addon name is required. Run `openpalm addon list` to see the available addons.');
|
|
109
|
+
await runAddonDisableAction(name);
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const listCmd = defineCommand({
|
|
114
|
+
meta: { name: 'list', description: 'List registry addons and whether they are enabled' },
|
|
115
|
+
async run() {
|
|
116
|
+
await runAddonListAction();
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export default defineCommand({
|
|
121
|
+
meta: {
|
|
122
|
+
name: 'addon',
|
|
123
|
+
description: 'Enable, disable, or list registry addons',
|
|
124
|
+
},
|
|
125
|
+
subCommands: {
|
|
126
|
+
list: listCmd,
|
|
127
|
+
enable: enableCmd,
|
|
128
|
+
disable: disableCmd,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { listEnabledAddonIds } from '@openpalm/lib';
|
|
3
|
+
import { ensureValidState } from '../lib/cli-state.ts';
|
|
4
|
+
import { runAddonDisableAction, runAddonEnableAction } from './addon.ts';
|
|
5
|
+
|
|
6
|
+
async function runAdminStatusAction(): Promise<void> {
|
|
7
|
+
const state = ensureValidState();
|
|
8
|
+
const enabled = listEnabledAddonIds(state.homeDir).includes('admin');
|
|
9
|
+
console.log(enabled ? 'Admin addon is enabled.' : 'Admin addon is disabled.');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const enableCmd = defineCommand({
|
|
13
|
+
meta: { name: 'enable', description: 'Enable the admin addon' },
|
|
14
|
+
async run() {
|
|
15
|
+
await runAddonEnableAction('admin');
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const disableCmd = defineCommand({
|
|
20
|
+
meta: { name: 'disable', description: 'Disable the admin addon' },
|
|
21
|
+
async run() {
|
|
22
|
+
await runAddonDisableAction('admin');
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const statusCmd = defineCommand({
|
|
27
|
+
meta: { name: 'status', description: 'Show whether the admin addon is enabled' },
|
|
28
|
+
async run() {
|
|
29
|
+
await runAdminStatusAction();
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export default defineCommand({
|
|
34
|
+
meta: {
|
|
35
|
+
name: 'admin',
|
|
36
|
+
description: 'Enable, disable, or inspect the admin addon',
|
|
37
|
+
},
|
|
38
|
+
subCommands: {
|
|
39
|
+
enable: enableCmd,
|
|
40
|
+
disable: disableCmd,
|
|
41
|
+
status: statusCmd,
|
|
42
|
+
},
|
|
43
|
+
});
|
package/src/commands/install.ts
CHANGED
|
@@ -5,8 +5,9 @@ import cliPkg from '../../package.json' with { type: 'json' };
|
|
|
5
5
|
import { defaultWorkDir } from '../lib/paths.ts';
|
|
6
6
|
import { resolveOpenPalmHome, resolveConfigDir, resolveVaultDir, resolveDataDir } from '@openpalm/lib';
|
|
7
7
|
import { ensureSecrets, ensureStackEnv, resolveRequestedImageTag } from '../lib/env.ts';
|
|
8
|
-
import { ensureDirectoryTree, seedOpenPalmDir, openBrowser, runDockerCompose } from '../lib/docker.ts';
|
|
8
|
+
import { ensureDirectoryTree, seedOpenPalmDir, openBrowser, runDockerCompose, runDockerComposeCapture } from '../lib/docker.ts';
|
|
9
9
|
import {
|
|
10
|
+
backupOpenPalmHome,
|
|
10
11
|
ensureOpenCodeConfig, ensureOpenCodeSystemConfig,
|
|
11
12
|
performSetup,
|
|
12
13
|
applyInstall,
|
|
@@ -136,6 +137,13 @@ export async function bootstrapInstall(options: InstallOptions): Promise<void> {
|
|
|
136
137
|
throw new Error('OpenPalm appears to already be installed. Re-run install with --force to continue.');
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
if (alreadyInstalled && options.force) {
|
|
141
|
+
const backupDir = backupOpenPalmHome(homeDir);
|
|
142
|
+
if (backupDir) {
|
|
143
|
+
console.log(`Backed up existing OP_HOME to ${backupDir}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
139
147
|
// ── Bootstrap files ────────────────────────────────────────────────────
|
|
140
148
|
await prepareInstallFiles(homeDir, configDir, vaultDir, dataDir, workDir, options.version);
|
|
141
149
|
|
|
@@ -195,9 +203,15 @@ async function prepareInstallFiles(
|
|
|
195
203
|
try { ensureOpenCodeConfig(); ensureOpenCodeSystemConfig(); } catch (err) { logger.debug('failed to ensure OpenCode config', { error: String(err) }); }
|
|
196
204
|
|
|
197
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.
|
|
198
209
|
await Promise.race([
|
|
199
|
-
|
|
200
|
-
|
|
210
|
+
(async () => {
|
|
211
|
+
const varlockBin = await ensureVarlock();
|
|
212
|
+
await runVarlockValidation(varlockBin, vaultDir);
|
|
213
|
+
})(),
|
|
214
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 30_000)),
|
|
201
215
|
]);
|
|
202
216
|
console.log('Configuration validated.');
|
|
203
217
|
} catch (err) { logger.debug('varlock validation skipped', { error: String(err) }); }
|
|
@@ -246,6 +260,7 @@ async function runWizardInstall(configDir: string, noOpen: boolean, noStart = fa
|
|
|
246
260
|
}
|
|
247
261
|
|
|
248
262
|
console.log('Setup complete. Checking Docker...');
|
|
263
|
+
wizard.setDeploying(true);
|
|
249
264
|
await requireDocker();
|
|
250
265
|
|
|
251
266
|
console.log('Starting services...');
|
|
@@ -257,13 +272,16 @@ async function runWizardInstall(configDir: string, noOpen: boolean, noStart = fa
|
|
|
257
272
|
const allServices = await buildManagedServices(state);
|
|
258
273
|
const composeArgs = fullComposeArgs(state);
|
|
259
274
|
try {
|
|
260
|
-
wizard.updateDeployStatus(buildDeployStatusEntries(allServices, 'pending', '
|
|
275
|
+
wizard.updateDeployStatus(buildDeployStatusEntries(allServices, 'pending', 'Pulling images...'));
|
|
261
276
|
await runDockerCompose([...composeArgs, 'pull', ...allServices]).catch(() => {
|
|
262
277
|
console.warn('Warning: image pull failed — if this is your first install, check your network connection.');
|
|
263
278
|
});
|
|
264
279
|
wizard.updateDeployStatus(buildDeployStatusEntries(allServices, 'pending', 'Starting...'));
|
|
265
280
|
await runDockerCompose([...composeArgs, 'up', '-d', ...allServices]);
|
|
266
|
-
|
|
281
|
+
|
|
282
|
+
// Poll container health so the wizard shows real progress per-service
|
|
283
|
+
await pollContainerHealth(composeArgs, allServices, wizard);
|
|
284
|
+
|
|
267
285
|
console.log('\n✓ All services are running:');
|
|
268
286
|
for (const svc of allServices) {
|
|
269
287
|
console.log(` • ${svc}`);
|
|
@@ -273,7 +291,10 @@ async function runWizardInstall(configDir: string, noOpen: boolean, noStart = fa
|
|
|
273
291
|
console.log(` Memory API: http://localhost:${3898}`);
|
|
274
292
|
console.log(` Guardian: http://localhost:${3899}`);
|
|
275
293
|
console.log('');
|
|
276
|
-
|
|
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));
|
|
277
298
|
} catch (err) {
|
|
278
299
|
wizard.updateDeployStatus(buildDeployStatusEntries(allServices, 'error', String(err)));
|
|
279
300
|
wizard.setDeployError(String(err));
|
|
@@ -291,17 +312,91 @@ async function runFileInstall(filePath: string, noStart: boolean): Promise<void>
|
|
|
291
312
|
throw new Error(`Setup config file not found: ${filePath}. Check the --file path and try again.`);
|
|
292
313
|
}
|
|
293
314
|
const config = await parseConfigFile(filePath, await Bun.file(filePath).text());
|
|
294
|
-
|
|
295
|
-
|
|
315
|
+
|
|
316
|
+
// Normalize old wrapped format: { spec: { version, capabilities }, capabilities: [...] }
|
|
317
|
+
// into flat format: { version, capabilities: {...}, connections: [...] }
|
|
318
|
+
if (config.spec && typeof config.spec === 'object') {
|
|
319
|
+
const spec = config.spec as Record<string, unknown>;
|
|
320
|
+
// Old format had connections array as top-level "capabilities"
|
|
321
|
+
if (Array.isArray(config.capabilities)) config.connections = config.capabilities;
|
|
322
|
+
config.version = spec.version;
|
|
323
|
+
config.capabilities = spec.capabilities;
|
|
324
|
+
delete config.spec;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (config.version !== 2) throw new Error('Setup config must be version 2. See example.spec.yaml for the format.');
|
|
328
|
+
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).');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Resolve security.adminToken from environment when not in spec
|
|
333
|
+
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;
|
|
336
|
+
config.security = security;
|
|
337
|
+
}
|
|
296
338
|
|
|
297
339
|
const result = await performSetup(config as unknown as SetupSpec);
|
|
298
340
|
if (!result.ok) throw new Error(`Setup failed: ${result.error}`);
|
|
299
341
|
console.log('Setup complete.');
|
|
300
342
|
if (noStart) { console.log('Config written. Run `openpalm start` to start services.'); return; }
|
|
301
343
|
await requireDocker();
|
|
344
|
+
await ensureVolumeMountTargets(resolveOpenPalmHome(), resolveVaultDir());
|
|
302
345
|
await deployServices('install');
|
|
303
346
|
}
|
|
304
347
|
|
|
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
|
+
|
|
305
400
|
/**
|
|
306
401
|
* Parse all compose files under homeDir/stack/ and pre-create every host-side
|
|
307
402
|
* volume mount target as the current user. This prevents Docker from creating
|
|
@@ -381,8 +476,7 @@ async function ensureVolumeMountTargets(homeDir: string, vaultDir: string): Prom
|
|
|
381
476
|
}
|
|
382
477
|
}
|
|
383
478
|
|
|
384
|
-
async function runVarlockValidation(
|
|
385
|
-
const varlockBin = await ensureVarlock();
|
|
479
|
+
async function runVarlockValidation(varlockBin: string, vaultDir: string): Promise<void> {
|
|
386
480
|
const schemaPath = join(vaultDir, 'user', 'user.env.schema');
|
|
387
481
|
if (!(await Bun.file(schemaPath).exists())) return;
|
|
388
482
|
const tmpDir = await prepareVarlockDir(schemaPath, join(vaultDir, 'user', 'user.env'));
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { basename, dirname, join } from 'node:path';
|
|
5
|
+
import { defineCommand } from 'citty';
|
|
6
|
+
|
|
7
|
+
const REPO = 'itlackey/openpalm';
|
|
8
|
+
|
|
9
|
+
function normalizeVersion(version: string): string {
|
|
10
|
+
return version.startsWith('v') ? version : `v${version}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function resolveLatestVersion(): Promise<string> {
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(`https://github.com/${REPO}/releases/latest`, {
|
|
16
|
+
redirect: 'manual',
|
|
17
|
+
signal: AbortSignal.timeout(10_000),
|
|
18
|
+
});
|
|
19
|
+
const match = (res.headers.get('location') ?? '').match(/\/tag\/(v[0-9]+\.[0-9]+\.[0-9]+[^\s]*)$/);
|
|
20
|
+
if (match?.[1]) return match[1];
|
|
21
|
+
} catch {
|
|
22
|
+
// fall through
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
throw new Error('Unable to resolve the latest OpenPalm release.');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveCliArtifactName(platform = process.platform, arch = process.arch): string {
|
|
29
|
+
if (platform === 'linux' && arch === 'x64') return 'openpalm-cli-linux-x64';
|
|
30
|
+
if (platform === 'linux' && arch === 'arm64') return 'openpalm-cli-linux-arm64';
|
|
31
|
+
if (platform === 'darwin' && arch === 'x64') return 'openpalm-cli-darwin-x64';
|
|
32
|
+
if (platform === 'darwin' && arch === 'arm64') return 'openpalm-cli-darwin-arm64';
|
|
33
|
+
if (platform === 'win32' && arch === 'x64') return 'openpalm-cli-windows-x64.exe';
|
|
34
|
+
if (platform === 'win32' && arch === 'arm64') return 'openpalm-cli-windows-arm64.exe';
|
|
35
|
+
throw new Error(`Unsupported platform for self-update: ${platform}/${arch}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function canReplaceCurrentExecutable(execPath = process.execPath): boolean {
|
|
39
|
+
const execName = basename(execPath).toLowerCase();
|
|
40
|
+
return execName !== 'bun' && execName !== 'bun.exe';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sha256Hex(content: Uint8Array): string {
|
|
44
|
+
return createHash('sha256').update(content).digest('hex');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseExpectedChecksum(checksums: string, artifact: string): string {
|
|
48
|
+
const line = checksums
|
|
49
|
+
.split('\n')
|
|
50
|
+
.map((entry) => entry.trim())
|
|
51
|
+
.find((entry) => entry.endsWith(` ${artifact}`) || entry.endsWith(` ${artifact}`));
|
|
52
|
+
|
|
53
|
+
if (!line) throw new Error(`No published checksum found for ${artifact}.`);
|
|
54
|
+
const checksum = line.split(/\s+/)[0]?.trim();
|
|
55
|
+
if (!checksum) throw new Error(`Published checksum entry for ${artifact} is invalid.`);
|
|
56
|
+
return checksum;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function posixShellQuote(value: string): string {
|
|
60
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function downloadVerifiedBinary(version: string, artifact: string): Promise<string> {
|
|
64
|
+
const tempDir = await mkdtemp(join(tmpdir(), 'openpalm-self-update-'));
|
|
65
|
+
const artifactPath = join(tempDir, artifact);
|
|
66
|
+
const binaryUrl = `https://github.com/${REPO}/releases/download/${version}/${artifact}`;
|
|
67
|
+
const checksumUrl = `https://github.com/${REPO}/releases/download/${version}/checksums-sha256.txt`;
|
|
68
|
+
|
|
69
|
+
const [binaryRes, checksumRes] = await Promise.all([
|
|
70
|
+
fetch(binaryUrl, { signal: AbortSignal.timeout(60_000) }),
|
|
71
|
+
fetch(checksumUrl, { signal: AbortSignal.timeout(30_000) }),
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
if (!binaryRes.ok) throw new Error(`Failed to download ${artifact} (${binaryRes.status}).`);
|
|
75
|
+
if (!checksumRes.ok) throw new Error(`Failed to download release checksums (${checksumRes.status}).`);
|
|
76
|
+
|
|
77
|
+
const binaryBytes = new Uint8Array(await binaryRes.arrayBuffer());
|
|
78
|
+
const expected = parseExpectedChecksum(await checksumRes.text(), artifact);
|
|
79
|
+
const actual = sha256Hex(binaryBytes);
|
|
80
|
+
if (actual !== expected) {
|
|
81
|
+
throw new Error(`Checksum mismatch for ${artifact}: expected ${expected}, got ${actual}.`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await Bun.write(artifactPath, binaryBytes);
|
|
85
|
+
await chmod(artifactPath, 0o755);
|
|
86
|
+
return artifactPath;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function schedulePosixReplacement(sourcePath: string, targetPath: string): Promise<void> {
|
|
90
|
+
const scriptDir = await mkdtemp(join(tmpdir(), 'openpalm-self-update-script-'));
|
|
91
|
+
const scriptPath = join(scriptDir, 'replace.sh');
|
|
92
|
+
const script = [
|
|
93
|
+
'#!/usr/bin/env sh',
|
|
94
|
+
'set -eu',
|
|
95
|
+
'sleep 1',
|
|
96
|
+
`tmp=${posixShellQuote(sourcePath)}`,
|
|
97
|
+
`dest=${posixShellQuote(targetPath)}`,
|
|
98
|
+
'chmod +x "$tmp"',
|
|
99
|
+
'mv "$tmp" "$dest"',
|
|
100
|
+
`rm -rf ${posixShellQuote(scriptDir)}`,
|
|
101
|
+
].join('\n') + '\n';
|
|
102
|
+
|
|
103
|
+
await Bun.write(scriptPath, script);
|
|
104
|
+
await chmod(scriptPath, 0o755);
|
|
105
|
+
|
|
106
|
+
const proc = Bun.spawn(['sh', scriptPath], {
|
|
107
|
+
stdout: 'ignore',
|
|
108
|
+
stderr: 'ignore',
|
|
109
|
+
});
|
|
110
|
+
proc.unref();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default defineCommand({
|
|
114
|
+
meta: {
|
|
115
|
+
name: 'self-update',
|
|
116
|
+
description: 'Replace the installed OpenPalm CLI binary with the latest release build',
|
|
117
|
+
},
|
|
118
|
+
args: {
|
|
119
|
+
version: {
|
|
120
|
+
type: 'string',
|
|
121
|
+
description: 'Install a specific release tag instead of the latest release',
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
async run({ args }) {
|
|
125
|
+
if (process.platform === 'win32') {
|
|
126
|
+
throw new Error('Self-update is not supported on Windows yet because running executables cannot be replaced reliably while they are in use. Download and run setup.ps1 with --cli-only to refresh only the CLI binary.');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!canReplaceCurrentExecutable()) {
|
|
130
|
+
throw new Error('Self-update requires the compiled OpenPalm binary. Reinstall with setup.sh --cli-only instead.');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const version = args.version ? normalizeVersion(args.version) : await resolveLatestVersion();
|
|
134
|
+
const artifact = resolveCliArtifactName();
|
|
135
|
+
const executablePath = process.execPath;
|
|
136
|
+
const tempBinary = await downloadVerifiedBinary(version, artifact);
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await schedulePosixReplacement(tempBinary, executablePath);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
await rm(dirname(tempBinary), { recursive: true, force: true }).catch(() => {});
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log(`Downloaded ${artifact} for ${version}.`);
|
|
146
|
+
console.log(`The CLI at ${executablePath} will be replaced after this command exits.`);
|
|
147
|
+
},
|
|
148
|
+
});
|
package/src/commands/update.ts
CHANGED
|
@@ -5,17 +5,21 @@ import { ensureValidState } from '../lib/cli-state.ts';
|
|
|
5
5
|
export default defineCommand({
|
|
6
6
|
meta: {
|
|
7
7
|
name: 'update',
|
|
8
|
-
description: '
|
|
8
|
+
description: 'Refresh stack assets, pull latest images, and recreate containers',
|
|
9
9
|
},
|
|
10
10
|
async run() {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
console.log('Upgrading stack...');
|
|
14
|
-
const result = await performUpgrade(state);
|
|
15
|
-
console.log(`Image tag: ${result.namespace}/*:${result.imageTag}`);
|
|
16
|
-
if (result.assetsUpdated.length > 0) {
|
|
17
|
-
console.log(`Assets updated: ${result.assetsUpdated.join(', ')}`);
|
|
18
|
-
}
|
|
19
|
-
console.log('Update complete.');
|
|
11
|
+
await runUpgradeAction();
|
|
20
12
|
},
|
|
21
13
|
});
|
|
14
|
+
|
|
15
|
+
export async function runUpgradeAction(): Promise<void> {
|
|
16
|
+
const state = await ensureValidState();
|
|
17
|
+
|
|
18
|
+
console.log('Upgrading stack...');
|
|
19
|
+
const result = await performUpgrade(state);
|
|
20
|
+
console.log(`Image tag: ${result.namespace}/*:${result.imageTag}`);
|
|
21
|
+
if (result.assetsUpdated.length > 0) {
|
|
22
|
+
console.log(`Assets updated: ${result.assetsUpdated.join(', ')}`);
|
|
23
|
+
}
|
|
24
|
+
console.log('Update complete.');
|
|
25
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { runUpgradeAction } from './update.ts';
|
|
3
|
+
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
meta: {
|
|
6
|
+
name: 'upgrade',
|
|
7
|
+
description: 'Refresh stack assets, pull latest images, and recreate containers',
|
|
8
|
+
},
|
|
9
|
+
async run() {
|
|
10
|
+
await runUpgradeAction();
|
|
11
|
+
},
|
|
12
|
+
});
|