@celilo/cli 0.9.1 → 0.11.0-alpha.0
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/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +16 -0
- package/drizzle/0014_api_principals.sql +10 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +14 -6
- package/src/api/protocol.test.ts +76 -0
- package/src/api/remote-client.test.ts +91 -0
- package/src/api/serve.ts +159 -0
- package/src/cli/command-tree-parser.ts +3 -1
- package/src/cli/commands/api.ts +194 -0
- package/src/cli/commands/apt-upgrade.test.ts +33 -0
- package/src/cli/commands/apt-upgrade.ts +63 -0
- package/src/cli/commands/commands-json.ts +29 -0
- package/src/cli/commands/completion.ts +1 -1
- package/src/cli/commands/module-list.ts +16 -2
- package/src/cli/commands/publish/helpers.ts +18 -0
- package/src/cli/commands/publish/types.ts +6 -8
- package/src/cli/commands/publish/workspace.test.ts +44 -7
- package/src/cli/commands/publish/workspace.ts +40 -164
- package/src/cli/commands/service-list.ts +15 -2
- package/src/cli/completion.ts +24 -0
- package/src/cli/generate-zsh-completion.test.ts +22 -4
- package/src/cli/generate-zsh-completion.ts +7 -3
- package/src/cli/index.ts +109 -2
- package/src/cli/parser.test.ts +13 -0
- package/src/cli/parser.ts +12 -3
- package/src/db/schema.ts +30 -0
- package/src/hooks/capability-loader.test.ts +77 -0
- package/src/hooks/capability-loader.ts +56 -0
- package/src/services/api-access.test.ts +138 -0
- package/src/services/api-access.ts +154 -0
- package/src/services/remote-responder.test.ts +78 -0
- package/src/services/remote-responder.ts +89 -0
- package/src/cli/command-registry.ts +0 -1443
package/CELILO_CORE_MODULES.md
CHANGED
|
@@ -44,7 +44,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
44
44
|
- **forgejo** — self-hosted Forgejo git forge (git-over-SSH, OIDC, public ingress). **provides:** `source_forge`. **requires:** `public_web`, `idp`, `firewall`, `dns_registrar`. See `v2/CI_PIPELINE.md`.
|
|
45
45
|
- **forgejo-runner** — host-mode (LXC) Forgejo Actions runner for unit/lint jobs; outbound-only, lives in dmz. **requires:** `source_forge`. See `v2/FORGEJO_RUNNER.md`.
|
|
46
46
|
- **forgejo-builder** — VM-based, Docker-capable Forgejo Actions runner (`requires.system.type: vm`) for hermetic release builds + the cele2e suite. **requires:** `source_forge`.
|
|
47
|
-
- **npm-cache-node** — self-hosted npm registry
|
|
47
|
+
- **npm-cache-node** — self-hosted npm registry: a pass-through disk cache (proxies upstream) plus a `PUT /<pkg>` publish endpoint that stores locally published `@celilo/*` tarballs as authoritative local-origin (never overwritten by upstream). PUT is gated on a static operator-set publish token (`publish_tokens` secret, SHA-256 model; fail-closed — absent/bad token → 401); reads stay open (it's a mirror). **requires:** `public_web`, `dns_registrar`. See `v2/NPM_CACHE_NODE.md`.
|
|
48
48
|
|
|
49
49
|
## Applications
|
|
50
50
|
|
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -77,7 +77,23 @@ see `design/README.md`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MO
|
|
|
77
77
|
|
|
78
78
|
- **Event bus** — `packages/event-bus/src/index.ts` — `Bus`, `openBus`, `defineEvents`, `defineHandler`, `runDispatcher`, pattern matching + timer ticks (`emitDueTimerTicks`, `retentionSweep`).
|
|
79
79
|
|
|
80
|
+
## Remote API (drive the CLI over the wire)
|
|
81
|
+
|
|
82
|
+
Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <host> celilo …`. Typed, streamed, per-operation authz, mid-run interviews. Design: `v2/API_COMMUNICATION.md`.
|
|
83
|
+
|
|
84
|
+
- **Lightweight core (`@celilo/core`)** — `packages/core/src/` — the transport primitives lifted out of `@celilo/cli` so a consumer (e.g. the MCP server) can reach the wire without dragging Ink/React/drizzle/aws-sdk: `command-registry.ts` (`COMMANDS` + `CommandDef`/`ArgDef`/`FlagDef`), `protocol.ts`, `remote-client.ts`. Public surface: `packages/core/src/index.ts`.
|
|
85
|
+
- **Wire protocol** — `packages/core/src/protocol.ts` (`@celilo/core`) — versioned NDJSON tagged union (`command`/`progress`/`log`/`result`/`error`/`interview`/`answer`) + `translateOutputLine`.
|
|
86
|
+
- **Registry serialization** — `apps/celilo/src/cli/commands/commands-json.ts` — `celilo commands --json` prints the full `COMMANDS` tree as JSON; the live source of truth the MCP fetches to generate its tool surface (so it mirrors whatever celilo version the server runs). `service list --json` similarly exposes configured providers for MCP auto-detect. `module list --json` prints the module roster (id/version/state) as stable JSON — the backbone the MCP composite troubleshooting tools correlate `audit --json` findings against.
|
|
87
|
+
- **Server** — `apps/celilo/src/api/serve.ts` (`apiServeMode`); the `celilo api-serve --principal=<id>` sshd forced-command entry point (dispatched in `apps/celilo/src/cli/index.ts`). Authorizes per principal, runs the command as a protocol-mode child, streams output, audits to stderr.
|
|
88
|
+
- **Client** — `packages/core/src/remote-client.ts` (`@celilo/core`) — `resolveRemote` (`--remote <dest>` / `CELILO_REMOTE`), `runRemoteClient` (`ssh -T`, renders progress via the local ProgressDisplay, answers interviews via clack).
|
|
89
|
+
- **Access control** — `apps/celilo/src/services/api-access.ts` — `grantPrincipal`, `isAuthorized` (deny-by-default, `command:subcommand` grants), `renderAuthorizedKeys`. Table: `api_principals` (`apps/celilo/src/db/schema.ts`). CLI: `apps/celilo/src/cli/commands/api.ts` (`api grant|list|revoke|authorized-keys|key new`).
|
|
90
|
+
- **Mid-run interview bridge (`kind:daemon` responder)** — `apps/celilo/src/services/remote-responder.ts` — `startRemoteResponder` bridges bus `interview.required.*` ↔ wire.
|
|
91
|
+
- **Server provisioning** — the `celilo-bootstrap` deb (`packaging/celilo-bootstrap/scripts/postinst`) creates the non-root `celilo-api` landing account + sshd; membership in the `celilo` group + `/etc/sudoers.d/celilo` (`!use_pty`) gives api-serve DB access via the wrapper's sudo-drop.
|
|
92
|
+
- **Self-upgrade (apt)** — `celilo apt-upgrade` (`apps/celilo/src/cli/commands/apt-upgrade.ts`) upgrades the deb-installed `celilo`/`celilo-bootstrap` packages (`apt-get update` → `--only-upgrade install`) then spawns a fresh `celilo system migrate` (ISS-0100). It's the RW target behind the MCP's registry-derived `celilo_apt_upgrade` tool; the celilo user's two apt invocations are scoped-sudo'd by `/etc/sudoers.d/celilo-apt-upgrade`, shipped by `celilo-bootstrap`.
|
|
93
|
+
- **MCP service (`@celilo/mcp`)** — `packages/mcp/src/` — an operator-facing stdio MCP server (official `@modelcontextprotocol/sdk`, bin `celilo-mcp`) that drives a remote celilo server over the Remote API for an AI client. Two-item config (`config.ts`: `server` + `defaultUser`, env or `~/.config/celilo-mcp/config.json`). Dual-principal auth (`auth.ts`: `celilo-mcp auth setup` enrolls read-only `celilo-mcp-ro` + full `celilo-mcp-rw` ed25519 keypairs, prints the exact `celilo api grant` lines the operator runs server-side). Transport (`transport.ts`): reuses `@celilo/core` `runRemoteClient`, selecting the principal by `ssh -i <key>` and capturing structured output. Tool surface is generated LIVE from the server's command registry — `registry-fetch.ts` fetches `celilo commands --json` (+ `service list --json` for configured providers) over the RO principal on connect; `tools-from-registry.ts` (pure) projects that into one tool per runnable leaf, grouped by top-level command (`celilo_module_*`, `celilo_proxmox_*`, …), each with a Zod input schema from the leaf's args/flags and a read/write tag → RO/RW routing, plus a generic `celilo_run` escape hatch. Auto-detect hides provider-gated groups (e.g. `celilo_proxmox_*` until a Proxmox service is configured) and re-detects on a timer, emitting `notifications/tools/list_changed` when the surface changes. Coverage gate (`tests/coverage.test.ts`) asserts every registry leaf maps to a tool. Composite RO troubleshooting tools (`troubleshoot.ts` pure correlation + `troubleshoot-tools.ts` thin adapters): `celilo_assess_module <id>` and `celilo_fleet_status` correlate `celilo audit --json` (the drift backbone) with the `module list --json` roster into a per-module / fleet-wide verdict. Design: `v2/CELILO_MCP_SERVICE.md`. (Distinct from the dev/ops `@celilo/mcp-server` below.)
|
|
94
|
+
|
|
80
95
|
## E2E simulation
|
|
81
96
|
|
|
82
97
|
- **cele2e harness** — `packages/e2e/src/` — `runner.ts`, `container-manager.ts` (`startNetwork`, `reconnectNetwork`), `network-builder.ts` (`NetworkBuilder`).
|
|
83
98
|
- **Run wrapper** — `infra/scripts/cele2e-run.sh` lives in the separate `infra/` clone, **not** in this repo. See the cele2e section of the repo-root `CLAUDE.md` for the operator workflow.
|
|
99
|
+
- **MCP server (agent-driven e2e)** — `packages/mcp-server/src/index.ts` — stdio MCP server exposing `start_run`/`run_status`/`run_result`/`stop_run` (detached cele2e runs read off the event bus, no ANSI scraping) + `env_check` (docker VM, run-lock, shared-infra, mgmt image CLI version, netapps). Dev/ops tool, `private`, not shipped to consumers.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
CREATE TABLE `api_principals` (
|
|
2
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
3
|
+
`name` text NOT NULL,
|
|
4
|
+
`public_key` text NOT NULL,
|
|
5
|
+
`grants` text DEFAULT '[]' NOT NULL,
|
|
6
|
+
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
|
7
|
+
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
|
8
|
+
);
|
|
9
|
+
--> statement-breakpoint
|
|
10
|
+
CREATE UNIQUE INDEX `api_principals_name_unique` ON `api_principals` (`name`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0-alpha.0",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,13 @@
|
|
|
16
16
|
"CELILO_SUBSYSTEMS.md",
|
|
17
17
|
"CELILO_CORE_MODULES.md"
|
|
18
18
|
],
|
|
19
|
-
"keywords": [
|
|
19
|
+
"keywords": [
|
|
20
|
+
"celilo",
|
|
21
|
+
"homelab",
|
|
22
|
+
"orchestration",
|
|
23
|
+
"ansible",
|
|
24
|
+
"terraform"
|
|
25
|
+
],
|
|
20
26
|
"license": "MIT",
|
|
21
27
|
"repository": {
|
|
22
28
|
"type": "git",
|
|
@@ -49,9 +55,10 @@
|
|
|
49
55
|
},
|
|
50
56
|
"dependencies": {
|
|
51
57
|
"@aws-sdk/client-s3": "^3.1024.0",
|
|
52
|
-
"@celilo/capabilities": "
|
|
53
|
-
"@celilo/cli-display": "
|
|
54
|
-
"@celilo/
|
|
58
|
+
"@celilo/capabilities": "0.6.0-alpha.0",
|
|
59
|
+
"@celilo/cli-display": "0.1.9-alpha.1",
|
|
60
|
+
"@celilo/core": "^0.1.0",
|
|
61
|
+
"@celilo/event-bus": "0.1.8-alpha.0",
|
|
55
62
|
"@clack/prompts": "^1.1.0",
|
|
56
63
|
"ajv": "^8.18.0",
|
|
57
64
|
"drizzle-orm": "^0.36.4",
|
|
@@ -72,5 +79,6 @@
|
|
|
72
79
|
"ink-testing-library": "^4.0.0",
|
|
73
80
|
"typescript": "^5.9.3",
|
|
74
81
|
"zod-to-json-schema": "^3.25.2"
|
|
75
|
-
}
|
|
82
|
+
},
|
|
83
|
+
"gitHead": "8fe2dff30ef6a59a0aed48e5501403e43be8429e"
|
|
76
84
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { translateOutputLine } from '@celilo/core';
|
|
3
|
+
|
|
4
|
+
describe('translateOutputLine', () => {
|
|
5
|
+
test('start marker → progress start', () => {
|
|
6
|
+
expect(translateOutputLine('[progress:start] Deploying caddy | Deployed caddy')).toEqual({
|
|
7
|
+
type: 'progress',
|
|
8
|
+
kind: 'start',
|
|
9
|
+
doing: 'Deploying caddy',
|
|
10
|
+
done: 'Deployed caddy',
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('push marker → progress push', () => {
|
|
15
|
+
expect(translateOutputLine('[progress:push] Sub step | Sub done')).toEqual({
|
|
16
|
+
type: 'progress',
|
|
17
|
+
kind: 'push',
|
|
18
|
+
doing: 'Sub step',
|
|
19
|
+
done: 'Sub done',
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('done marker with override message', () => {
|
|
24
|
+
expect(translateOutputLine('[progress:done] all good')).toEqual({
|
|
25
|
+
type: 'progress',
|
|
26
|
+
kind: 'done',
|
|
27
|
+
message: 'all good',
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('done marker without message', () => {
|
|
32
|
+
expect(translateOutputLine('[progress:done]')).toEqual({
|
|
33
|
+
type: 'progress',
|
|
34
|
+
kind: 'done',
|
|
35
|
+
message: undefined,
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('fail marker → progress fail', () => {
|
|
40
|
+
expect(translateOutputLine('[progress:fail] it broke')).toEqual({
|
|
41
|
+
type: 'progress',
|
|
42
|
+
kind: 'fail',
|
|
43
|
+
message: 'it broke',
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('sub marker → progress sub', () => {
|
|
48
|
+
expect(translateOutputLine('[progress:sub] running terraform')).toEqual({
|
|
49
|
+
type: 'progress',
|
|
50
|
+
kind: 'sub',
|
|
51
|
+
message: 'running terraform',
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('legacy instant marker → progress message', () => {
|
|
56
|
+
expect(translateOutputLine('[progress] heads up')).toEqual({
|
|
57
|
+
type: 'progress',
|
|
58
|
+
kind: 'message',
|
|
59
|
+
message: 'heads up',
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('plain line → log', () => {
|
|
64
|
+
expect(translateOutputLine('just some output')).toEqual({
|
|
65
|
+
type: 'log',
|
|
66
|
+
message: 'just some output',
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a line that only resembles a marker → log', () => {
|
|
71
|
+
expect(translateOutputLine('see [progress:start] in the docs')).toEqual({
|
|
72
|
+
type: 'log',
|
|
73
|
+
message: 'see [progress:start] in the docs',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { type RemoteTransport, resolveRemote, runRemoteClient } from '@celilo/core';
|
|
3
|
+
|
|
4
|
+
const argv = (rest: string[]) => ['bun', 'celilo', ...rest];
|
|
5
|
+
|
|
6
|
+
describe('resolveRemote', () => {
|
|
7
|
+
test('leading --remote <dest>', () => {
|
|
8
|
+
expect(resolveRemote(argv(['--remote', 'host', 'module', 'list']), undefined)).toEqual({
|
|
9
|
+
dest: 'host',
|
|
10
|
+
commandArgv: ['module', 'list'],
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('leading --remote=<dest>', () => {
|
|
15
|
+
expect(resolveRemote(argv(['--remote=user@host', 'status']), undefined)).toEqual({
|
|
16
|
+
dest: 'user@host',
|
|
17
|
+
commandArgv: ['status'],
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('CELILO_REMOTE env', () => {
|
|
22
|
+
expect(resolveRemote(argv(['module', 'list']), 'envhost')).toEqual({
|
|
23
|
+
dest: 'envhost',
|
|
24
|
+
commandArgv: ['module', 'list'],
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('leading flag beats env', () => {
|
|
29
|
+
expect(resolveRemote(argv(['--remote', 'flaghost', 'status']), 'envhost')?.dest).toBe(
|
|
30
|
+
'flaghost',
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('local invocation → null', () => {
|
|
35
|
+
expect(resolveRemote(argv(['module', 'list']), undefined)).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('--remote with no dest → null', () => {
|
|
39
|
+
expect(resolveRemote(argv(['--remote']), undefined)).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('renders a forwarded interview and sends the answer back', async () => {
|
|
44
|
+
const writes: string[] = [];
|
|
45
|
+
const encoder = new TextEncoder();
|
|
46
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
47
|
+
const stdout = new ReadableStream<Uint8Array>({
|
|
48
|
+
start(c) {
|
|
49
|
+
controller = c;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
const push = (obj: unknown) => controller.enqueue(encoder.encode(`${JSON.stringify(obj)}\n`));
|
|
53
|
+
|
|
54
|
+
const transport: RemoteTransport = {
|
|
55
|
+
stdin: {
|
|
56
|
+
write(chunk: string) {
|
|
57
|
+
writes.push(chunk);
|
|
58
|
+
// The command's answer arrived — finish the command.
|
|
59
|
+
if (chunk.includes('"answer"')) {
|
|
60
|
+
push({ type: 'result', success: true, exitCode: 0 });
|
|
61
|
+
controller.close();
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
stdout,
|
|
66
|
+
kill() {},
|
|
67
|
+
exited: Promise.resolve(0),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Server script: greet, then ask one interview.
|
|
71
|
+
push({ type: 'ready', protocolVersion: 1 });
|
|
72
|
+
push({ type: 'interview', id: 'q1', kind: 'text', message: 'Hostname?' });
|
|
73
|
+
|
|
74
|
+
const seen: Array<{ id: string }> = [];
|
|
75
|
+
const code = await runRemoteClient('ignored', ['module', 'deploy', 'site'], {
|
|
76
|
+
openTransport: () => transport,
|
|
77
|
+
out: { write() {} },
|
|
78
|
+
renderInterview: async (iv) => {
|
|
79
|
+
seen.push(iv);
|
|
80
|
+
return 'myhost';
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(code).toBe(0);
|
|
85
|
+
expect(seen).toHaveLength(1);
|
|
86
|
+
expect(seen[0].id).toBe('q1');
|
|
87
|
+
expect(writes.some((w) => w.includes('"command"'))).toBe(true);
|
|
88
|
+
const answer = writes.find((w) => w.includes('"answer"'));
|
|
89
|
+
expect(answer).toBeDefined();
|
|
90
|
+
expect(JSON.parse(answer as string)).toEqual({ type: 'answer', id: 'q1', value: 'myhost' });
|
|
91
|
+
});
|
package/src/api/serve.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote API server — `celilo api-serve --principal=<id>` (Slices 1–3).
|
|
3
|
+
*
|
|
4
|
+
* Invoked as the sshd forced command for an enrolled key, so it starts already
|
|
5
|
+
* authenticated as `principal`. Reads `command`/`answer` messages as NDJSON on
|
|
6
|
+
* stdin/stdout, authorizes each command against the principal's grants
|
|
7
|
+
* (deny-by-default), and — if allowed — runs it as a child `celilo` process,
|
|
8
|
+
* translating the child's protocol-mode output into `progress`/`log` and a
|
|
9
|
+
* terminal `result`. While a command runs, a remote responder bridges the event
|
|
10
|
+
* bus to the wire so mid-run `interview`s are answered by the client. Every
|
|
11
|
+
* attempt is audited to stderr.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createInterface } from 'node:readline';
|
|
15
|
+
import {
|
|
16
|
+
API_PROTOCOL_VERSION,
|
|
17
|
+
ClientMessageSchema,
|
|
18
|
+
type ServerMessage,
|
|
19
|
+
translateOutputLine,
|
|
20
|
+
} from '@celilo/core';
|
|
21
|
+
import { parseArguments } from '../cli/parser';
|
|
22
|
+
import { getEventBusPath } from '../config/paths';
|
|
23
|
+
import { isAuthorized } from '../services/api-access';
|
|
24
|
+
import { type WireInterview, startRemoteResponder } from '../services/remote-responder';
|
|
25
|
+
|
|
26
|
+
/** Exit code returned to the client when authz denies a command. */
|
|
27
|
+
const EXIT_PERMISSION_DENIED = 126;
|
|
28
|
+
|
|
29
|
+
function send(msg: ServerMessage): void {
|
|
30
|
+
process.stdout.write(`${JSON.stringify(msg)}\n`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function audit(principal: string, op: string, decision: string, exitCode?: number): void {
|
|
34
|
+
const suffix = exitCode === undefined ? '' : ` exit=${exitCode}`;
|
|
35
|
+
process.stderr.write(
|
|
36
|
+
`[api-audit] principal=${principal} op=${op} decision=${decision}${suffix}\n`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Derive the `command`/`subcommand` an argv would run, via the real parser. */
|
|
41
|
+
function opOf(argv: string[]): { command: string; subcommand?: string; label: string } {
|
|
42
|
+
const parsed = parseArguments(['bun', 'celilo', ...argv]);
|
|
43
|
+
const label = parsed.subcommand ? `${parsed.command}:${parsed.subcommand}` : parsed.command;
|
|
44
|
+
return { command: parsed.command, subcommand: parsed.subcommand, label };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Drain a child stream line-by-line, forwarding each line as a message. */
|
|
48
|
+
async function pumpLines(stream: ReadableStream<Uint8Array>): Promise<void> {
|
|
49
|
+
const decoder = new TextDecoder();
|
|
50
|
+
let buffer = '';
|
|
51
|
+
for await (const chunk of stream) {
|
|
52
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
53
|
+
let nl = buffer.indexOf('\n');
|
|
54
|
+
while (nl >= 0) {
|
|
55
|
+
const line = buffer.slice(0, nl);
|
|
56
|
+
buffer = buffer.slice(nl + 1);
|
|
57
|
+
send(translateOutputLine(line));
|
|
58
|
+
nl = buffer.indexOf('\n');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (buffer.length > 0) {
|
|
62
|
+
send(translateOutputLine(buffer));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Run an authorized command as a child; returns its exit code. */
|
|
67
|
+
async function runCommand(argv: string[]): Promise<number> {
|
|
68
|
+
// Re-invoke this same CLI as a child. The child is non-TTY (piped stdout) so
|
|
69
|
+
// ProgressDisplay resolves to protocol mode and emits `[progress:*]` markers.
|
|
70
|
+
const child = Bun.spawn([process.execPath, Bun.main, ...argv], {
|
|
71
|
+
stdin: 'ignore',
|
|
72
|
+
stdout: 'pipe',
|
|
73
|
+
stderr: 'pipe',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await Promise.all([pumpLines(child.stdout), pumpLines(child.stderr)]);
|
|
77
|
+
const exitCode = await child.exited;
|
|
78
|
+
send({ type: 'result', success: exitCode === 0, exitCode });
|
|
79
|
+
return exitCode;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function handleCommand(
|
|
83
|
+
principal: string,
|
|
84
|
+
argv: string[],
|
|
85
|
+
busDbPath: string,
|
|
86
|
+
ask: (interview: WireInterview) => Promise<unknown>,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const { command, subcommand, label } = opOf(argv);
|
|
89
|
+
|
|
90
|
+
if (!(await isAuthorized(principal, command, subcommand))) {
|
|
91
|
+
send({ type: 'error', error: `permission denied: "${principal}" is not granted "${label}"` });
|
|
92
|
+
send({ type: 'result', success: false, exitCode: EXIT_PERMISSION_DENIED });
|
|
93
|
+
audit(principal, label, 'deny');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Bridge the bus to the wire so mid-run interviews reach the client.
|
|
98
|
+
const responder = startRemoteResponder({ busDbPath, ask, emittedBy: `api:${principal}` });
|
|
99
|
+
try {
|
|
100
|
+
const exitCode = await runCommand(argv);
|
|
101
|
+
audit(principal, label, 'allow', exitCode);
|
|
102
|
+
} finally {
|
|
103
|
+
responder.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function apiServeMode(principal: string): Promise<void> {
|
|
108
|
+
send({ type: 'ready', protocolVersion: API_PROTOCOL_VERSION });
|
|
109
|
+
|
|
110
|
+
const busDbPath = getEventBusPath();
|
|
111
|
+
const pendingAnswers = new Map<string, (value: unknown) => void>();
|
|
112
|
+
|
|
113
|
+
const ask = (interview: WireInterview): Promise<unknown> =>
|
|
114
|
+
new Promise((resolve) => {
|
|
115
|
+
pendingAnswers.set(interview.id, resolve);
|
|
116
|
+
send({
|
|
117
|
+
type: 'interview',
|
|
118
|
+
id: interview.id,
|
|
119
|
+
kind: interview.kind,
|
|
120
|
+
message: interview.message,
|
|
121
|
+
description: interview.description,
|
|
122
|
+
defaultValue: interview.defaultValue,
|
|
123
|
+
placeholder: interview.placeholder,
|
|
124
|
+
options: interview.options,
|
|
125
|
+
required: interview.required,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
130
|
+
|
|
131
|
+
for await (const line of rl) {
|
|
132
|
+
if (!line.trim()) continue;
|
|
133
|
+
|
|
134
|
+
let msg: ReturnType<typeof ClientMessageSchema.parse>;
|
|
135
|
+
try {
|
|
136
|
+
msg = ClientMessageSchema.parse(JSON.parse(line));
|
|
137
|
+
} catch (error) {
|
|
138
|
+
send({ type: 'error', error: error instanceof Error ? error.message : String(error) });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (msg.type === 'answer') {
|
|
143
|
+
const resolve = pendingAnswers.get(msg.id);
|
|
144
|
+
if (resolve) {
|
|
145
|
+
pendingAnswers.delete(msg.id);
|
|
146
|
+
resolve(msg.value);
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (msg.type === 'command') {
|
|
152
|
+
// Fire-and-forget so the read loop keeps consuming `answer` messages while
|
|
153
|
+
// the command runs — mid-run interviews are answered in-flight.
|
|
154
|
+
void handleCommand(principal, msg.argv, busDbPath, ask);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
process.exit(0);
|
|
159
|
+
}
|
|
@@ -177,7 +177,9 @@ export class CommandTreeParser {
|
|
|
177
177
|
'usage',
|
|
178
178
|
'options',
|
|
179
179
|
'examples',
|
|
180
|
-
'commands'
|
|
180
|
+
// NB: 'commands' is intentionally NOT excluded — it's a real command
|
|
181
|
+
// (`celilo commands`). The "Commands:" section header is filtered earlier
|
|
182
|
+
// by the section-header regex, so it never reaches this check.
|
|
181
183
|
'description',
|
|
182
184
|
'example',
|
|
183
185
|
'for',
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo api ...` — manage remote-API principals (Slice 2a).
|
|
3
|
+
*
|
|
4
|
+
* grant / list / revoke operate on the api_principals table; authorized-keys
|
|
5
|
+
* renders the forced-command file for the API account. See v2/API_COMMUNICATION.md.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
grantPrincipal,
|
|
13
|
+
listPrincipals,
|
|
14
|
+
renderAuthorizedKeys,
|
|
15
|
+
revokePrincipal,
|
|
16
|
+
validatePrincipalName,
|
|
17
|
+
} from '../../services/api-access';
|
|
18
|
+
import { celiloIntro } from '../prompts';
|
|
19
|
+
import type { CommandResult } from '../types';
|
|
20
|
+
|
|
21
|
+
function errMsg(error: unknown): string {
|
|
22
|
+
return error instanceof Error ? error.message : String(error);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `celilo api grant <principal> --key <pubkey|path> --can <grant[,grant...]>` */
|
|
26
|
+
export async function handleApiGrant(
|
|
27
|
+
args: string[],
|
|
28
|
+
flags: Record<string, boolean | string> = {},
|
|
29
|
+
): Promise<CommandResult> {
|
|
30
|
+
try {
|
|
31
|
+
const name = args[0];
|
|
32
|
+
if (!name) {
|
|
33
|
+
return {
|
|
34
|
+
success: false,
|
|
35
|
+
error: 'Usage: celilo api grant <principal> --key <pubkey|path> --can <grant[,grant...]>',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const keyArg = typeof flags.key === 'string' ? flags.key : '';
|
|
40
|
+
if (!keyArg) {
|
|
41
|
+
return {
|
|
42
|
+
success: false,
|
|
43
|
+
error: '--key <public-key-or-path> is required (e.g. ~/.ssh/id_ed25519.pub)',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const canArg = typeof flags.can === 'string' ? flags.can : '';
|
|
48
|
+
if (!canArg) {
|
|
49
|
+
return {
|
|
50
|
+
success: false,
|
|
51
|
+
error: '--can <grant[,grant...]> is required (e.g. --can module:deploy,service:*)',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const publicKey = existsSync(keyArg) ? readFileSync(keyArg, 'utf8').trim() : keyArg.trim();
|
|
56
|
+
const grants = canArg
|
|
57
|
+
.split(',')
|
|
58
|
+
.map((g) => g.trim())
|
|
59
|
+
.filter(Boolean);
|
|
60
|
+
|
|
61
|
+
const { principal, created } = await grantPrincipal({ name, publicKey, grants });
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
success: true,
|
|
65
|
+
message: `${created ? 'Granted' : 'Updated'} API access for "${principal.name}" → ${grants.join(', ')}\n\nInstall the forced-command line on celilo-mgr with:\n celilo api authorized-keys >> ~celilo-api/.ssh/authorized_keys`,
|
|
66
|
+
};
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return { success: false, error: `Failed to grant API access: ${errMsg(error)}` };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `celilo api list` */
|
|
73
|
+
export async function handleApiList(): Promise<CommandResult> {
|
|
74
|
+
try {
|
|
75
|
+
celiloIntro('API Principals');
|
|
76
|
+
const principals = await listPrincipals();
|
|
77
|
+
|
|
78
|
+
if (principals.length === 0) {
|
|
79
|
+
console.log('No API principals.\n');
|
|
80
|
+
console.log('Grant access:');
|
|
81
|
+
console.log(' celilo api grant <name> --key ~/.ssh/id_ed25519.pub --can module:deploy');
|
|
82
|
+
return { success: true, message: 'No API principals found' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.log('');
|
|
86
|
+
for (const p of principals) {
|
|
87
|
+
const parts = p.publicKey.split(/\s+/);
|
|
88
|
+
const keyType = parts[0] ?? '';
|
|
89
|
+
const comment = parts.length > 2 ? parts.slice(2).join(' ') : '';
|
|
90
|
+
console.log(`${p.name}`);
|
|
91
|
+
console.log(` Grants: ${p.grants.join(', ') || '(none)'}`);
|
|
92
|
+
console.log(` Key: ${keyType}${comment ? ` (${comment})` : ''}`);
|
|
93
|
+
console.log('');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.log(`Total: ${principals.length} principal${principals.length === 1 ? '' : 's'}\n`);
|
|
97
|
+
return { success: true, message: `Found ${principals.length} principal(s)` };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return { success: false, error: `Failed to list API principals: ${errMsg(error)}` };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `celilo api revoke <principal>` */
|
|
104
|
+
export async function handleApiRevoke(args: string[]): Promise<CommandResult> {
|
|
105
|
+
try {
|
|
106
|
+
const name = args[0];
|
|
107
|
+
if (!name) {
|
|
108
|
+
return { success: false, error: 'Usage: celilo api revoke <principal>' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const removed = await revokePrincipal(name);
|
|
112
|
+
if (!removed) {
|
|
113
|
+
return { success: false, error: `No API principal named "${name}".` };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
success: true,
|
|
118
|
+
message: `Revoked API access for "${name}".\n\nRe-render celilo-mgr's authorized_keys to drop the line:\n celilo api authorized-keys > ~celilo-api/.ssh/authorized_keys`,
|
|
119
|
+
};
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return { success: false, error: `Failed to revoke API access: ${errMsg(error)}` };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `celilo api authorized-keys` — print the forced-command file for the API account. */
|
|
126
|
+
export async function handleApiAuthorizedKeys(): Promise<CommandResult> {
|
|
127
|
+
try {
|
|
128
|
+
const content = await renderAuthorizedKeys();
|
|
129
|
+
return { success: true, message: content, rawOutput: true };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return { success: false, error: `Failed to render authorized_keys: ${errMsg(error)}` };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `celilo api key new <name>` — generate a client-side keypair for API access. */
|
|
136
|
+
export async function handleApiKeyNew(args: string[]): Promise<CommandResult> {
|
|
137
|
+
try {
|
|
138
|
+
const name = args[0];
|
|
139
|
+
if (!name) {
|
|
140
|
+
return { success: false, error: 'Usage: celilo api key new <name>' };
|
|
141
|
+
}
|
|
142
|
+
validatePrincipalName(name);
|
|
143
|
+
|
|
144
|
+
// Prefer $HOME (operator shell always sets it, and it's test-controllable);
|
|
145
|
+
// homedir() is the fallback for the rare unset case.
|
|
146
|
+
const keyPath = join(process.env.HOME || homedir(), '.ssh', `celilo-api-${name}`);
|
|
147
|
+
if (existsSync(keyPath) || existsSync(`${keyPath}.pub`)) {
|
|
148
|
+
return {
|
|
149
|
+
success: false,
|
|
150
|
+
error: `A key already exists at ${keyPath}. Remove it or choose another name.`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
mkdirSync(dirname(keyPath), { recursive: true, mode: 0o700 });
|
|
155
|
+
|
|
156
|
+
const gen = Bun.spawnSync([
|
|
157
|
+
'ssh-keygen',
|
|
158
|
+
'-t',
|
|
159
|
+
'ed25519',
|
|
160
|
+
'-N',
|
|
161
|
+
'',
|
|
162
|
+
'-C',
|
|
163
|
+
`celilo-api-${name}`,
|
|
164
|
+
'-f',
|
|
165
|
+
keyPath,
|
|
166
|
+
]);
|
|
167
|
+
if (gen.exitCode !== 0) {
|
|
168
|
+
return {
|
|
169
|
+
success: false,
|
|
170
|
+
error: `ssh-keygen failed: ${gen.stderr.toString().trim() || `exit ${gen.exitCode}`}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const pubkey = readFileSync(`${keyPath}.pub`, 'utf8').trim();
|
|
175
|
+
return {
|
|
176
|
+
success: true,
|
|
177
|
+
message: [
|
|
178
|
+
`Generated API keypair for "${name}":`,
|
|
179
|
+
` private: ${keyPath} (keep secret — never share)`,
|
|
180
|
+
` public: ${keyPath}.pub`,
|
|
181
|
+
'',
|
|
182
|
+
`Public key: ${pubkey}`,
|
|
183
|
+
'',
|
|
184
|
+
'Enroll the public key on celilo-mgr:',
|
|
185
|
+
` celilo api grant ${name} --key ${keyPath}.pub --can module:deploy`,
|
|
186
|
+
'',
|
|
187
|
+
'Then run commands remotely (configure a Host alias in ~/.ssh/config as needed):',
|
|
188
|
+
' celilo --remote celilo-api@celilo-mgr <command>',
|
|
189
|
+
].join('\n'),
|
|
190
|
+
};
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return { success: false, error: `Failed to generate API key: ${errMsg(error)}` };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { handleAptUpgrade } from './apt-upgrade';
|
|
3
|
+
|
|
4
|
+
describe('handleAptUpgrade', () => {
|
|
5
|
+
test('runs the three steps in order when each succeeds', async () => {
|
|
6
|
+
const seen: string[][] = [];
|
|
7
|
+
const result = await handleAptUpgrade([], {}, (argv) => {
|
|
8
|
+
seen.push(argv);
|
|
9
|
+
return { status: 0 };
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
expect(result.success).toBe(true);
|
|
13
|
+
expect(seen).toEqual([
|
|
14
|
+
['sudo', 'apt-get', 'update'],
|
|
15
|
+
['sudo', 'apt-get', '-y', '--only-upgrade', 'install', 'celilo', 'celilo-bootstrap'],
|
|
16
|
+
['/usr/local/bin/celilo', 'system', 'migrate'],
|
|
17
|
+
]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('stops at the first failing step and does not run later ones', async () => {
|
|
21
|
+
const seen: string[][] = [];
|
|
22
|
+
const result = await handleAptUpgrade([], {}, (argv) => {
|
|
23
|
+
seen.push(argv);
|
|
24
|
+
// Fail the apt upgrade step (index 1).
|
|
25
|
+
return { status: argv.includes('install') ? 100 : 0 };
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(result.success).toBe(false);
|
|
29
|
+
if (!result.success) expect(result.error).toContain('apt-get upgrade');
|
|
30
|
+
// update + install ran; migrate did NOT.
|
|
31
|
+
expect(seen).toHaveLength(2);
|
|
32
|
+
});
|
|
33
|
+
});
|