@celilo/core 0.3.2 → 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/core",
3
- "version": "0.3.2",
3
+ "version": "0.4.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",
@@ -306,6 +306,12 @@ export const COMMANDS: CommandDef[] = [
306
306
  description: 'Remove the installed supervisor unit',
307
307
  flags: [{ name: 'system', description: 'Target the system-scope unit', takesValue: false }],
308
308
  },
309
+ {
310
+ name: 'restart-daemon',
311
+ description:
312
+ 'Restart the dispatcher through its supervisor, stopping any unsupervised one, and verify the new process reports the installed code version',
313
+ flags: [{ name: 'system', description: 'Target the system-scope unit', takesValue: false }],
314
+ },
309
315
  {
310
316
  name: 'show-daemon',
311
317
  description: 'Print the currently installed unit file',
@@ -1218,6 +1224,14 @@ export const COMMANDS: CommandDef[] = [
1218
1224
  {
1219
1225
  name: 'migrate',
1220
1226
  description: 'Apply pending database migrations (idempotent; safe to re-run)',
1227
+ flags: [
1228
+ {
1229
+ name: 'status',
1230
+ description:
1231
+ 'Report applied count, latest applied migration and pending ones by name without applying anything',
1232
+ takesValue: false,
1233
+ },
1234
+ ],
1221
1235
  },
1222
1236
  {
1223
1237
  name: 'doctor',
package/src/protocol.ts CHANGED
@@ -23,11 +23,19 @@ export const CommandMessageSchema = z.object({
23
23
  });
24
24
  export type CommandMessage = z.infer<typeof CommandMessageSchema>;
25
25
 
26
- /** Client's answer to a server `interview` message, correlated by `id`. */
26
+ /**
27
+ * Client's answer to a server `interview` message, correlated by `id`.
28
+ *
29
+ * `error` is the "I cannot answer this" reply: the client had no way to reach a
30
+ * decider (no TTY, no pre-staged answer). The server fails the waiting command
31
+ * with that message instead of it resolving to the question's `defaultValue` —
32
+ * an unanswered question must never become a silent "no".
33
+ */
27
34
  export const AnswerMessageSchema = z.object({
28
35
  type: z.literal('answer'),
29
36
  id: z.string(),
30
37
  value: z.unknown(),
38
+ error: z.string().optional(),
31
39
  });
32
40
  export type AnswerMessage = z.infer<typeof AnswerMessageSchema>;
33
41
 
@@ -91,6 +99,14 @@ export type ErrorMessage = z.infer<typeof ErrorMessageSchema>;
91
99
  export const InterviewMessageSchema = z.object({
92
100
  type: z.literal('interview'),
93
101
  id: z.string(),
102
+ /**
103
+ * The question's stable identity (`interview.required.<scope>.<key>`). A
104
+ * client can pre-stage an answer under `<scope>.<key>` — the same lookup key
105
+ * `celilo events respond --values` uses. Optional so a client still parses
106
+ * messages from an older server that didn't send them.
107
+ */
108
+ scope: z.string().optional(),
109
+ key: z.string().optional(),
94
110
  kind: z.enum(['text', 'confirm', 'select', 'multiselect']),
95
111
  message: z.string(),
96
112
  description: z.string().optional(),
@@ -16,8 +16,41 @@ import { type InterviewMessage, type ServerMessage, ServerMessageSchema } from '
16
16
 
17
17
  const REMOTE_FLAG = '--remote';
18
18
 
19
- /** Render a forwarded interview via clack, returning the operator's answer. */
19
+ /** Stable `<scope>.<key>` identity for a forwarded question, when the server sent one. */
20
+ export function interviewKey(iv: InterviewMessage): string | null {
21
+ return iv.scope && iv.key ? `${iv.scope}.${iv.key}` : null;
22
+ }
23
+
24
+ /**
25
+ * Thrown by a renderer that has no way to reach a decider. `runRemoteClient`
26
+ * turns it into an `answer` carrying `error`, so the server fails the waiting
27
+ * command loudly rather than the question resolving to its default.
28
+ */
29
+ export class InterviewUnanswerableError extends Error {
30
+ constructor(message: string) {
31
+ super(message);
32
+ this.name = 'InterviewUnanswerableError';
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Render a forwarded interview via clack, returning the operator's answer.
38
+ *
39
+ * Refuses to prompt when stdin isn't a TTY. clack reads keypresses off stdin
40
+ * whether or not it is a terminal, so on a piped stdin (an MCP server's
41
+ * JSON-RPC stream, a CI harness) the very next newline submits the prompt at
42
+ * its `initialValue` — i.e. the question's `defaultValue` — and the answer
43
+ * looks exactly like a considered human decision. That is how a live rollout
44
+ * came to report "operator declined" for a breaking update nobody was asked
45
+ * about. A client with no terminal must say so instead of guessing.
46
+ */
20
47
  async function defaultRenderInterview(iv: InterviewMessage): Promise<unknown> {
48
+ if (!process.stdin.isTTY) {
49
+ const key = interviewKey(iv);
50
+ throw new InterviewUnanswerableError(
51
+ `Cannot answer "${iv.message}"${key ? ` (${key})` : ''}: stdin isn't a terminal and no answer was pre-staged.`,
52
+ );
53
+ }
21
54
  const options = (iv.options ?? []).map((o) => ({ value: o.value, label: o.label, hint: o.hint }));
22
55
  let result: unknown;
23
56
  switch (iv.kind) {
@@ -193,8 +226,20 @@ export async function runRemoteClient(
193
226
  }
194
227
 
195
228
  if (msg.type === 'interview') {
196
- const value = await renderInterview(msg);
197
- transport.stdin.write(`${JSON.stringify({ type: 'answer', id: msg.id, value })}\n`);
229
+ let answer: { type: 'answer'; id: string; value: unknown; error?: string };
230
+ try {
231
+ answer = { type: 'answer', id: msg.id, value: await renderInterview(msg) };
232
+ } catch (err) {
233
+ // Never fabricate an answer: tell the server we can't decide, and let
234
+ // it fail the waiting command with this message.
235
+ answer = {
236
+ type: 'answer',
237
+ id: msg.id,
238
+ value: null,
239
+ error: err instanceof Error ? err.message : String(err),
240
+ };
241
+ }
242
+ transport.stdin.write(`${JSON.stringify(answer)}\n`);
198
243
  await transport.stdin.flush?.();
199
244
  continue;
200
245
  }