@celilo/core 0.12.0 → 0.14.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/package.json +1 -1
- package/src/command-registry.ts +94 -2
- package/src/protocol.ts +8 -0
- package/src/read-only-classifier.test.ts +28 -3
- package/src/read-only-classifier.ts +75 -20
- package/src/remote-client.test.ts +37 -0
- package/src/remote-client.ts +24 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Lightweight shared core for Celilo CLI tools — command registry, NDJSON API protocol, and remote SSH client. No Ink/React/drizzle/aws-sdk transitive deps, so an MCP server can reach the transport without installing the full CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
package/src/command-registry.ts
CHANGED
|
@@ -98,7 +98,7 @@ export const COMMANDS: CommandDef[] = [
|
|
|
98
98
|
subcommands: [
|
|
99
99
|
{
|
|
100
100
|
name: 'grant',
|
|
101
|
-
description: 'Grant or replace API access for a principal',
|
|
101
|
+
description: 'Grant, amend, or replace API access for a principal',
|
|
102
102
|
args: [{ name: 'principal', description: 'Principal name (kebab-case)' }],
|
|
103
103
|
flags: [
|
|
104
104
|
{
|
|
@@ -109,9 +109,24 @@ export const COMMANDS: CommandDef[] = [
|
|
|
109
109
|
},
|
|
110
110
|
{
|
|
111
111
|
name: 'can',
|
|
112
|
-
description: 'Comma-separated grants (e.g. module:deploy,service:*)',
|
|
112
|
+
description: 'Comma-separated grants, REPLACING the set (e.g. module:deploy,service:*)',
|
|
113
113
|
takesValue: true,
|
|
114
114
|
},
|
|
115
|
+
{
|
|
116
|
+
name: 'add',
|
|
117
|
+
description: 'Comma-separated grants to add to the existing set',
|
|
118
|
+
takesValue: true,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: 'remove',
|
|
122
|
+
description: 'Comma-separated grants to remove from the existing set',
|
|
123
|
+
takesValue: true,
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: 'force',
|
|
127
|
+
description: 'Allow a --can replacement that drops existing grants',
|
|
128
|
+
takesValue: false,
|
|
129
|
+
},
|
|
115
130
|
],
|
|
116
131
|
},
|
|
117
132
|
{ name: 'list', description: 'List API principals and their grants' },
|
|
@@ -1315,6 +1330,25 @@ export const COMMANDS: CommandDef[] = [
|
|
|
1315
1330
|
],
|
|
1316
1331
|
flags: [{ name: 'clear', description: 'Clear the earmark', takesValue: false }],
|
|
1317
1332
|
},
|
|
1333
|
+
{
|
|
1334
|
+
name: 'reclassify',
|
|
1335
|
+
description:
|
|
1336
|
+
"Re-classify a machine's stored interface zone labels against the current zone model",
|
|
1337
|
+
args: [
|
|
1338
|
+
{
|
|
1339
|
+
name: 'hostname',
|
|
1340
|
+
description: 'Machine hostname or IP',
|
|
1341
|
+
completion: 'machine_hostnames',
|
|
1342
|
+
},
|
|
1343
|
+
],
|
|
1344
|
+
flags: [
|
|
1345
|
+
{
|
|
1346
|
+
name: 'apply',
|
|
1347
|
+
description: 'Write the new labels (default: dry run)',
|
|
1348
|
+
takesValue: false,
|
|
1349
|
+
},
|
|
1350
|
+
],
|
|
1351
|
+
},
|
|
1318
1352
|
],
|
|
1319
1353
|
},
|
|
1320
1354
|
{
|
|
@@ -1469,6 +1503,25 @@ export const COMMANDS: CommandDef[] = [
|
|
|
1469
1503
|
},
|
|
1470
1504
|
],
|
|
1471
1505
|
},
|
|
1506
|
+
{
|
|
1507
|
+
name: 'reboot',
|
|
1508
|
+
description:
|
|
1509
|
+
'Reboot the management server. Refuses while a module operation is in flight, names it, and records the boot id so the return can be verified',
|
|
1510
|
+
flags: [
|
|
1511
|
+
{
|
|
1512
|
+
name: 'allow-reboot',
|
|
1513
|
+
description:
|
|
1514
|
+
'Approve the reboot without an interview question (the downtime takes the dispatcher, MCP server and remote API with it)',
|
|
1515
|
+
takesValue: false,
|
|
1516
|
+
},
|
|
1517
|
+
{
|
|
1518
|
+
name: 'verify',
|
|
1519
|
+
description:
|
|
1520
|
+
'Confirm a requested reboot came back: the boot id changed, the dispatcher is live and the CLI answers. Reports which is missing when it has not',
|
|
1521
|
+
takesValue: false,
|
|
1522
|
+
},
|
|
1523
|
+
],
|
|
1524
|
+
},
|
|
1472
1525
|
{
|
|
1473
1526
|
name: 'migrate',
|
|
1474
1527
|
description: 'Apply pending database migrations (idempotent; safe to re-run)',
|
|
@@ -1751,6 +1804,45 @@ export const COMMANDS: CommandDef[] = [
|
|
|
1751
1804
|
description:
|
|
1752
1805
|
'Per-subscriber delivery history (success rate, last delivery, recent failures)',
|
|
1753
1806
|
},
|
|
1807
|
+
{
|
|
1808
|
+
name: 'install-daemon',
|
|
1809
|
+
description: 'Write a supervisor unit (systemd/launchd) for the build-bus webhook receiver',
|
|
1810
|
+
flags: [
|
|
1811
|
+
{
|
|
1812
|
+
name: 'system',
|
|
1813
|
+
description: 'System-scope unit (/etc/systemd/system or LaunchDaemons)',
|
|
1814
|
+
takesValue: false,
|
|
1815
|
+
},
|
|
1816
|
+
{
|
|
1817
|
+
name: 'secret',
|
|
1818
|
+
description:
|
|
1819
|
+
'Shared HMAC secret incoming webhooks sign with (or CELILO_BUS_SECRET env)',
|
|
1820
|
+
takesValue: true,
|
|
1821
|
+
},
|
|
1822
|
+
{ name: 'port', description: 'TCP port to listen on (default 8123)', takesValue: true },
|
|
1823
|
+
{
|
|
1824
|
+
name: 'dispatch',
|
|
1825
|
+
description:
|
|
1826
|
+
'Render a combined unit (receiver + hook dispatcher) for boxes with no events daemon',
|
|
1827
|
+
takesValue: false,
|
|
1828
|
+
},
|
|
1829
|
+
{
|
|
1830
|
+
name: 'print',
|
|
1831
|
+
description: 'Render the unit to stdout without writing (Ansible seam)',
|
|
1832
|
+
takesValue: false,
|
|
1833
|
+
},
|
|
1834
|
+
{
|
|
1835
|
+
name: 'celilo-path',
|
|
1836
|
+
description: 'Explicit celilo binary path for ExecStart',
|
|
1837
|
+
takesValue: true,
|
|
1838
|
+
},
|
|
1839
|
+
],
|
|
1840
|
+
},
|
|
1841
|
+
{
|
|
1842
|
+
name: 'uninstall-daemon',
|
|
1843
|
+
description: 'Remove the build-bus receiver supervisor unit',
|
|
1844
|
+
flags: [{ name: 'system', description: 'System-scope unit', takesValue: false }],
|
|
1845
|
+
},
|
|
1754
1846
|
],
|
|
1755
1847
|
},
|
|
1756
1848
|
{
|
package/src/protocol.ts
CHANGED
|
@@ -100,6 +100,14 @@ export type ProgressMessage = z.infer<typeof ProgressMessageSchema>;
|
|
|
100
100
|
export const LogMessageSchema = z.object({
|
|
101
101
|
type: z.literal('log'),
|
|
102
102
|
message: z.string(),
|
|
103
|
+
/**
|
|
104
|
+
* Which child stream the line came from. The server merges nothing —
|
|
105
|
+
* each stream is pumped separately — but the message schema is the only
|
|
106
|
+
* place the distinction can survive the trip, so it is recorded here
|
|
107
|
+
* (celilo#1362). Optional so an older client parses a newer server
|
|
108
|
+
* unchanged; unmarked lines are stdout.
|
|
109
|
+
*/
|
|
110
|
+
stream: z.enum(['stdout', 'stderr']).optional(),
|
|
103
111
|
});
|
|
104
112
|
export type LogMessage = z.infer<typeof LogMessageSchema>;
|
|
105
113
|
|
|
@@ -99,10 +99,35 @@ describe('readOnlyGrants', () => {
|
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
test('a mixed-verb op is classified as a write', () => {
|
|
102
|
-
// `module config` covers get AND set.
|
|
103
|
-
//
|
|
104
|
-
// read-only principal the ability to set configuration.
|
|
102
|
+
// `module config` covers get AND set. One mutating leaf disqualifies the
|
|
103
|
+
// whole op, because a grant cannot be finer than the op. Collapsing it would
|
|
104
|
+
// hand a read-only principal the ability to set configuration.
|
|
105
105
|
expect(isReadOnlyPath(['module', 'config'])).toBe(false);
|
|
106
106
|
expect(readOnlyGrants(COMMANDS)).not.toContain('module:config');
|
|
107
107
|
});
|
|
108
108
|
});
|
|
109
|
+
|
|
110
|
+
describe('op-level classification (celilo#1404)', () => {
|
|
111
|
+
test('a sole-leaf read op nested 3 deep is a read, not a write', () => {
|
|
112
|
+
// `proxmox node list` is `proxmox:node`, whose only leaf is a read. Keying
|
|
113
|
+
// on the middle segment (`node`) called it a write, so the read-only
|
|
114
|
+
// principal was not granted it and the read tool that routes RO could not
|
|
115
|
+
// run — a silent exit 126.
|
|
116
|
+
expect(isReadOnlyPath(['proxmox', 'node', 'list'])).toBe(true);
|
|
117
|
+
expect(readOnlyGrants(COMMANDS)).toContain('proxmox:node');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('one mutating leaf disqualifies its whole op', () => {
|
|
121
|
+
// `proxmox vm` covers list AND resize. The grant cannot be finer than the
|
|
122
|
+
// op, so the read `vm list` rides the RW key rather than widening RO.
|
|
123
|
+
expect(isReadOnlyPath(['proxmox', 'vm', 'list'])).toBe(false);
|
|
124
|
+
expect(readOnlyGrants(COMMANDS)).not.toContain('proxmox:vm');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('module journal and module where are reachable', () => {
|
|
128
|
+
// The two the live server was missing.
|
|
129
|
+
const granted = new Set(readOnlyGrants(COMMANDS));
|
|
130
|
+
expect(granted).toContain('module:journal');
|
|
131
|
+
expect(granted).toContain('module:where');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* automatically and a new write verb is not.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import type
|
|
17
|
+
import { type ArgDef, COMMANDS, type CommandDef, type FlagDef } from './command-registry';
|
|
18
18
|
|
|
19
19
|
/** A runnable leaf command flattened from the registry tree. */
|
|
20
20
|
export interface Leaf {
|
|
@@ -26,18 +26,44 @@ export interface Leaf {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* Read-only verbs
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
29
|
+
* Read-only LEAF verbs — the last path segment of a runnable command.
|
|
30
|
+
*
|
|
31
|
+
* Classification is two steps, because the server's authz is 2-level
|
|
32
|
+
* (`command:subcommand`) while the registry is up to 3 deep. A LEAF is a read
|
|
33
|
+
* iff its own verb is here; an OP is a read iff EVERY leaf under it is. That
|
|
34
|
+
* second step is what keeps a mixed op like `module config` (get + set) a write,
|
|
35
|
+
* and it is what stopped `proxmox node list` from being one: keying on the
|
|
36
|
+
* middle segment (`node`) classified a sole-leaf read op as a write, so the
|
|
37
|
+
* read-only principal could not run it (celilo#1404).
|
|
38
|
+
*
|
|
39
|
+
* DELIBERATELY NOT read verbs, though they look like ones:
|
|
40
|
+
* health, verify — run hooks ON the target host; reads in intent, side effects
|
|
41
|
+
* in mechanism.
|
|
42
|
+
* doctor, poll — poll writes alert state; doctor can repair.
|
|
43
|
+
* search — reaches the registry over the network.
|
|
44
|
+
* authorized-keys — renders every principal's full public key and forced-command
|
|
45
|
+
* line. `api list` (granted) shows key type and comment only.
|
|
35
46
|
*/
|
|
36
47
|
export const READ_VERBS: ReadonlySet<string> = new Set([
|
|
37
48
|
'list',
|
|
38
49
|
'status',
|
|
39
50
|
'where',
|
|
40
51
|
'get',
|
|
52
|
+
'show',
|
|
53
|
+
'show-config',
|
|
54
|
+
'show-zone',
|
|
55
|
+
'show-daemon',
|
|
56
|
+
'info',
|
|
57
|
+
'logs',
|
|
58
|
+
'tail',
|
|
59
|
+
'registrations',
|
|
60
|
+
'list-allocations',
|
|
61
|
+
'list-reservations',
|
|
62
|
+
'list-exclusions',
|
|
63
|
+
'list-subscribers',
|
|
64
|
+
'list-pending',
|
|
65
|
+
'list-failed',
|
|
66
|
+
'list-unanswered',
|
|
41
67
|
// Reads a deployed module's daemon journal. Read-only by construction —
|
|
42
68
|
// the only remote command it can emit is `journalctl` (services/module-journal.ts).
|
|
43
69
|
'journal',
|
|
@@ -50,14 +76,14 @@ export function opOf(path: string[]): string {
|
|
|
50
76
|
return path.length >= 2 ? `${path[0]}:${path[1]}` : path[0];
|
|
51
77
|
}
|
|
52
78
|
|
|
53
|
-
/**
|
|
54
|
-
function
|
|
55
|
-
return path.length
|
|
79
|
+
/** The leaf's own verb — the token that decides read vs write. */
|
|
80
|
+
function leafVerb(path: string[]): string {
|
|
81
|
+
return path[path.length - 1];
|
|
56
82
|
}
|
|
57
83
|
|
|
58
|
-
/** Is this leaf read
|
|
59
|
-
export function
|
|
60
|
-
return READ_VERBS.has(
|
|
84
|
+
/** Is this single leaf a read? (Not the same question as its OP — see below.) */
|
|
85
|
+
export function isReadOnlyLeaf(path: string[]): boolean {
|
|
86
|
+
return READ_VERBS.has(leafVerb(path));
|
|
61
87
|
}
|
|
62
88
|
|
|
63
89
|
/** Flatten the registry tree into its runnable leaves (nodes with no subcommands). */
|
|
@@ -82,15 +108,44 @@ export function flattenLeaves(commands: CommandDef[], prefix: string[] = []): Le
|
|
|
82
108
|
/**
|
|
83
109
|
* Every authz op a read-only principal needs, derived from a command registry.
|
|
84
110
|
*
|
|
111
|
+
* An op is granted iff EVERY leaf it can run is a read. One mutating leaf under
|
|
112
|
+
* an op disqualifies the whole op, because the grant cannot be finer than the op.
|
|
113
|
+
*
|
|
85
114
|
* Sorted and de-duplicated so the result is stable enough to compare in a test
|
|
86
115
|
* and to print in a grant line.
|
|
87
116
|
*/
|
|
88
117
|
export function readOnlyGrants(commands: CommandDef[]): string[] {
|
|
89
|
-
return [
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
118
|
+
return [...readOnlyOps(commands)].sort();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readOnlyOps(commands: CommandDef[]): Set<string> {
|
|
122
|
+
const leavesByOp = new Map<string, string[][]>();
|
|
123
|
+
for (const leaf of flattenLeaves(commands)) {
|
|
124
|
+
const op = opOf(leaf.path);
|
|
125
|
+
const group = leavesByOp.get(op);
|
|
126
|
+
if (group) group.push(leaf.path);
|
|
127
|
+
else leavesByOp.set(op, [leaf.path]);
|
|
128
|
+
}
|
|
129
|
+
const ops = new Set<string>();
|
|
130
|
+
for (const [op, paths] of leavesByOp) {
|
|
131
|
+
if (paths.every(isReadOnlyLeaf)) ops.add(op);
|
|
132
|
+
}
|
|
133
|
+
return ops;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Every authz op the registry can run, read and write alike. Sorted, de-duped. */
|
|
137
|
+
export function allOps(commands: CommandDef[]): string[] {
|
|
138
|
+
return [...new Set(flattenLeaves(commands).map((l) => opOf(l.path)))].sort();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let liveReadOnlyOps: Set<string> | null = null;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Is this path routed to the read-only principal? Answered at OP level against
|
|
145
|
+
* the live registry, so a leaf's routing and the RO principal's grants can never
|
|
146
|
+
* disagree — a read tool routes RO iff RO is granted its op.
|
|
147
|
+
*/
|
|
148
|
+
export function isReadOnlyPath(path: string[]): boolean {
|
|
149
|
+
liveReadOnlyOps ??= readOnlyOps(COMMANDS);
|
|
150
|
+
return liveReadOnlyOps.has(opOf(path));
|
|
96
151
|
}
|
|
@@ -177,3 +177,40 @@ describe('stdin carrying JSON-RPC survives a raised interview', () => {
|
|
|
177
177
|
expect(out).toContain(`SURVIVED:${rpc}`);
|
|
178
178
|
}, 30_000);
|
|
179
179
|
});
|
|
180
|
+
|
|
181
|
+
describe("a denial's text survives to a capturing caller (celilo#1404)", () => {
|
|
182
|
+
test('the outcome carries the server error, which never reaches `out`', async () => {
|
|
183
|
+
// The server DOES say why: `permission denied: "<principal>" is not granted
|
|
184
|
+
// "<op>"`. But it says it as a protocol `error` message, not as command
|
|
185
|
+
// output, so a caller that captures `out` (the MCP server) saw exit 126 with
|
|
186
|
+
// an empty body and read it as the command failing on the target host.
|
|
187
|
+
const denial = 'permission denied: "celilo-mcp-ro" is not granted "module:journal"';
|
|
188
|
+
const transport = fakeTransport([
|
|
189
|
+
JSON.stringify({ type: 'error', error: denial }),
|
|
190
|
+
JSON.stringify({ type: 'result', success: false, exitCode: 126 }),
|
|
191
|
+
]);
|
|
192
|
+
const captured: string[] = [];
|
|
193
|
+
|
|
194
|
+
const outcome = await runRemoteClient('host', ['module', 'journal', 'caddy'], {
|
|
195
|
+
openTransport: () => transport,
|
|
196
|
+
out: { write: (s: string) => void captured.push(s), isTTY: false },
|
|
197
|
+
onBlocked: 'return',
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(outcome).toEqual({ status: 'result', exitCode: 126, error: denial });
|
|
201
|
+
// Still not command output — a caller must not mistake it for one.
|
|
202
|
+
expect(captured.join('')).toBe('');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('a clean run carries no error', async () => {
|
|
206
|
+
const transport = fakeTransport([
|
|
207
|
+
JSON.stringify({ type: 'result', success: true, exitCode: 0 }),
|
|
208
|
+
]);
|
|
209
|
+
const outcome = await runRemoteClient('host', ['module', 'list'], {
|
|
210
|
+
openTransport: () => transport,
|
|
211
|
+
out: { write() {}, isTTY: false },
|
|
212
|
+
onBlocked: 'return',
|
|
213
|
+
});
|
|
214
|
+
expect(outcome).toEqual({ status: 'result', exitCode: 0 });
|
|
215
|
+
});
|
|
216
|
+
});
|
package/src/remote-client.ts
CHANGED
|
@@ -190,16 +190,19 @@ function applyMessage(
|
|
|
190
190
|
// Nest under the active step so the footer isn't corrupted; otherwise print.
|
|
191
191
|
if (display.hasPending) {
|
|
192
192
|
display.subEvent(msg.message);
|
|
193
|
+
} else if (msg.stream === 'stderr') {
|
|
194
|
+
// The server names each log line's origin stream; keep stderr on
|
|
195
|
+
// stderr locally so diagnostics never masquerade as command output
|
|
196
|
+
// (celilo#1362). Unmarked lines are stdout from an older server.
|
|
197
|
+
process.stderr.write(`${msg.message}\n`);
|
|
193
198
|
} else {
|
|
194
199
|
out.write(`${msg.message}\n`);
|
|
195
200
|
}
|
|
196
201
|
return null;
|
|
197
202
|
case 'error':
|
|
198
|
-
process.stderr.write(`${msg.error}\n`);
|
|
199
|
-
return null;
|
|
200
203
|
case 'interview':
|
|
201
204
|
case 'blocked':
|
|
202
|
-
// Handled by
|
|
205
|
+
// Handled by consumeStream (needs to retain the text / reply) — never here.
|
|
203
206
|
return null;
|
|
204
207
|
case 'result':
|
|
205
208
|
return msg.exitCode;
|
|
@@ -212,7 +215,14 @@ function applyMessage(
|
|
|
212
215
|
* is still alive under `sessionId`.
|
|
213
216
|
*/
|
|
214
217
|
export type RemoteOutcome =
|
|
215
|
-
|
|
218
|
+
/**
|
|
219
|
+
* `error` is the server's in-band `error` text, retained so a CAPTURING
|
|
220
|
+
* caller can report it. It is written to stderr as well, for a terminal one.
|
|
221
|
+
* Without it an authz denial reached the MCP as exit 126 with empty output —
|
|
222
|
+
* a silent denial the caller reads as the command failing on the host
|
|
223
|
+
* (celilo#1404).
|
|
224
|
+
*/
|
|
225
|
+
| { status: 'result'; exitCode: number; error?: string }
|
|
216
226
|
| { status: 'blocked'; sessionId: string; eventId: string; question: string; key?: string };
|
|
217
227
|
|
|
218
228
|
/** Exit code a caller reports when a command parked rather than finishing. */
|
|
@@ -237,6 +247,7 @@ async function consumeStream(
|
|
|
237
247
|
): Promise<RemoteOutcome | null> {
|
|
238
248
|
const { out, display, renderInterview } = opts;
|
|
239
249
|
const decoder = new TextDecoder();
|
|
250
|
+
const errors: string[] = [];
|
|
240
251
|
let buffer = '';
|
|
241
252
|
|
|
242
253
|
for await (const chunk of transport.stdout) {
|
|
@@ -276,6 +287,12 @@ async function consumeStream(
|
|
|
276
287
|
continue;
|
|
277
288
|
}
|
|
278
289
|
|
|
290
|
+
if (msg.type === 'error') {
|
|
291
|
+
errors.push(msg.error);
|
|
292
|
+
process.stderr.write(`${msg.error}\n`);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
279
296
|
if (msg.type === 'blocked') {
|
|
280
297
|
if (opts.onBlocked === 'wait') {
|
|
281
298
|
out.write(
|
|
@@ -293,7 +310,9 @@ async function consumeStream(
|
|
|
293
310
|
}
|
|
294
311
|
|
|
295
312
|
const exit = applyMessage(msg, display, out);
|
|
296
|
-
if (exit !== null)
|
|
313
|
+
if (exit !== null) {
|
|
314
|
+
return { status: 'result', exitCode: exit, error: errors.join('\n') || undefined };
|
|
315
|
+
}
|
|
297
316
|
}
|
|
298
317
|
}
|
|
299
318
|
return null;
|