openpalm 0.9.2 → 0.9.4

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/src/main.ts CHANGED
@@ -1,995 +1,44 @@
1
1
  #!/usr/bin/env bun
2
- import { homedir, tmpdir } from 'node:os';
3
- import { basename, dirname, join } from 'node:path';
4
- import { mkdir, unlink, copyFile, mkdtemp, rm } from 'node:fs/promises';
2
+ import { defineCommand, runCommand, runMain } from 'citty';
5
3
  import cliPkg from '../package.json' with { type: 'json' };
6
4
 
7
- const ADMIN_URL = process.env.OPENPALM_ADMIN_API_URL || 'http://localhost:8100';
8
- const REPO_OWNER = 'itlackey';
9
- const REPO_NAME = 'openpalm';
10
- const EXPORT_ENV_PREFIX = 'export ';
11
-
12
- const IS_WINDOWS = process.platform === 'win32';
13
-
14
- /**
15
- * Co-locate a schema and env file in a temp directory so varlock can discover them.
16
- * Varlock discovers .env.schema files alongside the --path target. Since the schema
17
- * and env file live in separate XDG directories, we stage them into a temp dir.
18
- * Returns the temp dir path (caller must clean up with rm).
19
- */
20
- async function prepareVarlockDir(schemaPath: string, envPath: string): Promise<string> {
21
- const dir = await mkdtemp(join(tmpdir(), 'varlock-'));
22
- await copyFile(schemaPath, join(dir, '.env.schema'));
23
- await copyFile(envPath, join(dir, '.env'));
24
- return dir;
25
- }
26
-
27
- // Single source of truth for the pinned varlock version and per-platform SHA-256 checksums.
28
- const VARLOCK_VERSION = '0.4.0';
29
-
30
- /** Maps artifact filename to its SHA-256 checksum (from checksums.txt in the release). */
31
- const VARLOCK_CHECKSUMS: Record<string, string> = {
32
- 'varlock-linux-x64.tar.gz': '820295b271cece2679b2b9701b5285ce39354fc2f35797365fa36c70125f51ab',
33
- 'varlock-linux-arm64.tar.gz': 'e830baaa901b6389ecf281bdd2449bfaf7586e91fd3a7a038ec06f78e6fa92f8',
34
- 'varlock-macos-x64.tar.gz': 'e6abf0d97da8ff7c98b0e9044a8b71f48fbf74a0d7bfc2543a81575a07b7a03b',
35
- 'varlock-macos-arm64.tar.gz': '228e4c2666b9fa50a83a8713a848e7a0f0044d7fd7c9d441d43e6ebccad2f4a3',
36
- };
37
-
38
- /**
39
- * Resolves the varlock tarball filename for the current platform and architecture.
40
- * Maps Node.js process.platform / process.arch to the release artifact name.
41
- */
42
- function varlockArtifactName(): string {
43
- const platformMap: Record<string, string> = {
44
- linux: 'linux',
45
- darwin: 'macos',
46
- };
47
- const archMap: Record<string, string> = {
48
- x64: 'x64',
49
- arm64: 'arm64',
50
- };
51
-
52
- const os = platformMap[process.platform];
53
- const arch = archMap[process.arch];
54
-
55
- if (!os || !arch) {
56
- throw new Error(
57
- `Unsupported platform/arch for varlock: ${process.platform}/${process.arch}. ` +
58
- `Supported: linux/x64, linux/arm64, darwin/x64, darwin/arm64.`,
59
- );
60
- }
61
-
62
- return `varlock-${os}-${arch}.tar.gz`;
63
- }
64
-
65
- type Command =
66
- | 'install'
67
- | 'uninstall'
68
- | 'update'
69
- | 'start'
70
- | 'stop'
71
- | 'restart'
72
- | 'logs'
73
- | 'status'
74
- | 'service'
75
- | 'validate'
76
- | 'scan';
77
-
78
- const COMMANDS: readonly Command[] = ['install', 'uninstall', 'update', 'start', 'stop', 'restart', 'logs', 'status', 'service', 'validate', 'scan'];
79
-
80
- type InstallOptions = {
81
- force: boolean;
82
- version: string;
83
- noStart: boolean;
84
- noOpen: boolean;
85
- };
86
-
87
- const RELEASE_TAG_REGEX = /^v?\d+\.\d+\.\d+(?:[-+](?:[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*))?$/;
88
- const DEFAULT_INSTALL_REF = cliPkg.version ? `v${cliPkg.version}` : 'main';
89
-
90
- export interface HostInfo {
91
- platform: string;
92
- arch: string;
93
- docker: { available: boolean; running: boolean };
94
- ollama: { running: boolean; url: string };
95
- lmstudio: { running: boolean; url: string };
96
- llamacpp: { running: boolean; url: string };
97
- timestamp: string;
98
- }
99
-
100
- function defaultConfigHome(): string {
101
- if (process.env.OPENPALM_CONFIG_HOME) return process.env.OPENPALM_CONFIG_HOME;
102
- if (IS_WINDOWS) {
103
- return join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'openpalm');
104
- }
105
- return join(homedir(), '.config', 'openpalm');
106
- }
107
-
108
- function defaultDataHome(): string {
109
- if (process.env.OPENPALM_DATA_HOME) return process.env.OPENPALM_DATA_HOME;
110
- if (IS_WINDOWS) {
111
- return join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'openpalm', 'data');
112
- }
113
- return join(homedir(), '.local', 'share', 'openpalm');
114
- }
115
-
116
- function defaultStateHome(): string {
117
- if (process.env.OPENPALM_STATE_HOME) return process.env.OPENPALM_STATE_HOME;
118
- if (IS_WINDOWS) {
119
- return join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'openpalm', 'state');
120
- }
121
- return join(homedir(), '.local', 'state', 'openpalm');
122
- }
123
-
124
- function defaultDockerSock(): string {
125
- if (process.env.OPENPALM_DOCKER_SOCK) return process.env.OPENPALM_DOCKER_SOCK;
126
- return IS_WINDOWS ? '//./pipe/docker_engine' : '/var/run/docker.sock';
127
- }
128
-
129
- function defaultWorkDir(): string {
130
- return process.env.OPENPALM_WORK_DIR || join(homedir(), 'openpalm');
131
- }
132
-
133
- async function loadAdminToken(): Promise<string> {
134
- if (process.env.OPENPALM_ADMIN_TOKEN) {
135
- return process.env.OPENPALM_ADMIN_TOKEN;
136
- }
137
-
138
- if (process.env.ADMIN_TOKEN) {
139
- return process.env.ADMIN_TOKEN;
140
- }
141
-
142
- const configHome = defaultConfigHome();
143
- const secretsPaths = [join(configHome, 'secrets.env')];
144
- if (basename(configHome) === 'openpalm') {
145
- secretsPaths.push(join(dirname(configHome), 'secrets.env'));
146
- }
147
-
148
- for (const secretsPath of secretsPaths) {
149
- const token = await readAdminTokenFromFile(secretsPath);
150
- if (token) return token;
151
- }
152
-
153
- return '';
154
- }
155
-
156
- async function readAdminTokenFromFile(secretsPath: string): Promise<string | null> {
157
- try {
158
- const text = await Bun.file(secretsPath).text();
159
- for (const rawLine of text.split('\n')) {
160
- const line = rawLine.trim();
161
- if (!line || line.startsWith('#')) continue;
162
- const lineWithoutExportPrefix = line.startsWith(EXPORT_ENV_PREFIX)
163
- ? line.slice(EXPORT_ENV_PREFIX.length).trimStart()
164
- : line;
165
- const [key, ...rest] = lineWithoutExportPrefix.split('=');
166
- if (key !== 'ADMIN_TOKEN') continue;
167
- const value = unwrapQuotedEnvValue(rest.join('=').trim());
168
- if (!value) return null;
169
- return value;
170
- }
171
- } catch {
172
- // Best effort only.
173
- }
174
-
175
- return null;
176
- }
177
-
178
- function unwrapQuotedEnvValue(value: string): string {
179
- const isDoubleQuoted = value.startsWith('"') && value.endsWith('"');
180
- const isSingleQuoted = value.startsWith('\'') && value.endsWith('\'');
181
- if ((isDoubleQuoted || isSingleQuoted) && value.length >= 2) {
182
- return value.slice(1, -1);
183
- }
184
-
185
- return value;
186
- }
187
-
188
- async function adminRequest(path: string, init?: RequestInit): Promise<unknown> {
189
- const token = await loadAdminToken();
190
- const response = await fetch(`${ADMIN_URL}${path}`, {
191
- ...init,
192
- headers: {
193
- 'Content-Type': 'application/json',
194
- 'X-Requested-By': 'cli',
195
- ...(token ? { 'X-Admin-Token': token } : {}),
196
- ...init?.headers,
197
- },
198
- signal: init?.signal ?? AbortSignal.timeout(120_000),
199
- });
200
-
201
- const text = await response.text();
202
- if (!response.ok) {
203
- throw new Error(text || `${response.status} ${response.statusText}`);
204
- }
205
-
206
- if (!text) return { ok: true };
207
- try {
208
- return JSON.parse(text) as unknown;
209
- } catch {
210
- return text;
211
- }
212
- }
213
-
214
- function printUsage(): void {
215
- console.log('Usage: openpalm <command> [args]');
216
- console.log('');
217
- console.log('Commands:');
218
- console.log(' install Install and start the OpenPalm stack');
219
- console.log(' uninstall Stop and remove OpenPalm');
220
- console.log(' update Pull latest images and recreate containers');
221
- console.log(' start [service...] Start services');
222
- console.log(' stop [service...] Stop services');
223
- console.log(' restart [service...] Restart services');
224
- console.log(' logs [service...] View container logs');
225
- console.log(' status Show container status');
226
- console.log(' service Service lifecycle operations');
227
- console.log(' validate Validate configuration against schema');
228
- console.log(' scan Scan codebase for leaked secrets (requires local secrets.env)');
229
- }
230
-
231
- function parseInstallOptions(args: string[]): InstallOptions {
232
- let force = false;
233
- let version = DEFAULT_INSTALL_REF;
234
- let noStart = false;
235
- let noOpen = false;
236
-
237
- for (let index = 0; index < args.length; index += 1) {
238
- const arg = args[index];
239
- if (arg === '--force') {
240
- force = true;
241
- continue;
242
- }
243
- if (arg === '--version') {
244
- const value = args[index + 1];
245
- if (!value) throw new Error('--version requires a value');
246
- version = value;
247
- index += 1;
248
- continue;
249
- }
250
- if (arg === '--no-start') {
251
- noStart = true;
252
- continue;
253
- }
254
- if (arg === '--no-open') {
255
- noOpen = true;
256
- continue;
257
- }
258
- }
259
-
260
- return { force, version, noStart, noOpen };
261
- }
262
-
263
- export function resolveRequestedImageTag(repoRef: string): string | null {
264
- const trimmed = repoRef.trim();
265
- if (!trimmed || trimmed === 'main') return null;
266
- if (!RELEASE_TAG_REGEX.test(trimmed)) return null;
267
- return trimmed.startsWith('v') ? trimmed : `v${trimmed}`;
268
- }
269
-
270
- export function upsertEnvValue(content: string, key: string, value: string): string {
271
- const line = `${key}=${value}`;
272
- const escapedKey = key.replace(/[|\\{}()[\]^$+*?.-]/g, '\\$&');
273
- const pattern = new RegExp(`^${escapedKey}=.*$`, 'm');
274
- if (pattern.test(content)) {
275
- return content.replace(pattern, line);
276
- }
277
-
278
- const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n';
279
- return `${content}${suffix}${line}\n`;
280
- }
281
-
282
- export function reconcileStackEnvImageTag(
283
- content: string,
284
- repoRef: string,
285
- explicitImageTag?: string,
286
- ): string {
287
- const desiredImageTag = explicitImageTag || resolveRequestedImageTag(repoRef);
288
- if (!desiredImageTag) return content;
289
- return upsertEnvValue(content, 'OPENPALM_IMAGE_TAG', desiredImageTag);
290
- }
291
-
292
- async function isStackRunning(): Promise<boolean> {
293
- try {
294
- const response = await fetch('http://127.0.0.1:8100/health', {
295
- method: 'GET',
296
- signal: AbortSignal.timeout(2000),
297
- });
298
- return response.ok;
299
- } catch {
300
- return false;
301
- }
302
- }
303
-
304
- async function fetchAsset(repoRef: string, filename: string): Promise<string> {
305
- const releaseUrl = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/${repoRef}/${filename}`;
306
- const rawUrl = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${repoRef}/assets/${filename}`;
307
-
308
- const releaseResponse = await fetch(releaseUrl, { signal: AbortSignal.timeout(30000) });
309
- if (releaseResponse.ok) {
310
- return await releaseResponse.text();
311
- }
312
-
313
- const rawResponse = await fetch(rawUrl, { signal: AbortSignal.timeout(30000) });
314
- if (rawResponse.ok) {
315
- return await rawResponse.text();
316
- }
317
-
318
- throw new Error(`Failed to download ${filename} from ${repoRef}`);
319
- }
320
-
321
- async function ensureDirectoryTree(configHome: string, dataHome: string, stateHome: string, workDir: string): Promise<void> {
322
- const dirs = [
323
- configHome,
324
- join(configHome, 'channels'),
325
- join(configHome, 'assistant'),
326
- join(configHome, 'automations'),
327
- dataHome,
328
- join(dataHome, 'admin'),
329
- join(dataHome, 'memory'),
330
- join(dataHome, 'assistant'),
331
- join(dataHome, 'guardian'),
332
- join(dataHome, 'caddy'),
333
- join(dataHome, 'caddy', 'data'),
334
- join(dataHome, 'caddy', 'config'),
335
- join(dataHome, 'automations'),
336
- join(dataHome, 'opencode'),
337
- stateHome,
338
- join(stateHome, 'artifacts'),
339
- join(stateHome, 'audit'),
340
- join(stateHome, 'artifacts', 'channels'),
341
- join(stateHome, 'automations'),
342
- join(stateHome, 'opencode'),
343
- join(stateHome, 'bin'),
344
- workDir,
345
- ];
346
-
347
- for (const dir of dirs) {
348
- await mkdir(dir, { recursive: true });
349
- }
350
- }
351
-
352
- async function ensureSecrets(configHome: string): Promise<void> {
353
- const secretsPath = join(configHome, 'secrets.env');
354
- if (await Bun.file(secretsPath).exists()) {
355
- return;
356
- }
357
-
358
- const userId = process.env.USER || process.env.LOGNAME || process.env.USERNAME || 'default_user';
359
- const content = `# OpenPalm Secrets — generated by openpalm install\n# All values are configured via the setup wizard.\n\nADMIN_TOKEN=\n\n# LLM provider keys (configure at least one via the setup wizard)\nOPENAI_API_KEY=\nOPENAI_BASE_URL=\n# ANTHROPIC_API_KEY=\n# GROQ_API_KEY=\n# MISTRAL_API_KEY=\n# GOOGLE_API_KEY=\n\n# Memory\nMEMORY_USER_ID=${userId}\n`;
360
-
361
- await Bun.write(secretsPath, content);
362
- }
363
-
364
- async function ensureStackEnv(configHome: string, dataHome: string, stateHome: string, workDir: string, repoRef: string): Promise<void> {
365
- const dataStackEnv = join(dataHome, 'stack.env');
366
- const stagedStackEnv = join(stateHome, 'artifacts', 'stack.env');
367
- const explicitImageTag = process.env.OPENPALM_IMAGE_TAG;
368
- const hasExplicitImageTag = explicitImageTag !== undefined && explicitImageTag !== '';
369
- if (!(await Bun.file(dataStackEnv).exists())) {
370
- const defaultImageTag = hasExplicitImageTag
371
- ? explicitImageTag
372
- : (resolveRequestedImageTag(repoRef) || 'latest');
373
- const content = `# OpenPalm Stack Bootstrap — system-managed, do not edit\nOPENPALM_CONFIG_HOME=${configHome}\nOPENPALM_DATA_HOME=${dataHome}\nOPENPALM_STATE_HOME=${stateHome}\nOPENPALM_WORK_DIR=${workDir}\nOPENPALM_UID=${process.getuid?.() ?? 1000}\nOPENPALM_GID=${process.getgid?.() ?? 1000}\nOPENPALM_DOCKER_SOCK=${defaultDockerSock()}\nOPENPALM_IMAGE_NAMESPACE=${process.env.OPENPALM_IMAGE_NAMESPACE || 'openpalm'}\nOPENPALM_IMAGE_TAG=${defaultImageTag}\n`;
374
- await Bun.write(dataStackEnv, content);
375
- } else {
376
- const current = await Bun.file(dataStackEnv).text();
377
- const reconciled = reconcileStackEnvImageTag(
378
- current,
379
- repoRef,
380
- hasExplicitImageTag ? explicitImageTag : undefined,
381
- );
382
- if (reconciled !== current) {
383
- await Bun.write(dataStackEnv, reconciled);
384
- }
385
- }
386
- await Bun.write(stagedStackEnv, Bun.file(dataStackEnv));
387
-
388
- const stateSecrets = join(stateHome, 'artifacts', 'secrets.env');
389
- await Bun.write(stateSecrets, Bun.file(join(configHome, 'secrets.env')));
390
- }
391
-
392
- async function ensureOpenCodeConfig(configHome: string): Promise<void> {
393
- const opencodeDir = join(configHome, 'assistant');
394
- const configFile = join(opencodeDir, 'opencode.json');
395
- if (!(await Bun.file(configFile).exists())) {
396
- await Bun.write(configFile, '{\n "$schema": "https://opencode.ai/config.json"\n}\n');
397
- }
398
- await mkdir(join(opencodeDir, 'tools'), { recursive: true });
399
- await mkdir(join(opencodeDir, 'plugins'), { recursive: true });
400
- await mkdir(join(opencodeDir, 'skills'), { recursive: true });
401
- }
402
-
403
- async function writeIfChanged(path: string, content: string): Promise<void> {
404
- const file = Bun.file(path);
405
- if (await file.exists()) {
406
- const existing = await file.text();
407
- if (existing === content) {
408
- return;
409
- }
410
- }
411
- await Bun.write(path, content);
412
- }
413
-
414
- async function ensureOpenCodeSystemConfig(dataHome: string): Promise<void> {
415
- const opencodeSystemDir = join(dataHome, 'assistant');
416
- await mkdir(opencodeSystemDir, { recursive: true });
417
-
418
- const systemConfig = join(opencodeSystemDir, 'opencode.jsonc');
419
- const systemConfigContent =
420
- JSON.stringify(
421
- {
422
- "$schema": "https://opencode.ai/config.json",
423
- "plugin": ["@openpalm/assistant-tools", "akm-opencode"],
424
- "permission": {
425
- "read": {
426
- "/home/opencode/.local/share/opencode/auth.json": "deny",
427
- "/home/opencode/.local/share/opencode/mcp-auth.json": "deny"
428
- }
429
- }
430
- },
431
- null,
432
- 2,
433
- ) + "\n";
434
- await writeIfChanged(systemConfig, systemConfigContent);
435
-
436
- const agentsFile = join(opencodeSystemDir, 'AGENTS.md');
437
- const assetsAgentsPath = join(import.meta.dir, '..', '..', 'assets', 'AGENTS.md');
438
- let agentsContent: string;
439
- if (await Bun.file(assetsAgentsPath).exists()) {
440
- agentsContent = await Bun.file(assetsAgentsPath).text();
441
- } else {
442
- agentsContent =
443
- '# OpenPalm Assistant\n\n' +
444
- 'This file defines the assistant persona.\n' +
445
- 'It is seeded by the CLI on first install and managed by the admin on subsequent updates.\n';
446
- }
447
- await writeIfChanged(agentsFile, agentsContent);
448
- }
449
-
450
- async function runDockerCompose(args: string[]): Promise<void> {
451
- const proc = Bun.spawn(['docker', 'compose', ...args], {
452
- stdout: 'inherit',
453
- stderr: 'inherit',
454
- stdin: 'inherit',
455
- });
456
- const code = await proc.exited;
457
- if (code !== 0) {
458
- throw new Error(`docker compose ${args.join(' ')} failed with exit code ${code}`);
459
- }
460
- }
461
-
462
- async function waitForAdminHealthy(): Promise<void> {
463
- const started = Date.now();
464
- while (Date.now() - started < 120_000) {
465
- if (await isStackRunning()) {
466
- return;
467
- }
468
- await Bun.sleep(3000);
469
- }
470
- throw new Error('Admin did not become healthy within 120 seconds');
471
- }
472
-
473
- async function openBrowser(url: string): Promise<void> {
474
- const platform = process.platform;
475
- try {
476
- if (platform === 'darwin') {
477
- Bun.spawn(['open', url], { stdout: 'ignore', stderr: 'ignore' });
478
- return;
479
- }
480
- if (platform === 'win32') {
481
- Bun.spawn(['cmd', '/c', 'start', url], { stdout: 'ignore', stderr: 'ignore' });
482
- return;
483
- }
484
- Bun.spawn(['xdg-open', url], { stdout: 'ignore', stderr: 'ignore' });
485
- } catch {
486
- // Best effort
487
- }
488
- }
489
-
490
- /**
491
- * Downloads varlock binary via install script and caches it in STATE_HOME/bin/.
492
- * Skips download if binary already exists.
493
- *
494
- * @param stateHome - Path to STATE_HOME directory
495
- * @returns Absolute path to the varlock binary
496
- */
497
- async function ensureVarlock(stateHome: string): Promise<string> {
498
- const binDir = join(stateHome, 'bin');
499
- const varlockBin = join(binDir, 'varlock');
500
-
501
- if (await Bun.file(varlockBin).exists()) {
502
- return varlockBin;
503
- }
504
-
505
- await mkdir(binDir, { recursive: true });
506
-
507
- const artifact = varlockArtifactName();
508
- const expectedHash = VARLOCK_CHECKSUMS[artifact];
509
- if (!expectedHash) {
510
- throw new Error(
511
- `No SHA-256 checksum on record for ${artifact}. ` +
512
- `Cannot verify download integrity.`,
513
- );
514
- }
515
-
516
- const tarballUrl = `https://github.com/dmno-dev/varlock/releases/download/varlock%40${VARLOCK_VERSION}/${artifact}`;
517
- const tarballPath = join(binDir, 'varlock.tar.gz');
518
-
519
- // Download pinned tarball using argument array (no shell string interpolation).
520
- const downloadProc = Bun.spawn(
521
- ['curl', '-fsSL', '--retry', '5', '--retry-delay', '10', '--retry-all-errors', tarballUrl, '-o', tarballPath],
522
- {
523
- env: { ...process.env, HOME: process.env.HOME ?? '' },
524
- stdout: 'inherit',
525
- stderr: 'inherit',
526
- },
527
- );
528
- const downloadCode = await downloadProc.exited;
529
- if (downloadCode !== 0) {
530
- throw new Error(`Failed to download varlock tarball (curl exited with code ${downloadCode})`);
531
- }
532
-
533
- // Verify SHA-256 integrity using Bun built-in crypto (cross-platform).
534
- const hasher = new Bun.CryptoHasher('sha256');
535
- hasher.update(await Bun.file(tarballPath).arrayBuffer());
536
- const actualHash = hasher.digest('hex');
537
- if (actualHash !== expectedHash) {
538
- // Remove the suspect file before throwing.
539
- try { await unlink(tarballPath); } catch { /* best effort */ }
540
- throw new Error(
541
- `varlock tarball SHA-256 verification failed — download may be corrupted.\n` +
542
- ` Expected: ${expectedHash}\n` +
543
- ` Actual: ${actualHash}`,
544
- );
545
- }
546
-
547
- // Extract the binary.
548
- const extractProc = Bun.spawn(
549
- ['tar', 'xzf', tarballPath, '--strip-components=1', '-C', binDir],
550
- {
551
- env: { ...process.env, HOME: process.env.HOME ?? '' },
552
- stdout: 'inherit',
553
- stderr: 'inherit',
554
- },
555
- );
556
- const extractCode = await extractProc.exited;
557
- if (extractCode !== 0) {
558
- throw new Error(`Failed to extract varlock tarball (tar exited with code ${extractCode})`);
559
- }
560
-
561
- // Remove tarball after extraction.
562
- try { await unlink(tarballPath); } catch { /* best effort */ }
563
-
564
- // Set executable bit (no-op on Windows, but varlock ships as .zip there which is unsupported).
565
- const chmodProc = Bun.spawn(['chmod', '+x', varlockBin]);
566
- const chmodCode = await chmodProc.exited;
567
- if (chmodCode !== 0) {
568
- throw new Error(`chmod +x failed for varlock binary (exit code ${chmodCode})`);
569
- }
570
-
571
- if (!(await Bun.file(varlockBin).exists())) {
572
- throw new Error(`varlock binary not found at ${varlockBin} after install`);
573
- }
574
-
575
- return varlockBin;
576
- }
5
+ // Re-export public API used by tests and external consumers
6
+ export { detectHostInfo } from './lib/host-info.ts';
7
+ export type { HostInfo } from './lib/host-info.ts';
8
+ export { upsertEnvValue, resolveRequestedImageTag, reconcileStackEnvImageTag } from './lib/env.ts';
9
+ export { bootstrapInstall } from './commands/install.ts';
10
+
11
+ const mainCommand = defineCommand({
12
+ meta: {
13
+ name: 'openpalm',
14
+ version: cliPkg.version,
15
+ description: 'OpenPalm CLI install and manage a self-hosted OpenPalm stack',
16
+ },
17
+ subCommands: {
18
+ install: () => import('./commands/install.ts').then((m) => m.default),
19
+ uninstall: () => import('./commands/uninstall.ts').then((m) => m.default),
20
+ update: () => import('./commands/update.ts').then((m) => m.default),
21
+ start: () => import('./commands/start.ts').then((m) => m.default),
22
+ stop: () => import('./commands/stop.ts').then((m) => m.default),
23
+ restart: () => import('./commands/restart.ts').then((m) => m.default),
24
+ logs: () => import('./commands/logs.ts').then((m) => m.default),
25
+ status: () => import('./commands/status.ts').then((m) => m.default),
26
+ service: () => import('./commands/service.ts').then((m) => m.default),
27
+ validate: () => import('./commands/validate.ts').then((m) => m.default),
28
+ scan: () => import('./commands/scan.ts').then((m) => m.default),
29
+ },
30
+ });
577
31
 
578
32
  /**
579
- * Detects host system information including platform, Docker availability,
580
- * and local AI service endpoints.
33
+ * Programmatic entry point for tests and embedding.
34
+ * Uses runCommand directly (not runMain) to avoid the process.exit(1) wrapper
35
+ * and process.argv manipulation.
581
36
  */
582
- export async function detectHostInfo(): Promise<HostInfo> {
583
- // Docker detection
584
- const dockerAvailable = Boolean(Bun.which('docker'));
585
- let dockerRunning = false;
586
- if (dockerAvailable) {
587
- const proc = Bun.spawn(['docker', 'info'], { stdout: 'ignore', stderr: 'ignore' });
588
- dockerRunning = (await proc.exited) === 0;
589
- }
590
-
591
- // HTTP probes for local AI services
592
- async function probeHttp(url: string): Promise<boolean> {
593
- try {
594
- const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
595
- return res.ok;
596
- } catch {
597
- return false;
598
- }
599
- }
600
-
601
- const [ollamaRunning, lmstudioRunning, llamacppRunning] = await Promise.all([
602
- probeHttp('http://localhost:11434/api/tags'),
603
- probeHttp('http://localhost:1234/v1/models'),
604
- probeHttp('http://localhost:8080/health'),
605
- ]);
606
-
607
- return {
608
- platform: process.platform,
609
- arch: process.arch,
610
- docker: { available: dockerAvailable, running: dockerRunning },
611
- ollama: { running: ollamaRunning, url: 'http://localhost:11434' },
612
- lmstudio: { running: lmstudioRunning, url: 'http://localhost:1234' },
613
- llamacpp: { running: llamacppRunning, url: 'http://localhost:8080' },
614
- timestamp: new Date().toISOString(),
615
- };
616
- }
617
-
618
- export async function bootstrapInstall(options: InstallOptions): Promise<void> {
619
- if (!Bun.which('docker')) {
620
- throw new Error('Docker is not installed. Install Docker first: https://docs.docker.com/get-docker/');
621
- }
622
-
623
- const dockerInfo = Bun.spawn(['docker', 'info'], { stdout: 'ignore', stderr: 'ignore' });
624
- if ((await dockerInfo.exited) !== 0) {
625
- throw new Error('Docker is not running (or current user lacks permission). Start Docker and retry.');
626
- }
627
-
628
- const composeVersion = Bun.spawn(['docker', 'compose', 'version'], { stdout: 'ignore', stderr: 'ignore' });
629
- if ((await composeVersion.exited) !== 0) {
630
- throw new Error('Docker Compose v2 is required. Install it: https://docs.docker.com/compose/install/');
631
- }
632
-
633
- const configHome = defaultConfigHome();
634
- const dataHome = defaultDataHome();
635
- const stateHome = defaultStateHome();
636
- const workDir = defaultWorkDir();
637
-
638
- const secretsPath = join(configHome, 'secrets.env');
639
- const updateMode = await Bun.file(secretsPath).exists();
640
- if (updateMode && !options.force) {
641
- throw new Error('OpenPalm appears to already be installed. Re-run install with --force to continue.');
642
- }
643
-
644
- await ensureDirectoryTree(configHome, dataHome, stateHome, workDir);
645
-
646
- // Detect host system info (non-fatal)
647
- try {
648
- const hostInfo = await detectHostInfo();
649
- await Bun.write(join(dataHome, 'host.json'), JSON.stringify(hostInfo, null, 2) + '\n');
650
- } catch {
651
- // Host detection failure is non-fatal
652
- }
653
-
654
- const composeContent = await fetchAsset(options.version, 'docker-compose.yml');
655
- const caddyContent = await fetchAsset(options.version, 'Caddyfile');
656
- await Bun.write(join(dataHome, 'docker-compose.yml'), composeContent);
657
- await Bun.write(join(dataHome, 'caddy', 'Caddyfile'), caddyContent);
658
- await Bun.write(join(stateHome, 'artifacts', 'docker-compose.yml'), composeContent);
659
- await Bun.write(join(stateHome, 'artifacts', 'Caddyfile'), caddyContent);
660
-
661
- const secretsSchemaContent = await fetchAsset(options.version, 'secrets.env.schema');
662
- const stackSchemaContent = await fetchAsset(options.version, 'stack.env.schema');
663
- await Bun.write(join(stateHome, 'artifacts', 'secrets.env.schema'), secretsSchemaContent);
664
- await Bun.write(join(stateHome, 'artifacts', 'stack.env.schema'), stackSchemaContent);
665
-
666
- await ensureSecrets(configHome);
667
- await ensureStackEnv(configHome, dataHome, stateHome, workDir, options.version);
668
- await ensureOpenCodeConfig(configHome);
669
- await ensureOpenCodeSystemConfig(dataHome);
670
-
671
- // Non-fatal validation
672
- try {
673
- const varlockBin = await ensureVarlock(stateHome);
674
- const schemaPath = join(stateHome, 'artifacts', 'secrets.env.schema');
675
- const envPath = join(configHome, 'secrets.env');
676
- if (await Bun.file(schemaPath).exists()) {
677
- const tmpDir = await prepareVarlockDir(schemaPath, envPath);
678
- try {
679
- const proc = Bun.spawn([varlockBin, 'load', '--path', `${tmpDir}/`], {
680
- stdout: 'ignore',
681
- stderr: 'ignore',
682
- });
683
- const code = await proc.exited;
684
- if (code === 0) {
685
- console.log('Configuration validated.');
686
- } else {
687
- console.warn('Configuration has validation warnings (non-fatal on first install).');
688
- }
689
- } finally {
690
- await rm(tmpDir, { recursive: true, force: true });
691
- }
692
- }
693
- } catch {
694
- // Varlock install/execution failures are non-fatal during install
695
- }
696
-
697
- if (options.noStart) {
698
- console.log('OpenPalm files prepared. Run `openpalm start` to start services.');
699
- return;
700
- }
701
-
702
- await runDockerCompose([
703
- '--project-name',
704
- 'openpalm',
705
- '-f',
706
- join(stateHome, 'artifacts', 'docker-compose.yml'),
707
- '--env-file',
708
- join(configHome, 'secrets.env'),
709
- '--env-file',
710
- join(stateHome, 'artifacts', 'stack.env'),
711
- 'up',
712
- '-d',
713
- 'docker-socket-proxy',
714
- 'admin',
715
- ]);
716
-
717
- await waitForAdminHealthy();
718
- const targetUrl = updateMode ? 'http://localhost:8100/' : 'http://localhost:8100/setup';
719
- if (!options.noOpen) {
720
- await openBrowser(targetUrl);
721
- }
722
-
723
- console.log(JSON.stringify({ ok: true, mode: updateMode ? 'update' : 'install', url: targetUrl }, null, 2));
724
- }
725
-
726
- async function runInstall(args: string[]): Promise<void> {
727
- const options = parseInstallOptions(args);
728
- if (await isStackRunning()) {
729
- console.log(JSON.stringify(await adminRequest('/admin/install', { method: 'POST' }), null, 2));
730
- return;
731
- }
732
-
733
- await bootstrapInstall(options);
734
- }
735
-
736
- async function runComposeLogs(services: string[]): Promise<void> {
737
- const stateHome = defaultStateHome();
738
- const configHome = defaultConfigHome();
739
-
740
- const composeArgs = [
741
- 'compose',
742
- '--project-name',
743
- 'openpalm',
744
- '-f',
745
- join(stateHome, 'artifacts', 'docker-compose.yml'),
746
- '--env-file',
747
- join(configHome, 'secrets.env'),
748
- '--env-file',
749
- join(stateHome, 'artifacts', 'stack.env'),
750
- 'logs',
751
- '--tail',
752
- '100',
753
- ...services,
754
- ];
755
-
756
- const proc = Bun.spawn(['docker', ...composeArgs], { stdout: 'inherit', stderr: 'inherit', stdin: 'inherit' });
757
- const exitCode = await proc.exited;
758
- if (exitCode !== 0) {
759
- throw new Error(`Docker compose logs command failed (exit code ${exitCode})`);
760
- }
761
- }
762
-
763
- async function runServiceAction(action: 'up' | 'down' | 'restart', services: string[]): Promise<void> {
764
- if (services.length === 0) {
765
- if (action === 'up') {
766
- console.log(JSON.stringify(await adminRequest('/admin/install', { method: 'POST' }), null, 2));
767
- return;
768
- }
769
- if (action === 'down') {
770
- console.log(JSON.stringify(await adminRequest('/admin/uninstall', { method: 'POST' }), null, 2));
771
- return;
772
- }
773
- const status = await adminRequest('/admin/containers/list');
774
- const serviceNames = getServiceNames(status);
775
- for (const service of serviceNames) {
776
- const result = await adminRequest('/admin/containers/restart', {
777
- method: 'POST',
778
- body: JSON.stringify({ service }),
779
- });
780
- console.log(JSON.stringify(result, null, 2));
781
- }
782
- return;
783
- }
784
-
785
- const endpoint = action === 'up' ? '/admin/containers/up' : action === 'down' ? '/admin/containers/down' : '/admin/containers/restart';
786
-
787
- for (const service of services) {
788
- const result = await adminRequest(endpoint, {
789
- method: 'POST',
790
- body: JSON.stringify({ service }),
791
- });
792
- console.log(JSON.stringify(result, null, 2));
793
- }
794
- }
795
-
796
- function getServiceNames(status: unknown): string[] {
797
- if (!status || typeof status !== 'object' || !('containers' in status)) {
798
- return [];
799
- }
800
- const containers = (status as { containers?: unknown }).containers;
801
- if (!containers || typeof containers !== 'object') {
802
- return [];
803
- }
804
- return Object.keys(containers as Record<string, unknown>);
805
- }
806
-
807
- async function runServiceCommand(args: string[]): Promise<void> {
808
- const [subcommand, ...services] = args;
809
- if (!subcommand) {
810
- throw new Error('Missing subcommand. Usage: openpalm service <start|stop|restart|logs|update|status> [service...]');
811
- }
812
-
813
- if (subcommand === 'start' || subcommand === 'up') {
814
- await runServiceAction('up', services);
815
- return;
816
- }
817
- if (subcommand === 'stop' || subcommand === 'down') {
818
- await runServiceAction('down', services);
819
- return;
820
- }
821
- if (subcommand === 'restart') {
822
- await runServiceAction('restart', services);
823
- return;
824
- }
825
- if (subcommand === 'logs') {
826
- await runComposeLogs(services);
827
- return;
828
- }
829
- if (subcommand === 'update') {
830
- console.log(JSON.stringify(await adminRequest('/admin/containers/pull', { method: 'POST' }), null, 2));
831
- return;
832
- }
833
- if (subcommand === 'status') {
834
- console.log(JSON.stringify(await adminRequest('/admin/containers/list'), null, 2));
835
- return;
836
- }
837
-
838
- throw new Error(`Unknown subcommand: ${subcommand}`);
839
- }
840
-
841
- async function runScan(args: string[]): Promise<void> {
842
- void args; // reserved for future flags
843
- const stateHome = defaultStateHome();
844
- const configHome = defaultConfigHome();
845
-
846
- const schemaPath = join(stateHome, 'artifacts', 'secrets.env.schema');
847
- const envPath = join(configHome, 'secrets.env');
848
-
849
- if (!(await Bun.file(schemaPath).exists())) {
850
- console.error(
851
- `Error: secrets.env.schema not found at ${schemaPath}.\nRun 'openpalm install' first to stage schema files.`,
852
- );
853
- process.exit(1);
854
- }
855
-
856
- if (!(await Bun.file(envPath).exists())) {
857
- console.error(
858
- `Error: secrets.env not found at ${envPath}.\nRun 'openpalm install' first.`,
859
- );
860
- process.exit(1);
861
- }
862
-
863
- const varlockBin = await ensureVarlock(stateHome);
864
-
865
- // Co-locate schema + env in a temp dir so varlock can discover them.
866
- // varlock scan resolves sensitive values from the config, then scans
867
- // the current working directory for plaintext occurrences.
868
- const tmpDir = await prepareVarlockDir(schemaPath, envPath);
869
- let exitCode = 1;
870
- try {
871
- const proc = Bun.spawn([varlockBin, 'scan', '--path', `${tmpDir}/`], {
872
- stdout: 'inherit',
873
- stderr: 'inherit',
874
- });
875
- exitCode = await proc.exited;
876
- } finally {
877
- await rm(tmpDir, { recursive: true, force: true });
878
- }
879
- process.exit(exitCode);
880
- }
881
-
882
- async function runValidate(args: string[]): Promise<void> {
883
- void args; // reserved for future flags
884
- const stateHome = defaultStateHome();
885
- const configHome = defaultConfigHome();
886
-
887
- const primarySchema = join(stateHome, 'artifacts', 'secrets.env.schema');
888
- const envPath = join(configHome, 'secrets.env');
889
-
890
- if (!(await Bun.file(primarySchema).exists())) {
891
- console.error(
892
- `Error: secrets.env.schema not found at ${primarySchema}.\nRun 'openpalm install' first to stage schema files.`,
893
- );
894
- process.exit(1);
895
- }
896
-
897
- if (!(await Bun.file(envPath).exists())) {
898
- console.error(
899
- `Error: secrets.env not found at ${envPath}.\nRun 'openpalm install' first.`,
900
- );
901
- process.exit(1);
902
- }
903
-
904
- const varlockBin = await ensureVarlock(stateHome);
905
- const tmpDir = await prepareVarlockDir(primarySchema, envPath);
906
- let exitCode = 1;
907
- try {
908
- const proc = Bun.spawn(
909
- [varlockBin, 'load', '--path', `${tmpDir}/`],
910
- { stdout: 'inherit', stderr: 'inherit' },
911
- );
912
- exitCode = await proc.exited;
913
- } finally {
914
- await rm(tmpDir, { recursive: true, force: true });
915
- }
916
- process.exit(exitCode);
917
- }
918
-
919
37
  export async function main(argv = process.argv.slice(2)): Promise<void> {
920
- const [rawCommand, ...args] = argv;
921
-
922
- if (!rawCommand || rawCommand === 'help' || rawCommand === '--help' || rawCommand === '-h') {
923
- printUsage();
924
- return;
925
- }
926
- if (!COMMANDS.includes(rawCommand as Command)) {
927
- throw new Error(`Unknown command: ${rawCommand}`);
928
- }
929
-
930
- const command = rawCommand;
931
-
932
- if (command === 'install') {
933
- await runInstall(args);
934
- return;
935
- }
936
-
937
- if (command === 'uninstall') {
938
- console.log(JSON.stringify(await adminRequest('/admin/uninstall', { method: 'POST' }), null, 2));
939
- return;
940
- }
941
-
942
- if (command === 'update') {
943
- console.log(JSON.stringify(await adminRequest('/admin/containers/pull', { method: 'POST' }), null, 2));
944
- return;
945
- }
946
-
947
- if (command === 'start') {
948
- await runServiceAction('up', args);
949
- return;
950
- }
951
-
952
- if (command === 'stop') {
953
- await runServiceAction('down', args);
954
- return;
955
- }
956
-
957
- if (command === 'restart') {
958
- await runServiceAction('restart', args);
959
- return;
960
- }
961
-
962
- if (command === 'logs') {
963
- await runComposeLogs(args);
964
- return;
965
- }
966
-
967
- if (command === 'status') {
968
- console.log(JSON.stringify(await adminRequest('/admin/containers/list'), null, 2));
969
- return;
970
- }
971
-
972
- if (command === 'service') {
973
- await runServiceCommand(args);
974
- return;
975
- }
976
-
977
- if (command === 'validate') {
978
- await runValidate(args);
979
- return;
980
- }
981
-
982
- if (command === 'scan') {
983
- await runScan(args);
984
- return;
985
- }
986
-
987
- throw new Error(`Unknown command: ${command}`);
38
+ await runCommand(mainCommand, { rawArgs: argv });
988
39
  }
989
40
 
990
41
  if (import.meta.main) {
991
- main().catch((err) => {
992
- console.error(err instanceof Error ? err.message : String(err));
993
- process.exit(1);
994
- });
42
+ // Let citty handle process.argv natively
43
+ runMain(mainCommand);
995
44
  }