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.
Files changed (47) hide show
  1. package/README.md +11 -19
  2. package/package.json +4 -2
  3. package/src/commands/addon.ts +5 -4
  4. package/src/commands/automations.ts +63 -0
  5. package/src/commands/install.ts +98 -280
  6. package/src/commands/logs.ts +1 -1
  7. package/src/commands/restart.ts +5 -4
  8. package/src/commands/rollback.ts +4 -3
  9. package/src/commands/scan.ts +66 -32
  10. package/src/commands/service.ts +5 -4
  11. package/src/commands/start.ts +5 -4
  12. package/src/commands/status.ts +1 -1
  13. package/src/commands/stop.ts +2 -4
  14. package/src/commands/uninstall.ts +3 -5
  15. package/src/commands/update.ts +19 -2
  16. package/src/commands/validate.ts +16 -34
  17. package/src/install-flow.test.ts +153 -154
  18. package/src/lib/admin-skills/index.test.ts +70 -0
  19. package/src/lib/admin-skills/index.ts +113 -0
  20. package/src/lib/browser.ts +20 -0
  21. package/src/lib/cli-compose.ts +2 -20
  22. package/src/lib/cli-state.ts +1 -1
  23. package/src/lib/docker.ts +8 -214
  24. package/src/lib/env.ts +12 -83
  25. package/src/lib/io.ts +130 -0
  26. package/src/lib/opencode-subprocess.ts +14 -6
  27. package/src/lib/paths.ts +2 -2
  28. package/src/lib/ui-server.ts +150 -0
  29. package/src/main.test.ts +76 -173
  30. package/src/main.ts +131 -7
  31. package/e2e/start-wizard-server.ts +0 -59
  32. package/src/commands/admin.ts +0 -43
  33. package/src/commands/install-services.test.ts +0 -13
  34. package/src/commands/install-services.ts +0 -9
  35. package/src/commands/upgrade.ts +0 -12
  36. package/src/lib/embedded-assets.ts +0 -115
  37. package/src/lib/varlock.ts +0 -126
  38. package/src/setup-wizard/index.html +0 -321
  39. package/src/setup-wizard/server-errors.test.ts +0 -418
  40. package/src/setup-wizard/server-integration.test.ts +0 -511
  41. package/src/setup-wizard/server.test.ts +0 -508
  42. package/src/setup-wizard/server.ts +0 -342
  43. package/src/setup-wizard/wizard-renderers.js +0 -1294
  44. package/src/setup-wizard/wizard-state.js +0 -346
  45. package/src/setup-wizard/wizard-validators.js +0 -81
  46. package/src/setup-wizard/wizard.css +0 -1611
  47. 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
+ }
@@ -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 = fullComposeArgs(state);
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 = fullComposeArgs(state);
58
+ const composeArgs = buildComposeCliArgs(state);
77
59
  await runDockerCompose([...composeArgs, ...composeSubArgs]);
78
60
  }
@@ -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 fullComposeArgs().
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
- * Downloads an asset from a GitHub release, falling back to raw.githubusercontent.com.
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, dirname } from 'node:path';
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
- * Seeds vault/user/user.env with initial template.
61
- * Uses `export` prefix so the file can be sourced in a shell and is still
62
- * compatible with Docker Compose v2 `env_file`.
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(vaultDir: string): Promise<void> {
68
- const secretsPath = join(vaultDir, 'user', 'user.env');
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 vault/stack/stack.env bootstrap file.
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
- vaultDir: string,
25
+ configDir: string,
97
26
  workDir: string,
98
27
  repoRef: string,
99
28
  imageTagOverride?: string,
100
29
  ): Promise<void> {
101
- const systemEnvPath = join(vaultDir, 'stack', 'stack.env');
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(join(vaultDir, 'stack'), { recursive: true });
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
- * vault/config paths so OpenCode reads/writes auth.json at the right location.
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
- vaultDir: string;
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 → real vault location
58
- const authJsonSrc = join(opts.vaultDir, "stack", "auth.json");
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
- symlinkSync(authJsonSrc, authJsonDst);
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
- resolveDataDir,
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 || `${resolveDataDir()}/workspace`;
30
+ return process.env.OP_WORK_DIR || resolveWorkspaceDir();
31
31
  }