openpalm 0.9.4 → 0.9.5

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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openpalm",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
6
  "description": "OpenPalm CLI — install and manage a self-hosted OpenPalm stack",
@@ -24,6 +24,7 @@
24
24
  "build:windows-arm64": "bun build src/main.ts --compile --target=bun-windows-arm64 --outfile dist/openpalm-cli-windows-arm64.exe"
25
25
  },
26
26
  "dependencies": {
27
+ "@openpalm/lib": "workspace:*",
27
28
  "citty": "^0.2.1"
28
29
  }
29
30
  }
@@ -4,18 +4,22 @@ import { rm } from 'node:fs/promises';
4
4
  import cliPkg from '../../package.json' with { type: 'json' };
5
5
  import { defaultConfigHome, defaultDataHome, defaultStateHome, defaultWorkDir } from '../lib/paths.ts';
6
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';
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';
9
10
  import { ensureVarlock, prepareVarlockDir } from '../lib/varlock.ts';
10
11
  import { detectHostInfo } from '../lib/host-info.ts';
11
12
  import { loadAdminToken } from '../lib/env.ts';
13
+ import { ensureStagedState, fullComposeArgs, buildManagedServiceNames } from '../lib/staging.ts';
14
+ import { createSetupServer } from '../setup-wizard/server.ts';
12
15
 
13
16
  const DEFAULT_INSTALL_REF = cliPkg.version ? `v${cliPkg.version}` : 'main';
17
+ const SETUP_WIZARD_PORT = 8100;
14
18
 
15
19
  export default defineCommand({
16
20
  meta: {
17
21
  name: 'install',
18
- description: 'Bootstrap XDG dirs, download assets, start admin + docker-socket-proxy, open setup wizard',
22
+ description: 'Bootstrap XDG dirs, download assets, run setup wizard, start core services',
19
23
  },
20
24
  args: {
21
25
  force: {
@@ -42,7 +46,7 @@ export default defineCommand({
42
46
  async run({ args }) {
43
47
  // If the stack is already running AND we have a valid admin token,
44
48
  // delegate to the admin API. Otherwise fall through to bootstrap.
45
- if (await isStackRunning()) {
49
+ if (await isAdminReachable()) {
46
50
  const token = await loadAdminToken();
47
51
  if (token) {
48
52
  console.log(JSON.stringify(await adminRequest('/admin/install', { method: 'POST' }), null, 2));
@@ -111,15 +115,46 @@ export async function bootstrapInstall(options: InstallOptions): Promise<void> {
111
115
  await Bun.write(join(stateHome, 'artifacts', 'docker-compose.yml'), composeContent);
112
116
  await Bun.write(join(stateHome, 'artifacts', 'Caddyfile'), caddyContent);
113
117
 
118
+ // Download schemas to both DATA_HOME (for FilesystemAssetProvider) and STATE_HOME (for varlock validation)
114
119
  const secretsSchemaContent = await fetchAsset(options.version, 'secrets.env.schema');
115
120
  const stackSchemaContent = await fetchAsset(options.version, 'stack.env.schema');
121
+ await Bun.write(join(dataHome, 'secrets.env.schema'), secretsSchemaContent);
122
+ await Bun.write(join(dataHome, 'stack.env.schema'), stackSchemaContent);
116
123
  await Bun.write(join(stateHome, 'artifacts', 'secrets.env.schema'), secretsSchemaContent);
117
124
  await Bun.write(join(stateHome, 'artifacts', 'stack.env.schema'), stackSchemaContent);
118
125
 
126
+ // Download remaining assets needed by FilesystemAssetProvider
127
+ const assetFiles: Array<{ remote: string; localPath: string }> = [
128
+ { remote: 'ollama.yml', localPath: join(dataHome, 'ollama.yml') },
129
+ { remote: 'AGENTS.md', localPath: join(dataHome, 'assistant', 'AGENTS.md') },
130
+ { remote: 'opencode.jsonc', localPath: join(dataHome, 'assistant', 'opencode.jsonc') },
131
+ { remote: 'admin-opencode.jsonc', localPath: join(dataHome, 'admin', 'opencode.jsonc') },
132
+ { remote: 'cleanup-logs.yml', localPath: join(dataHome, 'automations', 'cleanup-logs.yml') },
133
+ { remote: 'cleanup-data.yml', localPath: join(dataHome, 'automations', 'cleanup-data.yml') },
134
+ { remote: 'validate-config.yml', localPath: join(dataHome, 'automations', 'validate-config.yml') },
135
+ ];
136
+ await Promise.all(
137
+ assetFiles.map(async ({ remote, localPath }) => {
138
+ try {
139
+ const content = await fetchAsset(options.version, remote);
140
+ await Bun.write(localPath, content);
141
+ } catch (err) {
142
+ console.warn(`Warning: could not download asset '${remote}': ${err instanceof Error ? err.message : String(err)}`);
143
+ }
144
+ }),
145
+ );
146
+
119
147
  await ensureSecrets(configHome);
120
148
  await ensureStackEnv(configHome, dataHome, stateHome, workDir, options.version);
121
- await ensureOpenCodeConfig(configHome);
122
- await ensureOpenCodeSystemConfig(dataHome);
149
+ // Seed OpenCode config — non-fatal since performSetup() also seeds these
150
+ try {
151
+ const fsAssets = new FilesystemAssetProvider(dataHome);
152
+ ensureOpenCodeConfig();
153
+ ensureOpenCodeSystemConfig(fsAssets);
154
+ ensureAdminOpenCodeConfig(fsAssets);
155
+ } catch {
156
+ // Assets may not be available yet on first install; performSetup() will retry
157
+ }
123
158
 
124
159
  // Non-fatal validation
125
160
  try {
@@ -152,19 +187,89 @@ export async function bootstrapInstall(options: InstallOptions): Promise<void> {
152
187
  return;
153
188
  }
154
189
 
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);
190
+ // ── Setup Wizard ──────────────────────────────────────────────────────
191
+ // First-time install: serve the setup wizard locally and wait for user
192
+ // to complete it. The wizard calls performSetup() from @openpalm/lib
193
+ // which writes secrets, connection profiles, memory config, and stages
194
+ // all artifacts. No admin container needed.
195
+
196
+ if (!updateMode) {
197
+ console.log('Starting setup wizard...');
198
+
199
+ const wizard = createSetupServer(SETUP_WIZARD_PORT, { configDir: configHome });
200
+ const wizardUrl = `http://localhost:${wizard.server.port}/setup`;
201
+ console.log(`Setup wizard running at ${wizardUrl}`);
202
+
203
+ if (!options.noOpen) {
204
+ await openBrowser(wizardUrl);
205
+ }
206
+
207
+ // Block until user completes the wizard
208
+ const result = await wizard.waitForComplete();
209
+
210
+ if (!result.ok) {
211
+ wizard.stop();
212
+ throw new Error(`Setup failed: ${result.error ?? 'unknown error'}`);
213
+ }
214
+
215
+ console.log('Setup complete. Starting services...');
216
+
217
+ // Keep wizard server running for deploy status polling from the browser.
218
+ // Stage artifacts and start services while the wizard shows progress.
219
+ try {
220
+ const state = await ensureStagedState();
221
+ const composeArgs = fullComposeArgs(state);
222
+ const managedServices = buildManagedServiceNames(state);
223
+
224
+ wizard.updateDeployStatus(
225
+ managedServices.map(s => ({ service: s, status: 'pending', label: 'Waiting...' })),
226
+ );
227
+
228
+ await runDockerCompose([...composeArgs, 'pull', ...managedServices]).catch(() => {
229
+ // Pull failure is non-fatal — images may already be cached
230
+ });
231
+
232
+ wizard.updateDeployStatus(
233
+ managedServices.map(s => ({ service: s, status: 'pulling', label: 'Starting...' })),
234
+ );
235
+
236
+ await runDockerCompose([...composeArgs, 'up', '-d', ...managedServices]);
237
+
238
+ wizard.markAllRunning();
239
+
240
+ console.log(JSON.stringify({
241
+ ok: true,
242
+ mode: 'install',
243
+ services: managedServices,
244
+ }, null, 2));
245
+
246
+ // Give the browser a moment to poll the final status, then stop
247
+ await new Promise(resolve => setTimeout(resolve, 3000));
248
+ } catch (err) {
249
+ wizard.setDeployError(String(err));
250
+ // Keep server alive briefly so user can see the error
251
+ await new Promise(resolve => setTimeout(resolve, 10000));
252
+ throw err;
253
+ } finally {
254
+ wizard.stop();
255
+ }
256
+
257
+ return;
167
258
  }
168
259
 
169
- console.log(JSON.stringify({ ok: true, mode: updateMode ? 'update' : 'install', url: targetUrl }, null, 2));
260
+ // ── Start Core Services (update mode no wizard) ────────────────────
261
+ // Stage artifacts and start all managed services directly via Docker
262
+ // Compose. No admin container required for lifecycle operations.
263
+
264
+ const state = await ensureStagedState();
265
+ const composeArgs = fullComposeArgs(state);
266
+ const managedServices = buildManagedServiceNames(state);
267
+
268
+ await runDockerCompose([...composeArgs, 'up', '-d', ...managedServices]);
269
+
270
+ console.log(JSON.stringify({
271
+ ok: true,
272
+ mode: 'update',
273
+ services: managedServices,
274
+ }, null, 2));
170
275
  }
@@ -1,21 +1,10 @@
1
1
  import { defineCommand } from 'citty';
2
- import { composeProjectArgs } from '../lib/docker.ts';
2
+ import { runDockerCompose } from '../lib/docker.ts';
3
+ import { ensureStagedState, fullComposeArgs } from '../lib/staging.ts';
3
4
 
4
5
  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
- }
6
+ const state = await ensureStagedState();
7
+ await runDockerCompose([...fullComposeArgs(state), 'logs', '--tail', '100', ...services]);
19
8
  }
20
9
 
21
10
  export default defineCommand({
@@ -1,5 +1,7 @@
1
1
  import { defineCommand } from 'citty';
2
- import { adminRequest, getServiceNames } from '../lib/admin.ts';
2
+ import { tryAdminRequest, getServiceNames } from '../lib/admin.ts';
3
+ import { runDockerCompose } from '../lib/docker.ts';
4
+ import { ensureStagedState, fullComposeArgs, buildManagedServiceNames } from '../lib/staging.ts';
3
5
 
4
6
  export default defineCommand({
5
7
  meta: {
@@ -21,23 +23,49 @@ export default defineCommand({
21
23
 
22
24
  export async function runRestartAction(services: string[]): Promise<void> {
23
25
  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));
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;
32
42
  }
43
+
44
+ // Direct compose restart
45
+ const state = await ensureStagedState();
46
+ const managedServices = buildManagedServiceNames(state);
47
+ await runDockerCompose([...fullComposeArgs(state), 'restart', ...managedServices]);
33
48
  return;
34
49
  }
35
50
 
51
+ // Restart specific services
36
52
  for (const service of services) {
37
- const result = await adminRequest('/admin/containers/restart', {
53
+ const adminResult = await tryAdminRequest('/admin/containers/restart', {
38
54
  method: 'POST',
39
55
  body: JSON.stringify({ service }),
40
56
  });
41
- console.log(JSON.stringify(result, null, 2));
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
+ }
42
70
  }
43
71
  }
@@ -1,5 +1,7 @@
1
1
  import { defineCommand } from 'citty';
2
- import { adminRequest } from '../lib/admin.ts';
2
+ import { tryAdminRequest } from '../lib/admin.ts';
3
+ import { runDockerCompose } from '../lib/docker.ts';
4
+ import { ensureStagedState, fullComposeArgs, buildManagedServiceNames } from '../lib/staging.ts';
3
5
  import { runLogsAction } from './logs.ts';
4
6
  import { runStartAction } from './start.ts';
5
7
  import { runStopAction } from './stop.ts';
@@ -40,14 +42,38 @@ const logsCmd = defineCommand({
40
42
  const updateCmd = defineCommand({
41
43
  meta: { name: 'update', description: 'Pull latest images' },
42
44
  async run() {
43
- console.log(JSON.stringify(await adminRequest('/admin/containers/pull', { method: 'POST' }), null, 2));
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.');
44
61
  },
45
62
  });
46
63
 
47
64
  const statusCmd = defineCommand({
48
65
  meta: { name: 'status', description: 'Show container status' },
49
66
  async run() {
50
- console.log(JSON.stringify(await adminRequest('/admin/containers/list'), null, 2));
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']);
51
77
  },
52
78
  });
53
79
 
@@ -1,7 +1,7 @@
1
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';
2
+ import { tryAdminRequest } from '../lib/admin.ts';
3
+ import { runDockerCompose } from '../lib/docker.ts';
4
+ import { ensureStagedState, fullComposeArgs, buildManagedServiceNames } from '../lib/staging.ts';
5
5
 
6
6
  export default defineCommand({
7
7
  meta: {
@@ -14,35 +14,66 @@ export default defineCommand({
14
14
  description: 'Service names to start (omit for all)',
15
15
  required: false,
16
16
  },
17
+ 'with-admin': {
18
+ type: 'boolean',
19
+ description: 'Include admin UI and docker-socket-proxy',
20
+ default: false,
21
+ },
17
22
  },
18
23
  async run({ args }) {
19
24
  const services = args._ ?? [];
20
- await runStartAction(services);
25
+ const withAdmin = args['with-admin'] ?? false;
26
+ await runStartAction(services, { withAdmin });
21
27
  },
22
28
  });
23
29
 
24
- export async function runStartAction(services: string[]): Promise<void> {
30
+ export async function runStartAction(
31
+ services: string[],
32
+ opts?: { withAdmin?: boolean },
33
+ ): Promise<void> {
34
+ const withAdmin = opts?.withAdmin ?? false;
35
+
25
36
  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));
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));
33
41
  return;
34
42
  }
35
43
 
36
- // Direct docker compose — works without auth
37
- await runDockerCompose([...composeProjectArgs(), 'up', '-d']);
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
+ }
38
55
  return;
39
56
  }
40
57
 
58
+ // Start specific services — try admin first, fall back to direct compose
41
59
  for (const service of services) {
42
- const result = await adminRequest('/admin/containers/up', {
60
+ const adminResult = await tryAdminRequest('/admin/containers/up', {
43
61
  method: 'POST',
44
62
  body: JSON.stringify({ service }),
45
63
  });
46
- console.log(JSON.stringify(result, null, 2));
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
+ }
47
78
  }
48
79
  }
@@ -1,5 +1,7 @@
1
1
  import { defineCommand } from 'citty';
2
- import { adminRequest } from '../lib/admin.ts';
2
+ import { tryAdminRequest } from '../lib/admin.ts';
3
+ import { runDockerCompose } from '../lib/docker.ts';
4
+ import { ensureStagedState, fullComposeArgs } from '../lib/staging.ts';
3
5
 
4
6
  export default defineCommand({
5
7
  meta: {
@@ -7,6 +9,15 @@ export default defineCommand({
7
9
  description: 'Show container status',
8
10
  },
9
11
  async run() {
10
- console.log(JSON.stringify(await adminRequest('/admin/containers/list'), null, 2));
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']);
11
22
  },
12
23
  });
@@ -1,7 +1,7 @@
1
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';
2
+ import { tryAdminRequest, getServiceNames } from '../lib/admin.ts';
3
+ import { runDockerCompose } from '../lib/docker.ts';
4
+ import { ensureStagedState, fullComposeArgs } from '../lib/staging.ts';
5
5
 
6
6
  export default defineCommand({
7
7
  meta: {
@@ -23,23 +23,48 @@ export default defineCommand({
23
23
 
24
24
  export async function runStopAction(services: string[]): Promise<void> {
25
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));
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
+ }
30
41
  return;
31
42
  }
32
43
 
33
- // Direct docker compose — works without auth
34
- await runDockerCompose([...composeProjectArgs(), 'down']);
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']);
35
47
  return;
36
48
  }
37
49
 
50
+ // Stop specific services
38
51
  for (const service of services) {
39
- const result = await adminRequest('/admin/containers/down', {
52
+ const adminResult = await tryAdminRequest('/admin/containers/down', {
40
53
  method: 'POST',
41
54
  body: JSON.stringify({ service }),
42
55
  });
43
- console.log(JSON.stringify(result, null, 2));
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
+ }
44
69
  }
45
70
  }