@celilo/core 0.6.0 → 0.7.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.6.0",
3
+ "version": "0.7.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",
@@ -24,8 +24,7 @@
24
24
  "directory": "packages/core"
25
25
  },
26
26
  "dependencies": {
27
- "@celilo/cli-display": "^0.1.10",
28
- "@clack/prompts": "^1.1.0",
27
+ "@celilo/cli-display": "^0.2.0",
29
28
  "zod": "^3.24.1"
30
29
  }
31
30
  }
@@ -581,6 +581,62 @@ export const COMMANDS: CommandDef[] = [
581
581
  },
582
582
  ],
583
583
  },
584
+ {
585
+ name: 'pause',
586
+ description:
587
+ "Take a module out of celilo's control plane without uninstalling it (keeps running unless --stop-infra)",
588
+ args: [{ name: 'id', description: 'Module ID', completion: 'module_ids' }],
589
+ flags: [
590
+ {
591
+ name: 'cascade',
592
+ description: 'Also pause every module that depends on it, consumers first',
593
+ takesValue: false,
594
+ },
595
+ {
596
+ name: 'stop-infra',
597
+ description: "Also stop the module's container or service",
598
+ takesValue: false,
599
+ },
600
+ {
601
+ name: 'reason',
602
+ description: 'Why the pause was taken, recorded on the module',
603
+ takesValue: true,
604
+ },
605
+ {
606
+ name: 'dry-run',
607
+ description: 'Print the ordered plan and change nothing',
608
+ takesValue: false,
609
+ },
610
+ {
611
+ name: 'yes',
612
+ description: 'Skip the cascade confirmation',
613
+ takesValue: false,
614
+ },
615
+ ],
616
+ },
617
+ {
618
+ name: 'unpause',
619
+ description:
620
+ 'Return a paused module to service by redeploying it, rebinding it to the providers present now',
621
+ args: [{ name: 'id', description: 'Module ID', completion: 'module_ids' }],
622
+ flags: [
623
+ {
624
+ name: 'cascade',
625
+ description: 'Also unpause everything that depends on it, providers first',
626
+ takesValue: false,
627
+ },
628
+ {
629
+ name: 'dry-run',
630
+ description: 'Print the ordered plan and change nothing',
631
+ takesValue: false,
632
+ },
633
+ {
634
+ name: 'yes',
635
+ description: 'Skip the cascade confirmation',
636
+ takesValue: false,
637
+ },
638
+ ],
639
+ },
584
640
  {
585
641
  name: 'update',
586
642
  description:
package/src/protocol.ts CHANGED
@@ -108,6 +108,20 @@ export const ResultMessageSchema = z.object({
108
108
  type: z.literal('result'),
109
109
  success: z.boolean(),
110
110
  exitCode: z.number(),
111
+ /**
112
+ * Fleet-level conditions the operator should know about, regardless of what
113
+ * they ran. Today that is exactly one thing: modules are paused
114
+ * (openspec/changes/module-pause-lifecycle, design D7).
115
+ *
116
+ * Attached to EVERY result, including commands unrelated to the paused
117
+ * modules, and that is the point — a pause switches off the alerting that
118
+ * would otherwise report those modules as down, so the only remaining defence
119
+ * is that the paused-ness is discovered incidentally, while doing something
120
+ * else. A warning the operator has to go looking for does not do that job.
121
+ *
122
+ * Optional so an older client parses a newer server's result unchanged.
123
+ */
124
+ warnings: z.array(z.string()).optional(),
111
125
  });
112
126
  export type ResultMessage = z.infer<typeof ResultMessageSchema>;
113
127
 
@@ -10,8 +10,16 @@
10
10
 
11
11
  import { spawn } from 'node:child_process';
12
12
  import { Readable } from 'node:stream';
13
- import { type DisplayWriter, ProgressDisplay } from '@celilo/cli-display';
14
- import * as clack from '@clack/prompts';
13
+ import {
14
+ type DisplayWriter,
15
+ ProgressDisplay,
16
+ isCancel,
17
+ cancel as uiCancel,
18
+ confirm as uiConfirm,
19
+ multiselect as uiMultiselect,
20
+ select as uiSelect,
21
+ text as uiText,
22
+ } from '@celilo/cli-display';
15
23
  import {
16
24
  type ClientMessage,
17
25
  type InterviewMessage,
@@ -40,15 +48,17 @@ export class InterviewUnanswerableError extends Error {
40
48
  }
41
49
 
42
50
  /**
43
- * Render a forwarded interview via clack, returning the operator's answer.
51
+ * Render a forwarded interview on the terminal, returning the operator's answer.
44
52
  *
45
- * Refuses to prompt when stdin isn't a TTY. clack reads keypresses off stdin
46
- * whether or not it is a terminal, so on a piped stdin (an MCP server's
47
- * JSON-RPC stream, a CI harness) the very next newline submits the prompt at
48
- * its `initialValue` — i.e. the question's `defaultValue` — and the answer
49
- * looks exactly like a considered human decision. That is how a live rollout
50
- * came to report "operator declined" for a breaking update nobody was asked
51
- * about. A client with no terminal must say so instead of guessing.
53
+ * Refuses to prompt when stdin isn't a TTY, and keeps its own check rather than
54
+ * relying on the prompt layer's: this is the boundary that must produce an
55
+ * `unanswerable` message, not an exception. The hazard it guards against is
56
+ * specific — a prompt that reads keypresses off a non-terminal stdin (an MCP
57
+ * server's JSON-RPC stream, a CI harness) takes the very next newline as a
58
+ * submit at the question's `defaultValue`, and the answer is indistinguishable
59
+ * from a considered human decision. That is how a live rollout came to report
60
+ * "operator declined" for a breaking update nobody was asked about. A client
61
+ * with no terminal must say so instead of guessing.
52
62
  */
53
63
  async function defaultRenderInterview(iv: InterviewMessage): Promise<unknown> {
54
64
  if (!process.stdin.isTTY) {
@@ -61,30 +71,30 @@ async function defaultRenderInterview(iv: InterviewMessage): Promise<unknown> {
61
71
  let result: unknown;
62
72
  switch (iv.kind) {
63
73
  case 'confirm':
64
- result = await clack.confirm({
74
+ result = await uiConfirm({
65
75
  message: iv.message,
66
76
  initialValue: iv.defaultValue === 'true',
67
77
  });
68
78
  break;
69
79
  case 'select':
70
- result = await clack.select({ message: iv.message, options });
80
+ result = await uiSelect({ message: iv.message, options });
71
81
  break;
72
82
  case 'multiselect':
73
- result = await clack.multiselect({
83
+ result = await uiMultiselect({
74
84
  message: iv.message,
75
85
  options,
76
86
  required: iv.required ?? false,
77
87
  });
78
88
  break;
79
89
  default:
80
- result = await clack.text({
90
+ result = await uiText({
81
91
  message: iv.message,
82
92
  defaultValue: iv.defaultValue,
83
93
  placeholder: iv.placeholder,
84
94
  });
85
95
  }
86
- if (clack.isCancel(result)) {
87
- clack.cancel('Cancelled.');
96
+ if (isCancel(result)) {
97
+ uiCancel('Cancelled.');
88
98
  process.exit(130);
89
99
  }
90
100
  return result;