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