openpalm 0.9.1 → 0.9.2-rc2
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 +40 -12
- package/e2e/start-wizard-server.ts +64 -0
- package/package.json +6 -2
- package/src/commands/install.ts +275 -0
- package/src/commands/logs.ts +25 -0
- package/src/commands/restart.ts +71 -0
- package/src/commands/scan.ts +48 -0
- package/src/commands/service.ts +93 -0
- package/src/commands/start.ts +79 -0
- package/src/commands/status.ts +23 -0
- package/src/commands/stop.ts +70 -0
- package/src/commands/uninstall.ts +37 -0
- package/src/commands/update.ts +32 -0
- package/src/commands/validate.ts +47 -0
- package/src/lib/admin.ts +107 -0
- package/src/lib/docker.ts +107 -0
- package/src/lib/env.ts +196 -0
- package/src/lib/host-info.ts +47 -0
- package/src/lib/paths.ts +27 -0
- package/src/lib/staging.ts +72 -0
- package/src/lib/varlock.ts +132 -0
- package/src/main.test.ts +142 -29
- package/src/main.ts +33 -954
- package/src/setup-wizard/index.html +349 -0
- package/src/setup-wizard/server.test.ts +347 -0
- package/src/setup-wizard/server.ts +297 -0
- package/src/setup-wizard/wizard.css +952 -0
- package/src/setup-wizard/wizard.js +1104 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { tryAdminRequest } from '../lib/admin.ts';
|
|
3
|
+
import { runDockerCompose } from '../lib/docker.ts';
|
|
4
|
+
import { ensureStagedState, fullComposeArgs, buildManagedServiceNames } from '../lib/staging.ts';
|
|
5
|
+
import { runLogsAction } from './logs.ts';
|
|
6
|
+
import { runStartAction } from './start.ts';
|
|
7
|
+
import { runStopAction } from './stop.ts';
|
|
8
|
+
import { runRestartAction } from './restart.ts';
|
|
9
|
+
|
|
10
|
+
const startCmd = defineCommand({
|
|
11
|
+
meta: { name: 'start', description: 'Start services' },
|
|
12
|
+
args: {
|
|
13
|
+
services: { type: 'positional', description: 'Service names', required: false },
|
|
14
|
+
},
|
|
15
|
+
async run({ args }) { await runStartAction(args._ ?? []); },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const stopCmd = defineCommand({
|
|
19
|
+
meta: { name: 'stop', description: 'Stop services' },
|
|
20
|
+
args: {
|
|
21
|
+
services: { type: 'positional', description: 'Service names', required: false },
|
|
22
|
+
},
|
|
23
|
+
async run({ args }) { await runStopAction(args._ ?? []); },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const restartCmd = defineCommand({
|
|
27
|
+
meta: { name: 'restart', description: 'Restart services' },
|
|
28
|
+
args: {
|
|
29
|
+
services: { type: 'positional', description: 'Service names', required: false },
|
|
30
|
+
},
|
|
31
|
+
async run({ args }) { await runRestartAction(args._ ?? []); },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const logsCmd = defineCommand({
|
|
35
|
+
meta: { name: 'logs', description: 'View service logs' },
|
|
36
|
+
args: {
|
|
37
|
+
services: { type: 'positional', description: 'Service names', required: false },
|
|
38
|
+
},
|
|
39
|
+
async run({ args }) { await runLogsAction(args._ ?? []); },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const updateCmd = defineCommand({
|
|
43
|
+
meta: { name: 'update', description: 'Pull latest images' },
|
|
44
|
+
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
|
+
const state = await ensureStagedState();
|
|
54
|
+
const composeArgs = fullComposeArgs(state);
|
|
55
|
+
const managedServices = buildManagedServiceNames(state);
|
|
56
|
+
console.log('Pulling latest images...');
|
|
57
|
+
await runDockerCompose([...composeArgs, 'pull', ...managedServices]);
|
|
58
|
+
console.log('Recreating containers...');
|
|
59
|
+
await runDockerCompose([...composeArgs, 'up', '-d', '--force-recreate', ...managedServices]);
|
|
60
|
+
console.log('Update complete.');
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const statusCmd = defineCommand({
|
|
65
|
+
meta: { name: 'status', description: 'Show container status' },
|
|
66
|
+
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
|
+
const state = await ensureStagedState();
|
|
76
|
+
await runDockerCompose([...fullComposeArgs(state), 'ps', '--format', 'table']);
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export default defineCommand({
|
|
81
|
+
meta: {
|
|
82
|
+
name: 'service',
|
|
83
|
+
description: 'Service lifecycle operations (start|stop|restart|logs|update|status)',
|
|
84
|
+
},
|
|
85
|
+
subCommands: {
|
|
86
|
+
start: startCmd,
|
|
87
|
+
stop: stopCmd,
|
|
88
|
+
restart: restartCmd,
|
|
89
|
+
logs: logsCmd,
|
|
90
|
+
update: updateCmd,
|
|
91
|
+
status: statusCmd,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { tryAdminRequest } from '../lib/admin.ts';
|
|
3
|
+
import { runDockerCompose } from '../lib/docker.ts';
|
|
4
|
+
import { ensureStagedState, fullComposeArgs, buildManagedServiceNames } from '../lib/staging.ts';
|
|
5
|
+
|
|
6
|
+
export default defineCommand({
|
|
7
|
+
meta: {
|
|
8
|
+
name: 'start',
|
|
9
|
+
description: 'Start services (all or named)',
|
|
10
|
+
},
|
|
11
|
+
args: {
|
|
12
|
+
services: {
|
|
13
|
+
type: 'positional',
|
|
14
|
+
description: 'Service names to start (omit for all)',
|
|
15
|
+
required: false,
|
|
16
|
+
},
|
|
17
|
+
'with-admin': {
|
|
18
|
+
type: 'boolean',
|
|
19
|
+
description: 'Include admin UI and docker-socket-proxy',
|
|
20
|
+
default: false,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
async run({ args }) {
|
|
24
|
+
const services = args._ ?? [];
|
|
25
|
+
const withAdmin = args['with-admin'] ?? false;
|
|
26
|
+
await runStartAction(services, { withAdmin });
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export async function runStartAction(
|
|
31
|
+
services: string[],
|
|
32
|
+
opts?: { withAdmin?: boolean },
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
const withAdmin = opts?.withAdmin ?? false;
|
|
35
|
+
|
|
36
|
+
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
|
|
45
|
+
const state = await ensureStagedState();
|
|
46
|
+
const composeArgs = fullComposeArgs(state);
|
|
47
|
+
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
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Start specific services — try admin first, fall back to direct compose
|
|
59
|
+
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
|
+
const state = await ensureStagedState();
|
|
71
|
+
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
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { tryAdminRequest } from '../lib/admin.ts';
|
|
3
|
+
import { runDockerCompose } from '../lib/docker.ts';
|
|
4
|
+
import { ensureStagedState, fullComposeArgs } from '../lib/staging.ts';
|
|
5
|
+
|
|
6
|
+
export default defineCommand({
|
|
7
|
+
meta: {
|
|
8
|
+
name: 'status',
|
|
9
|
+
description: 'Show container status',
|
|
10
|
+
},
|
|
11
|
+
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
|
+
const state = await ensureStagedState();
|
|
21
|
+
await runDockerCompose([...fullComposeArgs(state), 'ps', '--format', 'table']);
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { tryAdminRequest, getServiceNames } from '../lib/admin.ts';
|
|
3
|
+
import { runDockerCompose } from '../lib/docker.ts';
|
|
4
|
+
import { ensureStagedState, fullComposeArgs } from '../lib/staging.ts';
|
|
5
|
+
|
|
6
|
+
export default defineCommand({
|
|
7
|
+
meta: {
|
|
8
|
+
name: 'stop',
|
|
9
|
+
description: 'Stop services (all or named)',
|
|
10
|
+
},
|
|
11
|
+
args: {
|
|
12
|
+
services: {
|
|
13
|
+
type: 'positional',
|
|
14
|
+
description: 'Service names to stop (omit for all)',
|
|
15
|
+
required: false,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
async run({ args }) {
|
|
19
|
+
const services = args._ ?? [];
|
|
20
|
+
await runStopAction(services);
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export async function runStopAction(services: string[]): Promise<void> {
|
|
25
|
+
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
|
|
45
|
+
const state = await ensureStagedState();
|
|
46
|
+
await runDockerCompose([...fullComposeArgs(state), '--profile', 'admin', 'down']);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Stop specific services
|
|
51
|
+
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
|
+
const state = await ensureStagedState();
|
|
63
|
+
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
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { tryAdminRequest } from '../lib/admin.ts';
|
|
3
|
+
import { runDockerCompose } from '../lib/docker.ts';
|
|
4
|
+
import { ensureStagedState, fullComposeArgs } from '../lib/staging.ts';
|
|
5
|
+
|
|
6
|
+
export default defineCommand({
|
|
7
|
+
meta: {
|
|
8
|
+
name: 'uninstall',
|
|
9
|
+
description: 'Stop and remove the OpenPalm stack (preserves config and data)',
|
|
10
|
+
},
|
|
11
|
+
args: {
|
|
12
|
+
volumes: {
|
|
13
|
+
type: 'boolean',
|
|
14
|
+
description: 'Also remove Docker volumes',
|
|
15
|
+
default: false,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
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
|
|
27
|
+
const state = await ensureStagedState();
|
|
28
|
+
const composeArgs = fullComposeArgs(state);
|
|
29
|
+
const downArgs = args.volumes ? ['down', '-v'] : ['down'];
|
|
30
|
+
await runDockerCompose([...composeArgs, '--profile', 'admin', ...downArgs]);
|
|
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.');
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { tryAdminRequest } from '../lib/admin.ts';
|
|
3
|
+
import { runDockerCompose } from '../lib/docker.ts';
|
|
4
|
+
import { ensureStagedState, fullComposeArgs, buildManagedServiceNames } from '../lib/staging.ts';
|
|
5
|
+
|
|
6
|
+
export default defineCommand({
|
|
7
|
+
meta: {
|
|
8
|
+
name: 'update',
|
|
9
|
+
description: 'Pull latest images and recreate containers',
|
|
10
|
+
},
|
|
11
|
+
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
|
+
const state = await ensureStagedState();
|
|
21
|
+
const composeArgs = fullComposeArgs(state);
|
|
22
|
+
const managedServices = buildManagedServiceNames(state);
|
|
23
|
+
|
|
24
|
+
console.log('Pulling latest images...');
|
|
25
|
+
await runDockerCompose([...composeArgs, 'pull', ...managedServices]);
|
|
26
|
+
|
|
27
|
+
console.log('Recreating containers...');
|
|
28
|
+
await runDockerCompose([...composeArgs, 'up', '-d', '--force-recreate', ...managedServices]);
|
|
29
|
+
|
|
30
|
+
console.log('Update complete.');
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { rm } from 'node:fs/promises';
|
|
4
|
+
import { defaultConfigHome, defaultStateHome } from '../lib/paths.ts';
|
|
5
|
+
import { ensureVarlock, prepareVarlockDir } from '../lib/varlock.ts';
|
|
6
|
+
|
|
7
|
+
export default defineCommand({
|
|
8
|
+
meta: {
|
|
9
|
+
name: 'validate',
|
|
10
|
+
description: 'Validate configuration against schema',
|
|
11
|
+
},
|
|
12
|
+
async run() {
|
|
13
|
+
const stateHome = defaultStateHome();
|
|
14
|
+
const configHome = defaultConfigHome();
|
|
15
|
+
|
|
16
|
+
const primarySchema = join(stateHome, 'artifacts', 'secrets.env.schema');
|
|
17
|
+
const envPath = join(configHome, 'secrets.env');
|
|
18
|
+
|
|
19
|
+
if (!(await Bun.file(primarySchema).exists())) {
|
|
20
|
+
console.error(
|
|
21
|
+
`Error: secrets.env.schema not found at ${primarySchema}.\nRun 'openpalm install' first to stage schema files.`,
|
|
22
|
+
);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!(await Bun.file(envPath).exists())) {
|
|
27
|
+
console.error(
|
|
28
|
+
`Error: secrets.env not found at ${envPath}.\nRun 'openpalm install' first.`,
|
|
29
|
+
);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const varlockBin = await ensureVarlock(stateHome);
|
|
34
|
+
const tmpDir = await prepareVarlockDir(primarySchema, envPath);
|
|
35
|
+
let exitCode = 1;
|
|
36
|
+
try {
|
|
37
|
+
const proc = Bun.spawn(
|
|
38
|
+
[varlockBin, 'load', '--path', `${tmpDir}/`],
|
|
39
|
+
{ stdout: 'inherit', stderr: 'inherit' },
|
|
40
|
+
);
|
|
41
|
+
exitCode = await proc.exited;
|
|
42
|
+
} finally {
|
|
43
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
process.exit(exitCode);
|
|
46
|
+
},
|
|
47
|
+
});
|
package/src/lib/admin.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { loadAdminToken } from './env.ts';
|
|
2
|
+
|
|
3
|
+
export const ADMIN_URL = process.env.OPENPALM_ADMIN_API_URL || 'http://localhost:8100';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns true if the admin health endpoint is reachable.
|
|
7
|
+
*/
|
|
8
|
+
export async function isAdminReachable(): Promise<boolean> {
|
|
9
|
+
try {
|
|
10
|
+
const response = await fetch(`${ADMIN_URL}/health`, {
|
|
11
|
+
method: 'GET',
|
|
12
|
+
signal: AbortSignal.timeout(2000),
|
|
13
|
+
});
|
|
14
|
+
return response.ok;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @deprecated Use isAdminReachable() instead. Kept for backward compatibility.
|
|
22
|
+
*/
|
|
23
|
+
export const isStackRunning = isAdminReachable;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Makes an authenticated request to the admin API.
|
|
27
|
+
* Throws if the response is not ok.
|
|
28
|
+
*/
|
|
29
|
+
export async function adminRequest(path: string, init?: RequestInit): Promise<unknown> {
|
|
30
|
+
const token = await loadAdminToken();
|
|
31
|
+
if (!token) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
'No admin token found. Set OPENPALM_ADMIN_TOKEN in your environment or ' +
|
|
34
|
+
`configure it in secrets.env via the setup wizard (${ADMIN_URL}/setup).`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
const response = await fetch(`${ADMIN_URL}${path}`, {
|
|
38
|
+
...init,
|
|
39
|
+
headers: {
|
|
40
|
+
'Content-Type': 'application/json',
|
|
41
|
+
'X-Requested-By': 'cli',
|
|
42
|
+
'X-Admin-Token': token,
|
|
43
|
+
...init?.headers,
|
|
44
|
+
},
|
|
45
|
+
signal: init?.signal ?? AbortSignal.timeout(120_000),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const text = await response.text();
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
throw new Error(text || `${response.status} ${response.statusText}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!text) return { ok: true };
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(text) as unknown;
|
|
56
|
+
} catch {
|
|
57
|
+
return text;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Try to delegate an operation to the admin API.
|
|
63
|
+
* Returns the result if admin is reachable and has a valid token.
|
|
64
|
+
* Returns null if admin is unreachable or no token is available.
|
|
65
|
+
* Attempts the request directly — no separate health check round-trip.
|
|
66
|
+
*/
|
|
67
|
+
export async function tryAdminRequest(path: string, init?: RequestInit): Promise<unknown | null> {
|
|
68
|
+
const token = await loadAdminToken();
|
|
69
|
+
if (!token) return null;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
return await adminRequest(path, {
|
|
73
|
+
...init,
|
|
74
|
+
signal: init?.signal ?? AbortSignal.timeout(10_000),
|
|
75
|
+
});
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Waits for the admin health endpoint to become healthy (up to 120s).
|
|
83
|
+
*/
|
|
84
|
+
export async function waitForAdminHealthy(): Promise<void> {
|
|
85
|
+
const started = Date.now();
|
|
86
|
+
while (Date.now() - started < 120_000) {
|
|
87
|
+
if (await isAdminReachable()) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
await Bun.sleep(3000);
|
|
91
|
+
}
|
|
92
|
+
throw new Error('Admin did not become healthy within 120 seconds');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Extracts service names from an admin containers/list response.
|
|
97
|
+
*/
|
|
98
|
+
export function getServiceNames(status: unknown): string[] {
|
|
99
|
+
if (!status || typeof status !== 'object' || !('containers' in status)) {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
const containers = (status as { containers?: unknown }).containers;
|
|
103
|
+
if (!containers || typeof containers !== 'object') {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
return Object.keys(containers as Record<string, unknown>);
|
|
107
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ensureXdgDirs } from '@openpalm/lib';
|
|
4
|
+
|
|
5
|
+
const REPO_OWNER = 'itlackey';
|
|
6
|
+
const REPO_NAME = 'openpalm';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates the full XDG directory tree required by the stack.
|
|
10
|
+
* Delegates to @openpalm/lib for core dirs, then adds CLI-specific extras.
|
|
11
|
+
*/
|
|
12
|
+
export async function ensureDirectoryTree(
|
|
13
|
+
_configHome: string,
|
|
14
|
+
_dataHome: string,
|
|
15
|
+
stateHome: string,
|
|
16
|
+
workDir: string,
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
// Core XDG dirs (CONFIG_HOME, DATA_HOME, STATE_HOME subtrees)
|
|
19
|
+
ensureXdgDirs();
|
|
20
|
+
|
|
21
|
+
// CLI-specific extras not in lib
|
|
22
|
+
for (const dir of [
|
|
23
|
+
join(stateHome, 'bin'),
|
|
24
|
+
workDir,
|
|
25
|
+
]) {
|
|
26
|
+
await mkdir(dir, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Downloads an asset from a GitHub release, falling back to raw.githubusercontent.com.
|
|
32
|
+
*/
|
|
33
|
+
export async function fetchAsset(repoRef: string, filename: string): Promise<string> {
|
|
34
|
+
const releaseUrl = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/${repoRef}/${filename}`;
|
|
35
|
+
const rawUrl = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${repoRef}/assets/${filename}`;
|
|
36
|
+
|
|
37
|
+
const releaseResponse = await fetch(releaseUrl, { signal: AbortSignal.timeout(30000) });
|
|
38
|
+
if (releaseResponse.ok) {
|
|
39
|
+
return await releaseResponse.text();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const rawResponse = await fetch(rawUrl, { signal: AbortSignal.timeout(30000) });
|
|
43
|
+
if (rawResponse.ok) {
|
|
44
|
+
return await rawResponse.text();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
throw new Error(`Failed to download ${filename} from ${repoRef}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Runs a `docker compose` command with inherited stdio. Throws on non-zero exit.
|
|
52
|
+
*/
|
|
53
|
+
export async function runDockerCompose(args: string[]): Promise<void> {
|
|
54
|
+
const proc = Bun.spawn(['docker', 'compose', ...args], {
|
|
55
|
+
stdout: 'inherit',
|
|
56
|
+
stderr: 'inherit',
|
|
57
|
+
stdin: 'inherit',
|
|
58
|
+
});
|
|
59
|
+
const code = await proc.exited;
|
|
60
|
+
if (code !== 0) {
|
|
61
|
+
throw new Error(`docker compose ${args.join(' ')} failed with exit code ${code}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Runs a `docker compose` command and captures stdout as a string.
|
|
67
|
+
* Throws on non-zero exit.
|
|
68
|
+
*/
|
|
69
|
+
export async function runDockerComposeCapture(args: string[]): Promise<string> {
|
|
70
|
+
const proc = Bun.spawn(['docker', 'compose', ...args], {
|
|
71
|
+
stdout: 'pipe',
|
|
72
|
+
stderr: 'inherit',
|
|
73
|
+
stdin: 'inherit',
|
|
74
|
+
});
|
|
75
|
+
const output = await new Response(proc.stdout).text();
|
|
76
|
+
const code = await proc.exited;
|
|
77
|
+
if (code !== 0) {
|
|
78
|
+
throw new Error(`docker compose ${args.join(' ')} failed with exit code ${code}`);
|
|
79
|
+
}
|
|
80
|
+
return output;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// composeProjectArgs() removed — use fullComposeArgs(state) from staging.ts instead.
|
|
84
|
+
// That function builds the correct file list including channel overlays and staged env files.
|
|
85
|
+
|
|
86
|
+
// ensureOpenCodeConfig and ensureOpenCodeSystemConfig are imported from @openpalm/lib.
|
|
87
|
+
// See packages/lib/src/control-plane/secrets.ts and core-assets.ts.
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Opens a URL in the user's default browser. Best-effort, never throws.
|
|
91
|
+
*/
|
|
92
|
+
export async function openBrowser(url: string): Promise<void> {
|
|
93
|
+
const platform = process.platform;
|
|
94
|
+
try {
|
|
95
|
+
if (platform === 'darwin') {
|
|
96
|
+
Bun.spawn(['open', url], { stdout: 'ignore', stderr: 'ignore' });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (platform === 'win32') {
|
|
100
|
+
Bun.spawn(['cmd', '/c', 'start', url], { stdout: 'ignore', stderr: 'ignore' });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
Bun.spawn(['xdg-open', url], { stdout: 'ignore', stderr: 'ignore' });
|
|
104
|
+
} catch {
|
|
105
|
+
// Best effort
|
|
106
|
+
}
|
|
107
|
+
}
|