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.
- package/package.json +3 -1
- package/playwright.config.ts +16 -0
- package/src/commands/install-file.test.ts +306 -0
- package/src/commands/install-services.test.ts +12 -7
- package/src/commands/install-services.ts +1 -1
- package/src/commands/install.ts +113 -30
- package/src/commands/restart.ts +2 -35
- package/src/commands/service.ts +0 -17
- package/src/commands/start.ts +5 -43
- package/src/commands/status.ts +0 -9
- package/src/commands/stop.ts +4 -36
- package/src/commands/uninstall.ts +23 -14
- package/src/commands/update.ts +0 -9
- package/src/lib/docker.ts +25 -7
- package/src/lib/env.ts +6 -59
- package/src/lib/paths.ts +11 -1
- package/src/lib/staging.ts +3 -3
- package/src/main.test.ts +50 -82
- package/src/setup-wizard/index.html +114 -180
- package/src/setup-wizard/server-errors.test.ts +429 -0
- package/src/setup-wizard/server-integration.test.ts +511 -0
- package/src/setup-wizard/server.test.ts +6 -6
- package/src/setup-wizard/server.ts +17 -5
- package/src/setup-wizard/standalone.ts +166 -0
- package/src/setup-wizard/wizard.css +892 -299
- package/src/setup-wizard/wizard.js +1172 -559
- package/src/lib/admin.ts +0 -107
package/src/commands/service.ts
CHANGED
|
@@ -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
|
},
|
package/src/commands/start.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/commands/status.ts
CHANGED
|
@@ -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
|
},
|
package/src/commands/stop.ts
CHANGED
|
@@ -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
|
-
//
|
|
27
|
-
|
|
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), '
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
20
|
-
|
|
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,
|
|
29
|
+
const downArgs = args.volumes || args.purge ? ['down', '-v'] : ['down'];
|
|
30
|
+
await runDockerCompose([...composeArgs, ...downArgs]);
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
});
|
package/src/commands/update.ts
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
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
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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 {
|
package/src/lib/staging.ts
CHANGED
|
@@ -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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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('
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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(
|
|
101
|
-
writeFileSync(join(
|
|
102
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
83
|
+
fetchedUrls.push(url);
|
|
130
84
|
if (url.endsWith('/health')) {
|
|
131
85
|
return new Response('ok', { status: 200 });
|
|
132
86
|
}
|
|
133
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
'
|
|
141
|
-
|
|
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('
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
}
|