@celilo/cli 0.10.0 → 0.11.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.
@@ -77,6 +77,17 @@ 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
+ - **Wire protocol** — `apps/celilo/src/api/protocol.ts` — versioned NDJSON tagged union (`command`/`progress`/`log`/`result`/`error`/`interview`/`answer`) + `translateOutputLine`.
85
+ - **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.
86
+ - **Client** — `apps/celilo/src/api/remote-client.ts` — `resolveRemote` (`--remote <dest>` / `CELILO_REMOTE`), `runRemoteClient` (`ssh -T`, renders progress via the local ProgressDisplay, answers interviews via clack).
87
+ - **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`).
88
+ - **Mid-run interview bridge (`kind:daemon` responder)** — `apps/celilo/src/services/remote-responder.ts` — `startRemoteResponder` bridges bus `interview.required.*` ↔ wire.
89
+ - **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.
90
+
80
91
  ## E2E simulation
81
92
 
82
93
  - **cele2e harness** — `packages/e2e/src/` — `runner.ts`, `container-manager.ts` (`startNetwork`, `reconnectNetwork`), `network-builder.ts` (`NetworkBuilder`).
@@ -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`);
@@ -99,6 +99,13 @@
99
99
  "when": 1782456724000,
100
100
  "tag": "0013_dns_view_overrides",
101
101
  "breakpoints": true
102
+ },
103
+ {
104
+ "idx": 14,
105
+ "version": "6",
106
+ "when": 1783000000000,
107
+ "tag": "0014_api_principals",
108
+ "breakpoints": true
102
109
  }
103
110
  ]
104
111
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,13 +16,7 @@
16
16
  "CELILO_SUBSYSTEMS.md",
17
17
  "CELILO_CORE_MODULES.md"
18
18
  ],
19
- "keywords": [
20
- "celilo",
21
- "homelab",
22
- "orchestration",
23
- "ansible",
24
- "terraform"
25
- ],
19
+ "keywords": ["celilo", "homelab", "orchestration", "ansible", "terraform"],
26
20
  "license": "MIT",
27
21
  "repository": {
28
22
  "type": "git",
@@ -57,7 +51,7 @@
57
51
  "@aws-sdk/client-s3": "^3.1024.0",
58
52
  "@celilo/capabilities": "^0.6.0",
59
53
  "@celilo/cli-display": "^0.1.9",
60
- "@celilo/event-bus": "^0.1.8",
54
+ "@celilo/event-bus": "^0.1.7",
61
55
  "@clack/prompts": "^1.1.0",
62
56
  "ajv": "^8.18.0",
63
57
  "drizzle-orm": "^0.36.4",
@@ -0,0 +1,76 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { translateOutputLine } from './protocol';
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,159 @@
1
+ /**
2
+ * Remote API wire protocol (Slice 1).
3
+ *
4
+ * A versioned, Zod-validated tagged union carried as NDJSON. This is the
5
+ * transport-neutral message layer for driving the CLI over the wire — see
6
+ * `v2/API_COMMUNICATION.md`. Slice 1 covers non-interactive command execution
7
+ * + streamed output; `interview` / `answer` / `cancel` arrive in Slice 3.
8
+ *
9
+ * The command crosses the wire as a *structured, validated* `argv` array —
10
+ * never a shell string handed to `exec`.
11
+ */
12
+
13
+ import { z } from 'zod';
14
+
15
+ export const API_PROTOCOL_VERSION = 1;
16
+
17
+ // ── client → server ──────────────────────────────────────────────────────
18
+
19
+ export const CommandMessageSchema = z.object({
20
+ type: z.literal('command'),
21
+ /** Structured argv (e.g. ["module", "list"]) — dispatched into runCli. */
22
+ argv: z.array(z.string()).min(1),
23
+ });
24
+ export type CommandMessage = z.infer<typeof CommandMessageSchema>;
25
+
26
+ /** Client's answer to a server `interview` message, correlated by `id`. */
27
+ export const AnswerMessageSchema = z.object({
28
+ type: z.literal('answer'),
29
+ id: z.string(),
30
+ value: z.unknown(),
31
+ });
32
+ export type AnswerMessage = z.infer<typeof AnswerMessageSchema>;
33
+
34
+ export const ClientMessageSchema = z.discriminatedUnion('type', [
35
+ CommandMessageSchema,
36
+ AnswerMessageSchema,
37
+ // Slice 3+: CancelMessageSchema
38
+ ]);
39
+ export type ClientMessage = z.infer<typeof ClientMessageSchema>;
40
+
41
+ // ── server → client ──────────────────────────────────────────────────────
42
+
43
+ /** Handshake: server announces itself + the protocol version it speaks. */
44
+ export const ReadyMessageSchema = z.object({
45
+ type: z.literal('ready'),
46
+ protocolVersion: z.literal(API_PROTOCOL_VERSION),
47
+ });
48
+ export type ReadyMessage = z.infer<typeof ReadyMessageSchema>;
49
+
50
+ /**
51
+ * A forwarded `ProgressDisplay` protocol-mode marker. Mirrors the markers
52
+ * emitted by `packages/cli-display` in protocol mode (start/push/done/fail/sub
53
+ * and the legacy instant `[progress]`).
54
+ */
55
+ export const ProgressMessageSchema = z.object({
56
+ type: z.literal('progress'),
57
+ kind: z.enum(['start', 'push', 'done', 'fail', 'sub', 'message']),
58
+ doing: z.string().optional(),
59
+ done: z.string().optional(),
60
+ message: z.string().optional(),
61
+ });
62
+ export type ProgressMessage = z.infer<typeof ProgressMessageSchema>;
63
+
64
+ /** A plain output line from the command that isn't a progress marker. */
65
+ export const LogMessageSchema = z.object({
66
+ type: z.literal('log'),
67
+ message: z.string(),
68
+ });
69
+ export type LogMessage = z.infer<typeof LogMessageSchema>;
70
+
71
+ /** Terminal message for a command run. */
72
+ export const ResultMessageSchema = z.object({
73
+ type: z.literal('result'),
74
+ success: z.boolean(),
75
+ exitCode: z.number(),
76
+ });
77
+ export type ResultMessage = z.infer<typeof ResultMessageSchema>;
78
+
79
+ /** Protocol-level error (malformed message, etc.) — distinct from a failed command. */
80
+ export const ErrorMessageSchema = z.object({
81
+ type: z.literal('error'),
82
+ error: z.string(),
83
+ });
84
+ export type ErrorMessage = z.infer<typeof ErrorMessageSchema>;
85
+
86
+ /**
87
+ * A mid-run interview question forwarded from the server's event bus. The client
88
+ * renders it, then replies with an `answer` carrying the same `id`. Mirrors the
89
+ * generic `interview.required.*` payload (see bus-interview.ts).
90
+ */
91
+ export const InterviewMessageSchema = z.object({
92
+ type: z.literal('interview'),
93
+ id: z.string(),
94
+ kind: z.enum(['text', 'confirm', 'select', 'multiselect']),
95
+ message: z.string(),
96
+ description: z.string().optional(),
97
+ defaultValue: z.string().optional(),
98
+ placeholder: z.string().optional(),
99
+ options: z
100
+ .array(z.object({ value: z.string(), label: z.string(), hint: z.string().optional() }))
101
+ .optional(),
102
+ required: z.boolean().optional(),
103
+ });
104
+ export type InterviewMessage = z.infer<typeof InterviewMessageSchema>;
105
+
106
+ export const ServerMessageSchema = z.discriminatedUnion('type', [
107
+ ReadyMessageSchema,
108
+ ProgressMessageSchema,
109
+ LogMessageSchema,
110
+ ResultMessageSchema,
111
+ ErrorMessageSchema,
112
+ InterviewMessageSchema,
113
+ ]);
114
+ export type ServerMessage = z.infer<typeof ServerMessageSchema>;
115
+
116
+ // ── translation ───────────────────────────────────────────────────────────
117
+
118
+ /**
119
+ * Translate one line of the command's stdout into a server message. Progress
120
+ * markers become structured `progress` messages; everything else is a `log`.
121
+ *
122
+ * ponytail: the marker grammar is duplicated from packages/cli-display's
123
+ * ProgressDisplay (which *emits* it). If a third consumer appears, extract a
124
+ * shared parser into @celilo/cli-display and have both sides import it.
125
+ */
126
+ export function translateOutputLine(line: string): ProgressMessage | LogMessage {
127
+ const startPush = line.match(/^\[progress:(start|push)\] (.*?) \| (.*)$/);
128
+ if (startPush) {
129
+ return {
130
+ type: 'progress',
131
+ kind: startPush[1] as 'start' | 'push',
132
+ doing: startPush[2],
133
+ done: startPush[3],
134
+ };
135
+ }
136
+
137
+ const done = line.match(/^\[progress:done\](.*)$/);
138
+ if (done) {
139
+ const msg = done[1].trim();
140
+ return { type: 'progress', kind: 'done', message: msg || undefined };
141
+ }
142
+
143
+ const fail = line.match(/^\[progress:fail\] (.*)$/);
144
+ if (fail) {
145
+ return { type: 'progress', kind: 'fail', message: fail[1] };
146
+ }
147
+
148
+ const sub = line.match(/^\[progress:sub\] (.*)$/);
149
+ if (sub) {
150
+ return { type: 'progress', kind: 'sub', message: sub[1] };
151
+ }
152
+
153
+ const legacy = line.match(/^\[progress\] (.*)$/);
154
+ if (legacy) {
155
+ return { type: 'progress', kind: 'message', message: legacy[1] };
156
+ }
157
+
158
+ return { type: 'log', message: line };
159
+ }
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { type RemoteTransport, resolveRemote, runRemoteClient } from './remote-client';
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
+ });
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Remote API client — `celilo --remote <dest> <command...>` (Slice 2c).
3
+ *
4
+ * SSHes to <dest> (whose sshd forced command runs `api-serve --principal=…`),
5
+ * ships the command as a structured argv over the NDJSON protocol, renders the
6
+ * streamed `progress`/`log` through the local ProgressDisplay, and exits with
7
+ * the command's exit code. Reuses the system `ssh` client, so keys, known_hosts,
8
+ * agent, and ~/.ssh/config all apply — execution is remote, presentation local.
9
+ */
10
+
11
+ import { type DisplayWriter, ProgressDisplay } from '@celilo/cli-display';
12
+ import * as clack from '@clack/prompts';
13
+ import { type InterviewMessage, type ServerMessage, ServerMessageSchema } from './protocol';
14
+
15
+ const REMOTE_FLAG = '--remote';
16
+
17
+ /** Render a forwarded interview via clack, returning the operator's answer. */
18
+ async function defaultRenderInterview(iv: InterviewMessage): Promise<unknown> {
19
+ const options = (iv.options ?? []).map((o) => ({ value: o.value, label: o.label, hint: o.hint }));
20
+ let result: unknown;
21
+ switch (iv.kind) {
22
+ case 'confirm':
23
+ result = await clack.confirm({
24
+ message: iv.message,
25
+ initialValue: iv.defaultValue === 'true',
26
+ });
27
+ break;
28
+ case 'select':
29
+ result = await clack.select({ message: iv.message, options });
30
+ break;
31
+ case 'multiselect':
32
+ result = await clack.multiselect({
33
+ message: iv.message,
34
+ options,
35
+ required: iv.required ?? false,
36
+ });
37
+ break;
38
+ default:
39
+ result = await clack.text({
40
+ message: iv.message,
41
+ defaultValue: iv.defaultValue,
42
+ placeholder: iv.placeholder,
43
+ });
44
+ }
45
+ if (clack.isCancel(result)) {
46
+ clack.cancel('Cancelled.');
47
+ process.exit(130);
48
+ }
49
+ return result;
50
+ }
51
+
52
+ /** Minimal transport surface — satisfied by a `Bun.spawn` of `ssh`. */
53
+ export interface RemoteTransport {
54
+ stdin: { write(chunk: string): void; flush?(): number | Promise<number> };
55
+ stdout: ReadableStream<Uint8Array>;
56
+ kill(): void;
57
+ exited: Promise<number>;
58
+ }
59
+
60
+ /**
61
+ * Resolve the remote target from a leading `--remote <dest>` / `--remote=<dest>`
62
+ * or the `CELILO_REMOTE` env. Returns null for a local invocation.
63
+ */
64
+ export function resolveRemote(
65
+ argv: string[],
66
+ remoteEnv: string | undefined = process.env.CELILO_REMOTE,
67
+ ): { dest: string; commandArgv: string[] } | null {
68
+ const rest = argv.slice(2);
69
+
70
+ if (rest[0] === REMOTE_FLAG && rest[1]) {
71
+ return { dest: rest[1], commandArgv: rest.slice(2) };
72
+ }
73
+ if (rest[0]?.startsWith(`${REMOTE_FLAG}=`)) {
74
+ return { dest: rest[0].slice(REMOTE_FLAG.length + 1), commandArgv: rest.slice(1) };
75
+ }
76
+ if (remoteEnv) {
77
+ return { dest: remoteEnv, commandArgv: rest };
78
+ }
79
+ return null;
80
+ }
81
+
82
+ function openSshTransport(dest: string): RemoteTransport {
83
+ // -T: no pseudo-tty — we want a raw NDJSON pipe. stderr inherits so ssh's own
84
+ // diagnostics (host-key prompts, connection failures) reach the operator.
85
+ const proc = Bun.spawn(['ssh', '-T', dest], {
86
+ stdin: 'pipe',
87
+ stdout: 'pipe',
88
+ stderr: 'inherit',
89
+ });
90
+ return {
91
+ stdin: proc.stdin,
92
+ stdout: proc.stdout,
93
+ kill: () => proc.kill(),
94
+ exited: proc.exited,
95
+ };
96
+ }
97
+
98
+ /** Apply one server message to the local display; returns an exit code if terminal. */
99
+ function applyMessage(
100
+ msg: ServerMessage,
101
+ display: ProgressDisplay,
102
+ out: DisplayWriter,
103
+ ): number | null {
104
+ switch (msg.type) {
105
+ case 'ready':
106
+ return null;
107
+ case 'progress':
108
+ switch (msg.kind) {
109
+ case 'start':
110
+ display.startStep(msg.doing ?? '', msg.done ?? '');
111
+ break;
112
+ case 'push':
113
+ display.pushStep(msg.doing ?? '', msg.done ?? '');
114
+ break;
115
+ case 'done':
116
+ display.doneStep(msg.message);
117
+ break;
118
+ case 'fail':
119
+ display.failStep(msg.message ?? '');
120
+ break;
121
+ case 'sub':
122
+ display.subEvent(msg.message ?? '');
123
+ break;
124
+ case 'message':
125
+ display.instantEvent(msg.message ?? '');
126
+ break;
127
+ }
128
+ return null;
129
+ case 'log':
130
+ // Nest under the active step so the footer isn't corrupted; otherwise print.
131
+ if (display.hasPending) {
132
+ display.subEvent(msg.message);
133
+ } else {
134
+ out.write(`${msg.message}\n`);
135
+ }
136
+ return null;
137
+ case 'error':
138
+ process.stderr.write(`${msg.error}\n`);
139
+ return null;
140
+ case 'interview':
141
+ // Handled by the caller (needs the transport to reply) — never reached here.
142
+ return null;
143
+ case 'result':
144
+ return msg.exitCode;
145
+ }
146
+ }
147
+
148
+ export async function runRemoteClient(
149
+ dest: string,
150
+ argv: string[],
151
+ opts: {
152
+ openTransport?: (dest: string) => RemoteTransport;
153
+ out?: DisplayWriter;
154
+ renderInterview?: (interview: InterviewMessage) => Promise<unknown>;
155
+ } = {},
156
+ ): Promise<number> {
157
+ if (argv.length === 0) {
158
+ process.stderr.write(
159
+ 'celilo --remote <dest> requires a command (e.g. celilo --remote host module list)\n',
160
+ );
161
+ return 2;
162
+ }
163
+
164
+ const openTransport = opts.openTransport ?? openSshTransport;
165
+ const out = opts.out ?? process.stdout;
166
+ const renderInterview = opts.renderInterview ?? defaultRenderInterview;
167
+ const display = new ProgressDisplay({ out });
168
+
169
+ const transport = openTransport(dest);
170
+ transport.stdin.write(`${JSON.stringify({ type: 'command', argv })}\n`);
171
+ await transport.stdin.flush?.();
172
+
173
+ let resultExit: number | null = null;
174
+ const decoder = new TextDecoder();
175
+ let buffer = '';
176
+
177
+ outer: for await (const chunk of transport.stdout) {
178
+ buffer += decoder.decode(chunk, { stream: true });
179
+ let nl = buffer.indexOf('\n');
180
+ while (nl >= 0) {
181
+ const line = buffer.slice(0, nl);
182
+ buffer = buffer.slice(nl + 1);
183
+ nl = buffer.indexOf('\n');
184
+ if (!line.trim()) continue;
185
+
186
+ let msg: ServerMessage;
187
+ try {
188
+ msg = ServerMessageSchema.parse(JSON.parse(line));
189
+ } catch {
190
+ // Non-protocol line (e.g. an ssh notice) — pass through.
191
+ out.write(`${line}\n`);
192
+ continue;
193
+ }
194
+
195
+ if (msg.type === 'interview') {
196
+ const value = await renderInterview(msg);
197
+ transport.stdin.write(`${JSON.stringify({ type: 'answer', id: msg.id, value })}\n`);
198
+ await transport.stdin.flush?.();
199
+ continue;
200
+ }
201
+
202
+ const exit = applyMessage(msg, display, out);
203
+ if (exit !== null) {
204
+ resultExit = exit;
205
+ break outer;
206
+ }
207
+ }
208
+ }
209
+
210
+ if (resultExit !== null) {
211
+ // Server is still looping awaiting more input — tear it down.
212
+ transport.kill();
213
+ return resultExit;
214
+ }
215
+
216
+ // Stream ended without a result — surface the transport's exit (ssh failure).
217
+ return transport.exited;
218
+ }