openpalm 0.9.2 → 0.9.4
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 +4 -1
- package/src/commands/install.ts +170 -0
- package/src/commands/logs.ts +36 -0
- package/src/commands/restart.ts +43 -0
- package/src/commands/scan.ts +48 -0
- package/src/commands/service.ts +67 -0
- package/src/commands/start.ts +48 -0
- package/src/commands/status.ts +12 -0
- package/src/commands/stop.ts +45 -0
- package/src/commands/uninstall.ts +12 -0
- package/src/commands/update.ts +12 -0
- package/src/commands/validate.ts +47 -0
- package/src/lib/admin.ts +82 -0
- package/src/lib/docker.ts +180 -0
- package/src/lib/env.ts +196 -0
- package/src/lib/host-info.ts +47 -0
- package/src/lib/paths.ts +37 -0
- package/src/lib/varlock.ts +132 -0
- package/src/main.test.ts +87 -28
- package/src/main.ts +33 -984
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openpalm",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
6
|
"description": "OpenPalm CLI — install and manage a self-hosted OpenPalm stack",
|
|
@@ -22,5 +22,8 @@
|
|
|
22
22
|
"build:darwin-arm64": "bun build src/main.ts --compile --target=bun-darwin-arm64 --outfile dist/openpalm-cli-darwin-arm64",
|
|
23
23
|
"build:windows-x64": "bun build src/main.ts --compile --target=bun-windows-x64 --outfile dist/openpalm-cli-windows-x64.exe",
|
|
24
24
|
"build:windows-arm64": "bun build src/main.ts --compile --target=bun-windows-arm64 --outfile dist/openpalm-cli-windows-arm64.exe"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"citty": "^0.2.1"
|
|
25
28
|
}
|
|
26
29
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { rm } from 'node:fs/promises';
|
|
4
|
+
import cliPkg from '../../package.json' with { type: 'json' };
|
|
5
|
+
import { defaultConfigHome, defaultDataHome, defaultStateHome, defaultWorkDir } from '../lib/paths.ts';
|
|
6
|
+
import { ensureSecrets, ensureStackEnv } from '../lib/env.ts';
|
|
7
|
+
import { ADMIN_URL, isStackRunning, adminRequest, waitForAdminHealthy } from '../lib/admin.ts';
|
|
8
|
+
import { ensureDirectoryTree, fetchAsset, runDockerCompose, composeProjectArgs, ensureOpenCodeConfig, ensureOpenCodeSystemConfig, openBrowser } from '../lib/docker.ts';
|
|
9
|
+
import { ensureVarlock, prepareVarlockDir } from '../lib/varlock.ts';
|
|
10
|
+
import { detectHostInfo } from '../lib/host-info.ts';
|
|
11
|
+
import { loadAdminToken } from '../lib/env.ts';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_INSTALL_REF = cliPkg.version ? `v${cliPkg.version}` : 'main';
|
|
14
|
+
|
|
15
|
+
export default defineCommand({
|
|
16
|
+
meta: {
|
|
17
|
+
name: 'install',
|
|
18
|
+
description: 'Bootstrap XDG dirs, download assets, start admin + docker-socket-proxy, open setup wizard',
|
|
19
|
+
},
|
|
20
|
+
args: {
|
|
21
|
+
force: {
|
|
22
|
+
type: 'boolean',
|
|
23
|
+
description: 'Skip "already installed" check',
|
|
24
|
+
default: false,
|
|
25
|
+
},
|
|
26
|
+
version: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: 'Install specific release ref (default: current CLI version)',
|
|
29
|
+
default: DEFAULT_INSTALL_REF,
|
|
30
|
+
},
|
|
31
|
+
start: {
|
|
32
|
+
type: 'boolean',
|
|
33
|
+
description: 'Start services after install (use --no-start to skip)',
|
|
34
|
+
default: true,
|
|
35
|
+
},
|
|
36
|
+
open: {
|
|
37
|
+
type: 'boolean',
|
|
38
|
+
description: 'Open browser after install (use --no-open to skip)',
|
|
39
|
+
default: true,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
async run({ args }) {
|
|
43
|
+
// If the stack is already running AND we have a valid admin token,
|
|
44
|
+
// delegate to the admin API. Otherwise fall through to bootstrap.
|
|
45
|
+
if (await isStackRunning()) {
|
|
46
|
+
const token = await loadAdminToken();
|
|
47
|
+
if (token) {
|
|
48
|
+
console.log(JSON.stringify(await adminRequest('/admin/install', { method: 'POST' }), null, 2));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// No token available — fall through to bootstrap install which doesn't need auth.
|
|
52
|
+
console.warn('Stack is running but no admin token is configured. Proceeding with bootstrap install.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await bootstrapInstall({
|
|
56
|
+
force: args.force,
|
|
57
|
+
version: args.version,
|
|
58
|
+
noStart: !args.start,
|
|
59
|
+
noOpen: !args.open,
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
type InstallOptions = {
|
|
65
|
+
force: boolean;
|
|
66
|
+
version: string;
|
|
67
|
+
noStart: boolean;
|
|
68
|
+
noOpen: boolean;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export async function bootstrapInstall(options: InstallOptions): Promise<void> {
|
|
72
|
+
if (!Bun.which('docker')) {
|
|
73
|
+
throw new Error('Docker is not installed. Install Docker first: https://docs.docker.com/get-docker/');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const dockerInfo = Bun.spawn(['docker', 'info'], { stdout: 'ignore', stderr: 'ignore' });
|
|
77
|
+
if ((await dockerInfo.exited) !== 0) {
|
|
78
|
+
throw new Error('Docker is not running (or current user lacks permission). Start Docker and retry.');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const composeVersion = Bun.spawn(['docker', 'compose', 'version'], { stdout: 'ignore', stderr: 'ignore' });
|
|
82
|
+
if ((await composeVersion.exited) !== 0) {
|
|
83
|
+
throw new Error('Docker Compose v2 is required. Install it: https://docs.docker.com/compose/install/');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const configHome = defaultConfigHome();
|
|
87
|
+
const dataHome = defaultDataHome();
|
|
88
|
+
const stateHome = defaultStateHome();
|
|
89
|
+
const workDir = defaultWorkDir();
|
|
90
|
+
|
|
91
|
+
const secretsPath = join(configHome, 'secrets.env');
|
|
92
|
+
const updateMode = await Bun.file(secretsPath).exists();
|
|
93
|
+
if (updateMode && !options.force) {
|
|
94
|
+
throw new Error('OpenPalm appears to already be installed. Re-run install with --force to continue.');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await ensureDirectoryTree(configHome, dataHome, stateHome, workDir);
|
|
98
|
+
|
|
99
|
+
// Detect host system info (non-fatal)
|
|
100
|
+
try {
|
|
101
|
+
const hostInfo = await detectHostInfo();
|
|
102
|
+
await Bun.write(join(dataHome, 'host.json'), JSON.stringify(hostInfo, null, 2) + '\n');
|
|
103
|
+
} catch {
|
|
104
|
+
// Host detection failure is non-fatal
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const composeContent = await fetchAsset(options.version, 'docker-compose.yml');
|
|
108
|
+
const caddyContent = await fetchAsset(options.version, 'Caddyfile');
|
|
109
|
+
await Bun.write(join(dataHome, 'docker-compose.yml'), composeContent);
|
|
110
|
+
await Bun.write(join(dataHome, 'caddy', 'Caddyfile'), caddyContent);
|
|
111
|
+
await Bun.write(join(stateHome, 'artifacts', 'docker-compose.yml'), composeContent);
|
|
112
|
+
await Bun.write(join(stateHome, 'artifacts', 'Caddyfile'), caddyContent);
|
|
113
|
+
|
|
114
|
+
const secretsSchemaContent = await fetchAsset(options.version, 'secrets.env.schema');
|
|
115
|
+
const stackSchemaContent = await fetchAsset(options.version, 'stack.env.schema');
|
|
116
|
+
await Bun.write(join(stateHome, 'artifacts', 'secrets.env.schema'), secretsSchemaContent);
|
|
117
|
+
await Bun.write(join(stateHome, 'artifacts', 'stack.env.schema'), stackSchemaContent);
|
|
118
|
+
|
|
119
|
+
await ensureSecrets(configHome);
|
|
120
|
+
await ensureStackEnv(configHome, dataHome, stateHome, workDir, options.version);
|
|
121
|
+
await ensureOpenCodeConfig(configHome);
|
|
122
|
+
await ensureOpenCodeSystemConfig(dataHome);
|
|
123
|
+
|
|
124
|
+
// Non-fatal validation
|
|
125
|
+
try {
|
|
126
|
+
const varlockBin = await ensureVarlock(stateHome);
|
|
127
|
+
const schemaPath = join(stateHome, 'artifacts', 'secrets.env.schema');
|
|
128
|
+
const envPath = join(configHome, 'secrets.env');
|
|
129
|
+
if (await Bun.file(schemaPath).exists()) {
|
|
130
|
+
const tmpDir = await prepareVarlockDir(schemaPath, envPath);
|
|
131
|
+
try {
|
|
132
|
+
const proc = Bun.spawn([varlockBin, 'load', '--path', `${tmpDir}/`], {
|
|
133
|
+
stdout: 'ignore',
|
|
134
|
+
stderr: 'ignore',
|
|
135
|
+
});
|
|
136
|
+
const code = await proc.exited;
|
|
137
|
+
if (code === 0) {
|
|
138
|
+
console.log('Configuration validated.');
|
|
139
|
+
} else {
|
|
140
|
+
console.warn('Configuration has validation warnings (non-fatal on first install).');
|
|
141
|
+
}
|
|
142
|
+
} finally {
|
|
143
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// Varlock install/execution failures are non-fatal during install
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (options.noStart) {
|
|
151
|
+
console.log('OpenPalm files prepared. Run `openpalm start` to start services.');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await runDockerCompose([
|
|
156
|
+
...composeProjectArgs(),
|
|
157
|
+
'up',
|
|
158
|
+
'-d',
|
|
159
|
+
'docker-socket-proxy',
|
|
160
|
+
'admin',
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
await waitForAdminHealthy();
|
|
164
|
+
const targetUrl = updateMode ? `${ADMIN_URL}/` : `${ADMIN_URL}/setup`;
|
|
165
|
+
if (!options.noOpen) {
|
|
166
|
+
await openBrowser(targetUrl);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
console.log(JSON.stringify({ ok: true, mode: updateMode ? 'update' : 'install', url: targetUrl }, null, 2));
|
|
170
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { composeProjectArgs } from '../lib/docker.ts';
|
|
3
|
+
|
|
4
|
+
export async function runLogsAction(services: string[]): Promise<void> {
|
|
5
|
+
const composeArgs = [
|
|
6
|
+
'compose',
|
|
7
|
+
...composeProjectArgs(),
|
|
8
|
+
'logs',
|
|
9
|
+
'--tail',
|
|
10
|
+
'100',
|
|
11
|
+
...services,
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const proc = Bun.spawn(['docker', ...composeArgs], { stdout: 'inherit', stderr: 'inherit', stdin: 'inherit' });
|
|
15
|
+
const exitCode = await proc.exited;
|
|
16
|
+
if (exitCode !== 0) {
|
|
17
|
+
throw new Error(`Docker compose logs command failed (exit code ${exitCode})`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default defineCommand({
|
|
22
|
+
meta: {
|
|
23
|
+
name: 'logs',
|
|
24
|
+
description: 'Tail last 100 log lines for services',
|
|
25
|
+
},
|
|
26
|
+
args: {
|
|
27
|
+
services: {
|
|
28
|
+
type: 'positional',
|
|
29
|
+
description: 'Service names (omit for all)',
|
|
30
|
+
required: false,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
async run({ args }) {
|
|
34
|
+
await runLogsAction(args._ ?? []);
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { adminRequest, getServiceNames } from '../lib/admin.ts';
|
|
3
|
+
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
meta: {
|
|
6
|
+
name: 'restart',
|
|
7
|
+
description: 'Restart services (all or named)',
|
|
8
|
+
},
|
|
9
|
+
args: {
|
|
10
|
+
services: {
|
|
11
|
+
type: 'positional',
|
|
12
|
+
description: 'Service names to restart (omit for all)',
|
|
13
|
+
required: false,
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
async run({ args }) {
|
|
17
|
+
const services = args._ ?? [];
|
|
18
|
+
await runRestartAction(services);
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export async function runRestartAction(services: string[]): Promise<void> {
|
|
23
|
+
if (services.length === 0) {
|
|
24
|
+
const status = await adminRequest('/admin/containers/list');
|
|
25
|
+
const serviceNames = getServiceNames(status);
|
|
26
|
+
for (const service of serviceNames) {
|
|
27
|
+
const result = await adminRequest('/admin/containers/restart', {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
body: JSON.stringify({ service }),
|
|
30
|
+
});
|
|
31
|
+
console.log(JSON.stringify(result, null, 2));
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const service of services) {
|
|
37
|
+
const result = await adminRequest('/admin/containers/restart', {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
body: JSON.stringify({ service }),
|
|
40
|
+
});
|
|
41
|
+
console.log(JSON.stringify(result, null, 2));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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: 'scan',
|
|
10
|
+
description: 'Scan codebase for leaked secrets (requires local secrets.env)',
|
|
11
|
+
},
|
|
12
|
+
async run() {
|
|
13
|
+
const stateHome = defaultStateHome();
|
|
14
|
+
const configHome = defaultConfigHome();
|
|
15
|
+
|
|
16
|
+
const schemaPath = join(stateHome, 'artifacts', 'secrets.env.schema');
|
|
17
|
+
const envPath = join(configHome, 'secrets.env');
|
|
18
|
+
|
|
19
|
+
if (!(await Bun.file(schemaPath).exists())) {
|
|
20
|
+
console.error(
|
|
21
|
+
`Error: secrets.env.schema not found at ${schemaPath}.\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
|
+
|
|
35
|
+
const tmpDir = await prepareVarlockDir(schemaPath, envPath);
|
|
36
|
+
let exitCode = 1;
|
|
37
|
+
try {
|
|
38
|
+
const proc = Bun.spawn([varlockBin, 'scan', '--path', `${tmpDir}/`], {
|
|
39
|
+
stdout: 'inherit',
|
|
40
|
+
stderr: 'inherit',
|
|
41
|
+
});
|
|
42
|
+
exitCode = await proc.exited;
|
|
43
|
+
} finally {
|
|
44
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
process.exit(exitCode);
|
|
47
|
+
},
|
|
48
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { adminRequest } from '../lib/admin.ts';
|
|
3
|
+
import { runLogsAction } from './logs.ts';
|
|
4
|
+
import { runStartAction } from './start.ts';
|
|
5
|
+
import { runStopAction } from './stop.ts';
|
|
6
|
+
import { runRestartAction } from './restart.ts';
|
|
7
|
+
|
|
8
|
+
const startCmd = defineCommand({
|
|
9
|
+
meta: { name: 'start', description: 'Start services' },
|
|
10
|
+
args: {
|
|
11
|
+
services: { type: 'positional', description: 'Service names', required: false },
|
|
12
|
+
},
|
|
13
|
+
async run({ args }) { await runStartAction(args._ ?? []); },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const stopCmd = defineCommand({
|
|
17
|
+
meta: { name: 'stop', description: 'Stop services' },
|
|
18
|
+
args: {
|
|
19
|
+
services: { type: 'positional', description: 'Service names', required: false },
|
|
20
|
+
},
|
|
21
|
+
async run({ args }) { await runStopAction(args._ ?? []); },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const restartCmd = defineCommand({
|
|
25
|
+
meta: { name: 'restart', description: 'Restart services' },
|
|
26
|
+
args: {
|
|
27
|
+
services: { type: 'positional', description: 'Service names', required: false },
|
|
28
|
+
},
|
|
29
|
+
async run({ args }) { await runRestartAction(args._ ?? []); },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const logsCmd = defineCommand({
|
|
33
|
+
meta: { name: 'logs', description: 'View service logs' },
|
|
34
|
+
args: {
|
|
35
|
+
services: { type: 'positional', description: 'Service names', required: false },
|
|
36
|
+
},
|
|
37
|
+
async run({ args }) { await runLogsAction(args._ ?? []); },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const updateCmd = defineCommand({
|
|
41
|
+
meta: { name: 'update', description: 'Pull latest images' },
|
|
42
|
+
async run() {
|
|
43
|
+
console.log(JSON.stringify(await adminRequest('/admin/containers/pull', { method: 'POST' }), null, 2));
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const statusCmd = defineCommand({
|
|
48
|
+
meta: { name: 'status', description: 'Show container status' },
|
|
49
|
+
async run() {
|
|
50
|
+
console.log(JSON.stringify(await adminRequest('/admin/containers/list'), null, 2));
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export default defineCommand({
|
|
55
|
+
meta: {
|
|
56
|
+
name: 'service',
|
|
57
|
+
description: 'Service lifecycle operations (start|stop|restart|logs|update|status)',
|
|
58
|
+
},
|
|
59
|
+
subCommands: {
|
|
60
|
+
start: startCmd,
|
|
61
|
+
stop: stopCmd,
|
|
62
|
+
restart: restartCmd,
|
|
63
|
+
logs: logsCmd,
|
|
64
|
+
update: updateCmd,
|
|
65
|
+
status: statusCmd,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { adminRequest, isStackRunning } from '../lib/admin.ts';
|
|
3
|
+
import { loadAdminToken } from '../lib/env.ts';
|
|
4
|
+
import { runDockerCompose, composeProjectArgs } from '../lib/docker.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
|
+
},
|
|
18
|
+
async run({ args }) {
|
|
19
|
+
const services = args._ ?? [];
|
|
20
|
+
await runStartAction(services);
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export async function runStartAction(services: string[]): Promise<void> {
|
|
25
|
+
if (services.length === 0) {
|
|
26
|
+
// If admin is reachable and we have a token, use the admin API.
|
|
27
|
+
// Otherwise fall back to docker compose up directly — this handles
|
|
28
|
+
// the fresh-install case where no token exists yet.
|
|
29
|
+
const running = await isStackRunning();
|
|
30
|
+
const token = await loadAdminToken();
|
|
31
|
+
if (running && token) {
|
|
32
|
+
console.log(JSON.stringify(await adminRequest('/admin/install', { method: 'POST' }), null, 2));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Direct docker compose — works without auth
|
|
37
|
+
await runDockerCompose([...composeProjectArgs(), 'up', '-d']);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const service of services) {
|
|
42
|
+
const result = await adminRequest('/admin/containers/up', {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
body: JSON.stringify({ service }),
|
|
45
|
+
});
|
|
46
|
+
console.log(JSON.stringify(result, null, 2));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { adminRequest } from '../lib/admin.ts';
|
|
3
|
+
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
meta: {
|
|
6
|
+
name: 'status',
|
|
7
|
+
description: 'Show container status',
|
|
8
|
+
},
|
|
9
|
+
async run() {
|
|
10
|
+
console.log(JSON.stringify(await adminRequest('/admin/containers/list'), null, 2));
|
|
11
|
+
},
|
|
12
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { adminRequest, isStackRunning } from '../lib/admin.ts';
|
|
3
|
+
import { loadAdminToken } from '../lib/env.ts';
|
|
4
|
+
import { runDockerCompose, composeProjectArgs } from '../lib/docker.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
|
+
const running = await isStackRunning();
|
|
27
|
+
const token = await loadAdminToken();
|
|
28
|
+
if (running && token) {
|
|
29
|
+
console.log(JSON.stringify(await adminRequest('/admin/uninstall', { method: 'POST' }), null, 2));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Direct docker compose — works without auth
|
|
34
|
+
await runDockerCompose([...composeProjectArgs(), 'down']);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const service of services) {
|
|
39
|
+
const result = await adminRequest('/admin/containers/down', {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
body: JSON.stringify({ service }),
|
|
42
|
+
});
|
|
43
|
+
console.log(JSON.stringify(result, null, 2));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { adminRequest } from '../lib/admin.ts';
|
|
3
|
+
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
meta: {
|
|
6
|
+
name: 'uninstall',
|
|
7
|
+
description: 'Stop and remove the OpenPalm stack (preserves config and data)',
|
|
8
|
+
},
|
|
9
|
+
async run() {
|
|
10
|
+
console.log(JSON.stringify(await adminRequest('/admin/uninstall', { method: 'POST' }), null, 2));
|
|
11
|
+
},
|
|
12
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { adminRequest } from '../lib/admin.ts';
|
|
3
|
+
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
meta: {
|
|
6
|
+
name: 'update',
|
|
7
|
+
description: 'Pull latest images and recreate containers',
|
|
8
|
+
},
|
|
9
|
+
async run() {
|
|
10
|
+
console.log(JSON.stringify(await adminRequest('/admin/containers/pull', { method: 'POST' }), null, 2));
|
|
11
|
+
},
|
|
12
|
+
});
|
|
@@ -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,82 @@
|
|
|
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 isStackRunning(): 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
|
+
* Makes an authenticated request to the admin API.
|
|
22
|
+
* Throws if the response is not ok.
|
|
23
|
+
*/
|
|
24
|
+
export async function adminRequest(path: string, init?: RequestInit): Promise<unknown> {
|
|
25
|
+
const token = await loadAdminToken();
|
|
26
|
+
if (!token) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
'No admin token found. Set OPENPALM_ADMIN_TOKEN in your environment or ' +
|
|
29
|
+
`configure it in secrets.env via the setup wizard (${ADMIN_URL}/setup).`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
const response = await fetch(`${ADMIN_URL}${path}`, {
|
|
33
|
+
...init,
|
|
34
|
+
headers: {
|
|
35
|
+
'Content-Type': 'application/json',
|
|
36
|
+
'X-Requested-By': 'cli',
|
|
37
|
+
'X-Admin-Token': token,
|
|
38
|
+
...init?.headers,
|
|
39
|
+
},
|
|
40
|
+
signal: init?.signal ?? AbortSignal.timeout(120_000),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const text = await response.text();
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
throw new Error(text || `${response.status} ${response.statusText}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!text) return { ok: true };
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(text) as unknown;
|
|
51
|
+
} catch {
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Waits for the admin health endpoint to become healthy (up to 120s).
|
|
58
|
+
*/
|
|
59
|
+
export async function waitForAdminHealthy(): Promise<void> {
|
|
60
|
+
const started = Date.now();
|
|
61
|
+
while (Date.now() - started < 120_000) {
|
|
62
|
+
if (await isStackRunning()) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
await Bun.sleep(3000);
|
|
66
|
+
}
|
|
67
|
+
throw new Error('Admin did not become healthy within 120 seconds');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Extracts service names from an admin containers/list response.
|
|
72
|
+
*/
|
|
73
|
+
export function getServiceNames(status: unknown): string[] {
|
|
74
|
+
if (!status || typeof status !== 'object' || !('containers' in status)) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
const containers = (status as { containers?: unknown }).containers;
|
|
78
|
+
if (!containers || typeof containers !== 'object') {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
return Object.keys(containers as Record<string, unknown>);
|
|
82
|
+
}
|