openpalm 0.10.0 → 0.10.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 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",
3
+ "version": "0.10.2",
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.10.0 <1.0.0",
29
+ "@openpalm/lib": ">=0.10.1 <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
+ });
@@ -7,6 +7,7 @@ import { resolveOpenPalmHome, resolveConfigDir, resolveVaultDir, resolveDataDir
7
7
  import { ensureSecrets, ensureStackEnv, resolveRequestedImageTag } from '../lib/env.ts';
8
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
 
@@ -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
+ });
@@ -27,6 +27,7 @@ import { readStackSpec } from '@openpalm/lib';
27
27
  const REPO_ROOT = resolve(import.meta.dir, '../../..');
28
28
  const OPENPALM_SRC = join(REPO_ROOT, '.openpalm');
29
29
  const ASSISTANT_SRC = join(REPO_ROOT, 'core/assistant/opencode');
30
+ const SKIP_INSTALL_FLOW_IN_CI = process.env.CI === 'true';
30
31
 
31
32
  /** Copy a directory tree using cp -a (preserves structure, fast). */
32
33
  function cpTree(src: string, dest: string): void {
@@ -182,6 +183,7 @@ describe('install flow — tier 1 (file validation)', () => {
182
183
  let homeDir: string;
183
184
  const originalHome = process.env.OP_HOME;
184
185
  const originalWorkDir = process.env.OP_WORK_DIR;
186
+ const tier1Test = SKIP_INSTALL_FLOW_IN_CI ? it.skip : it;
185
187
 
186
188
  afterEach(() => {
187
189
  process.env.OP_HOME = originalHome;
@@ -189,7 +191,7 @@ describe('install flow — tier 1 (file validation)', () => {
189
191
  if (homeDir) rmSync(homeDir, { recursive: true, force: true });
190
192
  });
191
193
 
192
- it('seed + performSetup produces complete file structure for admin+chat', async () => {
194
+ tier1Test('seed + performSetup produces complete file structure for admin+chat', async () => {
193
195
  homeDir = mkdtempSync(join(tmpdir(), 'openpalm-install-test-'));
194
196
  process.env.OP_HOME = homeDir;
195
197
  process.env.OP_WORK_DIR = join(homeDir, 'data/workspace');
@@ -303,7 +305,7 @@ describe('install flow — tier 1 (file validation)', () => {
303
305
  expect(automations.length).toBe(0);
304
306
  }, 30_000);
305
307
 
306
- it('compose config validates with selected addons', async () => {
308
+ tier1Test('compose config validates with selected addons', async () => {
307
309
  homeDir = mkdtempSync(join(tmpdir(), 'openpalm-install-test-'));
308
310
  process.env.OP_HOME = homeDir;
309
311
  process.env.OP_WORK_DIR = join(homeDir, 'data/workspace');
@@ -352,7 +354,7 @@ describe('install flow — tier 1 (file validation)', () => {
352
354
  expect(proc.exitCode).toBe(0);
353
355
  }, 30_000);
354
356
 
355
- it('performSetup with no addons produces only core services', async () => {
357
+ tier1Test('performSetup with no addons produces only core services', async () => {
356
358
  homeDir = mkdtempSync(join(tmpdir(), 'openpalm-install-test-'));
357
359
  process.env.OP_HOME = homeDir;
358
360
  process.env.OP_WORK_DIR = join(homeDir, 'data/workspace');
package/src/main.test.ts CHANGED
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { detectHostInfo, main, reconcileStackEnvImageTag, resolveRequestedImageTag, upsertEnvValue } from './main.ts';
7
+ import { canReplaceCurrentExecutable, resolveCliArtifactName } from './commands/self-update.ts';
7
8
 
8
9
  /** Write a minimal SetupSpec YAML file that satisfies validation, allowing --file installs to skip the wizard. */
9
10
  function writeMinimalSetupSpec(dir: string): string {
@@ -277,6 +278,117 @@ describe('cli main', () => {
277
278
  rmSync(base, { recursive: true, force: true });
278
279
  }
279
280
  });
281
+
282
+ it('backs up the current OP_HOME before install --force rewrites assets', async () => {
283
+ const base = mkdtempSync(join(tmpdir(), 'openpalm-install-force-'));
284
+ const workDir = join(base, 'work');
285
+ const binDir = join(base, 'data', 'bin');
286
+ const userEnv = join(base, 'vault', 'user', 'user.env');
287
+ const stackConfig = join(base, 'config', 'stack.yml');
288
+ const specFile = writeMinimalSetupSpec(base);
289
+
290
+ mkdirSync(binDir, { recursive: true });
291
+ mkdirSync(join(base, 'vault', 'user'), { recursive: true });
292
+ mkdirSync(join(base, 'config'), { recursive: true });
293
+ writeFileSync(join(binDir, 'varlock'), '#!/bin/sh\nexit 0\n');
294
+ chmodSync(join(binDir, 'varlock'), 0o755);
295
+ writeFileSync(userEnv, 'EXISTING=1\n');
296
+ writeFileSync(stackConfig, 'llm: old\n');
297
+
298
+ process.env.OP_HOME = base;
299
+ process.env.OP_WORK_DIR = workDir;
300
+
301
+ mockDockerCli();
302
+ globalThis.fetch = mock(async (input: string | URL) => {
303
+ const url = String(input);
304
+ if (url.endsWith('/health')) throw new TypeError('fetch failed');
305
+ if (url.includes('/core.compose.yml') || url.includes('/compose.yml')) {
306
+ return new Response('services: {}\n', { status: 200 });
307
+ }
308
+ if (url.includes('.env.schema')) return new Response('KEY=string\n', { status: 200 });
309
+ if (url.includes('/AGENTS.md')) return new Response('# Agents\n', { status: 200 });
310
+ if (url.includes('/opencode.jsonc')) return new Response('{"$schema":"https://opencode.ai/config.json"}\n', { status: 200 });
311
+ if (url.endsWith('.yml')) return new Response('name: test\nschedule: daily\n', { status: 200 });
312
+ return new Response('', { status: 503 });
313
+ }) as unknown as typeof fetch;
314
+ console.log = mock(() => {}) as typeof console.log;
315
+ console.warn = mock(() => {}) as typeof console.warn;
316
+
317
+ try {
318
+ await main(['install', '--force', '--no-start', '--file', specFile]);
319
+
320
+ const backupsDir = join(base, 'backups');
321
+ const backups = readdirSync(backupsDir);
322
+ expect(backups.length).toBeGreaterThan(0);
323
+ expect(readFileSync(join(backupsDir, backups[0], 'config', 'stack.yml'), 'utf8')).toContain('llm: old');
324
+ expect(readFileSync(join(backupsDir, backups[0], 'vault', 'user', 'user.env'), 'utf8')).toContain('EXISTING=1');
325
+ } finally {
326
+ rmSync(base, { recursive: true, force: true });
327
+ }
328
+ });
329
+
330
+ it('supports addon and admin commands for enabling and disabling addons', async () => {
331
+ const base = mkdtempSync(join(tmpdir(), 'openpalm-addon-cli-'));
332
+ const coreCompose = join(base, 'stack', 'core.compose.yml');
333
+ const adminAddonDir = join(base, 'registry', 'addons', 'admin');
334
+ const chatAddonDir = join(base, 'registry', 'addons', 'chat');
335
+ const guardianEnv = join(base, 'vault', 'stack', 'guardian.env');
336
+ const logs: string[] = [];
337
+
338
+ mkdirSync(join(base, 'stack'), { recursive: true });
339
+ mkdirSync(join(base, 'vault', 'stack'), { recursive: true });
340
+ mkdirSync(adminAddonDir, { recursive: true });
341
+ mkdirSync(chatAddonDir, { recursive: true });
342
+ writeFileSync(coreCompose, 'services:\n assistant:\n image: test\n');
343
+ writeFileSync(join(adminAddonDir, 'compose.yml'), 'services:\n docker-socket-proxy:\n image: proxy\n admin:\n image: admin\n');
344
+ writeFileSync(join(adminAddonDir, '.env.schema'), 'OP_ADMIN_TOKEN=\n');
345
+ writeFileSync(join(chatAddonDir, 'compose.yml'), 'services:\n chat:\n image: chat\n environment:\n CHANNEL_NAME: "Chat"\n CHANNEL_ID: "chat"\n');
346
+ writeFileSync(join(chatAddonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
347
+ writeFileSync(guardianEnv, '# Guardian channel HMAC secrets — managed by openpalm\n');
348
+
349
+ process.env.OP_HOME = base;
350
+ process.env.OP_SKIP_COMPOSE_PREFLIGHT = '1';
351
+ mockDockerCli();
352
+ console.log = mock((message?: unknown) => { logs.push(String(message ?? '')); }) as typeof console.log;
353
+ console.warn = mock((message?: unknown) => { logs.push(String(message ?? '')); }) as typeof console.warn;
354
+
355
+ try {
356
+ await main(['addon', 'enable', 'chat']);
357
+ expect(existsSync(join(base, 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
358
+ expect(readFileSync(guardianEnv, 'utf8')).toMatch(/CHANNEL_CHAT_SECRET=/);
359
+
360
+ await main(['admin', 'enable']);
361
+ expect(existsSync(join(base, 'stack', 'addons', 'admin', 'compose.yml'))).toBe(true);
362
+
363
+ await main(['admin', 'status']);
364
+ expect(logs.some((line) => line.includes('Admin addon is enabled.'))).toBe(true);
365
+
366
+ await main(['addon', 'disable', 'chat']);
367
+ expect(existsSync(join(base, 'stack', 'addons', 'chat'))).toBe(false);
368
+
369
+ await main(['admin', 'disable']);
370
+ expect(existsSync(join(base, 'stack', 'addons', 'admin'))).toBe(false);
371
+ } finally {
372
+ delete process.env.OP_SKIP_COMPOSE_PREFLIGHT;
373
+ rmSync(base, { recursive: true, force: true });
374
+ }
375
+ });
376
+ });
377
+
378
+ describe('self-update helpers', () => {
379
+ it('maps supported platforms to release artifacts', () => {
380
+ expect(resolveCliArtifactName('linux', 'x64')).toBe('openpalm-cli-linux-x64');
381
+ expect(resolveCliArtifactName('darwin', 'arm64')).toBe('openpalm-cli-darwin-arm64');
382
+ });
383
+
384
+ it('rejects unsupported platforms', () => {
385
+ expect(() => resolveCliArtifactName('freebsd', 'mips64')).toThrow('Unsupported platform for self-update');
386
+ });
387
+
388
+ it('only allows replacing standalone executables', () => {
389
+ expect(canReplaceCurrentExecutable('/usr/local/bin/openpalm')).toBe(true);
390
+ expect(canReplaceCurrentExecutable('/home/runner/.bun/bin/bun')).toBe(false);
391
+ });
280
392
  });
281
393
 
282
394
  describe('npm bin launcher', () => {
package/src/main.ts CHANGED
@@ -18,6 +18,10 @@ export const mainCommand = defineCommand({
18
18
  install: () => import('./commands/install.ts').then((m) => m.default),
19
19
  uninstall: () => import('./commands/uninstall.ts').then((m) => m.default),
20
20
  update: () => import('./commands/update.ts').then((m) => m.default),
21
+ upgrade: () => import('./commands/upgrade.ts').then((m) => m.default),
22
+ 'self-update': () => import('./commands/self-update.ts').then((m) => m.default),
23
+ addon: () => import('./commands/addon.ts').then((m) => m.default),
24
+ admin: () => import('./commands/admin.ts').then((m) => m.default),
21
25
  start: () => import('./commands/start.ts').then((m) => m.default),
22
26
  stop: () => import('./commands/stop.ts').then((m) => m.default),
23
27
  restart: () => import('./commands/restart.ts').then((m) => m.default),