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 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, `--version TAG` install a specific ref (default: current CLI version), `--no-start` prepare files only, `--no-open` skip browser launch.
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 start --with-admin # Start core + admin profile
42
- openpalm start admin # Start admin service specifically
43
- openpalm stop admin # Stop admin service specifically
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) (shell-based installer).
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.0-rc9",
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.9.8 <1.0.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
+ });
@@ -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
- runVarlockValidation(dataDir, vaultDir),
200
- new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5_000)),
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', 'Waiting...'));
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
- wizard.markAllRunning();
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
- await new Promise(resolve => setTimeout(resolve, 3000));
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
- if (config.version === 1) throw new Error('v1 setup config format is no longer supported. Use the v2 SetupSpec format (with a "spec" field).');
295
- if (!config.spec) throw new Error('Setup config must contain a "spec" field with the v2 StackSpec.');
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(_dataDir: string, vaultDir: string): Promise<void> {
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
+ });
@@ -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: 'Pull latest images and recreate containers',
8
+ description: 'Refresh stack assets, pull latest images, and recreate containers',
9
9
  },
10
10
  async run() {
11
- const state = await ensureValidState();
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
+ });