@celilo/core 0.7.0 → 0.8.1

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 CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@celilo/core",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
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",
7
7
  "exports": {
8
8
  ".": "./src/index.ts"
9
9
  },
10
+ "scripts": {
11
+ "test": "bun test"
12
+ },
10
13
  "files": [
11
14
  "src/",
12
15
  "README.md"
@@ -715,6 +715,14 @@ export const COMMANDS: CommandDef[] = [
715
715
  { name: 'key', description: 'Config key', completion: 'config_keys' },
716
716
  ],
717
717
  },
718
+ {
719
+ name: 'unset',
720
+ description: "Remove an override so the key follows the module's manifest again",
721
+ args: [
722
+ { name: 'id', description: 'Module ID', completion: 'module_ids' },
723
+ { name: 'key', description: 'Config key', completion: 'config_keys' },
724
+ ],
725
+ },
718
726
  ],
719
727
  },
720
728
  {
@@ -1086,6 +1094,29 @@ export const COMMANDS: CommandDef[] = [
1086
1094
  },
1087
1095
  ],
1088
1096
  },
1097
+ {
1098
+ name: 'firewall',
1099
+ description: "Inspect a firewall's interfaces",
1100
+ subcommands: [
1101
+ {
1102
+ name: 'interface',
1103
+ description: 'Interface classification for a firewall',
1104
+ subcommands: [
1105
+ {
1106
+ name: 'list',
1107
+ description:
1108
+ 'Show how celilo classifies each interface (zone, external, or unaccounted for)',
1109
+ args: [
1110
+ {
1111
+ name: 'hostname',
1112
+ description: 'Firewall hostname (default: every machine celilo calls a router)',
1113
+ },
1114
+ ],
1115
+ },
1116
+ ],
1117
+ },
1118
+ ],
1119
+ },
1089
1120
  {
1090
1121
  name: 'machine',
1091
1122
  description: 'Manage existing machines (Raspberry Pi, VPS, bare metal)',
@@ -1198,6 +1229,11 @@ export const COMMANDS: CommandDef[] = [
1198
1229
  },
1199
1230
  ],
1200
1231
  },
1232
+ {
1233
+ name: 'discover-network',
1234
+ description:
1235
+ "Record the network this box sits on, read from its own routing table. celilo's own write: `apply-config` refuses network.* keys",
1236
+ },
1201
1237
  {
1202
1238
  name: 'config',
1203
1239
  description: 'Get or set system configuration',
@@ -1672,15 +1708,24 @@ export const COMMANDS: CommandDef[] = [
1672
1708
  description: 'Run a monitor now',
1673
1709
  args: [{ name: 'target', description: 'Module id or built-in check name' }],
1674
1710
  },
1711
+ {
1712
+ name: 'set-interval',
1713
+ description:
1714
+ "Change a built-in check's cadence (a module's comes from `module config set <m> health_check_interval`)",
1715
+ args: [
1716
+ { name: 'check', description: 'Built-in check name' },
1717
+ { name: 'cadence', description: 'Cadence, e.g. 15m, 1h, daily' },
1718
+ ],
1719
+ },
1675
1720
  {
1676
1721
  name: 'enable',
1677
- description: 'Enable a monitor',
1678
- args: [{ name: 'target', description: 'Module id or built-in check name' }],
1722
+ description: 'Enable a built-in check monitor',
1723
+ args: [{ name: 'target', description: 'Built-in check name' }],
1679
1724
  },
1680
1725
  {
1681
1726
  name: 'disable',
1682
- description: 'Disable a monitor and resolve its alerts',
1683
- args: [{ name: 'target', description: 'Module id or built-in check name' }],
1727
+ description: 'Disable a built-in check monitor and resolve its alerts',
1728
+ args: [{ name: 'target', description: 'Built-in check name' }],
1684
1729
  },
1685
1730
  ],
1686
1731
  },
@@ -0,0 +1,179 @@
1
+ /**
2
+ * celilo#606 — the interview path must never read the process stdin that
3
+ * carries JSON-RPC.
4
+ *
5
+ * An MCP server's stdin IS its JSON-RPC transport. `defaultRenderInterview`
6
+ * used to prompt with clack, and clack reads keypresses off stdin whether or
7
+ * not stdin is a TTY — so raising an interview consumed bytes off the wire.
8
+ * That desynchronises the framing, which is why the reported symptom was not a
9
+ * failed call but a session that never answered again: every later call hung,
10
+ * a fresh session was clean, and cancelling the stuck call could not help,
11
+ * because you cannot re-synchronise a corrupted stream by cancelling.
12
+ *
13
+ * Two independent fixes now close it — the non-TTY guard in
14
+ * `defaultRenderInterview`, and the MCP always supplying `answersRenderer`,
15
+ * which touches no stdin at all. This asserts the first, and asserts the
16
+ * PROPERTY rather than either implementation: a raised interview must leave
17
+ * stdin byte-for-byte intact.
18
+ *
19
+ * ⚠️ These are the first tests in `@celilo/core`. The package had no `test`
20
+ * script, so `bun run --filter '@celilo/*' test` silently skipped it — the only
21
+ * `@celilo/*` package that was skipped, while holding the SSH/NDJSON transport,
22
+ * the wire protocol, and the command registry the MCP builds its whole tool
23
+ * surface from. That absence is why this mechanism could ship unnoticed.
24
+ */
25
+
26
+ import { describe, expect, test } from 'bun:test';
27
+ import type { RemoteTransport } from './remote-client';
28
+ import { InterviewUnanswerableError, interviewKey, runRemoteClient } from './remote-client';
29
+
30
+ /** A transport that replays canned server lines and records what we wrote. */
31
+ function fakeTransport(serverLines: string[]): RemoteTransport & { written: string[] } {
32
+ const written: string[] = [];
33
+ return {
34
+ written,
35
+ stdin: {
36
+ write: (chunk: string) => {
37
+ written.push(chunk);
38
+ },
39
+ end: () => {},
40
+ },
41
+ stdout: new ReadableStream<Uint8Array>({
42
+ start(controller) {
43
+ for (const line of serverLines) {
44
+ controller.enqueue(new TextEncoder().encode(`${line}\n`));
45
+ }
46
+ controller.close();
47
+ },
48
+ }),
49
+ kill: () => {},
50
+ exited: Promise.resolve(0),
51
+ };
52
+ }
53
+
54
+ const INTERVIEW = JSON.stringify({
55
+ type: 'interview',
56
+ id: 'iv-1',
57
+ scope: 'module.deploy',
58
+ key: 'confirm_breaking',
59
+ kind: 'confirm',
60
+ message: 'This is a breaking change. Continue?',
61
+ });
62
+
63
+ describe('interviewKey', () => {
64
+ test('joins scope and key into the pre-staging identity', () => {
65
+ expect(
66
+ interviewKey({
67
+ type: 'interview',
68
+ id: 'x',
69
+ scope: 'module.deploy',
70
+ key: 'confirm_breaking',
71
+ kind: 'confirm',
72
+ message: 'go?',
73
+ }),
74
+ ).toBe('module.deploy.confirm_breaking');
75
+ });
76
+
77
+ test('is null when the server sent no scope/key (older server)', () => {
78
+ expect(
79
+ interviewKey({ type: 'interview', id: 'x', kind: 'confirm', message: 'go?' }),
80
+ ).toBeNull();
81
+ });
82
+ });
83
+
84
+ describe('an interview on a non-TTY stdin (celilo#606)', () => {
85
+ test('a supplied renderer is used and its answer goes back to the server', async () => {
86
+ const transport = fakeTransport([INTERVIEW, JSON.stringify({ type: 'result', exitCode: 0 })]);
87
+
88
+ await runRemoteClient('host', ['module', 'deploy', 'caddy'], {
89
+ openTransport: () => transport,
90
+ renderInterview: async () => true,
91
+ out: { write: () => {} },
92
+ });
93
+
94
+ const answer = transport.written.find((w) => w.includes('"answer"'));
95
+ expect(answer).toBeTruthy();
96
+ expect(answer).toContain('iv-1');
97
+ });
98
+
99
+ /**
100
+ * The default renderer must REFUSE rather than prompt. Refusing is what keeps
101
+ * the prompt away from stdin; a renderer that prompts on a non-TTY is the
102
+ * defect, whatever it does with the answer afterwards.
103
+ */
104
+ test('the DEFAULT renderer refuses instead of prompting', async () => {
105
+ const transport = fakeTransport([INTERVIEW, JSON.stringify({ type: 'result', exitCode: 0 })]);
106
+
107
+ // No `renderInterview` — exercises defaultRenderInterview, and this suite
108
+ // runs with stdin not a TTY, exactly like an MCP server.
109
+ expect(process.stdin.isTTY).toBeFalsy();
110
+
111
+ let threw: unknown;
112
+ try {
113
+ await runRemoteClient('host', ['module', 'deploy', 'caddy'], {
114
+ openTransport: () => transport,
115
+ out: { write: () => {} },
116
+ });
117
+ } catch (error) {
118
+ threw = error;
119
+ }
120
+
121
+ // Either it throws unanswerable, or it reports the command as unable to
122
+ // proceed — what it must NOT do is silently succeed by reading a keypress.
123
+ const answered = transport.written.some((w) => w.includes('"answer"'));
124
+ expect(answered).toBe(false);
125
+ if (threw) expect(threw).toBeInstanceOf(InterviewUnanswerableError);
126
+ });
127
+ });
128
+
129
+ /**
130
+ * The property, proven the only way it can be: in a child process whose stdin
131
+ * is a pipe carrying JSON-RPC, assert the bytes are still there afterwards.
132
+ *
133
+ * An in-process assertion cannot show this — the harness's own stdin is shared,
134
+ * and "nothing read it" is not observable from inside. The issue's original
135
+ * reproduction was `echo '{...}' | bun run clacktest.ts`; this is that,
136
+ * inverted into an assertion.
137
+ */
138
+ describe('stdin carrying JSON-RPC survives a raised interview', () => {
139
+ test('the JSON-RPC line is still readable after the interview is refused', async () => {
140
+ const script = `
141
+ const { runRemoteClient } = await import('${import.meta.dir}/remote-client.ts');
142
+ const transport = {
143
+ stdin: { write() {}, end() {} },
144
+ stdout: new ReadableStream({
145
+ start(c) {
146
+ c.enqueue(new TextEncoder().encode(${JSON.stringify(INTERVIEW)} + '\\n'));
147
+ c.enqueue(new TextEncoder().encode('{"type":"result","exitCode":0}\\n'));
148
+ c.close();
149
+ },
150
+ }),
151
+ kill() {}, exited: Promise.resolve(0),
152
+ };
153
+ try {
154
+ await runRemoteClient('host', ['module','deploy','caddy'], {
155
+ openTransport: () => transport, out: { write() {} },
156
+ });
157
+ } catch {}
158
+ // Whatever happened above, OUR stdin must be untouched.
159
+ const survived = await new Response(Bun.stdin.stream()).text();
160
+ process.stdout.write('SURVIVED:' + survived.trim());
161
+ `;
162
+
163
+ const proc = Bun.spawn(['bun', '-e', script], {
164
+ stdin: 'pipe',
165
+ stdout: 'pipe',
166
+ stderr: 'pipe',
167
+ });
168
+ const rpc = '{"jsonrpc":"2.0","id":7,"method":"tools/call"}';
169
+ proc.stdin.write(`${rpc}\n`);
170
+ await proc.stdin.end();
171
+ await proc.exited;
172
+
173
+ const out = await new Response(proc.stdout).text();
174
+ // The exact bytes we piped in, still unread by anything in the interview
175
+ // path. Before celilo#607 a clack prompt consumed this line and answered
176
+ // itself with it.
177
+ expect(out).toContain(`SURVIVED:${rpc}`);
178
+ }, 30_000);
179
+ });