openpalm 0.4.0 → 0.9.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
@@ -1,98 +1,56 @@
1
- # openpalm CLI
1
+ # @openpalm/cli
2
2
 
3
- CLI tool for installing, managing, and operating an OpenPalm stack. Published to npm as `openpalm`.
4
-
5
- ## Installation
6
-
7
- ```bash
8
- npx openpalm install
9
- # or
10
- bunx openpalm install
11
- ```
3
+ Bun CLI for bootstrapping and managing an OpenPalm installation. Handles the initial install (directory creation, asset download, secret seeding) and delegates to the Admin API once the stack is running.
12
4
 
13
5
  ## Commands
14
6
 
15
7
  | Command | Description |
16
8
  |---|---|
17
- | `install` | Install and start the OpenPalm stack |
18
- | `uninstall` | Stop and remove OpenPalm |
19
- | `update` | Pull latest images and recreate containers |
20
- | `start [service...]` | Start services |
21
- | `stop [service...]` | Stop services |
22
- | `restart [service...]` | Restart services |
23
- | `logs [service...]` | View container logs |
24
- | `status` | Show container status |
25
- | `service <up\|stop\|restart\|logs\|update\|status>` | Domain-based service operations |
26
- | `channel <add\|configure>` | Domain-based channel operations |
27
- | `automation <run\|trigger>` | Domain-based automation operations |
28
- | `extensions <install\|uninstall\|list>` | Manage extensions |
29
- | `dev preflight` | Validate development environment |
30
- | `dev create-channel` | Scaffold a new channel adapter |
9
+ | `openpalm install` | Bootstrap XDG dirs, download assets, start admin + docker-socket-proxy, open setup wizard |
10
+ | `openpalm uninstall` | Stop and remove the stack (preserves config and data) |
11
+ | `openpalm update` | Pull latest images and recreate containers |
12
+ | `openpalm start [svc...]` | Start all or named services |
13
+ | `openpalm stop [svc...]` | Stop all or named services |
14
+ | `openpalm restart [svc...]` | Restart all or named services |
15
+ | `openpalm logs [svc...]` | Tail last 100 log lines |
16
+ | `openpalm status` | Show container status |
17
+ | `openpalm service <sub> [svc...]` | Alias — subcommands: `start`, `stop`, `restart`, `logs`, `status`, `update` |
31
18
 
32
- ## Install options
19
+ ### Install options
33
20
 
34
- - `--runtime <docker|podman|orbstack>` Force container runtime
35
- - `--no-open` — Don't auto-open browser after install
36
- - `--ref <branch|tag>` — Git ref for asset download
21
+ `--force` skip "already installed" check, `--version TAG` install a specific ref (default `main`), `--no-start` prepare files only, `--no-open` skip browser launch.
37
22
 
38
- ## Building
23
+ ## Environment variables
39
24
 
40
- Cross-platform compiled binaries:
25
+ | Variable | Default | Purpose |
26
+ |---|---|---|
27
+ | `OPENPALM_CONFIG_HOME` | `~/.config/openpalm` | User config + secrets |
28
+ | `OPENPALM_DATA_HOME` | `~/.local/share/openpalm` | Persistent service data |
29
+ | `OPENPALM_STATE_HOME` | `~/.local/state/openpalm` | Assembled runtime |
30
+ | `OPENPALM_WORK_DIR` | `~/openpalm` | Assistant working directory |
31
+ | `OPENPALM_ADMIN_API_URL` | `http://localhost:8100` | Admin API endpoint |
32
+ | `OPENPALM_ADMIN_TOKEN` | (from `secrets.env`) | Admin API auth token |
41
33
 
42
- ```bash
43
- bun run build # Default platform
44
- bun run build:linux-x64 # Linux x64
45
- bun run build:linux-arm64 # Linux ARM64
46
- bun run build:darwin-x64 # macOS x64
47
- bun run build:darwin-arm64 # macOS ARM64
48
- bun run build:windows-x64 # Windows x64
49
- bun run build:windows-arm64 # Windows ARM64
50
- ```
34
+ ## How it works
51
35
 
52
- Binaries are output to `dist/`.
36
+ 1. **Bootstrap** (no stack running) — creates XDG directory tree, downloads `docker-compose.yml` + `Caddyfile` from GitHub, seeds `secrets.env` and `stack.env`, starts core services via `docker compose`
37
+ 2. **Running stack** — all commands delegate to the Admin API (`/admin/install`, `/admin/containers/*`, etc.) using `x-admin-token` auth
53
38
 
54
- ## Development
39
+ Follows the file-assembly principle: copies whole files, never renders templates. See [`docs/core-principles.md`](../../docs/technical/core-principles.md).
55
40
 
56
- ```bash
57
- # Run directly from source
58
- bun run src/main.ts install
41
+ ## Building
59
42
 
60
- # Run tests
61
- cd packages/cli && bun test
43
+ ```bash
44
+ bun run build # Current platform → dist/openpalm-cli
45
+ bun run build:linux-x64 # Cross-compile (also: linux-arm64, darwin-x64, darwin-arm64, windows-x64, windows-arm64)
62
46
  ```
63
47
 
64
- ## Dependencies
65
-
66
- Depends on `@openpalm/lib` (workspace package) for shared utilities like path resolution, runtime detection, and compose generation.
67
-
68
- ## Execution modes (same commands for local and remote admin)
69
-
70
- Domain commands automatically choose execution mode:
71
-
72
- - **Local mode (default):** if admin API env vars are not explicitly set, service commands run locally via compose.
73
- - **Remote admin API mode:** if admin API env vars are set, domain commands call admin over HTTP (`x-admin-token`), so callers do not need Docker socket access.
74
- - **Assistant env fallback:** CLI also reads `${OPENPALM_STATE_HOME}/assistant/.env` for admin URL/token values.
75
-
76
- Environment variables (all optional):
77
-
78
- - `OPENPALM_ADMIN_API_URL` (preferred), `ADMIN_APP_URL`, or `GATEWAY_URL` for admin endpoint resolution.
79
- - `OPENPALM_ADMIN_TOKEN` (preferred) or `ADMIN_TOKEN` for authentication.
80
- - `OPENPALM_ADMIN_TIMEOUT_MS` request timeout (default `15000`).
81
- - `OPENPALM_ALLOW_INSECURE_ADMIN_HTTP=1` to allow public/non-private HTTP URLs (not recommended).
82
-
83
- Examples:
48
+ ## Development
84
49
 
85
50
  ```bash
86
- # Local default service execution
87
- openpalm service restart assistant
88
-
89
- # Remote admin API execution (same command shape)
90
- export OPENPALM_ADMIN_API_URL=http://admin:8100
91
- export OPENPALM_ADMIN_TOKEN=...
92
- openpalm service restart assistant
93
-
94
- # Domain commands for channels/automations
95
- openpalm channel add /path/to/channel.yaml
96
- openpalm channel configure discord --exposure lan
97
- openpalm automation run example-job
51
+ cd packages/cli
52
+ bun run start -- install --no-start
53
+ bun test
98
54
  ```
55
+
56
+ See also: [`scripts/install.sh`](../../scripts/install.sh) (binary installer), [`scripts/setup.sh`](../../scripts/setup.sh) (shell-based installer).
package/package.json CHANGED
@@ -1,31 +1,26 @@
1
1
  {
2
2
  "name": "openpalm",
3
- "version": "0.4.0",
4
- "description": "CLI tool for installing and managing an OpenPalm stack",
3
+ "version": "0.9.2",
5
4
  "type": "module",
6
- "license": "MIT",
5
+ "license": "MPL-2.0",
6
+ "description": "OpenPalm CLI — install and manage a self-hosted OpenPalm stack",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "https://github.com/itlackey/openpalm.git",
10
10
  "directory": "packages/cli"
11
11
  },
12
- "homepage": "https://github.com/itlackey/openpalm",
13
- "keywords": [
14
- "openpalm",
15
- "ai",
16
- "cli",
17
- "docker",
18
- "installer"
19
- ],
20
12
  "bin": {
21
- "openpalm": "./dist/openpalm.js"
13
+ "openpalm": "./src/main.ts"
22
14
  },
23
- "files": [
24
- "dist/**",
25
- "README.md",
26
- "LICENSE"
27
- ],
28
- "engines": {
29
- "bun": ">=1.2.0"
15
+ "scripts": {
16
+ "start": "bun run src/main.ts",
17
+ "test": "bun test",
18
+ "build": "bun build src/main.ts --compile --outfile dist/openpalm-cli",
19
+ "build:linux-x64": "bun build src/main.ts --compile --target=bun-linux-x64 --outfile dist/openpalm-cli-linux-x64",
20
+ "build:linux-arm64": "bun build src/main.ts --compile --target=bun-linux-arm64 --outfile dist/openpalm-cli-linux-arm64",
21
+ "build:darwin-x64": "bun build src/main.ts --compile --target=bun-darwin-x64 --outfile dist/openpalm-cli-darwin-x64",
22
+ "build:darwin-arm64": "bun build src/main.ts --compile --target=bun-darwin-arm64 --outfile dist/openpalm-cli-darwin-arm64",
23
+ "build:windows-x64": "bun build src/main.ts --compile --target=bun-windows-x64 --outfile dist/openpalm-cli-windows-x64.exe",
24
+ "build:windows-arm64": "bun build src/main.ts --compile --target=bun-windows-arm64 --outfile dist/openpalm-cli-windows-arm64.exe"
30
25
  }
31
26
  }
@@ -0,0 +1,401 @@
1
+ import { afterEach, describe, expect, it, mock } from 'bun:test';
2
+ import { existsSync, mkdirSync, writeFileSync, chmodSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { detectHostInfo, main, reconcileStackEnvImageTag, resolveRequestedImageTag, upsertEnvValue } from './main.ts';
6
+
7
+ // Helpers to mock Bun.spawn and Bun.which for tests that would otherwise
8
+ // shell out to `docker info` / `docker compose version` and block in CI.
9
+ const originalBunSpawn = Bun.spawn;
10
+ const originalBunWhich = Bun.which;
11
+
12
+ function mockDockerCli(): void {
13
+ Bun.which = mock((_cmd: string) => '/usr/bin/docker') as typeof Bun.which;
14
+ Bun.spawn = mock((_cmd: string[] | readonly string[], _opts?: unknown) => ({
15
+ pid: 0,
16
+ exited: Promise.resolve(0),
17
+ exitCode: null,
18
+ signalCode: null,
19
+ killed: false,
20
+ stdin: null,
21
+ stdout: null,
22
+ stderr: null,
23
+ kill: () => {},
24
+ ref: () => {},
25
+ unref: () => {},
26
+ [Symbol.asyncDispose]: async () => {},
27
+ resourceUsage: () => undefined,
28
+ })) as unknown as typeof Bun.spawn;
29
+ }
30
+
31
+ function restoreDockerCli(): void {
32
+ Bun.spawn = originalBunSpawn;
33
+ Bun.which = originalBunWhich;
34
+ }
35
+
36
+ describe('cli main', () => {
37
+ const originalFetch = globalThis.fetch;
38
+ const originalLog = console.log;
39
+ const originalConfigHome = process.env.OPENPALM_CONFIG_HOME;
40
+ const originalDataHome = process.env.OPENPALM_DATA_HOME;
41
+ const originalStateHome = process.env.OPENPALM_STATE_HOME;
42
+ const originalWorkDir = process.env.OPENPALM_WORK_DIR;
43
+ const originalAdminToken = process.env.ADMIN_TOKEN;
44
+ const originalOpenPalmAdminToken = process.env.OPENPALM_ADMIN_TOKEN;
45
+
46
+ afterEach(() => {
47
+ globalThis.fetch = originalFetch;
48
+ console.log = originalLog;
49
+ restoreDockerCli();
50
+ process.env.OPENPALM_CONFIG_HOME = originalConfigHome;
51
+ process.env.OPENPALM_DATA_HOME = originalDataHome;
52
+ process.env.OPENPALM_STATE_HOME = originalStateHome;
53
+ process.env.OPENPALM_WORK_DIR = originalWorkDir;
54
+ process.env.ADMIN_TOKEN = originalAdminToken;
55
+ process.env.OPENPALM_ADMIN_TOKEN = originalOpenPalmAdminToken;
56
+ });
57
+
58
+ it('calls containers pull for update', async () => {
59
+ const calls: string[] = [];
60
+ globalThis.fetch = mock(async (input: string | URL) => {
61
+ calls.push(String(input));
62
+ return new Response('{"ok":true}', { status: 200 });
63
+ }) as typeof fetch;
64
+ console.log = mock(() => {}) as typeof console.log;
65
+
66
+ await main(['update']);
67
+
68
+ expect(calls).toEqual(['http://localhost:8100/admin/containers/pull']);
69
+ });
70
+
71
+ it('uses ADMIN_TOKEN from the environment for admin requests', async () => {
72
+ const adminTokens: string[] = [];
73
+ process.env.ADMIN_TOKEN = 'env-admin-token';
74
+ delete process.env.OPENPALM_ADMIN_TOKEN;
75
+
76
+ globalThis.fetch = mock(async (_input: string | URL, init?: RequestInit) => {
77
+ const headers = new Headers(init?.headers);
78
+ adminTokens.push(headers.get('X-Admin-Token') ?? '');
79
+ return new Response('{"ok":true}', { status: 200 });
80
+ }) as typeof fetch;
81
+ console.log = mock(() => {}) as typeof console.log;
82
+
83
+ await main(['update']);
84
+
85
+ expect(adminTokens).toEqual(['env-admin-token']);
86
+ });
87
+
88
+ it('falls back to the legacy parent secrets.env for admin requests', async () => {
89
+ const base = mkdtempSync(join(tmpdir(), 'openpalm-config-'));
90
+ const configHome = join(base, 'openpalm');
91
+ const adminTokens: string[] = [];
92
+
93
+ mkdirSync(configHome, { recursive: true });
94
+ writeFileSync(join(configHome, 'secrets.env'), 'ADMIN_TOKEN=\n');
95
+ writeFileSync(join(base, 'secrets.env'), 'export ADMIN_TOKEN="legacy-admin-token"\n');
96
+
97
+ process.env.OPENPALM_CONFIG_HOME = configHome;
98
+ delete process.env.ADMIN_TOKEN;
99
+ delete process.env.OPENPALM_ADMIN_TOKEN;
100
+
101
+ globalThis.fetch = mock(async (_input: string | URL, init?: RequestInit) => {
102
+ const headers = new Headers(init?.headers);
103
+ adminTokens.push(headers.get('X-Admin-Token') ?? '');
104
+ return new Response('{"ok":true}', { status: 200 });
105
+ }) as typeof fetch;
106
+ console.log = mock(() => {}) as typeof console.log;
107
+
108
+ try {
109
+ await main(['update']);
110
+ expect(adminTokens).toEqual(['legacy-admin-token']);
111
+ } finally {
112
+ rmSync(base, { recursive: true, force: true });
113
+ }
114
+ });
115
+
116
+ it('calls admin install when stack is already running', async () => {
117
+ const calls: string[] = [];
118
+ globalThis.fetch = mock(async (input: string | URL) => {
119
+ const url = String(input);
120
+ calls.push(url);
121
+ if (url.endsWith('/health')) {
122
+ return new Response('ok', { status: 200 });
123
+ }
124
+ return new Response('{\"ok\":true}', { status: 200 });
125
+ }) as typeof fetch;
126
+ console.log = mock(() => {}) as typeof console.log;
127
+
128
+ await main(['install']);
129
+
130
+ expect(calls).toEqual([
131
+ 'http://127.0.0.1:8100/health',
132
+ 'http://localhost:8100/admin/install',
133
+ ]);
134
+ });
135
+
136
+ it('throws for unknown command', async () => {
137
+ await expect(main(['nope'])).rejects.toThrow('Unknown command: nope');
138
+ });
139
+
140
+ it('creates the admin data directory during bootstrap install', async () => {
141
+ const base = mkdtempSync(join(tmpdir(), 'openpalm-install-'));
142
+ const configHome = join(base, 'config');
143
+ const dataHome = join(base, 'data');
144
+ const stateHome = join(base, 'state');
145
+ const workDir = join(base, 'work');
146
+ const binDir = join(stateHome, 'bin');
147
+
148
+ mkdirSync(binDir, { recursive: true });
149
+ writeFileSync(join(binDir, 'varlock'), '#!/bin/sh\nexit 0\n');
150
+ chmodSync(join(binDir, 'varlock'), 0o755);
151
+
152
+ process.env.OPENPALM_CONFIG_HOME = configHome;
153
+ process.env.OPENPALM_DATA_HOME = dataHome;
154
+ process.env.OPENPALM_STATE_HOME = stateHome;
155
+ process.env.OPENPALM_WORK_DIR = workDir;
156
+
157
+ mockDockerCli();
158
+ globalThis.fetch = mock(async (input: string | URL) => {
159
+ const url = String(input);
160
+ if (url.endsWith('/health')) {
161
+ throw new TypeError('fetch failed');
162
+ }
163
+ if (url.includes('/docker-compose.yml')) {
164
+ return new Response('services: {}\n', { status: 200 });
165
+ }
166
+ if (url.includes('/Caddyfile')) {
167
+ return new Response(':80 {\n}\n', { status: 200 });
168
+ }
169
+ if (url.includes('/secrets.env.schema') || url.includes('/stack.env.schema')) {
170
+ return new Response('KEY=string\n', { status: 200 });
171
+ }
172
+ return new Response('', { status: 503 });
173
+ }) as typeof fetch;
174
+ console.log = mock(() => {}) as typeof console.log;
175
+
176
+ try {
177
+ await main(['install', '--no-start', '--force', '--no-open']);
178
+ expect(existsSync(join(dataHome, 'admin'))).toBe(true);
179
+ } finally {
180
+ rmSync(base, { recursive: true, force: true });
181
+ }
182
+ });
183
+ });
184
+
185
+ describe('validate command', () => {
186
+ it('throws "Unknown command" is no longer thrown for validate', async () => {
187
+ // validate is now a known command, so it won't throw Unknown command.
188
+ // It will fail because varlock exits non-zero on a missing env/schema, but that's a different error.
189
+ // We set up a temp state dir with a fake varlock binary that exits immediately to avoid a network download.
190
+
191
+ const tempStateHome = mkdtempSync(join(tmpdir(), 'openpalm-test-'));
192
+ const binDir = join(tempStateHome, 'bin');
193
+ const artifactsDir = join(tempStateHome, 'artifacts');
194
+ mkdirSync(binDir, { recursive: true });
195
+ mkdirSync(artifactsDir, { recursive: true });
196
+
197
+ // Create a fake varlock script that exits 1 immediately
198
+ const fakeVarlock = join(binDir, 'varlock');
199
+ writeFileSync(fakeVarlock, '#!/bin/sh\nexit 1\n');
200
+ chmodSync(fakeVarlock, 0o755);
201
+
202
+ const originalStateHome = process.env.OPENPALM_STATE_HOME;
203
+ const originalExit = process.exit;
204
+ process.env.OPENPALM_STATE_HOME = tempStateHome;
205
+ // Prevent process.exit from terminating the test runner
206
+ process.exit = mock((_code?: number) => { throw new Error(`process.exit(${_code})`); }) as typeof process.exit;
207
+
208
+ try {
209
+ const err = await main(['validate']).catch((e: unknown) => e);
210
+ const message = err instanceof Error ? err.message : String(err);
211
+ expect(message).not.toContain('Unknown command: validate');
212
+ } finally {
213
+ process.exit = originalExit;
214
+ process.env.OPENPALM_STATE_HOME = originalStateHome;
215
+ rmSync(tempStateHome, { recursive: true, force: true });
216
+ }
217
+ });
218
+
219
+ });
220
+
221
+ describe('scan command', () => {
222
+ it('is a recognized command (does not throw Unknown command)', async () => {
223
+ // Verifies 'scan' is in the COMMANDS list and dispatches correctly.
224
+ // Does NOT test full scan behavior (which requires a real varlock binary,
225
+ // secrets.env, and secrets.env.schema). A more complete test would:
226
+ // - Stage a secrets.env.schema + secrets.env in temp dirs
227
+ // - Provide a fake varlock binary that echoes its args
228
+ // - Assert varlock is invoked with 'scan --path <tmpDir>/'
229
+ // - Verify the temp dir is cleaned up after execution
230
+
231
+ const tempStateHome = mkdtempSync(join(tmpdir(), 'openpalm-test-'));
232
+ const tempConfigHome = mkdtempSync(join(tmpdir(), 'openpalm-test-'));
233
+ const binDir = join(tempStateHome, 'bin');
234
+ const artifactsDir = join(tempStateHome, 'artifacts');
235
+ mkdirSync(binDir, { recursive: true });
236
+ mkdirSync(artifactsDir, { recursive: true });
237
+
238
+ // Create a fake varlock script that exits 0 immediately
239
+ const fakeVarlock = join(binDir, 'varlock');
240
+ writeFileSync(fakeVarlock, '#!/bin/sh\nexit 0\n');
241
+ chmodSync(fakeVarlock, 0o755);
242
+
243
+ // Create required files so the command reaches the varlock invocation
244
+ writeFileSync(join(artifactsDir, 'secrets.env.schema'), 'ADMIN_TOKEN\n');
245
+ writeFileSync(join(tempConfigHome, 'secrets.env'), 'ADMIN_TOKEN=testtoken\n');
246
+
247
+ const originalStateHome = process.env.OPENPALM_STATE_HOME;
248
+ const originalConfigHome = process.env.OPENPALM_CONFIG_HOME;
249
+ const originalExit = process.exit;
250
+ process.env.OPENPALM_STATE_HOME = tempStateHome;
251
+ process.env.OPENPALM_CONFIG_HOME = tempConfigHome;
252
+ process.exit = mock((_code?: number) => { throw new Error(`process.exit(${_code})`); }) as typeof process.exit;
253
+
254
+ try {
255
+ const err = await main(['scan']).catch((e: unknown) => e);
256
+ const message = err instanceof Error ? err.message : String(err);
257
+ // Should not be an unknown command error
258
+ expect(message).not.toContain('Unknown command: scan');
259
+ // The fake varlock exits 0, so process.exit(0) should be called
260
+ expect(message).toBe('process.exit(0)');
261
+ } finally {
262
+ process.exit = originalExit;
263
+ process.env.OPENPALM_STATE_HOME = originalStateHome;
264
+ process.env.OPENPALM_CONFIG_HOME = originalConfigHome;
265
+ rmSync(tempStateHome, { recursive: true, force: true });
266
+ rmSync(tempConfigHome, { recursive: true, force: true });
267
+ }
268
+ });
269
+
270
+ it('errors when secrets.env.schema is missing', async () => {
271
+ const tempStateHome = mkdtempSync(join(tmpdir(), 'openpalm-test-'));
272
+ const tempConfigHome = mkdtempSync(join(tmpdir(), 'openpalm-test-'));
273
+ const artifactsDir = join(tempStateHome, 'artifacts');
274
+ mkdirSync(artifactsDir, { recursive: true });
275
+
276
+ // secrets.env exists but secrets.env.schema does NOT
277
+ writeFileSync(join(tempConfigHome, 'secrets.env'), 'ADMIN_TOKEN=testtoken\n');
278
+
279
+ const originalStateHome = process.env.OPENPALM_STATE_HOME;
280
+ const originalConfigHome = process.env.OPENPALM_CONFIG_HOME;
281
+ const originalExit = process.exit;
282
+ const originalError = console.error;
283
+ const errorCalls: string[] = [];
284
+ process.env.OPENPALM_STATE_HOME = tempStateHome;
285
+ process.env.OPENPALM_CONFIG_HOME = tempConfigHome;
286
+ process.exit = mock((_code?: number) => { throw new Error(`process.exit(${_code})`); }) as typeof process.exit;
287
+ console.error = mock((...args: unknown[]) => { errorCalls.push(args.join(' ')); }) as typeof console.error;
288
+
289
+ try {
290
+ const err = await main(['scan']).catch((e: unknown) => e);
291
+ const message = err instanceof Error ? err.message : String(err);
292
+ expect(message).toBe('process.exit(1)');
293
+ expect(errorCalls.some(msg => msg.includes('secrets.env.schema not found'))).toBe(true);
294
+ expect(errorCalls.some(msg => msg.includes('openpalm install'))).toBe(true);
295
+ } finally {
296
+ process.exit = originalExit;
297
+ console.error = originalError;
298
+ process.env.OPENPALM_STATE_HOME = originalStateHome;
299
+ process.env.OPENPALM_CONFIG_HOME = originalConfigHome;
300
+ rmSync(tempStateHome, { recursive: true, force: true });
301
+ rmSync(tempConfigHome, { recursive: true, force: true });
302
+ }
303
+ });
304
+ });
305
+
306
+ describe('detectHostInfo', () => {
307
+ const originalFetch = globalThis.fetch;
308
+
309
+ afterEach(() => {
310
+ globalThis.fetch = originalFetch;
311
+ restoreDockerCli();
312
+ });
313
+
314
+ it('returns valid HostInfo structure', async () => {
315
+ mockDockerCli();
316
+ globalThis.fetch = mock(async () => new Response('', { status: 503 })) as typeof fetch;
317
+ const info = await detectHostInfo();
318
+ expect(info).toHaveProperty('platform');
319
+ expect(info).toHaveProperty('arch');
320
+ expect(info).toHaveProperty('docker');
321
+ expect(info).toHaveProperty('ollama');
322
+ expect(info).toHaveProperty('lmstudio');
323
+ expect(info).toHaveProperty('llamacpp');
324
+ expect(info).toHaveProperty('timestamp');
325
+ });
326
+
327
+ it('platform and arch match process values', async () => {
328
+ mockDockerCli();
329
+ globalThis.fetch = mock(async () => new Response('', { status: 503 })) as typeof fetch;
330
+ const info = await detectHostInfo();
331
+ expect(info.platform).toBe(process.platform);
332
+ expect(info.arch).toBe(process.arch);
333
+ });
334
+
335
+ it('HTTP probes handle connection refused gracefully', async () => {
336
+ mockDockerCli();
337
+ globalThis.fetch = mock(async () => { throw new TypeError('fetch failed'); }) as typeof fetch;
338
+ const info = await detectHostInfo();
339
+ expect(info.ollama.running).toBe(false);
340
+ expect(info.lmstudio.running).toBe(false);
341
+ expect(info.llamacpp.running).toBe(false);
342
+ });
343
+ });
344
+
345
+ describe('install image tag pinning', () => {
346
+ it('normalizes semver refs to image tags', () => {
347
+ expect(resolveRequestedImageTag('0.9.0-rc10')).toBe('v0.9.0-rc10');
348
+ expect(resolveRequestedImageTag('v0.9.0-rc10')).toBe('v0.9.0-rc10');
349
+ expect(resolveRequestedImageTag('main')).toBeNull();
350
+ expect(resolveRequestedImageTag(' ')).toBeNull();
351
+ expect(resolveRequestedImageTag('1.2')).toBeNull();
352
+ expect(resolveRequestedImageTag('v1.x.y')).toBeNull();
353
+ expect(resolveRequestedImageTag('invalid')).toBeNull();
354
+ expect(resolveRequestedImageTag('v1.0.0-rc..10')).toBeNull();
355
+ expect(resolveRequestedImageTag('v1.0.0..1')).toBeNull();
356
+ expect(resolveRequestedImageTag('v1.0.0-rc_10')).toBeNull();
357
+ });
358
+
359
+ it('pins existing stack.env image tag to the requested release tag', () => {
360
+ const original = 'OPENPALM_IMAGE_NAMESPACE=openpalm\nOPENPALM_IMAGE_TAG=latest\n';
361
+ expect(reconcileStackEnvImageTag(original, 'v0.9.0-rc10')).toBe(
362
+ 'OPENPALM_IMAGE_NAMESPACE=openpalm\nOPENPALM_IMAGE_TAG=v0.9.0-rc10\n',
363
+ );
364
+ });
365
+
366
+ it('does not overwrite existing stack.env image tag for main installs', () => {
367
+ const original = 'OPENPALM_IMAGE_NAMESPACE=openpalm\nOPENPALM_IMAGE_TAG=latest\n';
368
+ expect(reconcileStackEnvImageTag(original, 'main')).toBe(original);
369
+ });
370
+
371
+ it('prefers an explicit image tag over the requested release ref', () => {
372
+ const original = 'OPENPALM_IMAGE_NAMESPACE=openpalm\nOPENPALM_IMAGE_TAG=latest\n';
373
+ expect(reconcileStackEnvImageTag(original, 'v0.9.0-rc10', 'v9.9.9-test')).toBe(
374
+ 'OPENPALM_IMAGE_NAMESPACE=openpalm\nOPENPALM_IMAGE_TAG=v9.9.9-test\n',
375
+ );
376
+ });
377
+
378
+ it('updates an existing key in env content', () => {
379
+ expect(upsertEnvValue('OPENPALM_IMAGE_TAG=latest\n', 'OPENPALM_IMAGE_TAG', 'v0.9.0-rc10')).toBe(
380
+ 'OPENPALM_IMAGE_TAG=v0.9.0-rc10\n',
381
+ );
382
+ });
383
+
384
+ it('inserts a new key into empty env content', () => {
385
+ expect(upsertEnvValue('', 'OPENPALM_IMAGE_TAG', 'v0.9.0-rc10')).toBe(
386
+ 'OPENPALM_IMAGE_TAG=v0.9.0-rc10\n',
387
+ );
388
+ });
389
+
390
+ it('inserts a new key when the original content lacks a trailing newline', () => {
391
+ expect(upsertEnvValue('OPENPALM_IMAGE_NAMESPACE=openpalm', 'OPENPALM_IMAGE_TAG', 'v0.9.0-rc10')).toBe(
392
+ 'OPENPALM_IMAGE_NAMESPACE=openpalm\nOPENPALM_IMAGE_TAG=v0.9.0-rc10\n',
393
+ );
394
+ });
395
+
396
+ it('treats regex characters in keys literally when updating env content', () => {
397
+ expect(upsertEnvValue('KEY.WITH-HYPHEN=old\n', 'KEY.WITH-HYPHEN', 'new')).toBe(
398
+ 'KEY.WITH-HYPHEN=new\n',
399
+ );
400
+ });
401
+ });