openpalm 0.9.8 → 0.9.9

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.
@@ -1,5 +1,4 @@
1
1
  import { defineCommand } from 'citty';
2
- import { tryAdminRequest } from '../lib/admin.ts';
3
2
  import { runDockerCompose } from '../lib/docker.ts';
4
3
  import { ensureStagedState, fullComposeArgs, buildManagedServiceNames } from '../lib/staging.ts';
5
4
  import { runLogsAction } from './logs.ts';
@@ -42,14 +41,6 @@ const logsCmd = defineCommand({
42
41
  const updateCmd = defineCommand({
43
42
  meta: { name: 'update', description: 'Pull latest images' },
44
43
  async run() {
45
- // Try admin delegation first
46
- const adminResult = await tryAdminRequest('/admin/containers/pull', { method: 'POST' });
47
- if (adminResult !== null) {
48
- console.log(JSON.stringify(adminResult, null, 2));
49
- return;
50
- }
51
-
52
- // Direct compose pull + recreate (scoped to managed services)
53
44
  const state = await ensureStagedState();
54
45
  const composeArgs = fullComposeArgs(state);
55
46
  const managedServices = buildManagedServiceNames(state);
@@ -64,14 +55,6 @@ const updateCmd = defineCommand({
64
55
  const statusCmd = defineCommand({
65
56
  meta: { name: 'status', description: 'Show container status' },
66
57
  async run() {
67
- // Try admin delegation first
68
- const adminResult = await tryAdminRequest('/admin/containers/list');
69
- if (adminResult !== null) {
70
- console.log(JSON.stringify(adminResult, null, 2));
71
- return;
72
- }
73
-
74
- // Direct compose ps
75
58
  const state = await ensureStagedState();
76
59
  await runDockerCompose([...fullComposeArgs(state), 'ps', '--format', 'table']);
77
60
  },
@@ -1,5 +1,4 @@
1
1
  import { defineCommand } from 'citty';
2
- import { tryAdminRequest } from '../lib/admin.ts';
3
2
  import { runDockerCompose } from '../lib/docker.ts';
4
3
  import { ensureStagedState, fullComposeArgs, buildManagedServiceNames } from '../lib/staging.ts';
5
4
 
@@ -14,66 +13,29 @@ export default defineCommand({
14
13
  description: 'Service names to start (omit for all)',
15
14
  required: false,
16
15
  },
17
- 'with-admin': {
18
- type: 'boolean',
19
- description: 'Include admin UI and docker-socket-proxy (use --no-with-admin to skip)',
20
- default: true,
21
- },
22
16
  },
23
17
  async run({ args }) {
24
18
  const services = args._ ?? [];
25
- const withAdmin = args['with-admin'] ?? false;
26
- await runStartAction(services, { withAdmin });
19
+ await runStartAction(services);
27
20
  },
28
21
  });
29
22
 
30
23
  export async function runStartAction(
31
24
  services: string[],
32
- opts?: { withAdmin?: boolean },
33
25
  ): Promise<void> {
34
- const withAdmin = opts?.withAdmin ?? false;
35
-
36
26
  if (services.length === 0) {
37
- // Try admin delegation first (gives admin's scheduler/audit a chance to observe)
38
- const adminResult = await tryAdminRequest('/admin/install', { method: 'POST' });
39
- if (adminResult !== null) {
40
- console.log(JSON.stringify(adminResult, null, 2));
41
- return;
42
- }
43
-
44
- // Direct compose — stage artifacts and start managed services
27
+ // Stage artifacts and start all managed services (admin included if enabled)
45
28
  const state = await ensureStagedState();
46
29
  const composeArgs = fullComposeArgs(state);
47
30
  const managedServices = buildManagedServiceNames(state);
48
-
49
- if (withAdmin) {
50
- // Include the admin profile — starts admin + docker-socket-proxy
51
- await runDockerCompose([...composeArgs, '--profile', 'admin', 'up', '-d', ...managedServices, 'admin', 'docker-socket-proxy']);
52
- } else {
53
- await runDockerCompose([...composeArgs, 'up', '-d', ...managedServices]);
54
- }
31
+ await runDockerCompose([...composeArgs, 'up', '-d', ...managedServices]);
55
32
  return;
56
33
  }
57
34
 
58
- // Start specific services — try admin first, fall back to direct compose
35
+ // Start specific services
59
36
  for (const service of services) {
60
- const adminResult = await tryAdminRequest('/admin/containers/up', {
61
- method: 'POST',
62
- body: JSON.stringify({ service }),
63
- });
64
- if (adminResult !== null) {
65
- console.log(JSON.stringify(adminResult, null, 2));
66
- continue;
67
- }
68
-
69
- // Direct compose for specific service
70
37
  const state = await ensureStagedState();
71
38
  const composeArgs = fullComposeArgs(state);
72
- // If starting admin explicitly, include the admin profile
73
- if (service === 'admin' || service === 'docker-socket-proxy') {
74
- await runDockerCompose([...composeArgs, '--profile', 'admin', 'up', '-d', service]);
75
- } else {
76
- await runDockerCompose([...composeArgs, 'up', '-d', service]);
77
- }
39
+ await runDockerCompose([...composeArgs, 'up', '-d', service]);
78
40
  }
79
41
  }
@@ -1,5 +1,4 @@
1
1
  import { defineCommand } from 'citty';
2
- import { tryAdminRequest } from '../lib/admin.ts';
3
2
  import { runDockerCompose } from '../lib/docker.ts';
4
3
  import { ensureStagedState, fullComposeArgs } from '../lib/staging.ts';
5
4
 
@@ -9,14 +8,6 @@ export default defineCommand({
9
8
  description: 'Show container status',
10
9
  },
11
10
  async run() {
12
- // Try admin delegation first for richer output
13
- const adminResult = await tryAdminRequest('/admin/containers/list');
14
- if (adminResult !== null) {
15
- console.log(JSON.stringify(adminResult, null, 2));
16
- return;
17
- }
18
-
19
- // Direct compose ps
20
11
  const state = await ensureStagedState();
21
12
  await runDockerCompose([...fullComposeArgs(state), 'ps', '--format', 'table']);
22
13
  },
@@ -1,5 +1,4 @@
1
1
  import { defineCommand } from 'citty';
2
- import { tryAdminRequest, getServiceNames } from '../lib/admin.ts';
3
2
  import { runDockerCompose } from '../lib/docker.ts';
4
3
  import { ensureStagedState, fullComposeArgs } from '../lib/staging.ts';
5
4
 
@@ -23,48 +22,17 @@ export default defineCommand({
23
22
 
24
23
  export async function runStopAction(services: string[]): Promise<void> {
25
24
  if (services.length === 0) {
26
- // Try admin delegation stop each managed container
27
- const adminResult = await tryAdminRequest('/admin/containers/list');
28
- if (adminResult !== null) {
29
- const serviceNames = getServiceNames(adminResult);
30
- for (const service of serviceNames) {
31
- const result = await tryAdminRequest('/admin/containers/down', {
32
- method: 'POST',
33
- body: JSON.stringify({ service }),
34
- });
35
- if (result !== null) {
36
- console.log(JSON.stringify(result, null, 2));
37
- } else {
38
- console.warn(`Warning: failed to stop ${service} via admin API`);
39
- }
40
- }
41
- return;
42
- }
43
-
44
- // Direct compose down — include admin profile to tear down all services
25
+ // Compose file list includes admin.yml when admin is enabled,
26
+ // so `down` tears down all services including admin/caddy/socket-proxy.
45
27
  const state = await ensureStagedState();
46
- await runDockerCompose([...fullComposeArgs(state), '--profile', 'admin', 'down']);
28
+ await runDockerCompose([...fullComposeArgs(state), 'down']);
47
29
  return;
48
30
  }
49
31
 
50
32
  // Stop specific services
51
33
  for (const service of services) {
52
- const adminResult = await tryAdminRequest('/admin/containers/down', {
53
- method: 'POST',
54
- body: JSON.stringify({ service }),
55
- });
56
- if (adminResult !== null) {
57
- console.log(JSON.stringify(adminResult, null, 2));
58
- continue;
59
- }
60
-
61
- // Direct compose stop for specific service
62
34
  const state = await ensureStagedState();
63
35
  const composeArgs = fullComposeArgs(state);
64
- if (service === 'admin' || service === 'docker-socket-proxy') {
65
- await runDockerCompose([...composeArgs, '--profile', 'admin', 'stop', service]);
66
- } else {
67
- await runDockerCompose([...composeArgs, 'stop', service]);
68
- }
36
+ await runDockerCompose([...composeArgs, 'stop', service]);
69
37
  }
70
38
  }
@@ -1,7 +1,8 @@
1
1
  import { defineCommand } from 'citty';
2
- import { tryAdminRequest } from '../lib/admin.ts';
2
+ import { rmSync } from 'node:fs';
3
3
  import { runDockerCompose } from '../lib/docker.ts';
4
4
  import { ensureStagedState, fullComposeArgs } from '../lib/staging.ts';
5
+ import { resolveConfigHome, resolveDataHome, resolveStateHome } from '@openpalm/lib';
5
6
 
6
7
  export default defineCommand({
7
8
  meta: {
@@ -14,24 +15,32 @@ export default defineCommand({
14
15
  description: 'Also remove Docker volumes',
15
16
  default: false,
16
17
  },
18
+ purge: {
19
+ type: 'boolean',
20
+ description: 'Remove all OpenPalm XDG directories (config, data, state)',
21
+ default: false,
22
+ },
17
23
  },
18
24
  async run({ args }) {
19
- // Try admin delegation first
20
- const adminResult = await tryAdminRequest('/admin/uninstall', { method: 'POST' });
21
- if (adminResult !== null) {
22
- console.log(JSON.stringify(adminResult, null, 2));
23
- return;
24
- }
25
-
26
- // Direct compose down — include admin profile to tear down all services
25
+ // Compose file list includes admin.yml when admin is enabled,
26
+ // so `down` tears down all services including admin/caddy/socket-proxy.
27
27
  const state = await ensureStagedState();
28
28
  const composeArgs = fullComposeArgs(state);
29
- const downArgs = args.volumes ? ['down', '-v'] : ['down'];
30
- await runDockerCompose([...composeArgs, '--profile', 'admin', ...downArgs]);
29
+ const downArgs = args.volumes || args.purge ? ['down', '-v'] : ['down'];
30
+ await runDockerCompose([...composeArgs, ...downArgs]);
31
31
 
32
- console.log('OpenPalm stack stopped and removed.');
33
- if (!args.volumes) {
34
- console.log('Config and data directories are preserved. Use --volumes to also remove Docker volumes.');
32
+ if (args.purge) {
33
+ const dirs = [resolveConfigHome(), resolveDataHome(), resolveStateHome()];
34
+ for (const dir of dirs) {
35
+ console.log(`Removing ${dir}`);
36
+ rmSync(dir, { recursive: true, force: true });
37
+ }
38
+ console.log('OpenPalm stack and all data removed.');
39
+ } else {
40
+ console.log('OpenPalm stack stopped and removed.');
41
+ if (!args.volumes) {
42
+ console.log('Config and data directories are preserved. Use --purge to remove everything.');
43
+ }
35
44
  }
36
45
  },
37
46
  });
@@ -1,5 +1,4 @@
1
1
  import { defineCommand } from 'citty';
2
- import { tryAdminRequest } from '../lib/admin.ts';
3
2
  import { runDockerCompose } from '../lib/docker.ts';
4
3
  import { ensureStagedState, fullComposeArgs, buildManagedServiceNames } from '../lib/staging.ts';
5
4
 
@@ -9,14 +8,6 @@ export default defineCommand({
9
8
  description: 'Pull latest images and recreate containers',
10
9
  },
11
10
  async run() {
12
- // Try admin delegation first
13
- const adminResult = await tryAdminRequest('/admin/containers/pull', { method: 'POST' });
14
- if (adminResult !== null) {
15
- console.log(JSON.stringify(adminResult, null, 2));
16
- return;
17
- }
18
-
19
- // Direct compose: pull + recreate
20
11
  const state = await ensureStagedState();
21
12
  const composeArgs = fullComposeArgs(state);
22
13
  const managedServices = buildManagedServiceNames(state);
package/src/lib/docker.ts CHANGED
@@ -27,6 +27,23 @@ export async function ensureDirectoryTree(
27
27
  }
28
28
  }
29
29
 
30
+ /**
31
+ * Fetches a URL with retries and exponential backoff. Only retries on 5xx or network errors.
32
+ */
33
+ async function fetchWithRetry(url: string, retries = 3): Promise<Response> {
34
+ for (let i = 0; i < retries; i++) {
35
+ try {
36
+ const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
37
+ if (res.ok || res.status < 500) return res;
38
+ if (i < retries - 1) await Bun.sleep(200 * 2 ** i);
39
+ } catch (err) {
40
+ if (i === retries - 1) throw err;
41
+ await Bun.sleep(200 * 2 ** i);
42
+ }
43
+ }
44
+ throw new Error(`Failed to fetch ${url} after ${retries} attempts`);
45
+ }
46
+
30
47
  /**
31
48
  * Downloads an asset from a GitHub release, falling back to raw.githubusercontent.com.
32
49
  */
@@ -34,15 +51,15 @@ export async function fetchAsset(repoRef: string, filename: string): Promise<str
34
51
  const releaseUrl = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/${repoRef}/${filename}`;
35
52
  const rawUrl = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${repoRef}/assets/${filename}`;
36
53
 
37
- const releaseResponse = await fetch(releaseUrl, { signal: AbortSignal.timeout(30000) });
38
- if (releaseResponse.ok) {
39
- return await releaseResponse.text();
54
+ try {
55
+ const releaseResponse = await fetchWithRetry(releaseUrl);
56
+ if (releaseResponse.ok) return await releaseResponse.text();
57
+ } catch {
58
+ // Fall through to raw URL
40
59
  }
41
60
 
42
- const rawResponse = await fetch(rawUrl, { signal: AbortSignal.timeout(30000) });
43
- if (rawResponse.ok) {
44
- return await rawResponse.text();
45
- }
61
+ const rawResponse = await fetchWithRetry(rawUrl);
62
+ if (rawResponse.ok) return await rawResponse.text();
46
63
 
47
64
  throw new Error(`Failed to download ${filename} from ${repoRef}`);
48
65
  }
@@ -90,6 +107,7 @@ export async function runDockerComposeCapture(args: string[]): Promise<string> {
90
107
  * Opens a URL in the user's default browser. Best-effort, never throws.
91
108
  */
92
109
  export async function openBrowser(url: string): Promise<void> {
110
+ console.log(`Opening ${url} in your browser...`);
93
111
  const platform = process.platform;
94
112
  try {
95
113
  if (platform === 'darwin') {
package/src/lib/env.ts CHANGED
@@ -1,62 +1,7 @@
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
- }
1
+ import { join, dirname } from 'node:path';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { mkdirSync } from 'node:fs';
4
+ import { defaultDockerSock } from './paths.ts';
60
5
 
61
6
  export function unwrapQuotedEnvValue(value: string): string {
62
7
  const isDoubleQuoted = value.startsWith('"') && value.endsWith('"');
@@ -143,6 +88,7 @@ export OPENAI_BASE_URL=
143
88
 
144
89
  # Memory
145
90
  export MEMORY_USER_ID=${userId}
91
+ export MEMORY_AUTH_TOKEN=${randomBytes(32).toString('hex')}
146
92
  `;
147
93
 
148
94
  await Bun.write(secretsPath, content);
@@ -189,6 +135,7 @@ OPENPALM_IMAGE_TAG=${defaultImageTag}
189
135
  await Bun.write(dataStackEnv, reconciled);
190
136
  }
191
137
  }
138
+ mkdirSync(dirname(stagedStackEnv), { recursive: true });
192
139
  await Bun.write(stagedStackEnv, Bun.file(dataStackEnv));
193
140
 
194
141
  const stateSecrets = join(stateHome, 'artifacts', 'secrets.env');
package/src/lib/paths.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Path resolution — re-exports from @openpalm/lib with CLI-specific additions.
3
3
  */
4
+ import { existsSync } from 'node:fs';
4
5
  import { homedir } from 'node:os';
5
6
  import { join } from 'node:path';
6
7
  import {
@@ -19,7 +20,16 @@ export { resolveStateHome as defaultStateHome };
19
20
  // CLI-specific paths (not in lib)
20
21
  export function defaultDockerSock(): string {
21
22
  if (process.env.OPENPALM_DOCKER_SOCK) return process.env.OPENPALM_DOCKER_SOCK;
22
- return IS_WINDOWS ? '//./pipe/docker_engine' : '/var/run/docker.sock';
23
+ if (IS_WINDOWS) return '//./pipe/docker_engine';
24
+
25
+ const home = homedir();
26
+ const candidates = [
27
+ '/var/run/docker.sock',
28
+ join(home, '.orbstack/run/docker.sock'),
29
+ join(home, '.colima/default/docker.sock'),
30
+ join(home, '.rd/docker.sock'),
31
+ ];
32
+ return candidates.find((p) => existsSync(p)) ?? candidates[0];
23
33
  }
24
34
 
25
35
  export function defaultWorkDir(): string {
@@ -22,9 +22,9 @@ import { defaultDataHome } from './paths.ts';
22
22
  /**
23
23
  * Ensure all artifacts are staged from CONFIG_HOME/DATA_HOME to STATE_HOME.
24
24
  *
25
- * This is the CLI-side equivalent of the admin's staging pipeline.
26
- * It uses FilesystemAssetProvider (reads core assets from DATA_HOME,
27
- * persisted by the install command) rather than Vite bundle imports.
25
+ * Uses FilesystemAssetProvider (reads core assets from DATA_HOME,
26
+ * persisted by the install command) to assemble compose files, env
27
+ * files, and Caddyfile into STATE_HOME/artifacts for Docker Compose.
28
28
  *
29
29
  * Returns a ControlPlaneState usable with fullComposeArgs().
30
30
  */
package/src/main.test.ts CHANGED
@@ -57,92 +57,57 @@ describe('cli main', () => {
57
57
  process.env.OPENPALM_ADMIN_TOKEN = originalOpenPalmAdminToken;
58
58
  });
59
59
 
60
- it('calls containers pull for update (via admin when reachable)', async () => {
61
- const calls: string[] = [];
62
- process.env.OPENPALM_ADMIN_TOKEN = 'test-token';
63
- globalThis.fetch = mock(async (input: string | URL) => {
64
- calls.push(String(input));
65
- return new Response('{"ok":true}', { status: 200 });
66
- }) as typeof fetch;
67
- console.log = mock(() => {}) as typeof console.log;
68
-
69
- await main(['update']);
70
-
71
- // tryAdminRequest attempts directly — no separate health check
72
- expect(calls).toEqual([
73
- 'http://localhost:8100/admin/containers/pull',
74
- ]);
75
- });
76
-
77
- it('uses ADMIN_TOKEN from the environment for admin requests', async () => {
78
- const adminTokens: string[] = [];
79
- process.env.ADMIN_TOKEN = 'env-admin-token';
80
- delete process.env.OPENPALM_ADMIN_TOKEN;
81
-
82
- globalThis.fetch = mock(async (_input: string | URL, init?: RequestInit) => {
83
- const headers = new Headers(init?.headers);
84
- adminTokens.push(headers.get('X-Admin-Token') ?? '');
85
- return new Response('{"ok":true}', { status: 200 });
86
- }) as typeof fetch;
87
- console.log = mock(() => {}) as typeof console.log;
88
-
89
- await main(['update']);
90
-
91
- // Direct request — single call with token header
92
- expect(adminTokens).toEqual(['env-admin-token']);
93
- });
94
-
95
- it('falls back to the legacy parent secrets.env for admin requests', async () => {
96
- const base = mkdtempSync(join(tmpdir(), 'openpalm-config-'));
97
- const configHome = join(base, 'openpalm');
98
- const adminTokens: string[] = [];
60
+ it('runs bootstrap install directly without admin delegation', async () => {
61
+ const base = mkdtempSync(join(tmpdir(), 'openpalm-install-'));
62
+ const configHome = join(base, 'config');
63
+ const dataHome = join(base, 'data');
64
+ const stateHome = join(base, 'state');
65
+ const workDir = join(base, 'work');
66
+ const binDir = join(stateHome, 'bin');
99
67
 
100
- mkdirSync(configHome, { recursive: true });
101
- writeFileSync(join(configHome, 'secrets.env'), 'OPENPALM_ADMIN_TOKEN=\nADMIN_TOKEN=\n');
102
- writeFileSync(join(base, 'secrets.env'), 'export ADMIN_TOKEN="legacy-admin-token"\n');
68
+ mkdirSync(binDir, { recursive: true });
69
+ writeFileSync(join(binDir, 'varlock'), '#!/bin/sh\nexit 0\n');
70
+ chmodSync(join(binDir, 'varlock'), 0o755);
103
71
 
104
72
  process.env.OPENPALM_CONFIG_HOME = configHome;
73
+ process.env.OPENPALM_DATA_HOME = dataHome;
74
+ process.env.OPENPALM_STATE_HOME = stateHome;
75
+ process.env.OPENPALM_WORK_DIR = workDir;
105
76
  delete process.env.ADMIN_TOKEN;
106
77
  delete process.env.OPENPALM_ADMIN_TOKEN;
107
78
 
108
- globalThis.fetch = mock(async (_input: string | URL, init?: RequestInit) => {
109
- const headers = new Headers(init?.headers);
110
- adminTokens.push(headers.get('X-Admin-Token') ?? '');
111
- return new Response('{"ok":true}', { status: 200 });
112
- }) as typeof fetch;
113
- console.log = mock(() => {}) as typeof console.log;
114
-
115
- try {
116
- await main(['update']);
117
- // Direct request — single call with legacy token
118
- expect(adminTokens).toEqual(['legacy-admin-token']);
119
- } finally {
120
- rmSync(base, { recursive: true, force: true });
121
- }
122
- });
123
-
124
- it('calls admin install when stack is already running and token exists', async () => {
125
- const calls: string[] = [];
126
- process.env.OPENPALM_ADMIN_TOKEN = 'test-token';
79
+ mockDockerCli();
80
+ const fetchedUrls: string[] = [];
127
81
  globalThis.fetch = mock(async (input: string | URL) => {
128
82
  const url = String(input);
129
- calls.push(url);
83
+ fetchedUrls.push(url);
130
84
  if (url.endsWith('/health')) {
131
85
  return new Response('ok', { status: 200 });
132
86
  }
133
- return new Response('{\"ok\":true}', { status: 200 });
87
+ if (url.includes('/docker-compose.yml')) {
88
+ return new Response('services: {}\n', { status: 200 });
89
+ }
90
+ if (url.includes('/Caddyfile')) {
91
+ return new Response(':80 {\n}\n', { status: 200 });
92
+ }
93
+ if (url.includes('/secrets.env.schema') || url.includes('/stack.env.schema')) {
94
+ return new Response('KEY=string\n', { status: 200 });
95
+ }
96
+ return new Response('', { status: 503 });
134
97
  }) as typeof fetch;
135
98
  console.log = mock(() => {}) as typeof console.log;
99
+ console.warn = mock(() => {}) as typeof console.warn;
136
100
 
137
- await main(['install']);
138
-
139
- expect(calls).toEqual([
140
- 'http://localhost:8100/health',
141
- 'http://localhost:8100/admin/install',
142
- ]);
101
+ try {
102
+ await main(['install', '--no-start', '--force', '--no-open']);
103
+ // Bootstrap runs directly, creating directories
104
+ expect(existsSync(join(dataHome, 'admin'))).toBe(true);
105
+ } finally {
106
+ rmSync(base, { recursive: true, force: true });
107
+ }
143
108
  });
144
109
 
145
- it('falls back to bootstrap when stack is running but no token exists', async () => {
110
+ it('creates the admin data directory during bootstrap install', async () => {
146
111
  const base = mkdtempSync(join(tmpdir(), 'openpalm-install-'));
147
112
  const configHome = join(base, 'config');
148
113
  const dataHome = join(base, 'data');
@@ -158,16 +123,12 @@ describe('cli main', () => {
158
123
  process.env.OPENPALM_DATA_HOME = dataHome;
159
124
  process.env.OPENPALM_STATE_HOME = stateHome;
160
125
  process.env.OPENPALM_WORK_DIR = workDir;
161
- delete process.env.ADMIN_TOKEN;
162
- delete process.env.OPENPALM_ADMIN_TOKEN;
163
126
 
164
127
  mockDockerCli();
165
- const fetchedUrls: string[] = [];
166
128
  globalThis.fetch = mock(async (input: string | URL) => {
167
129
  const url = String(input);
168
- fetchedUrls.push(url);
169
130
  if (url.endsWith('/health')) {
170
- return new Response('ok', { status: 200 });
131
+ throw new TypeError('fetch failed');
171
132
  }
172
133
  if (url.includes('/docker-compose.yml')) {
173
134
  return new Response('services: {}\n', { status: 200 });
@@ -181,24 +142,23 @@ describe('cli main', () => {
181
142
  return new Response('', { status: 503 });
182
143
  }) as typeof fetch;
183
144
  console.log = mock(() => {}) as typeof console.log;
184
- console.warn = mock(() => {}) as typeof console.warn;
185
145
 
186
146
  try {
187
147
  await main(['install', '--no-start', '--force', '--no-open']);
188
- // Should have fallen through to bootstrap, creating directories
189
148
  expect(existsSync(join(dataHome, 'admin'))).toBe(true);
190
149
  } finally {
191
150
  rmSync(base, { recursive: true, force: true });
192
151
  }
193
152
  });
194
153
 
195
- it('creates the admin data directory during bootstrap install', async () => {
154
+ it('uses main as the default install ref for bootstrap asset downloads', async () => {
196
155
  const base = mkdtempSync(join(tmpdir(), 'openpalm-install-'));
197
156
  const configHome = join(base, 'config');
198
157
  const dataHome = join(base, 'data');
199
158
  const stateHome = join(base, 'state');
200
159
  const workDir = join(base, 'work');
201
160
  const binDir = join(stateHome, 'bin');
161
+ const fetchedUrls: string[] = [];
202
162
 
203
163
  mkdirSync(binDir, { recursive: true });
204
164
  writeFileSync(join(binDir, 'varlock'), '#!/bin/sh\nexit 0\n');
@@ -212,25 +172,33 @@ describe('cli main', () => {
212
172
  mockDockerCli();
213
173
  globalThis.fetch = mock(async (input: string | URL) => {
214
174
  const url = String(input);
175
+ fetchedUrls.push(url);
215
176
  if (url.endsWith('/health')) {
216
177
  throw new TypeError('fetch failed');
217
178
  }
218
- if (url.includes('/docker-compose.yml')) {
179
+ if (url.includes('/main/assets/docker-compose.yml')) {
219
180
  return new Response('services: {}\n', { status: 200 });
220
181
  }
221
- if (url.includes('/Caddyfile')) {
182
+ if (url.includes('/main/assets/Caddyfile')) {
222
183
  return new Response(':80 {\n}\n', { status: 200 });
223
184
  }
224
- if (url.includes('/secrets.env.schema') || url.includes('/stack.env.schema')) {
185
+ if (url.includes('/main/assets/secrets.env.schema') || url.includes('/main/assets/stack.env.schema')) {
225
186
  return new Response('KEY=string\n', { status: 200 });
226
187
  }
227
188
  return new Response('', { status: 503 });
228
189
  }) as typeof fetch;
229
190
  console.log = mock(() => {}) as typeof console.log;
191
+ console.warn = mock(() => {}) as typeof console.warn;
230
192
 
231
193
  try {
232
194
  await main(['install', '--no-start', '--force', '--no-open']);
233
- expect(existsSync(join(dataHome, 'admin'))).toBe(true);
195
+
196
+ expect(fetchedUrls).toContain(
197
+ 'https://raw.githubusercontent.com/itlackey/openpalm/main/assets/docker-compose.yml',
198
+ );
199
+ expect(fetchedUrls).toContain(
200
+ 'https://raw.githubusercontent.com/itlackey/openpalm/main/assets/Caddyfile',
201
+ );
234
202
  } finally {
235
203
  rmSync(base, { recursive: true, force: true });
236
204
  }