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.
@@ -0,0 +1,180 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { defaultConfigHome, defaultStateHome } from './paths.ts';
4
+
5
+ const REPO_OWNER = 'itlackey';
6
+ const REPO_NAME = 'openpalm';
7
+
8
+ /**
9
+ * Creates the full XDG directory tree required by the stack.
10
+ */
11
+ export async function ensureDirectoryTree(
12
+ configHome: string,
13
+ dataHome: string,
14
+ stateHome: string,
15
+ workDir: string,
16
+ ): Promise<void> {
17
+ const dirs = [
18
+ configHome,
19
+ join(configHome, 'channels'),
20
+ join(configHome, 'assistant'),
21
+ join(configHome, 'automations'),
22
+ dataHome,
23
+ join(dataHome, 'admin'),
24
+ join(dataHome, 'memory'),
25
+ join(dataHome, 'assistant'),
26
+ join(dataHome, 'guardian'),
27
+ join(dataHome, 'caddy'),
28
+ join(dataHome, 'caddy', 'data'),
29
+ join(dataHome, 'caddy', 'config'),
30
+ join(dataHome, 'automations'),
31
+ join(dataHome, 'opencode'),
32
+ stateHome,
33
+ join(stateHome, 'artifacts'),
34
+ join(stateHome, 'audit'),
35
+ join(stateHome, 'artifacts', 'channels'),
36
+ join(stateHome, 'automations'),
37
+ join(stateHome, 'opencode'),
38
+ join(stateHome, 'bin'),
39
+ workDir,
40
+ ];
41
+
42
+ for (const dir of dirs) {
43
+ await mkdir(dir, { recursive: true });
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Downloads an asset from a GitHub release, falling back to raw.githubusercontent.com.
49
+ */
50
+ export async function fetchAsset(repoRef: string, filename: string): Promise<string> {
51
+ const releaseUrl = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/${repoRef}/${filename}`;
52
+ const rawUrl = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${repoRef}/assets/${filename}`;
53
+
54
+ const releaseResponse = await fetch(releaseUrl, { signal: AbortSignal.timeout(30000) });
55
+ if (releaseResponse.ok) {
56
+ return await releaseResponse.text();
57
+ }
58
+
59
+ const rawResponse = await fetch(rawUrl, { signal: AbortSignal.timeout(30000) });
60
+ if (rawResponse.ok) {
61
+ return await rawResponse.text();
62
+ }
63
+
64
+ throw new Error(`Failed to download ${filename} from ${repoRef}`);
65
+ }
66
+
67
+ /**
68
+ * Runs a `docker compose` command with inherited stdio. Throws on non-zero exit.
69
+ */
70
+ export async function runDockerCompose(args: string[]): Promise<void> {
71
+ const proc = Bun.spawn(['docker', 'compose', ...args], {
72
+ stdout: 'inherit',
73
+ stderr: 'inherit',
74
+ stdin: 'inherit',
75
+ });
76
+ const code = await proc.exited;
77
+ if (code !== 0) {
78
+ throw new Error(`docker compose ${args.join(' ')} failed with exit code ${code}`);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Returns the standard compose flags for --project-name, -f, and --env-file.
84
+ */
85
+ export function composeProjectArgs(): string[] {
86
+ const stateHome = defaultStateHome();
87
+ const configHome = defaultConfigHome();
88
+ return [
89
+ '--project-name',
90
+ 'openpalm',
91
+ '-f',
92
+ join(stateHome, 'artifacts', 'docker-compose.yml'),
93
+ '--env-file',
94
+ join(configHome, 'secrets.env'),
95
+ '--env-file',
96
+ join(stateHome, 'artifacts', 'stack.env'),
97
+ ];
98
+ }
99
+
100
+ /**
101
+ * Ensures the opencode config and system config directories exist with defaults.
102
+ */
103
+ export async function ensureOpenCodeConfig(configHome: string): Promise<void> {
104
+ const opencodeDir = join(configHome, 'assistant');
105
+ const configFile = join(opencodeDir, 'opencode.json');
106
+ if (!(await Bun.file(configFile).exists())) {
107
+ await Bun.write(configFile, '{\n "$schema": "https://opencode.ai/config.json"\n}\n');
108
+ }
109
+ await mkdir(join(opencodeDir, 'tools'), { recursive: true });
110
+ await mkdir(join(opencodeDir, 'plugins'), { recursive: true });
111
+ await mkdir(join(opencodeDir, 'skills'), { recursive: true });
112
+ }
113
+
114
+ async function writeIfChanged(path: string, content: string): Promise<void> {
115
+ const file = Bun.file(path);
116
+ if (await file.exists()) {
117
+ const existing = await file.text();
118
+ if (existing === content) {
119
+ return;
120
+ }
121
+ }
122
+ await Bun.write(path, content);
123
+ }
124
+
125
+ export async function ensureOpenCodeSystemConfig(dataHome: string): Promise<void> {
126
+ const opencodeSystemDir = join(dataHome, 'assistant');
127
+ await mkdir(opencodeSystemDir, { recursive: true });
128
+
129
+ const systemConfig = join(opencodeSystemDir, 'opencode.jsonc');
130
+ const systemConfigContent =
131
+ JSON.stringify(
132
+ {
133
+ "$schema": "https://opencode.ai/config.json",
134
+ "plugin": ["@openpalm/assistant-tools", "akm-opencode"],
135
+ "permission": {
136
+ "read": {
137
+ "/home/opencode/.local/share/opencode/auth.json": "deny",
138
+ "/home/opencode/.local/share/opencode/mcp-auth.json": "deny"
139
+ }
140
+ }
141
+ },
142
+ null,
143
+ 2,
144
+ ) + "\n";
145
+ await writeIfChanged(systemConfig, systemConfigContent);
146
+
147
+ const agentsFile = join(opencodeSystemDir, 'AGENTS.md');
148
+ // import.meta.dir = packages/cli/src/lib/ → need 4 levels up to reach repo root
149
+ const assetsAgentsPath = join(import.meta.dir, '..', '..', '..', '..', 'assets', 'AGENTS.md');
150
+ let agentsContent: string;
151
+ if (await Bun.file(assetsAgentsPath).exists()) {
152
+ agentsContent = await Bun.file(assetsAgentsPath).text();
153
+ } else {
154
+ agentsContent =
155
+ '# OpenPalm Assistant\n\n' +
156
+ 'This file defines the assistant persona.\n' +
157
+ 'It is seeded by the CLI on first install and managed by the admin on subsequent updates.\n';
158
+ }
159
+ await writeIfChanged(agentsFile, agentsContent);
160
+ }
161
+
162
+ /**
163
+ * Opens a URL in the user's default browser. Best-effort, never throws.
164
+ */
165
+ export async function openBrowser(url: string): Promise<void> {
166
+ const platform = process.platform;
167
+ try {
168
+ if (platform === 'darwin') {
169
+ Bun.spawn(['open', url], { stdout: 'ignore', stderr: 'ignore' });
170
+ return;
171
+ }
172
+ if (platform === 'win32') {
173
+ Bun.spawn(['cmd', '/c', 'start', url], { stdout: 'ignore', stderr: 'ignore' });
174
+ return;
175
+ }
176
+ Bun.spawn(['xdg-open', url], { stdout: 'ignore', stderr: 'ignore' });
177
+ } catch {
178
+ // Best effort
179
+ }
180
+ }
package/src/lib/env.ts ADDED
@@ -0,0 +1,196 @@
1
+ import { basename, dirname, join } from 'node:path';
2
+ import { defaultConfigHome, defaultDockerSock } from './paths.ts';
3
+
4
+ const EXPORT_ENV_PREFIX = 'export ';
5
+
6
+ /**
7
+ * Loads the admin token from environment variables or secrets.env file.
8
+ * Checks OPENPALM_ADMIN_TOKEN first, then ADMIN_TOKEN (legacy), then reads from
9
+ * CONFIG_HOME/secrets.env. Returns empty string if no token is found.
10
+ */
11
+ export async function loadAdminToken(): Promise<string> {
12
+ if (process.env.OPENPALM_ADMIN_TOKEN) {
13
+ return process.env.OPENPALM_ADMIN_TOKEN;
14
+ }
15
+
16
+ if (process.env.ADMIN_TOKEN) {
17
+ return process.env.ADMIN_TOKEN;
18
+ }
19
+
20
+ const configHome = defaultConfigHome();
21
+ const secretsPaths = [join(configHome, 'secrets.env')];
22
+ if (basename(configHome) === 'openpalm') {
23
+ secretsPaths.push(join(dirname(configHome), 'secrets.env'));
24
+ }
25
+
26
+ for (const secretsPath of secretsPaths) {
27
+ const token = await readTokenFromFile(secretsPath, 'OPENPALM_ADMIN_TOKEN');
28
+ if (token) return token;
29
+ const legacyToken = await readTokenFromFile(secretsPath, 'ADMIN_TOKEN');
30
+ if (legacyToken) return legacyToken;
31
+ }
32
+
33
+ return '';
34
+ }
35
+
36
+ /**
37
+ * Reads a specific key from an env file. Handles `export` prefix and quoted values.
38
+ */
39
+ async function readTokenFromFile(secretsPath: string, key: string): Promise<string | null> {
40
+ try {
41
+ const text = await Bun.file(secretsPath).text();
42
+ for (const rawLine of text.split('\n')) {
43
+ const line = rawLine.trim();
44
+ if (!line || line.startsWith('#')) continue;
45
+ const lineWithoutExportPrefix = line.startsWith(EXPORT_ENV_PREFIX)
46
+ ? line.slice(EXPORT_ENV_PREFIX.length).trimStart()
47
+ : line;
48
+ const [lineKey, ...rest] = lineWithoutExportPrefix.split('=');
49
+ if (lineKey !== key) continue;
50
+ const value = unwrapQuotedEnvValue(rest.join('=').trim());
51
+ if (!value) return null;
52
+ return value;
53
+ }
54
+ } catch {
55
+ // Best effort only.
56
+ }
57
+
58
+ return null;
59
+ }
60
+
61
+ export function unwrapQuotedEnvValue(value: string): string {
62
+ const isDoubleQuoted = value.startsWith('"') && value.endsWith('"');
63
+ const isSingleQuoted = value.startsWith('\'') && value.endsWith('\'');
64
+ if ((isDoubleQuoted || isSingleQuoted) && value.length >= 2) {
65
+ return value.slice(1, -1);
66
+ }
67
+
68
+ return value;
69
+ }
70
+
71
+ /**
72
+ * Upserts a key=value pair in env file content. If the key exists, replaces the line;
73
+ * otherwise appends a new line.
74
+ */
75
+ export function upsertEnvValue(content: string, key: string, value: string): string {
76
+ const escapedKey = key.replace(/[|\\{}()[\]^$+*?.-]/g, '\\$&');
77
+ const pattern = new RegExp(`^((?:export\\s+)?)${escapedKey}=.*$`, 'm');
78
+ if (pattern.test(content)) {
79
+ // Preserve the `export ` prefix if the original line had one
80
+ return content.replace(pattern, `$1${key}=${value}`);
81
+ }
82
+
83
+ const line = `${key}=${value}`;
84
+ const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n';
85
+ return `${content}${suffix}${line}\n`;
86
+ }
87
+
88
+ export const RELEASE_TAG_REGEX = /^v?\d+\.\d+\.\d+(?:[-+](?:[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*))?$/;
89
+
90
+ /**
91
+ * Normalizes a repository ref to an image tag. Returns null for non-release refs.
92
+ * E.g. "0.9.0" → "v0.9.0", "v0.9.0" → "v0.9.0", "main" → null.
93
+ */
94
+ export function resolveRequestedImageTag(repoRef: string): string | null {
95
+ const trimmed = repoRef.trim();
96
+ if (!trimmed || trimmed === 'main') return null;
97
+ if (!RELEASE_TAG_REGEX.test(trimmed)) return null;
98
+ return trimmed.startsWith('v') ? trimmed : `v${trimmed}`;
99
+ }
100
+
101
+ /**
102
+ * Reconciles the OPENPALM_IMAGE_TAG value in stack.env content.
103
+ */
104
+ export function reconcileStackEnvImageTag(
105
+ content: string,
106
+ repoRef: string,
107
+ explicitImageTag?: string,
108
+ ): string {
109
+ const desiredImageTag = explicitImageTag || resolveRequestedImageTag(repoRef);
110
+ if (!desiredImageTag) return content;
111
+ return upsertEnvValue(content, 'OPENPALM_IMAGE_TAG', desiredImageTag);
112
+ }
113
+
114
+ /**
115
+ * Seeds secrets.env with initial template.
116
+ * Uses `export` prefix so the file can be sourced in a shell and is still
117
+ * compatible with Docker Compose v2 `env_file`.
118
+ * Uses OPENPALM_ADMIN_TOKEN as the canonical variable name with a
119
+ * commented-out legacy ADMIN_TOKEN alias for backward compatibility.
120
+ */
121
+ export async function ensureSecrets(configHome: string): Promise<void> {
122
+ const secretsPath = join(configHome, 'secrets.env');
123
+ if (await Bun.file(secretsPath).exists()) {
124
+ return;
125
+ }
126
+
127
+ const userId = process.env.USER || process.env.LOGNAME || process.env.USERNAME || 'default_user';
128
+ const content = `# OpenPalm Secrets — generated by openpalm install
129
+ # All values are configured via the setup wizard.
130
+ # This file is compatible with both \`source secrets.env\` and Docker Compose env_file.
131
+
132
+ export OPENPALM_ADMIN_TOKEN=
133
+ # Legacy alias — only needed if your compose file still references ADMIN_TOKEN:
134
+ # export ADMIN_TOKEN=
135
+
136
+ # LLM provider keys (configure at least one via the setup wizard)
137
+ export OPENAI_API_KEY=
138
+ export OPENAI_BASE_URL=
139
+ # export ANTHROPIC_API_KEY=
140
+ # export GROQ_API_KEY=
141
+ # export MISTRAL_API_KEY=
142
+ # export GOOGLE_API_KEY=
143
+
144
+ # Memory
145
+ export MEMORY_USER_ID=${userId}
146
+ `;
147
+
148
+ await Bun.write(secretsPath, content);
149
+ }
150
+
151
+ /**
152
+ * Creates or updates the stack.env bootstrap file.
153
+ */
154
+ export async function ensureStackEnv(
155
+ configHome: string,
156
+ dataHome: string,
157
+ stateHome: string,
158
+ workDir: string,
159
+ repoRef: string,
160
+ ): Promise<void> {
161
+ const dataStackEnv = join(dataHome, 'stack.env');
162
+ const stagedStackEnv = join(stateHome, 'artifacts', 'stack.env');
163
+ const explicitImageTag = process.env.OPENPALM_IMAGE_TAG;
164
+ const hasExplicitImageTag = explicitImageTag !== undefined && explicitImageTag !== '';
165
+ if (!(await Bun.file(dataStackEnv).exists())) {
166
+ const defaultImageTag = hasExplicitImageTag
167
+ ? explicitImageTag
168
+ : (resolveRequestedImageTag(repoRef) || 'latest');
169
+ const content = `# OpenPalm Stack Bootstrap — system-managed, do not edit
170
+ OPENPALM_CONFIG_HOME=${configHome}
171
+ OPENPALM_DATA_HOME=${dataHome}
172
+ OPENPALM_STATE_HOME=${stateHome}
173
+ OPENPALM_WORK_DIR=${workDir}
174
+ OPENPALM_UID=${process.getuid?.() ?? 1000}
175
+ OPENPALM_GID=${process.getgid?.() ?? 1000}
176
+ OPENPALM_DOCKER_SOCK=${defaultDockerSock()}
177
+ OPENPALM_IMAGE_NAMESPACE=${process.env.OPENPALM_IMAGE_NAMESPACE || 'openpalm'}
178
+ OPENPALM_IMAGE_TAG=${defaultImageTag}
179
+ `;
180
+ await Bun.write(dataStackEnv, content);
181
+ } else {
182
+ const current = await Bun.file(dataStackEnv).text();
183
+ const reconciled = reconcileStackEnvImageTag(
184
+ current,
185
+ repoRef,
186
+ hasExplicitImageTag ? explicitImageTag : undefined,
187
+ );
188
+ if (reconciled !== current) {
189
+ await Bun.write(dataStackEnv, reconciled);
190
+ }
191
+ }
192
+ await Bun.write(stagedStackEnv, Bun.file(dataStackEnv));
193
+
194
+ const stateSecrets = join(stateHome, 'artifacts', 'secrets.env');
195
+ await Bun.write(stateSecrets, Bun.file(join(configHome, 'secrets.env')));
196
+ }
@@ -0,0 +1,47 @@
1
+ export interface HostInfo {
2
+ platform: string;
3
+ arch: string;
4
+ docker: { available: boolean; running: boolean };
5
+ ollama: { running: boolean; url: string };
6
+ lmstudio: { running: boolean; url: string };
7
+ llamacpp: { running: boolean; url: string };
8
+ timestamp: string;
9
+ }
10
+
11
+ /**
12
+ * Detects host system information including platform, Docker availability,
13
+ * and local AI service endpoints.
14
+ */
15
+ export async function detectHostInfo(): Promise<HostInfo> {
16
+ const dockerAvailable = Boolean(Bun.which('docker'));
17
+ let dockerRunning = false;
18
+ if (dockerAvailable) {
19
+ const proc = Bun.spawn(['docker', 'info'], { stdout: 'ignore', stderr: 'ignore' });
20
+ dockerRunning = (await proc.exited) === 0;
21
+ }
22
+
23
+ async function probeHttp(url: string): Promise<boolean> {
24
+ try {
25
+ const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
26
+ return res.ok;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ const [ollamaRunning, lmstudioRunning, llamacppRunning] = await Promise.all([
33
+ probeHttp('http://localhost:11434/api/tags'),
34
+ probeHttp('http://localhost:1234/v1/models'),
35
+ probeHttp('http://localhost:8080/health'),
36
+ ]);
37
+
38
+ return {
39
+ platform: process.platform,
40
+ arch: process.arch,
41
+ docker: { available: dockerAvailable, running: dockerRunning },
42
+ ollama: { running: ollamaRunning, url: 'http://localhost:11434' },
43
+ lmstudio: { running: lmstudioRunning, url: 'http://localhost:1234' },
44
+ llamacpp: { running: llamacppRunning, url: 'http://localhost:8080' },
45
+ timestamp: new Date().toISOString(),
46
+ };
47
+ }
@@ -0,0 +1,37 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ export const IS_WINDOWS = process.platform === 'win32';
5
+
6
+ export function defaultConfigHome(): string {
7
+ if (process.env.OPENPALM_CONFIG_HOME) return process.env.OPENPALM_CONFIG_HOME;
8
+ if (IS_WINDOWS) {
9
+ return join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'openpalm');
10
+ }
11
+ return join(homedir(), '.config', 'openpalm');
12
+ }
13
+
14
+ export function defaultDataHome(): string {
15
+ if (process.env.OPENPALM_DATA_HOME) return process.env.OPENPALM_DATA_HOME;
16
+ if (IS_WINDOWS) {
17
+ return join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'openpalm', 'data');
18
+ }
19
+ return join(homedir(), '.local', 'share', 'openpalm');
20
+ }
21
+
22
+ export function defaultStateHome(): string {
23
+ if (process.env.OPENPALM_STATE_HOME) return process.env.OPENPALM_STATE_HOME;
24
+ if (IS_WINDOWS) {
25
+ return join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'openpalm', 'state');
26
+ }
27
+ return join(homedir(), '.local', 'state', 'openpalm');
28
+ }
29
+
30
+ export function defaultDockerSock(): string {
31
+ if (process.env.OPENPALM_DOCKER_SOCK) return process.env.OPENPALM_DOCKER_SOCK;
32
+ return IS_WINDOWS ? '//./pipe/docker_engine' : '/var/run/docker.sock';
33
+ }
34
+
35
+ export function defaultWorkDir(): string {
36
+ return process.env.OPENPALM_WORK_DIR || join(homedir(), 'openpalm');
37
+ }
@@ -0,0 +1,124 @@
1
+ import { copyFile, mkdir, mkdtemp, unlink } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ const VARLOCK_VERSION = '0.4.0';
6
+
7
+ const VARLOCK_CHECKSUMS: Record<string, string> = {
8
+ 'varlock-linux-x64.tar.gz': '820295b271cece2679b2b9701b5285ce39354fc2f35797365fa36c70125f51ab',
9
+ 'varlock-linux-arm64.tar.gz': 'e830baaa901b6389ecf281bdd2449bfaf7586e91fd3a7a038ec06f78e6fa92f8',
10
+ 'varlock-macos-x64.tar.gz': 'e6abf0d97da8ff7c98b0e9044a8b71f48fbf74a0d7bfc2543a81575a07b7a03b',
11
+ 'varlock-macos-arm64.tar.gz': '228e4c2666b9fa50a83a8713a848e7a0f0044d7fd7c9d441d43e6ebccad2f4a3',
12
+ };
13
+
14
+ function varlockArtifactName(): string {
15
+ const platformMap: Record<string, string> = {
16
+ linux: 'linux',
17
+ darwin: 'macos',
18
+ };
19
+ const archMap: Record<string, string> = {
20
+ x64: 'x64',
21
+ arm64: 'arm64',
22
+ };
23
+
24
+ const os = platformMap[process.platform];
25
+ const arch = archMap[process.arch];
26
+
27
+ if (!os || !arch) {
28
+ throw new Error(
29
+ `Unsupported platform/arch for varlock: ${process.platform}/${process.arch}. ` +
30
+ `Supported: linux/x64, linux/arm64, darwin/x64, darwin/arm64.`,
31
+ );
32
+ }
33
+
34
+ return `varlock-${os}-${arch}.tar.gz`;
35
+ }
36
+
37
+ /**
38
+ * Co-locate a schema and env file in a temp directory so varlock can discover them.
39
+ */
40
+ export async function prepareVarlockDir(schemaPath: string, envPath: string): Promise<string> {
41
+ const dir = await mkdtemp(join(tmpdir(), 'varlock-'));
42
+ await copyFile(schemaPath, join(dir, '.env.schema'));
43
+ await copyFile(envPath, join(dir, '.env'));
44
+ return dir;
45
+ }
46
+
47
+ /**
48
+ * Downloads varlock binary and caches it in STATE_HOME/bin/.
49
+ * Skips download if binary already exists.
50
+ */
51
+ export async function ensureVarlock(stateHome: string): Promise<string> {
52
+ const binDir = join(stateHome, 'bin');
53
+ const varlockBin = join(binDir, 'varlock');
54
+
55
+ if (await Bun.file(varlockBin).exists()) {
56
+ return varlockBin;
57
+ }
58
+
59
+ await mkdir(binDir, { recursive: true });
60
+
61
+ const artifact = varlockArtifactName();
62
+ const expectedHash = VARLOCK_CHECKSUMS[artifact];
63
+ if (!expectedHash) {
64
+ throw new Error(
65
+ `No SHA-256 checksum on record for ${artifact}. ` +
66
+ `Cannot verify download integrity.`,
67
+ );
68
+ }
69
+
70
+ const tarballUrl = `https://github.com/dmno-dev/varlock/releases/download/varlock%40${VARLOCK_VERSION}/${artifact}`;
71
+ const tarballPath = join(binDir, 'varlock.tar.gz');
72
+
73
+ const downloadProc = Bun.spawn(
74
+ ['curl', '-fsSL', '--retry', '5', '--retry-delay', '10', '--retry-all-errors', tarballUrl, '-o', tarballPath],
75
+ {
76
+ env: { ...process.env, HOME: process.env.HOME ?? '' },
77
+ stdout: 'inherit',
78
+ stderr: 'inherit',
79
+ },
80
+ );
81
+ const downloadCode = await downloadProc.exited;
82
+ if (downloadCode !== 0) {
83
+ throw new Error(`Failed to download varlock tarball (curl exited with code ${downloadCode})`);
84
+ }
85
+
86
+ const hasher = new Bun.CryptoHasher('sha256');
87
+ hasher.update(await Bun.file(tarballPath).arrayBuffer());
88
+ const actualHash = hasher.digest('hex');
89
+ if (actualHash !== expectedHash) {
90
+ try { await unlink(tarballPath); } catch { /* best effort */ }
91
+ throw new Error(
92
+ `varlock tarball SHA-256 verification failed — download may be corrupted.\n` +
93
+ ` Expected: ${expectedHash}\n` +
94
+ ` Actual: ${actualHash}`,
95
+ );
96
+ }
97
+
98
+ const extractProc = Bun.spawn(
99
+ ['tar', 'xzf', tarballPath, '--strip-components=1', '-C', binDir],
100
+ {
101
+ env: { ...process.env, HOME: process.env.HOME ?? '' },
102
+ stdout: 'inherit',
103
+ stderr: 'inherit',
104
+ },
105
+ );
106
+ const extractCode = await extractProc.exited;
107
+ if (extractCode !== 0) {
108
+ throw new Error(`Failed to extract varlock tarball (tar exited with code ${extractCode})`);
109
+ }
110
+
111
+ try { await unlink(tarballPath); } catch { /* best effort */ }
112
+
113
+ const chmodProc = Bun.spawn(['chmod', '+x', varlockBin]);
114
+ const chmodCode = await chmodProc.exited;
115
+ if (chmodCode !== 0) {
116
+ throw new Error(`chmod +x failed for varlock binary (exit code ${chmodCode})`);
117
+ }
118
+
119
+ if (!(await Bun.file(varlockBin).exists())) {
120
+ throw new Error(`varlock binary not found at ${varlockBin} after install`);
121
+ }
122
+
123
+ return varlockBin;
124
+ }