openpalm 0.9.3 → 0.9.5

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.
@@ -1,12 +1,37 @@
1
1
  import { defineCommand } from 'citty';
2
- import { adminRequest } from '../lib/admin.ts';
2
+ import { tryAdminRequest } from '../lib/admin.ts';
3
+ import { runDockerCompose } from '../lib/docker.ts';
4
+ import { ensureStagedState, fullComposeArgs } from '../lib/staging.ts';
3
5
 
4
6
  export default defineCommand({
5
7
  meta: {
6
8
  name: 'uninstall',
7
9
  description: 'Stop and remove the OpenPalm stack (preserves config and data)',
8
10
  },
9
- async run() {
10
- console.log(JSON.stringify(await adminRequest('/admin/uninstall', { method: 'POST' }), null, 2));
11
+ args: {
12
+ volumes: {
13
+ type: 'boolean',
14
+ description: 'Also remove Docker volumes',
15
+ default: false,
16
+ },
17
+ },
18
+ async run({ args }) {
19
+ // Try admin delegation first
20
+ const adminResult = await tryAdminRequest('/admin/uninstall', { method: 'POST' });
21
+ if (adminResult !== null) {
22
+ console.log(JSON.stringify(adminResult, null, 2));
23
+ return;
24
+ }
25
+
26
+ // Direct compose down — include admin profile to tear down all services
27
+ const state = await ensureStagedState();
28
+ const composeArgs = fullComposeArgs(state);
29
+ const downArgs = args.volumes ? ['down', '-v'] : ['down'];
30
+ await runDockerCompose([...composeArgs, '--profile', 'admin', ...downArgs]);
31
+
32
+ console.log('OpenPalm stack stopped and removed.');
33
+ if (!args.volumes) {
34
+ console.log('Config and data directories are preserved. Use --volumes to also remove Docker volumes.');
35
+ }
11
36
  },
12
37
  });
@@ -1,5 +1,7 @@
1
1
  import { defineCommand } from 'citty';
2
- import { adminRequest } from '../lib/admin.ts';
2
+ import { tryAdminRequest } from '../lib/admin.ts';
3
+ import { runDockerCompose } from '../lib/docker.ts';
4
+ import { ensureStagedState, fullComposeArgs, buildManagedServiceNames } from '../lib/staging.ts';
3
5
 
4
6
  export default defineCommand({
5
7
  meta: {
@@ -7,6 +9,24 @@ export default defineCommand({
7
9
  description: 'Pull latest images and recreate containers',
8
10
  },
9
11
  async run() {
10
- console.log(JSON.stringify(await adminRequest('/admin/containers/pull', { method: 'POST' }), null, 2));
12
+ // Try admin delegation first
13
+ const adminResult = await tryAdminRequest('/admin/containers/pull', { method: 'POST' });
14
+ if (adminResult !== null) {
15
+ console.log(JSON.stringify(adminResult, null, 2));
16
+ return;
17
+ }
18
+
19
+ // Direct compose: pull + recreate
20
+ const state = await ensureStagedState();
21
+ const composeArgs = fullComposeArgs(state);
22
+ const managedServices = buildManagedServiceNames(state);
23
+
24
+ console.log('Pulling latest images...');
25
+ await runDockerCompose([...composeArgs, 'pull', ...managedServices]);
26
+
27
+ console.log('Recreating containers...');
28
+ await runDockerCompose([...composeArgs, 'up', '-d', '--force-recreate', ...managedServices]);
29
+
30
+ console.log('Update complete.');
11
31
  },
12
32
  });
package/src/lib/admin.ts CHANGED
@@ -5,7 +5,7 @@ export const ADMIN_URL = process.env.OPENPALM_ADMIN_API_URL || 'http://localhost
5
5
  /**
6
6
  * Returns true if the admin health endpoint is reachable.
7
7
  */
8
- export async function isStackRunning(): Promise<boolean> {
8
+ export async function isAdminReachable(): Promise<boolean> {
9
9
  try {
10
10
  const response = await fetch(`${ADMIN_URL}/health`, {
11
11
  method: 'GET',
@@ -17,6 +17,11 @@ export async function isStackRunning(): Promise<boolean> {
17
17
  }
18
18
  }
19
19
 
20
+ /**
21
+ * @deprecated Use isAdminReachable() instead. Kept for backward compatibility.
22
+ */
23
+ export const isStackRunning = isAdminReachable;
24
+
20
25
  /**
21
26
  * Makes an authenticated request to the admin API.
22
27
  * Throws if the response is not ok.
@@ -53,13 +58,33 @@ export async function adminRequest(path: string, init?: RequestInit): Promise<un
53
58
  }
54
59
  }
55
60
 
61
+ /**
62
+ * Try to delegate an operation to the admin API.
63
+ * Returns the result if admin is reachable and has a valid token.
64
+ * Returns null if admin is unreachable or no token is available.
65
+ * Attempts the request directly — no separate health check round-trip.
66
+ */
67
+ export async function tryAdminRequest(path: string, init?: RequestInit): Promise<unknown | null> {
68
+ const token = await loadAdminToken();
69
+ if (!token) return null;
70
+
71
+ try {
72
+ return await adminRequest(path, {
73
+ ...init,
74
+ signal: init?.signal ?? AbortSignal.timeout(10_000),
75
+ });
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
56
81
  /**
57
82
  * Waits for the admin health endpoint to become healthy (up to 120s).
58
83
  */
59
84
  export async function waitForAdminHealthy(): Promise<void> {
60
85
  const started = Date.now();
61
86
  while (Date.now() - started < 120_000) {
62
- if (await isStackRunning()) {
87
+ if (await isAdminReachable()) {
63
88
  return;
64
89
  }
65
90
  await Bun.sleep(3000);
package/src/lib/docker.ts CHANGED
@@ -1,45 +1,28 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { defaultConfigHome, defaultStateHome } from './paths.ts';
3
+ import { ensureXdgDirs } from '@openpalm/lib';
4
4
 
5
5
  const REPO_OWNER = 'itlackey';
6
6
  const REPO_NAME = 'openpalm';
7
7
 
8
8
  /**
9
9
  * Creates the full XDG directory tree required by the stack.
10
+ * Delegates to @openpalm/lib for core dirs, then adds CLI-specific extras.
10
11
  */
11
12
  export async function ensureDirectoryTree(
12
- configHome: string,
13
- dataHome: string,
13
+ _configHome: string,
14
+ _dataHome: string,
14
15
  stateHome: string,
15
16
  workDir: string,
16
17
  ): Promise<void> {
17
- const dirs = [
18
- configHome,
19
- join(configHome, 'channels'),
20
- join(configHome, 'assistant'),
21
- join(configHome, 'automations'),
22
- dataHome,
23
- join(dataHome, 'admin'),
24
- join(dataHome, 'memory'),
25
- join(dataHome, 'assistant'),
26
- join(dataHome, 'guardian'),
27
- join(dataHome, 'caddy'),
28
- join(dataHome, 'caddy', 'data'),
29
- join(dataHome, 'caddy', 'config'),
30
- join(dataHome, 'automations'),
31
- join(dataHome, 'opencode'),
32
- stateHome,
33
- join(stateHome, 'artifacts'),
34
- join(stateHome, 'audit'),
35
- join(stateHome, 'artifacts', 'channels'),
36
- join(stateHome, 'automations'),
37
- join(stateHome, 'opencode'),
18
+ // Core XDG dirs (CONFIG_HOME, DATA_HOME, STATE_HOME subtrees)
19
+ ensureXdgDirs();
20
+
21
+ // CLI-specific extras not in lib
22
+ for (const dir of [
38
23
  join(stateHome, 'bin'),
39
24
  workDir,
40
- ];
41
-
42
- for (const dir of dirs) {
25
+ ]) {
43
26
  await mkdir(dir, { recursive: true });
44
27
  }
45
28
  }
@@ -80,84 +63,28 @@ export async function runDockerCompose(args: string[]): Promise<void> {
80
63
  }
81
64
 
82
65
  /**
83
- * Returns the standard compose flags for --project-name, -f, and --env-file.
84
- */
85
- export function composeProjectArgs(): string[] {
86
- const stateHome = defaultStateHome();
87
- const configHome = defaultConfigHome();
88
- return [
89
- '--project-name',
90
- 'openpalm',
91
- '-f',
92
- join(stateHome, 'artifacts', 'docker-compose.yml'),
93
- '--env-file',
94
- join(configHome, 'secrets.env'),
95
- '--env-file',
96
- join(stateHome, 'artifacts', 'stack.env'),
97
- ];
98
- }
99
-
100
- /**
101
- * Ensures the opencode config and system config directories exist with defaults.
66
+ * Runs a `docker compose` command and captures stdout as a string.
67
+ * Throws on non-zero exit.
102
68
  */
103
- export async function ensureOpenCodeConfig(configHome: string): Promise<void> {
104
- const opencodeDir = join(configHome, 'assistant');
105
- const configFile = join(opencodeDir, 'opencode.json');
106
- if (!(await Bun.file(configFile).exists())) {
107
- await Bun.write(configFile, '{\n "$schema": "https://opencode.ai/config.json"\n}\n');
108
- }
109
- await mkdir(join(opencodeDir, 'tools'), { recursive: true });
110
- await mkdir(join(opencodeDir, 'plugins'), { recursive: true });
111
- await mkdir(join(opencodeDir, 'skills'), { recursive: true });
112
- }
113
-
114
- async function writeIfChanged(path: string, content: string): Promise<void> {
115
- const file = Bun.file(path);
116
- if (await file.exists()) {
117
- const existing = await file.text();
118
- if (existing === content) {
119
- return;
120
- }
69
+ export async function runDockerComposeCapture(args: string[]): Promise<string> {
70
+ const proc = Bun.spawn(['docker', 'compose', ...args], {
71
+ stdout: 'pipe',
72
+ stderr: 'inherit',
73
+ stdin: 'inherit',
74
+ });
75
+ const output = await new Response(proc.stdout).text();
76
+ const code = await proc.exited;
77
+ if (code !== 0) {
78
+ throw new Error(`docker compose ${args.join(' ')} failed with exit code ${code}`);
121
79
  }
122
- await Bun.write(path, content);
80
+ return output;
123
81
  }
124
82
 
125
- export async function ensureOpenCodeSystemConfig(dataHome: string): Promise<void> {
126
- const opencodeSystemDir = join(dataHome, 'assistant');
127
- await mkdir(opencodeSystemDir, { recursive: true });
128
-
129
- const systemConfig = join(opencodeSystemDir, 'opencode.jsonc');
130
- const systemConfigContent =
131
- JSON.stringify(
132
- {
133
- "$schema": "https://opencode.ai/config.json",
134
- "plugin": ["@openpalm/assistant-tools", "akm-opencode"],
135
- "permission": {
136
- "read": {
137
- "/home/opencode/.local/share/opencode/auth.json": "deny",
138
- "/home/opencode/.local/share/opencode/mcp-auth.json": "deny"
139
- }
140
- }
141
- },
142
- null,
143
- 2,
144
- ) + "\n";
145
- await writeIfChanged(systemConfig, systemConfigContent);
83
+ // composeProjectArgs() removed — use fullComposeArgs(state) from staging.ts instead.
84
+ // That function builds the correct file list including channel overlays and staged env files.
146
85
 
147
- const agentsFile = join(opencodeSystemDir, 'AGENTS.md');
148
- // import.meta.dir = packages/cli/src/lib/ need 4 levels up to reach repo root
149
- const assetsAgentsPath = join(import.meta.dir, '..', '..', '..', '..', 'assets', 'AGENTS.md');
150
- let agentsContent: string;
151
- if (await Bun.file(assetsAgentsPath).exists()) {
152
- agentsContent = await Bun.file(assetsAgentsPath).text();
153
- } else {
154
- agentsContent =
155
- '# OpenPalm Assistant\n\n' +
156
- 'This file defines the assistant persona.\n' +
157
- 'It is seeded by the CLI on first install and managed by the admin on subsequent updates.\n';
158
- }
159
- await writeIfChanged(agentsFile, agentsContent);
160
- }
86
+ // ensureOpenCodeConfig and ensureOpenCodeSystemConfig are imported from @openpalm/lib.
87
+ // See packages/lib/src/control-plane/secrets.ts and core-assets.ts.
161
88
 
162
89
  /**
163
90
  * Opens a URL in the user's default browser. Best-effort, never throws.
package/src/lib/paths.ts CHANGED
@@ -1,32 +1,22 @@
1
+ /**
2
+ * Path resolution — re-exports from @openpalm/lib with CLI-specific additions.
3
+ */
1
4
  import { homedir } from 'node:os';
2
5
  import { join } from 'node:path';
6
+ import {
7
+ resolveConfigHome,
8
+ resolveDataHome,
9
+ resolveStateHome,
10
+ } from '@openpalm/lib';
3
11
 
4
12
  export const IS_WINDOWS = process.platform === 'win32';
5
13
 
6
- export function defaultConfigHome(): string {
7
- if (process.env.OPENPALM_CONFIG_HOME) return process.env.OPENPALM_CONFIG_HOME;
8
- if (IS_WINDOWS) {
9
- return join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'openpalm');
10
- }
11
- return join(homedir(), '.config', 'openpalm');
12
- }
13
-
14
- export function defaultDataHome(): string {
15
- if (process.env.OPENPALM_DATA_HOME) return process.env.OPENPALM_DATA_HOME;
16
- if (IS_WINDOWS) {
17
- return join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'openpalm', 'data');
18
- }
19
- return join(homedir(), '.local', 'share', 'openpalm');
20
- }
21
-
22
- export function defaultStateHome(): string {
23
- if (process.env.OPENPALM_STATE_HOME) return process.env.OPENPALM_STATE_HOME;
24
- if (IS_WINDOWS) {
25
- return join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'openpalm', 'state');
26
- }
27
- return join(homedir(), '.local', 'state', 'openpalm');
28
- }
14
+ // Re-export lib's XDG resolvers under CLI's existing names
15
+ export { resolveConfigHome as defaultConfigHome };
16
+ export { resolveDataHome as defaultDataHome };
17
+ export { resolveStateHome as defaultStateHome };
29
18
 
19
+ // CLI-specific paths (not in lib)
30
20
  export function defaultDockerSock(): string {
31
21
  if (process.env.OPENPALM_DOCKER_SOCK) return process.env.OPENPALM_DOCKER_SOCK;
32
22
  return IS_WINDOWS ? '//./pipe/docker_engine' : '/var/run/docker.sock';
@@ -0,0 +1,72 @@
1
+ /**
2
+ * CLI-side staging pipeline for Docker Compose operations.
3
+ *
4
+ * Delegates to @openpalm/lib for all control-plane logic. The CLI
5
+ * uses FilesystemAssetProvider (reads from DATA_HOME) and
6
+ * FilesystemRegistryProvider (reads from registry dir).
7
+ */
8
+ import { existsSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import {
11
+ createState,
12
+ stageArtifacts,
13
+ persistArtifacts,
14
+ buildComposeFileList,
15
+ buildManagedServices,
16
+ buildEnvFiles,
17
+ FilesystemAssetProvider,
18
+ } from '@openpalm/lib';
19
+ import type { ControlPlaneState } from '@openpalm/lib';
20
+ import { defaultDataHome } from './paths.ts';
21
+
22
+ /**
23
+ * Ensure all artifacts are staged from CONFIG_HOME/DATA_HOME to STATE_HOME.
24
+ *
25
+ * This is the CLI-side equivalent of the admin's staging pipeline.
26
+ * It uses FilesystemAssetProvider (reads core assets from DATA_HOME,
27
+ * persisted by the install command) rather than Vite bundle imports.
28
+ *
29
+ * Returns a ControlPlaneState usable with fullComposeArgs().
30
+ */
31
+ export async function ensureStagedState(): Promise<ControlPlaneState> {
32
+ const dataDir = defaultDataHome();
33
+
34
+ // Verify DATA_HOME has core assets (populated by `openpalm install`)
35
+ if (!existsSync(join(dataDir, 'docker-compose.yml'))) {
36
+ throw new Error(
37
+ `Core assets not found in ${dataDir}. Run 'openpalm install' first.`,
38
+ );
39
+ }
40
+
41
+ const assets = new FilesystemAssetProvider(dataDir);
42
+
43
+ const state = createState();
44
+ state.artifacts = stageArtifacts(state, assets);
45
+ persistArtifacts(state, assets);
46
+
47
+ return state;
48
+ }
49
+
50
+ /**
51
+ * Build the full list of docker compose CLI arguments for a given state.
52
+ *
53
+ * Returns: ['--project-name', 'openpalm', '-f', '...', '--env-file', '...']
54
+ */
55
+ export function fullComposeArgs(state: ControlPlaneState): string[] {
56
+ const files = buildComposeFileList(state);
57
+ const envFiles = buildEnvFiles(state);
58
+
59
+ return [
60
+ '--project-name',
61
+ 'openpalm',
62
+ ...files.flatMap((f) => ['-f', f]),
63
+ ...envFiles.filter((f) => existsSync(f)).flatMap((f) => ['--env-file', f]),
64
+ ];
65
+ }
66
+
67
+ /**
68
+ * Build the list of managed service names (used for targeted `up` commands).
69
+ */
70
+ export function buildManagedServiceNames(state: ControlPlaneState): string[] {
71
+ return buildManagedServices(state);
72
+ }
@@ -116,6 +116,14 @@ export async function ensureVarlock(stateHome: string): Promise<string> {
116
116
  throw new Error(`chmod +x failed for varlock binary (exit code ${chmodCode})`);
117
117
  }
118
118
 
119
+ // macOS: clear quarantine flag and ad-hoc codesign so Gatekeeper does not kill the binary
120
+ if (process.platform === 'darwin') {
121
+ const xattr = Bun.spawn(['xattr', '-cr', varlockBin], { stdout: 'ignore', stderr: 'ignore' });
122
+ await xattr.exited;
123
+ const codesign = Bun.spawn(['codesign', '--force', '--sign', '-', varlockBin], { stdout: 'ignore', stderr: 'ignore' });
124
+ await codesign.exited;
125
+ }
126
+
119
127
  if (!(await Bun.file(varlockBin).exists())) {
120
128
  throw new Error(`varlock binary not found at ${varlockBin} after install`);
121
129
  }
package/src/main.test.ts CHANGED
@@ -57,7 +57,7 @@ describe('cli main', () => {
57
57
  process.env.OPENPALM_ADMIN_TOKEN = originalOpenPalmAdminToken;
58
58
  });
59
59
 
60
- it('calls containers pull for update', async () => {
60
+ it('calls containers pull for update (via admin when reachable)', async () => {
61
61
  const calls: string[] = [];
62
62
  process.env.OPENPALM_ADMIN_TOKEN = 'test-token';
63
63
  globalThis.fetch = mock(async (input: string | URL) => {
@@ -68,7 +68,10 @@ describe('cli main', () => {
68
68
 
69
69
  await main(['update']);
70
70
 
71
- expect(calls).toEqual(['http://localhost:8100/admin/containers/pull']);
71
+ // tryAdminRequest attempts directly — no separate health check
72
+ expect(calls).toEqual([
73
+ 'http://localhost:8100/admin/containers/pull',
74
+ ]);
72
75
  });
73
76
 
74
77
  it('uses ADMIN_TOKEN from the environment for admin requests', async () => {
@@ -85,6 +88,7 @@ describe('cli main', () => {
85
88
 
86
89
  await main(['update']);
87
90
 
91
+ // Direct request — single call with token header
88
92
  expect(adminTokens).toEqual(['env-admin-token']);
89
93
  });
90
94
 
@@ -110,6 +114,7 @@ describe('cli main', () => {
110
114
 
111
115
  try {
112
116
  await main(['update']);
117
+ // Direct request — single call with legacy token
113
118
  expect(adminTokens).toEqual(['legacy-admin-token']);
114
119
  } finally {
115
120
  rmSync(base, { recursive: true, force: true });