@celilo/cli 0.3.2 → 0.3.4

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/cli",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,7 +54,7 @@
54
54
  "@aws-sdk/client-s3": "^3.1024.0",
55
55
  "@celilo/capabilities": "^0.1.10",
56
56
  "@celilo/cli-display": "^0.1.6",
57
- "@celilo/event-bus": "^0.1.0",
57
+ "@celilo/event-bus": "^0.1.3",
58
58
  "@clack/prompts": "^1.1.0",
59
59
  "ajv": "^8.18.0",
60
60
  "drizzle-orm": "^0.36.4",
@@ -442,29 +442,13 @@ export async function interviewForMissingConfig(
442
442
  payload,
443
443
  );
444
444
 
445
- // The reply.value is typed per payload.type; the terminal-
446
- // responder coerces strings to numbers/bools/json-as-array etc.
447
- // For storage, we still need a string + an optional valueJson
448
- // for non-scalar types. Match what `module config set` does.
449
- value = typeof reply.value === 'string' ? reply.value : JSON.stringify(reply.value);
450
-
451
- await db
452
- .insert(moduleConfigs)
453
- .values({
454
- moduleId,
455
- key: variable.name,
456
- value,
457
- valueJson: null,
458
- createdAt: new Date(),
459
- updatedAt: new Date(),
460
- })
461
- .onConflictDoUpdate({
462
- target: [moduleConfigs.moduleId, moduleConfigs.key],
463
- set: {
464
- value,
465
- updatedAt: new Date(),
466
- },
467
- });
445
+ // Persist via writeModuleConfigKey so the (string value,
446
+ // typed valueJson) pair is set correctly. Downstream readers
447
+ // (validate_config hooks, capability resolvers) deserialize
448
+ // typed values from valueJson earlier I was only writing
449
+ // value with valueJson:null, which broke `array`/`object`
450
+ // typed configs that consumers expected to be JSON-parsed.
451
+ await writeModuleConfigKey(moduleId, variable.name, reply.value, db);
468
452
 
469
453
  configured.push(variable.name);
470
454
  }
@@ -54,50 +54,70 @@ export function startTerminalResponder(): TerminalResponderHandle {
54
54
  // re-fire (e.g. validation re-emit) doesn't double-prompt.
55
55
  const handled = new Set<number>();
56
56
 
57
- const watch = bus.watch('config.required.**', async (event) => {
57
+ // Pattern matches exactly `config.required.<module>.<key>` (4 dot
58
+ // segments). The reply we emit is `config.required.<m>.<k>.reply`
59
+ // (5 segments) — wouldn't match — but `**` would have matched, and
60
+ // we'd recursively try to prompt for our own reply event. The
61
+ // explicit replyFor === null check below is defense in depth in
62
+ // case any responder ever emits a reply with the same shape.
63
+ const watch = bus.watch('config.required.*.*', async (event) => {
64
+ // Reply events carry replyFor; queries don't. Skip anything that
65
+ // looks like a reply, even if it accidentally matches the pattern.
66
+ if (event.replyFor !== null) return;
58
67
  if (handled.has(event.id)) return;
59
68
  handled.add(event.id);
60
69
 
61
70
  const payload = event.payload as ConfigRequiredPayload;
62
- try {
63
- const message = payload.description
64
- ? `${payload.module}.${payload.key} ${payload.description}:`
65
- : `${payload.module}.${payload.key}:`;
66
-
67
- const value = await promptText({
68
- message,
69
- validate: (val) => {
70
- if (payload.required && (!val || val.trim() === '')) {
71
- return 'This field is required';
72
- }
73
- if (payload.pattern && val) {
74
- const re = new RegExp(payload.pattern);
75
- if (!re.test(val)) return `Value must match: ${payload.pattern}`;
76
- }
77
- },
78
- });
79
-
80
- // Coerce to the declared type. clack returns string; the
81
- // responder honors the type contract so the deploy doesn't
82
- // have to second-guess the reply shape.
83
- const coerced = coerceValue(value, payload.type);
84
-
85
- bus.emitRaw(
86
- `${event.type}.reply`,
87
- { value: coerced },
88
- {
89
- replyFor: event.id,
90
- emittedBy: me,
91
- },
71
+ // Defensive: if the payload is malformed (no module/key), don't
72
+ // render a "undefined.undefined:" prompt — log and skip.
73
+ if (!payload || typeof payload.module !== 'string' || typeof payload.key !== 'string') {
74
+ log.warn(
75
+ `Terminal responder skipped malformed event ${event.type} (id ${event.id}): missing module/key`,
92
76
  );
93
- } catch (err) {
94
- // Reply with an error-shaped payload so the deploy can surface
95
- // it cleanly. The deploy can decide whether to re-emit (for
96
- // recoverable errors) or fail.
97
- const message = err instanceof Error ? err.message : String(err);
98
- log.warn(`Terminal responder error for ${event.type}: ${message}`);
99
- bus.emitRaw(`${event.type}.reply`, { error: message }, { replyFor: event.id, emittedBy: me });
77
+ return;
100
78
  }
79
+
80
+ const message = payload.description
81
+ ? `${payload.module}.${payload.key} — ${payload.description}:`
82
+ : `${payload.module}.${payload.key}:`;
83
+
84
+ // Hint shown next to the prompt. For non-string types, tell the
85
+ // user what shape we're expecting so they don't get a coercion
86
+ // failure mid-deploy.
87
+ const typeHint = describeTypeHint(payload.type);
88
+
89
+ const value = await promptText({
90
+ message: typeHint ? `${message} (${typeHint})` : message,
91
+ validate: (val) => {
92
+ if (payload.required && (!val || val.trim() === '')) {
93
+ return 'This field is required';
94
+ }
95
+ if (payload.pattern && val) {
96
+ const re = new RegExp(payload.pattern);
97
+ if (!re.test(val)) return `Value must match: ${payload.pattern}`;
98
+ }
99
+ // Type-shape validation runs HERE, at submit time, so clack
100
+ // re-prompts on bad input rather than letting a malformed
101
+ // value through that fails post-submit.
102
+ try {
103
+ coerceValue(val, payload.type);
104
+ } catch (err) {
105
+ return err instanceof Error ? err.message : String(err);
106
+ }
107
+ },
108
+ });
109
+
110
+ // Already validated above; coerce can't throw here.
111
+ const coerced = coerceValue(value, payload.type);
112
+
113
+ bus.emitRaw(
114
+ `${event.type}.reply`,
115
+ { value: coerced },
116
+ {
117
+ replyFor: event.id,
118
+ emittedBy: me,
119
+ },
120
+ );
101
121
  });
102
122
 
103
123
  return {
@@ -108,7 +128,37 @@ export function startTerminalResponder(): TerminalResponderHandle {
108
128
  };
109
129
  }
110
130
 
111
- function coerceValue(raw: string, type: ConfigRequiredPayload['type']): unknown {
131
+ /**
132
+ * Short hint shown in the prompt for non-string types so the operator
133
+ * isn't guessing what shape we want. Returns null for plain strings —
134
+ * no hint needed.
135
+ */
136
+ function describeTypeHint(type: ConfigRequiredPayload['type']): string | null {
137
+ switch (type) {
138
+ case 'string':
139
+ return null;
140
+ case 'integer':
141
+ return 'integer';
142
+ case 'number':
143
+ return 'number';
144
+ case 'boolean':
145
+ return 'true/false/yes/no/y/n/1/0';
146
+ case 'array':
147
+ return 'JSON array, e.g. ["a","b"]';
148
+ case 'object':
149
+ return 'JSON object, e.g. {"k":"v"}';
150
+ default:
151
+ return null;
152
+ }
153
+ }
154
+
155
+ function coerceValue(raw: string | undefined, type: ConfigRequiredPayload['type']): unknown {
156
+ // clack returns undefined when the user cancels (Ctrl+C). Bubble
157
+ // that up as an error so validate sees it and the responder can
158
+ // skip the reply rather than emit a malformed one.
159
+ if (raw === undefined) {
160
+ throw new Error('Cancelled');
161
+ }
112
162
  switch (type) {
113
163
  case 'string':
114
164
  return raw;