openpalm 0.9.1 → 0.9.2-rc3

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 CHANGED
@@ -1,26 +1,53 @@
1
1
  # @openpalm/cli
2
2
 
3
- Bun CLI for bootstrapping and managing an OpenPalm installation. Handles the initial install (directory creation, asset download, secret seeding) and delegates to the Admin API once the stack is running.
3
+ Bun CLI for bootstrapping and managing an OpenPalm installation. The CLI is the primary orchestrator -- all commands work without the admin container. When admin is running, commands optionally delegate to the admin API.
4
+
5
+ ## Self-Sufficient Mode
6
+
7
+ The CLI operates directly against Docker Compose without requiring an admin container:
8
+
9
+ - **Install** -- creates XDG dirs, downloads assets, serves the setup wizard locally via `Bun.serve()`, stages artifacts, starts core services
10
+ - **All lifecycle commands** -- stage artifacts from `@openpalm/lib` using `FilesystemAssetProvider`, then run Docker Compose directly
11
+ - **Admin delegation** -- the `install` command checks for a running admin and delegates if reachable. Other commands operate directly via Docker Compose.
12
+
13
+ The admin container is optional. Use `--with-admin` to include the admin UI profile.
4
14
 
5
15
  ## Commands
6
16
 
7
17
  | Command | Description |
8
18
  |---|---|
9
- | `openpalm install` | Bootstrap XDG dirs, download assets, start admin + docker-socket-proxy, open setup wizard |
19
+ | `openpalm install` | Bootstrap XDG dirs, download assets, run setup wizard, start core services |
10
20
  | `openpalm uninstall` | Stop and remove the stack (preserves config and data) |
11
21
  | `openpalm update` | Pull latest images and recreate containers |
12
22
  | `openpalm start [svc...]` | Start all or named services |
23
+ | `openpalm start --with-admin` | Start all services including admin UI and docker-socket-proxy |
13
24
  | `openpalm stop [svc...]` | Stop all or named services |
14
25
  | `openpalm restart [svc...]` | Restart all or named services |
15
26
  | `openpalm logs [svc...]` | Tail last 100 log lines |
16
27
  | `openpalm status` | Show container status |
17
- | `openpalm service <sub> [svc...]` | Alias subcommands: `start`, `stop`, `restart`, `logs`, `status`, `update` |
28
+ | `openpalm service <sub> [svc...]` | Alias -- subcommands: `start`, `stop`, `restart`, `logs`, `status`, `update` |
29
+ | `openpalm validate` | Validate `secrets.env` against the schema (requires prior install) |
30
+ | `openpalm scan` | Scan for leaked secrets in config files |
18
31
 
19
32
  ### Install options
20
33
 
21
- `--force` skip "already installed" check, `--version TAG` install a specific ref (default `main`), `--no-start` prepare files only, `--no-open` skip browser launch.
34
+ `--force` skip "already installed" check, `--version TAG` install a specific ref (default: current CLI version), `--no-start` prepare files only, `--no-open` skip browser launch.
35
+
36
+ ### Admin profile
37
+
38
+ Admin and docker-socket-proxy use Docker Compose profiles. They start only when explicitly requested:
39
+
40
+ ```bash
41
+ openpalm start --with-admin # Start core + admin profile
42
+ openpalm start admin # Start admin service specifically
43
+ openpalm stop admin # Stop admin service specifically
44
+ ```
45
+
46
+ ## Setup Wizard
47
+
48
+ On first install, the CLI serves a setup wizard on port 8100 via `Bun.serve()`. The wizard runs entirely in the browser (vanilla HTML/JS) and calls `performSetup()` from `@openpalm/lib` to write secrets, connection profiles, memory config, and stage artifacts. No admin container is involved.
22
49
 
23
- ## Environment variables
50
+ ## Environment Variables
24
51
 
25
52
  | Variable | Default | Purpose |
26
53
  |---|---|---|
@@ -28,20 +55,21 @@ Bun CLI for bootstrapping and managing an OpenPalm installation. Handles the ini
28
55
  | `OPENPALM_DATA_HOME` | `~/.local/share/openpalm` | Persistent service data |
29
56
  | `OPENPALM_STATE_HOME` | `~/.local/state/openpalm` | Assembled runtime |
30
57
  | `OPENPALM_WORK_DIR` | `~/openpalm` | Assistant working directory |
31
- | `OPENPALM_ADMIN_API_URL` | `http://localhost:8100` | Admin API endpoint |
58
+ | `OPENPALM_ADMIN_API_URL` | `http://localhost:8100` | Admin API endpoint (for optional delegation) |
32
59
  | `OPENPALM_ADMIN_TOKEN` | (from `secrets.env`) | Admin API auth token |
33
60
 
34
- ## How it works
61
+ ## How It Works
35
62
 
36
- 1. **Bootstrap** (no stack running) creates XDG directory tree, downloads `docker-compose.yml` + `Caddyfile` from GitHub, seeds `secrets.env` and `stack.env`, starts core services via `docker compose`
37
- 2. **Running stack** all commands delegate to the Admin API (`/admin/install`, `/admin/containers/*`, etc.) using `x-admin-token` auth
63
+ 1. **Bootstrap** (first install) -- creates XDG directory tree, downloads core assets from GitHub, seeds `secrets.env` and `stack.env`, serves setup wizard, stages artifacts via `@openpalm/lib`, starts core services via `docker compose up`
64
+ 2. **Running stack** -- commands stage artifacts locally using `FilesystemAssetProvider`, then execute Docker Compose directly.
65
+ 3. **Admin absent** -- all commands work identically. Admin is never required for any operation.
38
66
 
39
- Follows the file-assembly principle: copies whole files, never renders templates. See [`docs/core-principles.md`](../../docs/technical/core-principles.md).
67
+ Follows the file-assembly principle: copies whole files, never renders templates. See [`core-principles.md`](../../docs/technical/core-principles.md).
40
68
 
41
69
  ## Building
42
70
 
43
71
  ```bash
44
- bun run build # Current platform dist/openpalm-cli
72
+ bun run build # Current platform -> dist/openpalm-cli
45
73
  bun run build:linux-x64 # Cross-compile (also: linux-arm64, darwin-x64, darwin-arm64, windows-x64, windows-arm64)
46
74
  ```
47
75
 
@@ -53,4 +81,4 @@ bun run start -- install --no-start
53
81
  bun test
54
82
  ```
55
83
 
56
- See also: [`scripts/install.sh`](../../scripts/install.sh) (binary installer), [`scripts/setup.sh`](../../scripts/setup.sh) (shell-based installer).
84
+ See also: [`scripts/setup.sh`](../../scripts/setup.sh) (shell-based installer).
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Bun-only launcher for the CLI setup wizard server.
3
+ *
4
+ * Called from Playwright tests as a child process:
5
+ * bun run packages/cli/e2e/start-wizard-server.ts <port>
6
+ *
7
+ * Starts the wizard server on the given port with a temp config directory
8
+ * so tests do not affect real dev state. Prints "WIZARD_READY:<port>" to
9
+ * stdout when listening, which the Playwright test waits for.
10
+ */
11
+ import { createSetupServer } from "../src/setup-wizard/server.ts";
12
+ import { mkdirSync, writeFileSync } from "node:fs";
13
+ import type { CoreAssetProvider } from "@openpalm/lib";
14
+
15
+ const port = parseInt(Bun.argv[2] || "18100", 10);
16
+ const tmpBase = `/tmp/openpalm-wizard-test-${port}`;
17
+
18
+ // Create minimal directory structure so the server can start.
19
+ // API endpoints that need real files are mocked at the browser level
20
+ // by Playwright's page.route(), so these dirs just prevent crashes.
21
+ mkdirSync(`${tmpBase}/config`, { recursive: true });
22
+ mkdirSync(`${tmpBase}/data`, { recursive: true });
23
+ mkdirSync(`${tmpBase}/state/artifacts`, { recursive: true });
24
+
25
+ writeFileSync(`${tmpBase}/config/secrets.env`, "# test\n");
26
+ writeFileSync(`${tmpBase}/state/artifacts/stack.env`, "OPENPALM_SETUP_COMPLETE=false\n");
27
+
28
+ // No-op asset provider — mocked tests intercept API calls before they
29
+ // reach performSetup(), so these methods are never invoked.
30
+ const noopAssetProvider: CoreAssetProvider = {
31
+ coreCompose: () => "",
32
+ caddyfile: () => "",
33
+ ollamaCompose: () => "",
34
+ agentsMd: () => "",
35
+ opencodeConfig: () => "",
36
+ adminOpencodeConfig: () => "",
37
+ secretsSchema: () => "",
38
+ stackSchema: () => "",
39
+ cleanupLogs: () => "",
40
+ cleanupData: () => "",
41
+ validateConfig: () => "",
42
+ };
43
+
44
+ // Override state/config home so the server doesn't touch real dirs.
45
+ process.env.OPENPALM_CONFIG_HOME = `${tmpBase}/config`;
46
+ process.env.OPENPALM_STATE_HOME = `${tmpBase}/state`;
47
+ process.env.OPENPALM_DATA_HOME = `${tmpBase}/data`;
48
+
49
+ const { server } = createSetupServer(port, {
50
+ configDir: `${tmpBase}/config`,
51
+ assetProvider: noopAssetProvider,
52
+ });
53
+
54
+ console.log(`WIZARD_READY:${port}`);
55
+
56
+ // Keep alive until killed
57
+ process.on("SIGTERM", () => {
58
+ server.stop();
59
+ process.exit(0);
60
+ });
61
+ process.on("SIGINT", () => {
62
+ server.stop();
63
+ process.exit(0);
64
+ });
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "openpalm",
3
- "version": "0.9.1",
3
+ "version": "0.9.2-rc3",
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
6
  "description": "OpenPalm CLI — install and manage a self-hosted OpenPalm stack",
7
- "repository": {
7
+ "repository": {
8
8
  "type": "git",
9
9
  "url": "https://github.com/itlackey/openpalm.git",
10
10
  "directory": "packages/cli"
@@ -22,5 +22,9 @@
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
+ "@openpalm/lib": "0.9.4",
28
+ "citty": "^0.2.1"
25
29
  }
26
30
  }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { buildDeployStatusEntries, buildInstallServiceNames } from './install-services.ts';
3
+
4
+ describe('install service helpers', () => {
5
+ it('appends admin services to managed services', () => {
6
+ expect(buildInstallServiceNames(['caddy', 'memory'])).toEqual([
7
+ 'caddy',
8
+ 'memory',
9
+ 'admin',
10
+ 'docker-socket-proxy',
11
+ ]);
12
+ });
13
+
14
+ it('builds deploy status entries for the full install service list', () => {
15
+ const services = buildInstallServiceNames(['caddy', 'memory']);
16
+
17
+ expect(buildDeployStatusEntries(services, 'pending', 'Waiting...')).toEqual([
18
+ { service: 'caddy', status: 'pending', label: 'Waiting...' },
19
+ { service: 'memory', status: 'pending', label: 'Waiting...' },
20
+ { service: 'admin', status: 'pending', label: 'Waiting...' },
21
+ { service: 'docker-socket-proxy', status: 'pending', label: 'Waiting...' },
22
+ ]);
23
+ });
24
+ });
@@ -0,0 +1,13 @@
1
+ export type DeployStatusState = 'pending' | 'pulling';
2
+
3
+ export function buildInstallServiceNames(managedServices: string[]): string[] {
4
+ return [...managedServices, 'admin', 'docker-socket-proxy'];
5
+ }
6
+
7
+ export function buildDeployStatusEntries(
8
+ services: string[],
9
+ status: DeployStatusState,
10
+ label: string,
11
+ ): Array<{ service: string; status: DeployStatusState; label: string }> {
12
+ return services.map(service => ({ service, status, label }));
13
+ }
@@ -0,0 +1,274 @@
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 { isAdminReachable, adminRequest } from '../lib/admin.ts';
8
+ import { ensureDirectoryTree, fetchAsset, runDockerCompose, openBrowser } from '../lib/docker.ts';
9
+ import { ensureOpenCodeConfig, ensureOpenCodeSystemConfig, ensureAdminOpenCodeConfig, FilesystemAssetProvider } from '@openpalm/lib';
10
+ import { ensureVarlock, prepareVarlockDir } from '../lib/varlock.ts';
11
+ import { detectHostInfo } from '../lib/host-info.ts';
12
+ import { loadAdminToken } from '../lib/env.ts';
13
+ import { ensureStagedState, fullComposeArgs, buildManagedServiceNames } from '../lib/staging.ts';
14
+ import { createSetupServer } from '../setup-wizard/server.ts';
15
+ import { buildInstallServiceNames, buildDeployStatusEntries } from './install-services.ts';
16
+
17
+ const DEFAULT_INSTALL_REF = cliPkg.version ? `v${cliPkg.version}` : 'main';
18
+ const SETUP_WIZARD_PORT = 8100;
19
+
20
+ export default defineCommand({
21
+ meta: {
22
+ name: 'install',
23
+ description: 'Bootstrap XDG dirs, download assets, run setup wizard, start core services',
24
+ },
25
+ args: {
26
+ force: {
27
+ type: 'boolean',
28
+ description: 'Skip "already installed" check',
29
+ default: false,
30
+ },
31
+ version: {
32
+ type: 'string',
33
+ description: 'Install specific release ref (default: current CLI version)',
34
+ default: DEFAULT_INSTALL_REF,
35
+ },
36
+ start: {
37
+ type: 'boolean',
38
+ description: 'Start services after install (use --no-start to skip)',
39
+ default: true,
40
+ },
41
+ open: {
42
+ type: 'boolean',
43
+ description: 'Open browser after install (use --no-open to skip)',
44
+ default: true,
45
+ },
46
+ },
47
+ async run({ args }) {
48
+ // If the stack is already running AND we have a valid admin token,
49
+ // delegate to the admin API. Otherwise fall through to bootstrap.
50
+ if (await isAdminReachable()) {
51
+ const token = await loadAdminToken();
52
+ if (token) {
53
+ console.log(JSON.stringify(await adminRequest('/admin/install', { method: 'POST' }), null, 2));
54
+ return;
55
+ }
56
+ // No token available — fall through to bootstrap install which doesn't need auth.
57
+ console.warn('Stack is running but no admin token is configured. Proceeding with bootstrap install.');
58
+ }
59
+
60
+ await bootstrapInstall({
61
+ force: args.force,
62
+ version: args.version,
63
+ noStart: !args.start,
64
+ noOpen: !args.open,
65
+ });
66
+ },
67
+ });
68
+
69
+ type InstallOptions = {
70
+ force: boolean;
71
+ version: string;
72
+ noStart: boolean;
73
+ noOpen: boolean;
74
+ };
75
+
76
+ export async function bootstrapInstall(options: InstallOptions): Promise<void> {
77
+ if (!Bun.which('docker')) {
78
+ throw new Error('Docker is not installed. Install Docker first: https://docs.docker.com/get-docker/');
79
+ }
80
+
81
+ const dockerInfo = Bun.spawn(['docker', 'info'], { stdout: 'ignore', stderr: 'ignore' });
82
+ if ((await dockerInfo.exited) !== 0) {
83
+ throw new Error('Docker is not running (or current user lacks permission). Start Docker and retry.');
84
+ }
85
+
86
+ const composeVersion = Bun.spawn(['docker', 'compose', 'version'], { stdout: 'ignore', stderr: 'ignore' });
87
+ if ((await composeVersion.exited) !== 0) {
88
+ throw new Error('Docker Compose v2 is required. Install it: https://docs.docker.com/compose/install/');
89
+ }
90
+
91
+ const configHome = defaultConfigHome();
92
+ const dataHome = defaultDataHome();
93
+ const stateHome = defaultStateHome();
94
+ const workDir = defaultWorkDir();
95
+
96
+ const secretsPath = join(configHome, 'secrets.env');
97
+ const updateMode = await Bun.file(secretsPath).exists();
98
+ if (updateMode && !options.force) {
99
+ throw new Error('OpenPalm appears to already be installed. Re-run install with --force to continue.');
100
+ }
101
+
102
+ await ensureDirectoryTree(configHome, dataHome, stateHome, workDir);
103
+
104
+ // Detect host system info (non-fatal)
105
+ try {
106
+ const hostInfo = await detectHostInfo();
107
+ await Bun.write(join(dataHome, 'host.json'), JSON.stringify(hostInfo, null, 2) + '\n');
108
+ } catch {
109
+ // Host detection failure is non-fatal
110
+ }
111
+
112
+ const composeContent = await fetchAsset(options.version, 'docker-compose.yml');
113
+ const caddyContent = await fetchAsset(options.version, 'Caddyfile');
114
+ await Bun.write(join(dataHome, 'docker-compose.yml'), composeContent);
115
+ await Bun.write(join(dataHome, 'caddy', 'Caddyfile'), caddyContent);
116
+ await Bun.write(join(stateHome, 'artifacts', 'docker-compose.yml'), composeContent);
117
+ await Bun.write(join(stateHome, 'artifacts', 'Caddyfile'), caddyContent);
118
+
119
+ // Download schemas to both DATA_HOME (for FilesystemAssetProvider) and STATE_HOME (for varlock validation)
120
+ const secretsSchemaContent = await fetchAsset(options.version, 'secrets.env.schema');
121
+ const stackSchemaContent = await fetchAsset(options.version, 'stack.env.schema');
122
+ await Bun.write(join(dataHome, 'secrets.env.schema'), secretsSchemaContent);
123
+ await Bun.write(join(dataHome, 'stack.env.schema'), stackSchemaContent);
124
+ await Bun.write(join(stateHome, 'artifacts', 'secrets.env.schema'), secretsSchemaContent);
125
+ await Bun.write(join(stateHome, 'artifacts', 'stack.env.schema'), stackSchemaContent);
126
+
127
+ // Download remaining assets needed by FilesystemAssetProvider
128
+ const assetFiles: Array<{ remote: string; localPath: string }> = [
129
+ { remote: 'ollama.yml', localPath: join(dataHome, 'ollama.yml') },
130
+ { remote: 'AGENTS.md', localPath: join(dataHome, 'assistant', 'AGENTS.md') },
131
+ { remote: 'opencode.jsonc', localPath: join(dataHome, 'assistant', 'opencode.jsonc') },
132
+ { remote: 'admin-opencode.jsonc', localPath: join(dataHome, 'admin', 'opencode.jsonc') },
133
+ { remote: 'cleanup-logs.yml', localPath: join(dataHome, 'automations', 'cleanup-logs.yml') },
134
+ { remote: 'cleanup-data.yml', localPath: join(dataHome, 'automations', 'cleanup-data.yml') },
135
+ { remote: 'validate-config.yml', localPath: join(dataHome, 'automations', 'validate-config.yml') },
136
+ ];
137
+ await Promise.all(
138
+ assetFiles.map(async ({ remote, localPath }) => {
139
+ try {
140
+ const content = await fetchAsset(options.version, remote);
141
+ await Bun.write(localPath, content);
142
+ } catch (err) {
143
+ console.warn(`Warning: could not download asset '${remote}': ${err instanceof Error ? err.message : String(err)}`);
144
+ }
145
+ }),
146
+ );
147
+
148
+ await ensureSecrets(configHome);
149
+ await ensureStackEnv(configHome, dataHome, stateHome, workDir, options.version);
150
+ // Seed OpenCode config — non-fatal since performSetup() also seeds these
151
+ try {
152
+ const fsAssets = new FilesystemAssetProvider(dataHome);
153
+ ensureOpenCodeConfig();
154
+ ensureOpenCodeSystemConfig(fsAssets);
155
+ ensureAdminOpenCodeConfig(fsAssets);
156
+ } catch {
157
+ // Assets may not be available yet on first install; performSetup() will retry
158
+ }
159
+
160
+ // Non-fatal validation
161
+ try {
162
+ const varlockBin = await ensureVarlock(stateHome);
163
+ const schemaPath = join(stateHome, 'artifacts', 'secrets.env.schema');
164
+ const envPath = join(configHome, 'secrets.env');
165
+ if (await Bun.file(schemaPath).exists()) {
166
+ const tmpDir = await prepareVarlockDir(schemaPath, envPath);
167
+ try {
168
+ const proc = Bun.spawn([varlockBin, 'load', '--path', `${tmpDir}/`], {
169
+ stdout: 'ignore',
170
+ stderr: 'ignore',
171
+ });
172
+ const code = await proc.exited;
173
+ if (code === 0) {
174
+ console.log('Configuration validated.');
175
+ } else {
176
+ console.warn('Configuration has validation warnings (non-fatal on first install).');
177
+ }
178
+ } finally {
179
+ await rm(tmpDir, { recursive: true, force: true });
180
+ }
181
+ }
182
+ } catch {
183
+ // Varlock install/execution failures are non-fatal during install
184
+ }
185
+
186
+ if (options.noStart) {
187
+ console.log('OpenPalm files prepared. Run `openpalm start` to start services.');
188
+ return;
189
+ }
190
+
191
+ // ── Setup Wizard ──────────────────────────────────────────────────────
192
+ // First-time install: serve the setup wizard locally and wait for user
193
+ // to complete it. The wizard calls performSetup() from @openpalm/lib
194
+ // which writes secrets, connection profiles, memory config, and stages
195
+ // all artifacts. No admin container needed.
196
+
197
+ if (!updateMode) {
198
+ console.log('Starting setup wizard...');
199
+
200
+ const wizard = createSetupServer(SETUP_WIZARD_PORT, { configDir: configHome });
201
+ const wizardUrl = `http://localhost:${wizard.server.port}/setup`;
202
+ console.log(`Setup wizard running at ${wizardUrl}`);
203
+
204
+ if (!options.noOpen) {
205
+ await openBrowser(wizardUrl);
206
+ }
207
+
208
+ // Block until user completes the wizard
209
+ const result = await wizard.waitForComplete();
210
+
211
+ if (!result.ok) {
212
+ wizard.stop();
213
+ throw new Error(`Setup failed: ${result.error ?? 'unknown error'}`);
214
+ }
215
+
216
+ console.log('Setup complete. Starting services...');
217
+
218
+ // Keep wizard server running for deploy status polling from the browser.
219
+ // Stage artifacts and start services while the wizard shows progress.
220
+ try {
221
+ const state = await ensureStagedState();
222
+ const composeArgs = fullComposeArgs(state);
223
+ const managedServices = buildManagedServiceNames(state);
224
+ const allServices = buildInstallServiceNames(managedServices);
225
+
226
+ wizard.updateDeployStatus(buildDeployStatusEntries(allServices, 'pending', 'Waiting...'));
227
+
228
+ await runDockerCompose([...composeArgs, '--profile', 'admin', 'pull', ...allServices]).catch(() => {
229
+ // Pull failure is non-fatal — images may already be cached
230
+ });
231
+
232
+ wizard.updateDeployStatus(buildDeployStatusEntries(allServices, 'pulling', 'Starting...'));
233
+
234
+ await runDockerCompose([...composeArgs, '--profile', 'admin', 'up', '-d', ...allServices]);
235
+
236
+ wizard.markAllRunning();
237
+
238
+ console.log(JSON.stringify({
239
+ ok: true,
240
+ mode: 'install',
241
+ services: allServices,
242
+ }, null, 2));
243
+
244
+ // Give the browser a moment to poll the final status, then stop
245
+ await new Promise(resolve => setTimeout(resolve, 3000));
246
+ } catch (err) {
247
+ wizard.setDeployError(String(err));
248
+ // Keep server alive briefly so user can see the error
249
+ await new Promise(resolve => setTimeout(resolve, 10000));
250
+ throw err;
251
+ } finally {
252
+ wizard.stop();
253
+ }
254
+
255
+ return;
256
+ }
257
+
258
+ // ── Start Core Services (update mode — no wizard) ────────────────────
259
+ // Stage artifacts and start all managed services directly via Docker
260
+ // Compose. No admin container required for lifecycle operations.
261
+
262
+ const state = await ensureStagedState();
263
+ const composeArgs = fullComposeArgs(state);
264
+ const managedServices = buildManagedServiceNames(state);
265
+ const allServices = buildInstallServiceNames(managedServices);
266
+
267
+ await runDockerCompose([...composeArgs, '--profile', 'admin', 'up', '-d', ...allServices]);
268
+
269
+ console.log(JSON.stringify({
270
+ ok: true,
271
+ mode: 'update',
272
+ services: allServices,
273
+ }, null, 2));
274
+ }
@@ -0,0 +1,25 @@
1
+ import { defineCommand } from 'citty';
2
+ import { runDockerCompose } from '../lib/docker.ts';
3
+ import { ensureStagedState, fullComposeArgs } from '../lib/staging.ts';
4
+
5
+ export async function runLogsAction(services: string[]): Promise<void> {
6
+ const state = await ensureStagedState();
7
+ await runDockerCompose([...fullComposeArgs(state), 'logs', '--tail', '100', ...services]);
8
+ }
9
+
10
+ export default defineCommand({
11
+ meta: {
12
+ name: 'logs',
13
+ description: 'Tail last 100 log lines for services',
14
+ },
15
+ args: {
16
+ services: {
17
+ type: 'positional',
18
+ description: 'Service names (omit for all)',
19
+ required: false,
20
+ },
21
+ },
22
+ async run({ args }) {
23
+ await runLogsAction(args._ ?? []);
24
+ },
25
+ });
@@ -0,0 +1,71 @@
1
+ import { defineCommand } from 'citty';
2
+ import { tryAdminRequest, getServiceNames } 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: 'restart',
9
+ description: 'Restart services (all or named)',
10
+ },
11
+ args: {
12
+ services: {
13
+ type: 'positional',
14
+ description: 'Service names to restart (omit for all)',
15
+ required: false,
16
+ },
17
+ },
18
+ async run({ args }) {
19
+ const services = args._ ?? [];
20
+ await runRestartAction(services);
21
+ },
22
+ });
23
+
24
+ export async function runRestartAction(services: string[]): Promise<void> {
25
+ if (services.length === 0) {
26
+ // Try admin delegation first
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/restart', {
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 restart ${service} via admin API`);
39
+ }
40
+ }
41
+ return;
42
+ }
43
+
44
+ // Direct compose restart
45
+ const state = await ensureStagedState();
46
+ const managedServices = buildManagedServiceNames(state);
47
+ await runDockerCompose([...fullComposeArgs(state), 'restart', ...managedServices]);
48
+ return;
49
+ }
50
+
51
+ // Restart specific services
52
+ for (const service of services) {
53
+ const adminResult = await tryAdminRequest('/admin/containers/restart', {
54
+ method: 'POST',
55
+ body: JSON.stringify({ service }),
56
+ });
57
+ if (adminResult !== null) {
58
+ console.log(JSON.stringify(adminResult, null, 2));
59
+ continue;
60
+ }
61
+
62
+ // Direct compose restart for specific service
63
+ const state = await ensureStagedState();
64
+ const composeArgs = fullComposeArgs(state);
65
+ if (service === 'admin' || service === 'docker-socket-proxy') {
66
+ await runDockerCompose([...composeArgs, '--profile', 'admin', 'restart', service]);
67
+ } else {
68
+ await runDockerCompose([...composeArgs, 'restart', service]);
69
+ }
70
+ }
71
+ }
@@ -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
+ });