openpalm 0.10.2 → 0.11.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -19
- package/package.json +4 -2
- package/src/commands/addon.ts +5 -4
- package/src/commands/automations.ts +63 -0
- package/src/commands/install.ts +98 -280
- package/src/commands/logs.ts +1 -1
- package/src/commands/restart.ts +5 -4
- package/src/commands/rollback.ts +4 -3
- package/src/commands/scan.ts +66 -32
- package/src/commands/service.ts +5 -4
- package/src/commands/start.ts +5 -4
- package/src/commands/status.ts +1 -1
- package/src/commands/stop.ts +2 -4
- package/src/commands/uninstall.ts +3 -5
- package/src/commands/update.ts +19 -2
- package/src/commands/validate.ts +16 -34
- package/src/install-flow.test.ts +153 -154
- package/src/lib/admin-skills/index.test.ts +70 -0
- package/src/lib/admin-skills/index.ts +113 -0
- package/src/lib/browser.ts +20 -0
- package/src/lib/cli-compose.ts +2 -20
- package/src/lib/cli-state.ts +1 -1
- package/src/lib/docker.ts +8 -214
- package/src/lib/env.ts +12 -83
- package/src/lib/io.ts +130 -0
- package/src/lib/opencode-subprocess.ts +14 -6
- package/src/lib/paths.ts +2 -2
- package/src/lib/ui-server.ts +150 -0
- package/src/main.test.ts +76 -173
- package/src/main.ts +131 -7
- package/e2e/start-wizard-server.ts +0 -59
- package/src/commands/admin.ts +0 -43
- package/src/commands/install-services.test.ts +0 -13
- package/src/commands/install-services.ts +0 -9
- package/src/commands/upgrade.ts +0 -12
- package/src/lib/embedded-assets.ts +0 -115
- package/src/lib/varlock.ts +0 -126
- package/src/setup-wizard/index.html +0 -321
- package/src/setup-wizard/server-errors.test.ts +0 -418
- package/src/setup-wizard/server-integration.test.ts +0 -511
- package/src/setup-wizard/server.test.ts +0 -508
- package/src/setup-wizard/server.ts +0 -342
- package/src/setup-wizard/wizard-renderers.js +0 -1294
- package/src/setup-wizard/wizard-state.js +0 -346
- package/src/setup-wizard/wizard-validators.js +0 -81
- package/src/setup-wizard/wizard.css +0 -1611
- package/src/setup-wizard/wizard.js +0 -613
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opens a URL in the user's default browser. Best-effort, never throws.
|
|
3
|
+
*/
|
|
4
|
+
export async function openBrowser(url: string): Promise<void> {
|
|
5
|
+
console.log(`Opening ${url} in your browser...`);
|
|
6
|
+
const platform = process.platform;
|
|
7
|
+
try {
|
|
8
|
+
if (platform === 'darwin') {
|
|
9
|
+
Bun.spawn(['open', url], { stdout: 'ignore', stderr: 'ignore' });
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
if (platform === 'win32') {
|
|
13
|
+
Bun.spawn(['cmd', '/c', 'start', url], { stdout: 'ignore', stderr: 'ignore' });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
Bun.spawn(['xdg-open', url], { stdout: 'ignore', stderr: 'ignore' });
|
|
17
|
+
} catch {
|
|
18
|
+
// Best effort
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/lib/cli-compose.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* CLI argument construction, and preflight checks.
|
|
6
6
|
*/
|
|
7
7
|
import {
|
|
8
|
-
buildManagedServices,
|
|
9
8
|
buildComposeCliArgs,
|
|
10
9
|
buildComposeOptions,
|
|
11
10
|
composePreflight,
|
|
@@ -14,23 +13,6 @@ import {
|
|
|
14
13
|
import type { ControlPlaneState } from '@openpalm/lib';
|
|
15
14
|
import { runDockerCompose } from './docker.ts';
|
|
16
15
|
|
|
17
|
-
/**
|
|
18
|
-
* Build the full list of docker compose CLI arguments for a given state.
|
|
19
|
-
*
|
|
20
|
-
* Returns: ['--project-name', 'openpalm', '-f', '...', '--env-file', '...']
|
|
21
|
-
*/
|
|
22
|
-
export function fullComposeArgs(state: ControlPlaneState): string[] {
|
|
23
|
-
return buildComposeCliArgs(state);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Build the list of managed service names (used for targeted `up` commands).
|
|
28
|
-
* Uses compose-derived discovery when Docker is available.
|
|
29
|
-
*/
|
|
30
|
-
export async function buildManagedServiceNames(state: ControlPlaneState): Promise<string[]> {
|
|
31
|
-
return buildManagedServices(state);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
16
|
/**
|
|
35
17
|
* Run a compose command that does NOT mutate state (e.g. logs, ps, status).
|
|
36
18
|
* Skips preflight validation since these commands are read-only.
|
|
@@ -39,7 +21,7 @@ export async function runComposeReadOnly(
|
|
|
39
21
|
state: ControlPlaneState,
|
|
40
22
|
composeSubArgs: string[],
|
|
41
23
|
): Promise<void> {
|
|
42
|
-
const composeArgs =
|
|
24
|
+
const composeArgs = buildComposeCliArgs(state);
|
|
43
25
|
await runDockerCompose([...composeArgs, ...composeSubArgs]);
|
|
44
26
|
}
|
|
45
27
|
|
|
@@ -73,6 +55,6 @@ export async function runComposeWithPreflight(
|
|
|
73
55
|
}
|
|
74
56
|
}
|
|
75
57
|
|
|
76
|
-
const composeArgs =
|
|
58
|
+
const composeArgs = buildComposeCliArgs(state);
|
|
77
59
|
await runDockerCompose([...composeArgs, ...composeSubArgs]);
|
|
78
60
|
}
|
package/src/lib/cli-state.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { ControlPlaneState } from '@openpalm/lib';
|
|
|
17
17
|
* Does NOT persist to disk — persistence happens inside runComposeWithPreflight()
|
|
18
18
|
* after compose preflight validation, ensuring no mutation before validation.
|
|
19
19
|
*
|
|
20
|
-
* Returns a ControlPlaneState usable with
|
|
20
|
+
* Returns a ControlPlaneState usable with buildComposeCliArgs().
|
|
21
21
|
*/
|
|
22
22
|
export function ensureValidState(): ControlPlaneState {
|
|
23
23
|
const state = createState();
|
package/src/lib/docker.ts
CHANGED
|
@@ -1,94 +1,13 @@
|
|
|
1
|
-
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { join, dirname, relative } from 'node:path';
|
|
3
|
-
import { resolveCacheHome } from '@openpalm/lib';
|
|
4
|
-
|
|
5
|
-
const REPO_OWNER = 'itlackey';
|
|
6
|
-
const REPO_NAME = 'openpalm';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Creates the full directory tree required by the stack.
|
|
10
|
-
* Uses the caller-provided directory roots, then adds CLI-specific extras.
|
|
11
|
-
*/
|
|
12
|
-
export async function ensureDirectoryTree(
|
|
13
|
-
homeDir: string,
|
|
14
|
-
configDir: string,
|
|
15
|
-
vaultDir: string,
|
|
16
|
-
dataDir: string,
|
|
17
|
-
workDir: string,
|
|
18
|
-
): Promise<void> {
|
|
19
|
-
const cacheDir = resolveCacheHome();
|
|
20
|
-
|
|
21
|
-
for (const dir of [
|
|
22
|
-
homeDir,
|
|
23
|
-
configDir,
|
|
24
|
-
join(configDir, 'automations'),
|
|
25
|
-
join(configDir, 'assistant'),
|
|
26
|
-
join(configDir, 'assistant', 'tools'),
|
|
27
|
-
join(configDir, 'assistant', 'plugins'),
|
|
28
|
-
join(configDir, 'assistant', 'skills'),
|
|
29
|
-
join(configDir, 'guardian'),
|
|
30
|
-
vaultDir,
|
|
31
|
-
join(vaultDir, 'user'),
|
|
32
|
-
join(vaultDir, 'stack'),
|
|
33
|
-
join(vaultDir, 'stack', 'addons'),
|
|
34
|
-
dataDir,
|
|
35
|
-
join(dataDir, 'assistant'),
|
|
36
|
-
join(dataDir, 'admin'),
|
|
37
|
-
join(dataDir, 'memory'),
|
|
38
|
-
join(dataDir, 'guardian'),
|
|
39
|
-
join(dataDir, 'stash'),
|
|
40
|
-
join(homeDir, 'stack'),
|
|
41
|
-
join(homeDir, 'stack', 'addons'),
|
|
42
|
-
join(homeDir, 'registry'),
|
|
43
|
-
join(homeDir, 'registry', 'addons'),
|
|
44
|
-
join(homeDir, 'registry', 'automations'),
|
|
45
|
-
join(homeDir, 'backups'),
|
|
46
|
-
join(homeDir, 'logs'),
|
|
47
|
-
join(homeDir, 'logs', 'opencode'),
|
|
48
|
-
cacheDir,
|
|
49
|
-
join(cacheDir, 'rollback'),
|
|
50
|
-
workDir,
|
|
51
|
-
]) {
|
|
52
|
-
await mkdir(dir, { recursive: true });
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Fetches a URL with retries and exponential backoff. Only retries on 5xx or network errors.
|
|
58
|
-
*/
|
|
59
|
-
async function fetchWithRetry(url: string, retries = 3): Promise<Response> {
|
|
60
|
-
for (let i = 0; i < retries; i++) {
|
|
61
|
-
try {
|
|
62
|
-
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
|
63
|
-
if (res.ok || res.status < 500) return res;
|
|
64
|
-
if (i < retries - 1) await Bun.sleep(200 * 2 ** i);
|
|
65
|
-
} catch (err) {
|
|
66
|
-
if (i === retries - 1) throw err;
|
|
67
|
-
await Bun.sleep(200 * 2 ** i);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
throw new Error(`Failed to fetch ${url} after ${retries} attempts`);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
1
|
/**
|
|
74
|
-
*
|
|
2
|
+
* Thin wrappers around `docker compose` invocations used by the CLI.
|
|
3
|
+
*
|
|
4
|
+
* Both spawn `docker compose` directly via `Bun.spawn` (no shell). The
|
|
5
|
+
* inherit-stdio variant is used for interactive operations (logs, ps);
|
|
6
|
+
* the capture variant returns stdout for parsing (e.g. `ps --format json`).
|
|
7
|
+
*
|
|
8
|
+
* Compose file/env-file resolution lives in `@openpalm/lib`'s
|
|
9
|
+
* `buildComposeCliArgs` — callers prepend that result before sub-args.
|
|
75
10
|
*/
|
|
76
|
-
export async function fetchAsset(repoRef: string, filename: string): Promise<string> {
|
|
77
|
-
const releaseUrl = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/${repoRef}/${filename}`;
|
|
78
|
-
const rawUrl = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${repoRef}/${filename}`;
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
const releaseResponse = await fetchWithRetry(releaseUrl);
|
|
82
|
-
if (releaseResponse.ok) return await releaseResponse.text();
|
|
83
|
-
} catch {
|
|
84
|
-
// Fall through to raw URL
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const rawResponse = await fetchWithRetry(rawUrl);
|
|
88
|
-
if (rawResponse.ok) return await rawResponse.text();
|
|
89
|
-
|
|
90
|
-
throw new Error(`Failed to download ${filename} from ${repoRef}`);
|
|
91
|
-
}
|
|
92
11
|
|
|
93
12
|
/**
|
|
94
13
|
* Runs a `docker compose` command with inherited stdio. Throws on non-zero exit.
|
|
@@ -122,128 +41,3 @@ export async function runDockerComposeCapture(args: string[]): Promise<string> {
|
|
|
122
41
|
}
|
|
123
42
|
return output;
|
|
124
43
|
}
|
|
125
|
-
|
|
126
|
-
// composeProjectArgs() removed — use fullComposeArgs(state) from cli-compose.ts instead.
|
|
127
|
-
// That function builds the correct file list including channel overlays and env files.
|
|
128
|
-
|
|
129
|
-
// ensureOpenCodeConfig and ensureOpenCodeSystemConfig are imported from @openpalm/lib.
|
|
130
|
-
// See packages/lib/src/control-plane/secrets.ts and core-assets.ts.
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Downloads the .openpalm/ directory from GitHub and seeds it into homeDir.
|
|
134
|
-
*
|
|
135
|
-
* Mapping:
|
|
136
|
-
* .openpalm/stack/core.compose.yml → homeDir/stack/core.compose.yml
|
|
137
|
-
* .openpalm/registry/ → homeDir/registry/
|
|
138
|
-
* .openpalm/vault/ → homeDir/vault/ (schemas only)
|
|
139
|
-
*
|
|
140
|
-
* Also seeds assistant config files from core/assistant/opencode/.
|
|
141
|
-
*/
|
|
142
|
-
/**
|
|
143
|
-
* Download latest assets from GitHub. Optional — embedded assets in lib
|
|
144
|
-
* provide the baseline. This upgrades to the latest release versions.
|
|
145
|
-
*/
|
|
146
|
-
export async function seedOpenPalmDir(
|
|
147
|
-
repoRef: string,
|
|
148
|
-
homeDir: string,
|
|
149
|
-
configDir: string,
|
|
150
|
-
vaultDir: string,
|
|
151
|
-
dataDir: string,
|
|
152
|
-
): Promise<void> {
|
|
153
|
-
const tarballUrl = `https://github.com/${REPO_OWNER}/${REPO_NAME}/archive/${repoRef}.tar.gz`;
|
|
154
|
-
const tmpDir = join(homeDir, '.seed-tmp');
|
|
155
|
-
const tmpTar = join(tmpDir, 'repo.tar.gz');
|
|
156
|
-
|
|
157
|
-
try {
|
|
158
|
-
await mkdir(tmpDir, { recursive: true });
|
|
159
|
-
|
|
160
|
-
const res = await fetch(tarballUrl, { signal: AbortSignal.timeout(60_000) });
|
|
161
|
-
if (!res.ok) throw new Error(`Failed to download tarball (HTTP ${res.status})`);
|
|
162
|
-
await Bun.write(tmpTar, res);
|
|
163
|
-
|
|
164
|
-
// Extract full tarball — avoid --wildcards which is GNU tar-only and
|
|
165
|
-
// breaks on macOS (BSD tar), causing silent extraction failure.
|
|
166
|
-
const extractProc = Bun.spawn(
|
|
167
|
-
['tar', 'xzf', tmpTar, '--strip-components=1'],
|
|
168
|
-
{ cwd: tmpDir, stdout: 'ignore', stderr: 'pipe' },
|
|
169
|
-
);
|
|
170
|
-
const extractCode = await extractProc.exited;
|
|
171
|
-
if (extractCode !== 0) {
|
|
172
|
-
throw new Error(`tar extraction failed (exit code ${extractCode})`);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const srcCoreCompose = join(tmpDir, '.openpalm', 'stack', 'core.compose.yml');
|
|
176
|
-
if (!await Bun.file(srcCoreCompose).exists()) {
|
|
177
|
-
throw new Error('core.compose.yml not found in downloaded assets');
|
|
178
|
-
}
|
|
179
|
-
await mkdir(join(homeDir, 'stack'), { recursive: true });
|
|
180
|
-
await writeFile(join(homeDir, 'stack', 'core.compose.yml'), new Uint8Array(await Bun.file(srcCoreCompose).arrayBuffer()));
|
|
181
|
-
|
|
182
|
-
const srcRegistry = join(tmpDir, '.openpalm', 'registry');
|
|
183
|
-
if (await dirExists(srcRegistry)) {
|
|
184
|
-
await copyTree(srcRegistry, join(homeDir, 'registry'));
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const srcVault = join(tmpDir, '.openpalm', 'vault');
|
|
188
|
-
if (await dirExists(srcVault)) {
|
|
189
|
-
await copyTree(srcVault, vaultDir, { onlyPattern: /\.schema$/ });
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const srcAssistant = join(tmpDir, 'core', 'assistant', 'opencode');
|
|
193
|
-
if (await dirExists(srcAssistant)) {
|
|
194
|
-
await copyTree(srcAssistant, join(dataDir, 'assistant'));
|
|
195
|
-
}
|
|
196
|
-
} finally {
|
|
197
|
-
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async function dirExists(path: string): Promise<boolean> {
|
|
202
|
-
try {
|
|
203
|
-
const stat = await Bun.file(join(path, '.')).exists();
|
|
204
|
-
// Bun.file().exists() doesn't work for dirs, use a different check
|
|
205
|
-
const proc = Bun.spawn(['test', '-d', path], { stdout: 'ignore', stderr: 'ignore' });
|
|
206
|
-
return (await proc.exited) === 0;
|
|
207
|
-
} catch { return false; }
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
async function copyTree(
|
|
211
|
-
src: string,
|
|
212
|
-
dest: string,
|
|
213
|
-
opts?: { skipExisting?: boolean; onlyPattern?: RegExp },
|
|
214
|
-
): Promise<void> {
|
|
215
|
-
const proc = Bun.spawn(['find', src, '-type', 'f'], { stdout: 'pipe' });
|
|
216
|
-
const output = await new Response(proc.stdout).text();
|
|
217
|
-
await proc.exited;
|
|
218
|
-
|
|
219
|
-
for (const srcFile of output.trim().split('\n').filter(Boolean)) {
|
|
220
|
-
const rel = relative(src, srcFile);
|
|
221
|
-
if (opts?.onlyPattern && !opts.onlyPattern.test(rel)) continue;
|
|
222
|
-
const destFile = join(dest, rel);
|
|
223
|
-
if (opts?.skipExisting && await Bun.file(destFile).exists()) continue;
|
|
224
|
-
await mkdir(dirname(destFile), { recursive: true });
|
|
225
|
-
const content = await Bun.file(srcFile).arrayBuffer();
|
|
226
|
-
await writeFile(destFile, new Uint8Array(content));
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Opens a URL in the user's default browser. Best-effort, never throws.
|
|
232
|
-
*/
|
|
233
|
-
export async function openBrowser(url: string): Promise<void> {
|
|
234
|
-
console.log(`Opening ${url} in your browser...`);
|
|
235
|
-
const platform = process.platform;
|
|
236
|
-
try {
|
|
237
|
-
if (platform === 'darwin') {
|
|
238
|
-
Bun.spawn(['open', url], { stdout: 'ignore', stderr: 'ignore' });
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
if (platform === 'win32') {
|
|
242
|
-
Bun.spawn(['cmd', '/c', 'start', url], { stdout: 'ignore', stderr: 'ignore' });
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
Bun.spawn(['xdg-open', url], { stdout: 'ignore', stderr: 'ignore' });
|
|
246
|
-
} catch {
|
|
247
|
-
// Best effort
|
|
248
|
-
}
|
|
249
|
-
}
|
package/src/lib/env.ts
CHANGED
|
@@ -1,90 +1,19 @@
|
|
|
1
|
-
import { join
|
|
2
|
-
import { randomBytes } from 'node:crypto';
|
|
1
|
+
import { join } from 'node:path';
|
|
3
2
|
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { reconcileStackEnvImageTag, resolveRequestedImageTag } from '@openpalm/lib';
|
|
4
4
|
import { defaultDockerSock } from './paths.ts';
|
|
5
5
|
|
|
6
|
-
export function unwrapQuotedEnvValue(value: string): string {
|
|
7
|
-
const isDoubleQuoted = value.startsWith('"') && value.endsWith('"');
|
|
8
|
-
const isSingleQuoted = value.startsWith('\'') && value.endsWith('\'');
|
|
9
|
-
if ((isDoubleQuoted || isSingleQuoted) && value.length >= 2) {
|
|
10
|
-
return value.slice(1, -1);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
return value;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Upserts a key=value pair in env file content. If the key exists, replaces the line;
|
|
18
|
-
* otherwise appends a new line.
|
|
19
|
-
*/
|
|
20
|
-
export function upsertEnvValue(content: string, key: string, value: string): string {
|
|
21
|
-
const escapedKey = key.replace(/[|\\{}()[\]^$+*?.-]/g, '\\$&');
|
|
22
|
-
const pattern = new RegExp(`^((?:export\\s+)?)${escapedKey}=.*$`, 'm');
|
|
23
|
-
if (pattern.test(content)) {
|
|
24
|
-
// Preserve the `export ` prefix if the original line had one
|
|
25
|
-
return content.replace(pattern, `$1${key}=${value}`);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const line = `${key}=${value}`;
|
|
29
|
-
const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n';
|
|
30
|
-
return `${content}${suffix}${line}\n`;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export const RELEASE_TAG_REGEX = /^v?\d+\.\d+\.\d+(?:[-+](?:[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*))?$/;
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Normalizes a repository ref to an image tag. Returns null for non-release refs.
|
|
37
|
-
* E.g. "0.9.0" → "v0.9.0", "v0.9.0" → "v0.9.0", "main" → null.
|
|
38
|
-
*/
|
|
39
|
-
export function resolveRequestedImageTag(repoRef: string): string | null {
|
|
40
|
-
const trimmed = repoRef.trim();
|
|
41
|
-
if (!trimmed || trimmed === 'main') return null;
|
|
42
|
-
if (!RELEASE_TAG_REGEX.test(trimmed)) return null;
|
|
43
|
-
return trimmed.startsWith('v') ? trimmed : `v${trimmed}`;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Reconciles the OP_IMAGE_TAG value in stack.env content.
|
|
48
|
-
*/
|
|
49
|
-
export function reconcileStackEnvImageTag(
|
|
50
|
-
content: string,
|
|
51
|
-
repoRef: string,
|
|
52
|
-
explicitImageTag?: string,
|
|
53
|
-
): string {
|
|
54
|
-
const desiredImageTag = explicitImageTag || resolveRequestedImageTag(repoRef);
|
|
55
|
-
if (!desiredImageTag) return content;
|
|
56
|
-
return upsertEnvValue(content, 'OP_IMAGE_TAG', desiredImageTag);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
6
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* Contains user-managed secrets only (API keys, memory user ID).
|
|
64
|
-
* System secrets (OP_ADMIN_TOKEN, OP_ASSISTANT_TOKEN, OP_MEMORY_TOKEN)
|
|
65
|
-
* live in vault/stack/stack.env and are managed by the control plane.
|
|
7
|
+
* Ensures the state/ directory exists.
|
|
8
|
+
* User-managed env secrets live in the akm `vault:user` store and are sourced
|
|
9
|
+
* by the assistant entrypoint directly.
|
|
66
10
|
*/
|
|
67
|
-
export async function ensureSecrets(
|
|
68
|
-
|
|
69
|
-
if (await Bun.file(secretsPath).exists()) {
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
mkdirSync(join(vaultDir, 'user'), { recursive: true });
|
|
74
|
-
// user.env is for user-added custom env vars only.
|
|
75
|
-
// All standard secrets (API keys, tokens) live in stack.env.
|
|
76
|
-
// Do NOT put API key placeholders here — user.env is loaded after
|
|
77
|
-
// stack.env by Docker Compose, so empty values would override real keys.
|
|
78
|
-
const content = `# OpenPalm — User Extensions
|
|
79
|
-
# Add any custom environment variables here.
|
|
80
|
-
# These are loaded by compose alongside stack.env.
|
|
81
|
-
`;
|
|
82
|
-
|
|
83
|
-
await Bun.write(secretsPath, content);
|
|
11
|
+
export async function ensureSecrets(stateDir: string): Promise<void> {
|
|
12
|
+
mkdirSync(stateDir, { recursive: true });
|
|
84
13
|
}
|
|
85
14
|
|
|
86
15
|
/**
|
|
87
|
-
* Creates or updates the
|
|
16
|
+
* Creates or updates the config/stack/stack.env bootstrap file.
|
|
88
17
|
*
|
|
89
18
|
* When `imageTagOverride` is provided (e.g. derived from --version during
|
|
90
19
|
* install), it takes precedence over both the OP_IMAGE_TAG env var
|
|
@@ -93,15 +22,16 @@ export async function ensureSecrets(vaultDir: string): Promise<void> {
|
|
|
93
22
|
*/
|
|
94
23
|
export async function ensureStackEnv(
|
|
95
24
|
homeDir: string,
|
|
96
|
-
|
|
25
|
+
configDir: string,
|
|
97
26
|
workDir: string,
|
|
98
27
|
repoRef: string,
|
|
99
28
|
imageTagOverride?: string,
|
|
100
29
|
): Promise<void> {
|
|
101
|
-
const
|
|
30
|
+
const stackDir = join(configDir, 'stack');
|
|
31
|
+
const systemEnvPath = join(stackDir, 'stack.env');
|
|
102
32
|
const explicitImageTag = imageTagOverride ?? process.env.OP_IMAGE_TAG;
|
|
103
33
|
const hasExplicitImageTag = explicitImageTag !== undefined && explicitImageTag !== '';
|
|
104
|
-
mkdirSync(
|
|
34
|
+
mkdirSync(stackDir, { recursive: true });
|
|
105
35
|
if (!(await Bun.file(systemEnvPath).exists())) {
|
|
106
36
|
const defaultImageTag = hasExplicitImageTag
|
|
107
37
|
? explicitImageTag
|
|
@@ -111,7 +41,6 @@ OP_HOME=${homeDir}
|
|
|
111
41
|
OP_WORK_DIR=${workDir}
|
|
112
42
|
OP_UID=${process.getuid?.() ?? 1000}
|
|
113
43
|
OP_GID=${process.getgid?.() ?? 1000}
|
|
114
|
-
OP_DOCKER_SOCK=${defaultDockerSock()}
|
|
115
44
|
OP_IMAGE_NAMESPACE=${process.env.OP_IMAGE_NAMESPACE || 'openpalm'}
|
|
116
45
|
OP_IMAGE_TAG=${defaultImageTag}
|
|
117
46
|
`;
|
package/src/lib/io.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem and HTTP helpers used by the CLI install/upgrade flows.
|
|
3
|
+
*
|
|
4
|
+
* Asset seeding (seedOpenPalmDir, seedUiBuild) and path resolution
|
|
5
|
+
* (resolveLocalUiBuild, resolveUiBuildDir) now live in @openpalm/lib
|
|
6
|
+
* so both the CLI and any future Electron shell can import them directly.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
10
|
+
import { join, dirname, relative } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const REPO_OWNER = 'itlackey';
|
|
13
|
+
const REPO_NAME = 'openpalm';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Creates the full directory tree required by the stack.
|
|
17
|
+
*/
|
|
18
|
+
export async function ensureDirectoryTree(
|
|
19
|
+
homeDir: string,
|
|
20
|
+
_configDir: string,
|
|
21
|
+
_vaultDir: string,
|
|
22
|
+
_dataDir: string,
|
|
23
|
+
workDir: string,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const configDir = `${homeDir}/config`;
|
|
26
|
+
const stateDir = `${homeDir}/state`;
|
|
27
|
+
const cacheDir = `${homeDir}/cache`;
|
|
28
|
+
|
|
29
|
+
for (const dir of [
|
|
30
|
+
homeDir,
|
|
31
|
+
configDir,
|
|
32
|
+
join(configDir, 'assistant'),
|
|
33
|
+
join(configDir, 'assistant', 'tools'),
|
|
34
|
+
join(configDir, 'assistant', 'plugins'),
|
|
35
|
+
join(configDir, 'assistant', 'skills'),
|
|
36
|
+
join(configDir, 'akm'),
|
|
37
|
+
join(configDir, 'stack'),
|
|
38
|
+
join(configDir, 'stack', 'addons'),
|
|
39
|
+
join(homeDir, 'stash'),
|
|
40
|
+
join(homeDir, 'stash', 'vaults'),
|
|
41
|
+
join(homeDir, 'stash', 'tasks'),
|
|
42
|
+
join(homeDir, 'workspace'),
|
|
43
|
+
cacheDir,
|
|
44
|
+
join(cacheDir, 'akm'),
|
|
45
|
+
join(cacheDir, 'guardian'),
|
|
46
|
+
join(cacheDir, 'rollback'),
|
|
47
|
+
stateDir,
|
|
48
|
+
join(stateDir, 'assistant'),
|
|
49
|
+
join(stateDir, 'admin'),
|
|
50
|
+
join(stateDir, 'guardian'),
|
|
51
|
+
join(stateDir, 'guardian', 'stash'),
|
|
52
|
+
join(stateDir, 'guardian', 'akm'),
|
|
53
|
+
join(stateDir, 'guardian', 'akm', 'data'),
|
|
54
|
+
join(stateDir, 'guardian', 'akm', 'state'),
|
|
55
|
+
join(stateDir, 'akm'),
|
|
56
|
+
join(stateDir, 'akm', 'data'),
|
|
57
|
+
join(stateDir, 'akm', 'state'),
|
|
58
|
+
join(stateDir, 'scheduler'),
|
|
59
|
+
join(stateDir, 'scheduler', 'triggers'),
|
|
60
|
+
join(stateDir, 'logs'),
|
|
61
|
+
join(stateDir, 'logs', 'opencode'),
|
|
62
|
+
join(stateDir, 'backups'),
|
|
63
|
+
join(stateDir, 'registry'),
|
|
64
|
+
join(stateDir, 'registry', 'addons'),
|
|
65
|
+
join(stateDir, 'registry', 'automations'),
|
|
66
|
+
join(stateDir, 'ui'),
|
|
67
|
+
workDir,
|
|
68
|
+
]) {
|
|
69
|
+
await mkdir(dir, { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function fetchWithRetry(url: string, retries = 3): Promise<Response> {
|
|
74
|
+
for (let i = 0; i < retries; i++) {
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
|
77
|
+
if (res.ok || res.status < 500) return res;
|
|
78
|
+
if (i < retries - 1) await new Promise(r => setTimeout(r, 200 * 2 ** i));
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (i === retries - 1) throw err;
|
|
81
|
+
await new Promise(r => setTimeout(r, 200 * 2 ** i));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`Failed to fetch ${url} after ${retries} attempts`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Downloads a text asset from a GitHub release, falling back to raw URL. */
|
|
88
|
+
export async function fetchAsset(repoRef: string, filename: string): Promise<string> {
|
|
89
|
+
const releaseUrl = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/${repoRef}/${filename}`;
|
|
90
|
+
const rawUrl = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${repoRef}/${filename}`;
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const r = await fetchWithRetry(releaseUrl);
|
|
94
|
+
if (r.ok) return await r.text();
|
|
95
|
+
} catch { /* fall through */ }
|
|
96
|
+
|
|
97
|
+
const r = await fetchWithRetry(rawUrl);
|
|
98
|
+
if (r.ok) return await r.text();
|
|
99
|
+
throw new Error(`Failed to download ${filename} from ${repoRef}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Returns true if `path` is an existing directory. */
|
|
103
|
+
export function dirExists(path: string): boolean {
|
|
104
|
+
try { return statSync(path).isDirectory(); } catch { return false; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Recursively copy files from src to dest. */
|
|
108
|
+
export async function copyTree(
|
|
109
|
+
src: string,
|
|
110
|
+
dest: string,
|
|
111
|
+
opts?: { skipExisting?: boolean; onlyPattern?: RegExp },
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
if (!dirExists(src)) return;
|
|
114
|
+
const entries = readdirSync(src, { recursive: true, withFileTypes: true });
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (!entry.isFile()) continue;
|
|
117
|
+
const parentDir = (entry as unknown as { parentPath?: string; path?: string }).parentPath
|
|
118
|
+
?? (entry as unknown as { path: string }).path;
|
|
119
|
+
const srcFile = join(parentDir, entry.name);
|
|
120
|
+
const rel = relative(src, srcFile);
|
|
121
|
+
if (opts?.onlyPattern && !opts.onlyPattern.test(rel)) continue;
|
|
122
|
+
const destFile = join(dest, rel);
|
|
123
|
+
if (opts?.skipExisting && existsSync(destFile)) continue;
|
|
124
|
+
await mkdir(dirname(destFile), { recursive: true });
|
|
125
|
+
await writeFile(destFile, new Uint8Array(await Bun.file(srcFile).arrayBuffer()));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Re-export from lib so existing imports in CLI commands keep working.
|
|
130
|
+
export { seedOpenPalmDir, seedUiBuild } from '@openpalm/lib';
|
|
@@ -28,13 +28,12 @@ const STOP_TIMEOUT_MS = 5_000;
|
|
|
28
28
|
* Start an OpenCode subprocess for the wizard to talk to.
|
|
29
29
|
*
|
|
30
30
|
* Creates a temporary HOME directory structure with symlinks to the real
|
|
31
|
-
*
|
|
31
|
+
* state/config paths so OpenCode reads/writes auth.json at the right location.
|
|
32
32
|
*/
|
|
33
33
|
export async function startOpenCodeSubprocess(opts: {
|
|
34
34
|
homeDir: string;
|
|
35
35
|
configDir: string;
|
|
36
|
-
|
|
37
|
-
dataDir: string;
|
|
36
|
+
stateDir: string;
|
|
38
37
|
port?: number;
|
|
39
38
|
}): Promise<OpenCodeSubprocess> {
|
|
40
39
|
const opencodeBin = Bun.which("opencode");
|
|
@@ -54,11 +53,20 @@ export async function startOpenCodeSubprocess(opts: {
|
|
|
54
53
|
mkdirSync(ocConfigDir, { recursive: true });
|
|
55
54
|
mkdirSync(ocStateDir, { recursive: true });
|
|
56
55
|
|
|
57
|
-
// Symlink auth.json →
|
|
58
|
-
|
|
56
|
+
// Symlink auth.json → the canonical OpenCode credential file at
|
|
57
|
+
// ${OP_HOME}/config/auth.json. This is the same file the assistant
|
|
58
|
+
// container bind-mounts (see .openpalm/config/stack/core.compose.yml),
|
|
59
|
+
// so credentials written by this wizard subprocess are immediately
|
|
60
|
+
// visible to the chat assistant on next start.
|
|
61
|
+
// SEC-5: Windows does not support unprivileged symlinks; use copyFileSync instead.
|
|
62
|
+
const authJsonSrc = join(opts.configDir, "auth.json");
|
|
59
63
|
const authJsonDst = join(ocShareDir, "auth.json");
|
|
60
64
|
if (!existsSync(authJsonDst)) {
|
|
61
|
-
|
|
65
|
+
if (process.platform === "win32") {
|
|
66
|
+
if (existsSync(authJsonSrc)) copyFileSync(authJsonSrc, authJsonDst);
|
|
67
|
+
} else {
|
|
68
|
+
symlinkSync(authJsonSrc, authJsonDst);
|
|
69
|
+
}
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
// Copy opencode.json config (not symlink — OpenCode may modify it)
|
package/src/lib/paths.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { existsSync } from 'node:fs';
|
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import {
|
|
8
|
-
|
|
8
|
+
resolveWorkspaceDir,
|
|
9
9
|
} from '@openpalm/lib';
|
|
10
10
|
|
|
11
11
|
export const IS_WINDOWS = process.platform === 'win32';
|
|
@@ -27,5 +27,5 @@ export function defaultDockerSock(): string {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function defaultWorkDir(): string {
|
|
30
|
-
return process.env.OP_WORK_DIR ||
|
|
30
|
+
return process.env.OP_WORK_DIR || resolveWorkspaceDir();
|
|
31
31
|
}
|