openpalm 0.3.4 → 0.9.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 +36 -78
- package/package.json +15 -20
- package/src/main.test.ts +352 -0
- package/src/main.ts +965 -0
- package/LICENSE +0 -121
- package/dist/openpalm.js +0 -9401
package/README.md
CHANGED
|
@@ -1,98 +1,56 @@
|
|
|
1
|
-
# openpalm
|
|
1
|
+
# @openpalm/cli
|
|
2
2
|
|
|
3
|
-
CLI
|
|
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` |
|
|
18
|
-
| `uninstall` | Stop and remove
|
|
19
|
-
| `update` | Pull latest images and recreate containers |
|
|
20
|
-
| `start [
|
|
21
|
-
| `stop [
|
|
22
|
-
| `restart [
|
|
23
|
-
| `logs [
|
|
24
|
-
| `status` | Show container status |
|
|
25
|
-
| `service <
|
|
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
|
-
|
|
19
|
+
### Install options
|
|
33
20
|
|
|
34
|
-
|
|
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
|
-
##
|
|
23
|
+
## Environment variables
|
|
39
24
|
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
# Run directly from source
|
|
58
|
-
bun run src/main.ts install
|
|
41
|
+
## Building
|
|
59
42
|
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
"description": "CLI tool for installing and managing an OpenPalm stack",
|
|
3
|
+
"version": "0.9.1",
|
|
5
4
|
"type": "module",
|
|
6
|
-
"license": "
|
|
7
|
-
"
|
|
5
|
+
"license": "MPL-2.0",
|
|
6
|
+
"description": "OpenPalm CLI — install and manage a self-hosted OpenPalm stack",
|
|
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": "./
|
|
13
|
+
"openpalm": "./src/main.ts"
|
|
22
14
|
},
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
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
|
}
|
package/src/main.test.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
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
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
globalThis.fetch = originalFetch;
|
|
46
|
+
console.log = originalLog;
|
|
47
|
+
restoreDockerCli();
|
|
48
|
+
process.env.OPENPALM_CONFIG_HOME = originalConfigHome;
|
|
49
|
+
process.env.OPENPALM_DATA_HOME = originalDataHome;
|
|
50
|
+
process.env.OPENPALM_STATE_HOME = originalStateHome;
|
|
51
|
+
process.env.OPENPALM_WORK_DIR = originalWorkDir;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('calls containers pull for update', async () => {
|
|
55
|
+
const calls: string[] = [];
|
|
56
|
+
globalThis.fetch = mock(async (input: string | URL) => {
|
|
57
|
+
calls.push(String(input));
|
|
58
|
+
return new Response('{"ok":true}', { status: 200 });
|
|
59
|
+
}) as typeof fetch;
|
|
60
|
+
console.log = mock(() => {}) as typeof console.log;
|
|
61
|
+
|
|
62
|
+
await main(['update']);
|
|
63
|
+
|
|
64
|
+
expect(calls).toEqual(['http://localhost:8100/admin/containers/pull']);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('calls admin install when stack is already running', async () => {
|
|
68
|
+
const calls: string[] = [];
|
|
69
|
+
globalThis.fetch = mock(async (input: string | URL) => {
|
|
70
|
+
const url = String(input);
|
|
71
|
+
calls.push(url);
|
|
72
|
+
if (url.endsWith('/health')) {
|
|
73
|
+
return new Response('ok', { status: 200 });
|
|
74
|
+
}
|
|
75
|
+
return new Response('{\"ok\":true}', { status: 200 });
|
|
76
|
+
}) as typeof fetch;
|
|
77
|
+
console.log = mock(() => {}) as typeof console.log;
|
|
78
|
+
|
|
79
|
+
await main(['install']);
|
|
80
|
+
|
|
81
|
+
expect(calls).toEqual([
|
|
82
|
+
'http://127.0.0.1:8100/health',
|
|
83
|
+
'http://localhost:8100/admin/install',
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('throws for unknown command', async () => {
|
|
88
|
+
await expect(main(['nope'])).rejects.toThrow('Unknown command: nope');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('creates the admin data directory during bootstrap install', async () => {
|
|
92
|
+
const base = mkdtempSync(join(tmpdir(), 'openpalm-install-'));
|
|
93
|
+
const configHome = join(base, 'config');
|
|
94
|
+
const dataHome = join(base, 'data');
|
|
95
|
+
const stateHome = join(base, 'state');
|
|
96
|
+
const workDir = join(base, 'work');
|
|
97
|
+
const binDir = join(stateHome, 'bin');
|
|
98
|
+
|
|
99
|
+
mkdirSync(binDir, { recursive: true });
|
|
100
|
+
writeFileSync(join(binDir, 'varlock'), '#!/bin/sh\nexit 0\n');
|
|
101
|
+
chmodSync(join(binDir, 'varlock'), 0o755);
|
|
102
|
+
|
|
103
|
+
process.env.OPENPALM_CONFIG_HOME = configHome;
|
|
104
|
+
process.env.OPENPALM_DATA_HOME = dataHome;
|
|
105
|
+
process.env.OPENPALM_STATE_HOME = stateHome;
|
|
106
|
+
process.env.OPENPALM_WORK_DIR = workDir;
|
|
107
|
+
|
|
108
|
+
mockDockerCli();
|
|
109
|
+
globalThis.fetch = mock(async (input: string | URL) => {
|
|
110
|
+
const url = String(input);
|
|
111
|
+
if (url.endsWith('/health')) {
|
|
112
|
+
throw new TypeError('fetch failed');
|
|
113
|
+
}
|
|
114
|
+
if (url.includes('/docker-compose.yml')) {
|
|
115
|
+
return new Response('services: {}\n', { status: 200 });
|
|
116
|
+
}
|
|
117
|
+
if (url.includes('/Caddyfile')) {
|
|
118
|
+
return new Response(':80 {\n}\n', { status: 200 });
|
|
119
|
+
}
|
|
120
|
+
if (url.includes('/secrets.env.schema') || url.includes('/stack.env.schema')) {
|
|
121
|
+
return new Response('KEY=string\n', { status: 200 });
|
|
122
|
+
}
|
|
123
|
+
return new Response('', { status: 503 });
|
|
124
|
+
}) as typeof fetch;
|
|
125
|
+
console.log = mock(() => {}) as typeof console.log;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
await main(['install', '--no-start', '--force', '--no-open']);
|
|
129
|
+
expect(existsSync(join(dataHome, 'admin'))).toBe(true);
|
|
130
|
+
} finally {
|
|
131
|
+
rmSync(base, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('validate command', () => {
|
|
137
|
+
it('throws "Unknown command" is no longer thrown for validate', async () => {
|
|
138
|
+
// validate is now a known command, so it won't throw Unknown command.
|
|
139
|
+
// It will fail because varlock exits non-zero on a missing env/schema, but that's a different error.
|
|
140
|
+
// We set up a temp state dir with a fake varlock binary that exits immediately to avoid a network download.
|
|
141
|
+
|
|
142
|
+
const tempStateHome = mkdtempSync(join(tmpdir(), 'openpalm-test-'));
|
|
143
|
+
const binDir = join(tempStateHome, 'bin');
|
|
144
|
+
const artifactsDir = join(tempStateHome, 'artifacts');
|
|
145
|
+
mkdirSync(binDir, { recursive: true });
|
|
146
|
+
mkdirSync(artifactsDir, { recursive: true });
|
|
147
|
+
|
|
148
|
+
// Create a fake varlock script that exits 1 immediately
|
|
149
|
+
const fakeVarlock = join(binDir, 'varlock');
|
|
150
|
+
writeFileSync(fakeVarlock, '#!/bin/sh\nexit 1\n');
|
|
151
|
+
chmodSync(fakeVarlock, 0o755);
|
|
152
|
+
|
|
153
|
+
const originalStateHome = process.env.OPENPALM_STATE_HOME;
|
|
154
|
+
const originalExit = process.exit;
|
|
155
|
+
process.env.OPENPALM_STATE_HOME = tempStateHome;
|
|
156
|
+
// Prevent process.exit from terminating the test runner
|
|
157
|
+
process.exit = mock((_code?: number) => { throw new Error(`process.exit(${_code})`); }) as typeof process.exit;
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const err = await main(['validate']).catch((e: unknown) => e);
|
|
161
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
162
|
+
expect(message).not.toContain('Unknown command: validate');
|
|
163
|
+
} finally {
|
|
164
|
+
process.exit = originalExit;
|
|
165
|
+
process.env.OPENPALM_STATE_HOME = originalStateHome;
|
|
166
|
+
rmSync(tempStateHome, { recursive: true, force: true });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('scan command', () => {
|
|
173
|
+
it('is a recognized command (does not throw Unknown command)', async () => {
|
|
174
|
+
// Verifies 'scan' is in the COMMANDS list and dispatches correctly.
|
|
175
|
+
// Does NOT test full scan behavior (which requires a real varlock binary,
|
|
176
|
+
// secrets.env, and secrets.env.schema). A more complete test would:
|
|
177
|
+
// - Stage a secrets.env.schema + secrets.env in temp dirs
|
|
178
|
+
// - Provide a fake varlock binary that echoes its args
|
|
179
|
+
// - Assert varlock is invoked with 'scan --path <tmpDir>/'
|
|
180
|
+
// - Verify the temp dir is cleaned up after execution
|
|
181
|
+
|
|
182
|
+
const tempStateHome = mkdtempSync(join(tmpdir(), 'openpalm-test-'));
|
|
183
|
+
const tempConfigHome = mkdtempSync(join(tmpdir(), 'openpalm-test-'));
|
|
184
|
+
const binDir = join(tempStateHome, 'bin');
|
|
185
|
+
const artifactsDir = join(tempStateHome, 'artifacts');
|
|
186
|
+
mkdirSync(binDir, { recursive: true });
|
|
187
|
+
mkdirSync(artifactsDir, { recursive: true });
|
|
188
|
+
|
|
189
|
+
// Create a fake varlock script that exits 0 immediately
|
|
190
|
+
const fakeVarlock = join(binDir, 'varlock');
|
|
191
|
+
writeFileSync(fakeVarlock, '#!/bin/sh\nexit 0\n');
|
|
192
|
+
chmodSync(fakeVarlock, 0o755);
|
|
193
|
+
|
|
194
|
+
// Create required files so the command reaches the varlock invocation
|
|
195
|
+
writeFileSync(join(artifactsDir, 'secrets.env.schema'), 'ADMIN_TOKEN\n');
|
|
196
|
+
writeFileSync(join(tempConfigHome, 'secrets.env'), 'ADMIN_TOKEN=testtoken\n');
|
|
197
|
+
|
|
198
|
+
const originalStateHome = process.env.OPENPALM_STATE_HOME;
|
|
199
|
+
const originalConfigHome = process.env.OPENPALM_CONFIG_HOME;
|
|
200
|
+
const originalExit = process.exit;
|
|
201
|
+
process.env.OPENPALM_STATE_HOME = tempStateHome;
|
|
202
|
+
process.env.OPENPALM_CONFIG_HOME = tempConfigHome;
|
|
203
|
+
process.exit = mock((_code?: number) => { throw new Error(`process.exit(${_code})`); }) as typeof process.exit;
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const err = await main(['scan']).catch((e: unknown) => e);
|
|
207
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
208
|
+
// Should not be an unknown command error
|
|
209
|
+
expect(message).not.toContain('Unknown command: scan');
|
|
210
|
+
// The fake varlock exits 0, so process.exit(0) should be called
|
|
211
|
+
expect(message).toBe('process.exit(0)');
|
|
212
|
+
} finally {
|
|
213
|
+
process.exit = originalExit;
|
|
214
|
+
process.env.OPENPALM_STATE_HOME = originalStateHome;
|
|
215
|
+
process.env.OPENPALM_CONFIG_HOME = originalConfigHome;
|
|
216
|
+
rmSync(tempStateHome, { recursive: true, force: true });
|
|
217
|
+
rmSync(tempConfigHome, { recursive: true, force: true });
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('errors when secrets.env.schema is missing', async () => {
|
|
222
|
+
const tempStateHome = mkdtempSync(join(tmpdir(), 'openpalm-test-'));
|
|
223
|
+
const tempConfigHome = mkdtempSync(join(tmpdir(), 'openpalm-test-'));
|
|
224
|
+
const artifactsDir = join(tempStateHome, 'artifacts');
|
|
225
|
+
mkdirSync(artifactsDir, { recursive: true });
|
|
226
|
+
|
|
227
|
+
// secrets.env exists but secrets.env.schema does NOT
|
|
228
|
+
writeFileSync(join(tempConfigHome, 'secrets.env'), 'ADMIN_TOKEN=testtoken\n');
|
|
229
|
+
|
|
230
|
+
const originalStateHome = process.env.OPENPALM_STATE_HOME;
|
|
231
|
+
const originalConfigHome = process.env.OPENPALM_CONFIG_HOME;
|
|
232
|
+
const originalExit = process.exit;
|
|
233
|
+
const originalError = console.error;
|
|
234
|
+
const errorCalls: string[] = [];
|
|
235
|
+
process.env.OPENPALM_STATE_HOME = tempStateHome;
|
|
236
|
+
process.env.OPENPALM_CONFIG_HOME = tempConfigHome;
|
|
237
|
+
process.exit = mock((_code?: number) => { throw new Error(`process.exit(${_code})`); }) as typeof process.exit;
|
|
238
|
+
console.error = mock((...args: unknown[]) => { errorCalls.push(args.join(' ')); }) as typeof console.error;
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const err = await main(['scan']).catch((e: unknown) => e);
|
|
242
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
243
|
+
expect(message).toBe('process.exit(1)');
|
|
244
|
+
expect(errorCalls.some(msg => msg.includes('secrets.env.schema not found'))).toBe(true);
|
|
245
|
+
expect(errorCalls.some(msg => msg.includes('openpalm install'))).toBe(true);
|
|
246
|
+
} finally {
|
|
247
|
+
process.exit = originalExit;
|
|
248
|
+
console.error = originalError;
|
|
249
|
+
process.env.OPENPALM_STATE_HOME = originalStateHome;
|
|
250
|
+
process.env.OPENPALM_CONFIG_HOME = originalConfigHome;
|
|
251
|
+
rmSync(tempStateHome, { recursive: true, force: true });
|
|
252
|
+
rmSync(tempConfigHome, { recursive: true, force: true });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('detectHostInfo', () => {
|
|
258
|
+
const originalFetch = globalThis.fetch;
|
|
259
|
+
|
|
260
|
+
afterEach(() => {
|
|
261
|
+
globalThis.fetch = originalFetch;
|
|
262
|
+
restoreDockerCli();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('returns valid HostInfo structure', async () => {
|
|
266
|
+
mockDockerCli();
|
|
267
|
+
globalThis.fetch = mock(async () => new Response('', { status: 503 })) as typeof fetch;
|
|
268
|
+
const info = await detectHostInfo();
|
|
269
|
+
expect(info).toHaveProperty('platform');
|
|
270
|
+
expect(info).toHaveProperty('arch');
|
|
271
|
+
expect(info).toHaveProperty('docker');
|
|
272
|
+
expect(info).toHaveProperty('ollama');
|
|
273
|
+
expect(info).toHaveProperty('lmstudio');
|
|
274
|
+
expect(info).toHaveProperty('llamacpp');
|
|
275
|
+
expect(info).toHaveProperty('timestamp');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('platform and arch match process values', async () => {
|
|
279
|
+
mockDockerCli();
|
|
280
|
+
globalThis.fetch = mock(async () => new Response('', { status: 503 })) as typeof fetch;
|
|
281
|
+
const info = await detectHostInfo();
|
|
282
|
+
expect(info.platform).toBe(process.platform);
|
|
283
|
+
expect(info.arch).toBe(process.arch);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('HTTP probes handle connection refused gracefully', async () => {
|
|
287
|
+
mockDockerCli();
|
|
288
|
+
globalThis.fetch = mock(async () => { throw new TypeError('fetch failed'); }) as typeof fetch;
|
|
289
|
+
const info = await detectHostInfo();
|
|
290
|
+
expect(info.ollama.running).toBe(false);
|
|
291
|
+
expect(info.lmstudio.running).toBe(false);
|
|
292
|
+
expect(info.llamacpp.running).toBe(false);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('install image tag pinning', () => {
|
|
297
|
+
it('normalizes semver refs to image tags', () => {
|
|
298
|
+
expect(resolveRequestedImageTag('0.9.0-rc10')).toBe('v0.9.0-rc10');
|
|
299
|
+
expect(resolveRequestedImageTag('v0.9.0-rc10')).toBe('v0.9.0-rc10');
|
|
300
|
+
expect(resolveRequestedImageTag('main')).toBeNull();
|
|
301
|
+
expect(resolveRequestedImageTag(' ')).toBeNull();
|
|
302
|
+
expect(resolveRequestedImageTag('1.2')).toBeNull();
|
|
303
|
+
expect(resolveRequestedImageTag('v1.x.y')).toBeNull();
|
|
304
|
+
expect(resolveRequestedImageTag('invalid')).toBeNull();
|
|
305
|
+
expect(resolveRequestedImageTag('v1.0.0-rc..10')).toBeNull();
|
|
306
|
+
expect(resolveRequestedImageTag('v1.0.0..1')).toBeNull();
|
|
307
|
+
expect(resolveRequestedImageTag('v1.0.0-rc_10')).toBeNull();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('pins existing stack.env image tag to the requested release tag', () => {
|
|
311
|
+
const original = 'OPENPALM_IMAGE_NAMESPACE=openpalm\nOPENPALM_IMAGE_TAG=latest\n';
|
|
312
|
+
expect(reconcileStackEnvImageTag(original, 'v0.9.0-rc10')).toBe(
|
|
313
|
+
'OPENPALM_IMAGE_NAMESPACE=openpalm\nOPENPALM_IMAGE_TAG=v0.9.0-rc10\n',
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('does not overwrite existing stack.env image tag for main installs', () => {
|
|
318
|
+
const original = 'OPENPALM_IMAGE_NAMESPACE=openpalm\nOPENPALM_IMAGE_TAG=latest\n';
|
|
319
|
+
expect(reconcileStackEnvImageTag(original, 'main')).toBe(original);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('prefers an explicit image tag over the requested release ref', () => {
|
|
323
|
+
const original = 'OPENPALM_IMAGE_NAMESPACE=openpalm\nOPENPALM_IMAGE_TAG=latest\n';
|
|
324
|
+
expect(reconcileStackEnvImageTag(original, 'v0.9.0-rc10', 'v9.9.9-test')).toBe(
|
|
325
|
+
'OPENPALM_IMAGE_NAMESPACE=openpalm\nOPENPALM_IMAGE_TAG=v9.9.9-test\n',
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('updates an existing key in env content', () => {
|
|
330
|
+
expect(upsertEnvValue('OPENPALM_IMAGE_TAG=latest\n', 'OPENPALM_IMAGE_TAG', 'v0.9.0-rc10')).toBe(
|
|
331
|
+
'OPENPALM_IMAGE_TAG=v0.9.0-rc10\n',
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('inserts a new key into empty env content', () => {
|
|
336
|
+
expect(upsertEnvValue('', 'OPENPALM_IMAGE_TAG', 'v0.9.0-rc10')).toBe(
|
|
337
|
+
'OPENPALM_IMAGE_TAG=v0.9.0-rc10\n',
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('inserts a new key when the original content lacks a trailing newline', () => {
|
|
342
|
+
expect(upsertEnvValue('OPENPALM_IMAGE_NAMESPACE=openpalm', 'OPENPALM_IMAGE_TAG', 'v0.9.0-rc10')).toBe(
|
|
343
|
+
'OPENPALM_IMAGE_NAMESPACE=openpalm\nOPENPALM_IMAGE_TAG=v0.9.0-rc10\n',
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('treats regex characters in keys literally when updating env content', () => {
|
|
348
|
+
expect(upsertEnvValue('KEY.WITH-HYPHEN=old\n', 'KEY.WITH-HYPHEN', 'new')).toBe(
|
|
349
|
+
'KEY.WITH-HYPHEN=new\n',
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
});
|