@celilo/cli 0.18.0 → 0.19.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.
@@ -76,3 +76,73 @@ test('answers responder.probe with kind daemon', async () => {
76
76
  responder.close();
77
77
  }
78
78
  }, 15_000);
79
+
80
+ /**
81
+ * When the client can't reach a decider it says so. The responder must relay
82
+ * that as an error reply — the waiting command then fails loudly instead of the
83
+ * question quietly resolving to `defaultValue`, which is how a breaking update
84
+ * came to be recorded as "operator declined".
85
+ */
86
+ test('an ask that cannot be answered replies with an error, not the default', async () => {
87
+ const responder = startRemoteResponder({
88
+ busDbPath,
89
+ ask: async () => {
90
+ throw new Error("stdin isn't a terminal and no answer was pre-staged");
91
+ },
92
+ });
93
+
94
+ const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
95
+ try {
96
+ const replies = (await bus.query(
97
+ 'interview.required.module-upgrade:iptables.apply_breaking' as never,
98
+ {
99
+ scope: 'module-upgrade:iptables',
100
+ key: 'apply_breaking',
101
+ kind: 'confirm',
102
+ message: 'Apply breaking update for iptables?',
103
+ required: true,
104
+ defaultValue: 'false',
105
+ } as never,
106
+ { timeoutMs: 8000, pollIntervalMs: 100, expect: 'first' } as never,
107
+ )) as BusEvent[];
108
+
109
+ expect(replies).toHaveLength(1);
110
+ const payload = replies[0].payload as { value?: unknown; error?: string };
111
+ expect(payload.value).toBeUndefined();
112
+ expect(payload.error).toContain('terminal');
113
+ } finally {
114
+ bus.close();
115
+ responder.close();
116
+ }
117
+ }, 15_000);
118
+
119
+ test('forwards the question scope/key so a client can pre-stage an answer', async () => {
120
+ const asked: WireInterview[] = [];
121
+ const responder = startRemoteResponder({
122
+ busDbPath,
123
+ ask: async (iv) => {
124
+ asked.push(iv);
125
+ return true;
126
+ },
127
+ });
128
+
129
+ const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
130
+ try {
131
+ await bus.query(
132
+ 'interview.required.module-upgrade:iptables.apply_breaking' as never,
133
+ {
134
+ scope: 'module-upgrade:iptables',
135
+ key: 'apply_breaking',
136
+ kind: 'confirm',
137
+ message: 'Apply breaking update for iptables?',
138
+ required: true,
139
+ } as never,
140
+ { timeoutMs: 8000, pollIntervalMs: 100, expect: 'first' } as never,
141
+ );
142
+ expect(asked[0].scope).toBe('module-upgrade:iptables');
143
+ expect(asked[0].key).toBe('apply_breaking');
144
+ } finally {
145
+ bus.close();
146
+ responder.close();
147
+ }
148
+ }, 15_000);
@@ -20,6 +20,9 @@ const NO_SCHEMAS = defineEvents({});
20
20
  /** The normalized question handed to the wire (mirrors InterviewMessage). */
21
21
  export interface WireInterview {
22
22
  id: string;
23
+ /** The question's stable identity, so a client can pre-stage `<scope>.<key>`. */
24
+ scope: string;
25
+ key: string;
23
26
  kind: InterviewRequiredPayload['kind'];
24
27
  message: string;
25
28
  description?: string;
@@ -54,16 +57,30 @@ export function startRemoteResponder(opts: RemoteResponderOptions): RemoteRespon
54
57
  return;
55
58
  }
56
59
 
57
- const value = await opts.ask({
58
- id: String(event.id),
59
- kind: payload.kind,
60
- message: payload.message,
61
- description: payload.description,
62
- defaultValue: payload.defaultValue,
63
- placeholder: payload.placeholder,
64
- options: payload.options,
65
- required: payload.required,
66
- });
60
+ let value: unknown;
61
+ try {
62
+ value = await opts.ask({
63
+ id: String(event.id),
64
+ scope: payload.scope,
65
+ key: payload.key,
66
+ kind: payload.kind,
67
+ message: payload.message,
68
+ description: payload.description,
69
+ defaultValue: payload.defaultValue,
70
+ placeholder: payload.placeholder,
71
+ options: payload.options,
72
+ required: payload.required,
73
+ });
74
+ } catch (err) {
75
+ // The client had no way to answer. Reply with the reason so the waiting
76
+ // command fails loudly — never let the question decay to its default.
77
+ bus.emitRaw(
78
+ `${event.type}.reply`,
79
+ { error: err instanceof Error ? err.message : String(err) },
80
+ { replyFor: event.id, emittedBy: me },
81
+ );
82
+ return;
83
+ }
67
84
 
68
85
  bus.emitRaw(`${event.type}.reply`, { value }, { replyFor: event.id, emittedBy: me });
69
86
  });
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { defineEvents, openBus } from '@celilo/event-bus';
16
16
  import { getEventBusPath } from '../config/paths';
17
+ import { InterviewUnansweredError } from './interview-errors';
17
18
 
18
19
  const NO_SCHEMAS = defineEvents({});
19
20
 
@@ -63,7 +64,8 @@ export async function ensureResponderForInterview(queryType: string): Promise<vo
63
64
  if (process.stdin.isTTY) return;
64
65
  const available = await probeForResponder(getEventBusPath());
65
66
  if (available) return;
66
- throw new Error(
67
+ throw new InterviewUnansweredError(
68
+ queryType,
67
69
  `No responder is listening and stdin isn't a TTY, so this interview prompt can't be answered (${queryType}).
68
70
 
69
71
  Either: