@celilo/cli 0.22.0 → 0.23.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.
Files changed (51) hide show
  1. package/CELILO_SUBSYSTEMS.md +34 -2
  2. package/drizzle/0024_module_pause.sql +20 -0
  3. package/drizzle/meta/_journal.json +8 -1
  4. package/package.json +4 -5
  5. package/src/__integration__/container-services-cli.integration.test.ts +8 -2
  6. package/src/api/remote-client.test.ts +6 -5
  7. package/src/api/serve.ts +41 -7
  8. package/src/api-clients/proxmox.ts +34 -0
  9. package/src/cli/commands/alerts-sweep.ts +2 -0
  10. package/src/cli/commands/events.ts +34 -3
  11. package/src/cli/commands/module-deploy.ts +2 -2
  12. package/src/cli/commands/module-health.ts +1 -0
  13. package/src/cli/commands/module-import.ts +3 -3
  14. package/src/cli/commands/module-list.ts +12 -1
  15. package/src/cli/commands/module-pause.ts +317 -0
  16. package/src/cli/commands/module-remove.ts +78 -40
  17. package/src/cli/commands/module-status.ts +3 -4
  18. package/src/cli/commands/module-update.test.ts +1 -1
  19. package/src/cli/commands/proxmox-template-selection.ts +1 -1
  20. package/src/cli/commands/status.ts +25 -3
  21. package/src/cli/completion.ts +4 -0
  22. package/src/cli/fuel-gauge.ts +4 -4
  23. package/src/cli/index.ts +45 -20
  24. package/src/cli/json-output.test.ts +162 -0
  25. package/src/cli/prompts.ts +53 -74
  26. package/src/cli/service-credential.ts +3 -3
  27. package/src/cli/stdout-is-undecorated.test.ts +94 -0
  28. package/src/cli/types.ts +7 -2
  29. package/src/db/schema.ts +73 -15
  30. package/src/hooks/run-named-hook.ts +28 -0
  31. package/src/services/alerting/suppression.test.ts +5 -0
  32. package/src/services/alerting/suppression.ts +18 -1
  33. package/src/services/alerting/sweep-runner.test.ts +1 -0
  34. package/src/services/alerting/sweep-runner.ts +11 -1
  35. package/src/services/bus-interview.ts +2 -2
  36. package/src/services/bus-secret-flow.test.ts +1 -1
  37. package/src/services/fleet-checks.ts +48 -0
  38. package/src/services/module-deploy.ts +1 -1
  39. package/src/services/module-pause-observability.test.ts +224 -0
  40. package/src/services/module-pause-quiescence.test.ts +163 -0
  41. package/src/services/module-pause.test.ts +573 -0
  42. package/src/services/module-pause.ts +544 -0
  43. package/src/services/remove-guard.test.ts +175 -0
  44. package/src/services/remove-guard.ts +109 -0
  45. package/src/services/terminal-responder.ts +16 -16
  46. package/src/services/update/dep-graph.test.ts +33 -4
  47. package/src/services/update/dep-graph.ts +39 -17
  48. package/src/services/zone-detector.ts +2 -39
  49. package/src/test-utils/cli.ts +15 -14
  50. package/src/test-utils/integration-guard.ts +26 -0
  51. package/src/test-utils/setup-test-db.ts +13 -23
package/src/cli/index.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * Orchestration function (Rule 10.1) - routes commands to handlers
5
5
  */
6
6
 
7
+ import { log as uiLog } from '@celilo/cli-display';
7
8
  import {
8
9
  COMMANDS,
9
10
  type CommandDef,
@@ -11,7 +12,6 @@ import {
11
12
  resolveRemote,
12
13
  runRemoteClient,
13
14
  } from '@celilo/core';
14
- import * as p from '@clack/prompts';
15
15
  import { CLIServerRequestSchema, parseJsonWithValidation } from '../validation/schemas';
16
16
  import {
17
17
  handleApiAuthorizedKeys,
@@ -77,6 +77,7 @@ import { handleModuleJournal } from './commands/module-journal';
77
77
  import { handleModuleList } from './commands/module-list';
78
78
  import { handleModuleLogs } from './commands/module-logs';
79
79
  import { handleModuleOperations } from './commands/module-operations';
80
+ import { handleModulePause, handleModuleUnpause } from './commands/module-pause';
80
81
  import { handleModulePublish } from './commands/module-publish';
81
82
  import { handleModuleRemove } from './commands/module-remove';
82
83
  import { handleModuleSearch } from './commands/module-search';
@@ -578,6 +579,20 @@ Subcommands:
578
579
 
579
580
  remove <id> Remove a module and all its data
580
581
 
582
+ pause <id> Quiesce a module without uninstalling it (container keeps running)
583
+ Options:
584
+ --cascade Also pause every dependent module, consumers first
585
+ --stop-infra Also stop the module's container or service
586
+ --reason <text> Why the pause was taken, recorded on the module
587
+ --dry-run Print the ordered plan and change nothing
588
+ --yes Skip the cascade confirmation
589
+
590
+ unpause <id> Redeploy a paused module, rebinding it to current providers
591
+ Options:
592
+ --cascade Also unpause every dependent module, providers first
593
+ --dry-run Print the ordered plan and change nothing
594
+ --yes Skip the cascade confirmation
595
+
581
596
  verify <id> Verify module integrity (signature + checksums)
582
597
  (legacy alias: 'audit')
583
598
 
@@ -649,6 +664,12 @@ Examples:
649
664
  celilo module publish ./modules/* # publish every module in a dir
650
665
  celilo module list
651
666
  celilo module remove homebridge
667
+
668
+ # Swap a capability provider (e.g. a replaced edge router):
669
+ celilo module pause --cascade greenwave --reason "ISP swapped the router"
670
+ celilo module remove greenwave
671
+ celilo module import axon && celilo module deploy axon
672
+ celilo module unpause --cascade axon
652
673
  celilo module verify homebridge
653
674
  celilo module config set homebridge hostname myhost
654
675
  celilo module config set homebridge container_ip "192.168.0.110/24"
@@ -1397,8 +1418,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1397
1418
  const { main: runPublish } = await import('./commands/publish');
1398
1419
  await runPublish(publishArgv);
1399
1420
  // runPublish handles its own console output (multi-phase, multi-line);
1400
- // returning an empty success message tells the outer CLI loop to skip
1401
- // the clack outro and exit 0 cleanly. Any failure inside runPublish
1421
+ // returning an empty success message tells the outer CLI loop to write
1422
+ // nothing more and exit 0 cleanly. Any failure inside runPublish
1402
1423
  // calls process.exit() directly and never returns here.
1403
1424
  return { success: true, message: '' };
1404
1425
  }
@@ -1499,6 +1520,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1499
1520
  return handleModuleHealth(parsed.args, parsed.flags);
1500
1521
  case 'operations':
1501
1522
  return handleModuleOperations(parsed.args, parsed.flags);
1523
+ case 'pause':
1524
+ return handleModulePause(parsed.args, parsed.flags);
1525
+ case 'unpause':
1526
+ return handleModuleUnpause(parsed.args, parsed.flags);
1502
1527
  case 'remove':
1503
1528
  return handleModuleRemove(parsed.args, parsed.flags);
1504
1529
  case 'update':
@@ -2465,31 +2490,31 @@ export async function main(): Promise<void> {
2465
2490
  process.exit(0);
2466
2491
  }
2467
2492
 
2468
- // Script-friendly commands bypass clack and write directly to stdout
2469
- if (result.rawOutput) {
2470
- process.stdout.write(`${result.message}\n`);
2471
- process.exit(0);
2472
- }
2473
-
2474
2493
  // Empty message → command already emitted its own output (e.g. via
2475
- // ProgressDisplay) and doesn't want a clack outro on top.
2494
+ // ProgressDisplay) and doesn't want anything written on top.
2476
2495
  if (!result.message) {
2477
2496
  process.exit(0);
2478
2497
  }
2479
2498
 
2480
- // Handle multi-line messages: split on section boundaries (\n\n) so
2481
- // lines within a section are logged together (avoiding clack's per-call spacing).
2482
- const sections = result.message.split('\n\n');
2483
- p.log.success(sections[0]);
2484
- for (let i = 1; i < sections.length; i++) {
2485
- if (sections[i].trim()) {
2486
- p.log.message(sections[i]);
2487
- }
2488
- }
2499
+ // A successful command's message IS its result, so it goes to stdout
2500
+ // verbatim no glyph, no `│ ` prefix, no re-wrapping on `\n\n`.
2501
+ //
2502
+ // `rawOutput` no longer selects a different destination; it is kept
2503
+ // because it still records which commands are contractually
2504
+ // machine-readable, and because JSON payloads must never acquire a
2505
+ // decoration if this branch ever grows one again (celilo#698).
2506
+ //
2507
+ // The old path split the message on `\n\n` and fed each section to the
2508
+ // clack renderer, which prefixed every line. That is what made
2509
+ // `line.startsWith('<module-id> ')` over `celilo module list` match
2510
+ // nothing — read in celilo#695 as a missing module rather than as a
2511
+ // parse failure, at the cost of a full e2e run.
2512
+ process.stdout.write(`${result.message}\n`);
2489
2513
  process.exit(0);
2490
2514
  }
2491
2515
 
2492
- p.log.error(`Error: ${result.error}`);
2516
+ // Diagnostics go to stderr so a caller can separate them from the result.
2517
+ uiLog.error(`Error: ${result.error}`);
2493
2518
  if (result.details) {
2494
2519
  console.error('Details:', result.details);
2495
2520
  }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Recurrence gate for celilo#698 — a command that emits JSON must emit JSON
3
+ * that parses, with no preprocessing.
4
+ *
5
+ * `cli/index.ts` already implements the rule: a `CommandResult` carrying
6
+ * `rawOutput: true` is written straight to stdout, while everything else goes
7
+ * through the decorating renderer that prefixes each line with `│ ` and wraps
8
+ * it in ANSI colour. A JSON payload that forgets the flag therefore reaches
9
+ * stdout as something no `JSON.parse` will accept — and the workaround
10
+ * (`| sed 's/\x1b\[[0-9;]*m//g'`) got written into CLAUDE.md instead of the fix.
11
+ *
12
+ * Two gates here, deliberately different in kind:
13
+ *
14
+ * 1. `emits parseable JSON` — spawns the REAL CLI and parses its raw stdout.
15
+ * This is the honest end-to-end check, and it is the one that fails today
16
+ * without the fix. It cannot use `CLIContext`: that harness drives the CLI
17
+ * in `CLI_SERVER_MODE`, which returns `result.message` over a protocol and
18
+ * never exercises the stdout renderer where the bug lives.
19
+ *
20
+ * 2. `every JSON CommandResult sets rawOutput` — a static scan, so a NEW
21
+ * command that forgets the flag fails even though nobody added it to the
22
+ * table above.
23
+ *
24
+ * Lives under `src/` rather than `test-integration/` for one blunt reason:
25
+ * `test-integration/` is run by no CI workflow (the `validate` job runs
26
+ * `test:unit`, which is `bun test src/`), so a gate placed there would never
27
+ * have failed anything. See celilo#703.
28
+ */
29
+
30
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
31
+ import { spawnSync } from 'node:child_process';
32
+ import { readFileSync, readdirSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+ import { type IntegrationTestContext, setupIntegrationTest } from '@/test-utils/integration';
35
+
36
+ const COMMANDS_DIR = join(import.meta.dir, 'commands');
37
+
38
+ /**
39
+ * Commands whose stdout is a JSON document. Each is spawned as a real process
40
+ * and its stdout handed to `JSON.parse` verbatim.
41
+ *
42
+ * `events` verbs share one `jsonResult()` helper, so a few representatives
43
+ * cover all 16 of its call sites; the rest are the distinct `--json` surfaces.
44
+ */
45
+ const JSON_COMMANDS = [
46
+ 'events status',
47
+ 'events tail --limit 5',
48
+ 'events list-subscribers',
49
+ 'module list --json',
50
+ 'module health --json',
51
+ 'system audit --json',
52
+ 'alerts list --json',
53
+ 'commands --json',
54
+ ] as const;
55
+
56
+ describe('celilo#698 — JSON commands emit parseable JSON', () => {
57
+ let ctx: IntegrationTestContext;
58
+
59
+ beforeAll(async () => {
60
+ ctx = await setupIntegrationTest();
61
+ });
62
+
63
+ afterAll(async () => {
64
+ await ctx.cleanup();
65
+ });
66
+
67
+ for (const command of JSON_COMMANDS) {
68
+ test(`celilo ${command} emits parseable JSON`, () => {
69
+ const result = spawnSync('bun', ['run', 'src/cli/index.ts', ...command.split(' ')], {
70
+ encoding: 'utf8',
71
+ env: {
72
+ ...process.env,
73
+ CELILO_DB_PATH: ctx.dbPath,
74
+ CELILO_DATA_DIR: ctx.dataDir,
75
+ CELILO_SUPPRESS_DEPRECATION: '1',
76
+ },
77
+ stdio: ['ignore', 'pipe', 'pipe'],
78
+ timeout: 60_000,
79
+ });
80
+
81
+ expect(
82
+ result.status,
83
+ `celilo ${command} exited ${result.status}\nstderr: ${result.stderr}`,
84
+ ).toBe(0);
85
+
86
+ // Verbatim: no ANSI stripping, no prefix removal, no line filtering.
87
+ // If this throws, the payload went through the decorating renderer.
88
+ expect(() => JSON.parse(result.stdout)).not.toThrow();
89
+ });
90
+ }
91
+ });
92
+
93
+ /**
94
+ * Walk from the index of a `{` to the index just past its matching `}`,
95
+ * skipping over string literals so a brace inside a string doesn't unbalance
96
+ * the count.
97
+ */
98
+ function objectEnd(source: string, open: number): number {
99
+ let depth = 0;
100
+ for (let i = open; i < source.length; i++) {
101
+ const char = source[i];
102
+ if (char === '{') {
103
+ depth++;
104
+ } else if (char === '}') {
105
+ depth--;
106
+ if (depth === 0) return i + 1;
107
+ } else if (char === '"' || char === "'" || char === '`') {
108
+ const quote = char;
109
+ i++;
110
+ while (i < source.length && source[i] !== quote) {
111
+ if (source[i] === '\\') i++;
112
+ i++;
113
+ }
114
+ }
115
+ }
116
+ return -1;
117
+ }
118
+
119
+ /** Index of the `{` opening the object literal that encloses `index`. */
120
+ function enclosingObjectStart(source: string, index: number): number {
121
+ let depth = 0;
122
+ for (let i = index; i >= 0; i--) {
123
+ if (source[i] === '}') {
124
+ depth++;
125
+ } else if (source[i] === '{') {
126
+ if (depth === 0) return i;
127
+ depth--;
128
+ }
129
+ }
130
+ return -1;
131
+ }
132
+
133
+ describe('celilo#698 recurrence gate — every JSON CommandResult sets rawOutput', () => {
134
+ const files = readdirSync(COMMANDS_DIR).filter(
135
+ (f) => f.endsWith('.ts') && !f.endsWith('.test.ts'),
136
+ );
137
+
138
+ test('command files exist to scan', () => {
139
+ expect(files.length).toBeGreaterThan(0);
140
+ });
141
+
142
+ for (const file of files) {
143
+ test(`${file} sets rawOutput on every JSON message`, () => {
144
+ const source = readFileSync(join(COMMANDS_DIR, file), 'utf8');
145
+ const pattern = /\bmessage:\s*JSON\.stringify\b/g;
146
+ let match: RegExpExecArray | null = pattern.exec(source);
147
+
148
+ while (match !== null) {
149
+ const open = enclosingObjectStart(source, match.index);
150
+ const literal = open < 0 ? '' : source.slice(open, objectEnd(source, open));
151
+ const line = source.slice(0, match.index).split('\n').length;
152
+
153
+ expect(
154
+ /\brawOutput\b/.test(literal),
155
+ `${file}:${line} returns a JSON message without rawOutput: true. Without the flag the payload goes through the decorating renderer and no longer parses. Add \`rawOutput: true\` to the same result object.`,
156
+ ).toBe(true);
157
+
158
+ match = pattern.exec(source);
159
+ }
160
+ });
161
+ }
162
+ });
@@ -1,10 +1,32 @@
1
1
  /**
2
2
  * Prompt Utilities
3
- * Beautiful interactive prompts using @clack/prompts
3
+ *
4
+ * A thin celilo-facing wrapper over the prompt/message primitives in
5
+ * `@celilo/cli-display`. Roughly 160 `log.*` call sites and every legacy
6
+ * direct prompt route through this module, which is what made replacing
7
+ * `@clack/prompts` a one-file change rather than a 160-file one (celilo#699).
8
+ *
9
+ * NOTE: new interactive decisions must NOT be added here. They belong on the
10
+ * event-bus interview (`services/bus-interview`), which a headless responder
11
+ * can answer; `test-integration/cli/headless-drivable.test.ts` enforces that
12
+ * no command reaches for these directly. What remains is the terminal renderer
13
+ * for a bus question, plus the legacy prompts that predate the interview.
4
14
  */
5
15
 
6
- import { getActiveDisplay, setActiveDisplay } from '@celilo/cli-display';
7
- import * as p from '@clack/prompts';
16
+ import {
17
+ CANCEL,
18
+ cancel,
19
+ getActiveDisplay,
20
+ intro,
21
+ isCancel,
22
+ log,
23
+ note,
24
+ outro,
25
+ password,
26
+ setActiveDisplay,
27
+ text,
28
+ confirm as uiConfirm,
29
+ } from '@celilo/cli-display';
8
30
 
9
31
  // Re-export so existing imports from `../cli/prompts` keep working.
10
32
  // The singleton itself lives in @celilo/cli-display so module code
@@ -12,25 +34,26 @@ import * as p from '@clack/prompts';
12
34
  // it via @celilo/capabilities's re-export.
13
35
  export { getActiveDisplay, setActiveDisplay };
14
36
 
37
+ export { log };
38
+
15
39
  /**
16
40
  * Interactive prompt wrapper with celilo branding
17
41
  */
18
42
  export async function celiloIntro(title: string): Promise<void> {
19
- p.intro(title);
43
+ intro(title);
20
44
  }
21
45
 
22
46
  export async function celiloOutro(message: string): Promise<void> {
23
- p.outro(message);
47
+ outro(message);
24
48
  }
25
49
 
26
50
  /**
27
- * Prompt for text input with validation and help text
51
+ * Prompt for text input with validation and help text.
28
52
  *
29
- * Displays:
30
- * - Question/prompt message
31
- * - Input field with placeholder
32
- * - Help text below (muted) - like @clack/prompts screenshot
33
- * - Validation errors inline
53
+ * Displays the question, an input field with the placeholder, and any
54
+ * validation error inline. A cancel (Ctrl-C / Escape) exits the process, which
55
+ * is the behaviour every existing caller is written against: they treat the
56
+ * return as an unconditional `string`.
34
57
  */
35
58
  export async function promptText(options: {
36
59
  message: string;
@@ -38,19 +61,14 @@ export async function promptText(options: {
38
61
  placeholder?: string;
39
62
  validate?: (value: string | undefined) => string | Error | undefined;
40
63
  }): Promise<string> {
41
- const result = await p.text({
42
- message: options.message,
43
- defaultValue: options.defaultValue,
44
- placeholder: options.placeholder,
45
- validate: options.validate,
46
- });
47
-
48
- if (p.isCancel(result)) {
49
- p.cancel('Operation cancelled');
64
+ const result = await text(options);
65
+
66
+ if (isCancel(result)) {
67
+ cancel('Operation cancelled');
50
68
  process.exit(0);
51
69
  }
52
70
 
53
- return result as string;
71
+ return result;
54
72
  }
55
73
 
56
74
  /**
@@ -62,30 +80,25 @@ export async function promptPassword(options: {
62
80
  validate?: (value: string | undefined) => string | Error | undefined;
63
81
  }): Promise<string> {
64
82
  while (true) {
65
- const result = await p.password({
66
- message: options.message,
67
- validate: options.validate,
68
- });
83
+ const result = await password(options);
69
84
 
70
- if (p.isCancel(result)) {
71
- p.cancel('Operation cancelled');
85
+ if (isCancel(result)) {
86
+ cancel('Operation cancelled');
72
87
  process.exit(0);
73
88
  }
74
89
 
75
- const confirm = await p.password({
76
- message: `Confirm ${options.message}`,
77
- });
90
+ const confirmation = await password({ message: `Confirm ${options.message}` });
78
91
 
79
- if (p.isCancel(confirm)) {
80
- p.cancel('Operation cancelled');
92
+ if (isCancel(confirmation)) {
93
+ cancel('Operation cancelled');
81
94
  process.exit(0);
82
95
  }
83
96
 
84
- if (result === confirm) {
85
- return result as string;
97
+ if (result === confirmation) {
98
+ return result;
86
99
  }
87
100
 
88
- p.log.warning('Passwords do not match. Please try again.');
101
+ log.warn('Passwords do not match. Please try again.');
89
102
  }
90
103
  }
91
104
 
@@ -96,53 +109,19 @@ export async function promptConfirm(options: {
96
109
  message: string;
97
110
  initialValue?: boolean;
98
111
  }): Promise<boolean> {
99
- const result = await p.confirm({
100
- message: options.message,
101
- initialValue: options.initialValue,
102
- });
112
+ const result = await uiConfirm(options);
103
113
 
104
- if (p.isCancel(result)) {
105
- p.cancel('Operation cancelled');
114
+ if (result === CANCEL) {
115
+ cancel('Operation cancelled');
106
116
  process.exit(0);
107
117
  }
108
118
 
109
- return result as boolean;
119
+ return result;
110
120
  }
111
121
 
112
122
  /**
113
123
  * Show note/information box
114
124
  */
115
125
  export function showNote(message: string, title?: string): void {
116
- p.note(message, title);
126
+ note(message, title);
117
127
  }
118
-
119
- /**
120
- * Show log messages
121
- */
122
- export const log = {
123
- success: (message: string) => {
124
- const d = getActiveDisplay();
125
- if (d) return d.subEvent(`\x1b[32m✔\x1b[0m ${message}`);
126
- p.log.success(message);
127
- },
128
- error: (message: string) => {
129
- const d = getActiveDisplay();
130
- if (d) return d.subEvent(`\x1b[31m✗\x1b[0m ${message}`);
131
- p.log.error(message);
132
- },
133
- warn: (message: string) => {
134
- const d = getActiveDisplay();
135
- if (d) return d.subEvent(`\x1b[33m⚠\x1b[0m ${message}`);
136
- p.log.warn(message);
137
- },
138
- info: (message: string) => {
139
- const d = getActiveDisplay();
140
- if (d) return d.instantEvent(message);
141
- p.log.info(message);
142
- },
143
- message: (message: string) => {
144
- const d = getActiveDisplay();
145
- if (d) return d.subEvent(message);
146
- p.log.message(message);
147
- },
148
- };
@@ -8,10 +8,10 @@
8
8
  * secrets therefore travel by **flag or env var**, never over the event bus.
9
9
  *
10
10
  * The resolution order is: explicit `--<flag>` → `$ENV` → (only when stdin is
11
- * a TTY) a local clack `password` prompt → otherwise a fail-fast error naming
11
+ * a TTY) a local masked `password` prompt → otherwise a fail-fast error naming
12
12
  * the flag and env var. This keeps a zero-TTY `service add` possible while the
13
13
  * credential never lands on the bus, and the local password prompt is the one
14
- * clack input the recurrence gate (D6) allow-lists — precisely because a
14
+ * direct prompt the recurrence gate (D6) tolerates — precisely because a
15
15
  * flag/env path always exists alongside it.
16
16
  */
17
17
 
@@ -29,7 +29,7 @@ export interface ServiceCredentialSpec {
29
29
  }
30
30
 
31
31
  /**
32
- * Resolve a service-credential secret from flag → env → (TTY) clack password →
32
+ * Resolve a service-credential secret from flag → env → (TTY) password prompt
33
33
  * error. Returns the secret value as a string. Throws an actionable error when
34
34
  * no value is available on a non-TTY run.
35
35
  */
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Recurrence gate for celilo#699 — ordinary stdout carries no decoration, and
3
+ * diagnostics do not share the stream with results.
4
+ *
5
+ * The CLI used to render every successful message through `@clack/prompts`,
6
+ * which prefixed each line with `│ ` and coloured it. Two costs followed, and
7
+ * this file pins both shut:
8
+ *
9
+ * 1. `line.startsWith('<module-id> ')` over `celilo module list` matched
10
+ * nothing, because the line actually began `│ caddy (v2.2.0) - …`. In
11
+ * celilo#695 that read as a MISSING MODULE rather than as a parse failure,
12
+ * and cost a full e2e run to find. `e2e/tests/module-pause.test.ts` asserts
13
+ * the same property against the real fleet topology; this is its fast
14
+ * equivalent, so a regression is caught in seconds rather than in Docker.
15
+ *
16
+ * 2. clack wrote errors to stdout, so no caller could tell a result from a
17
+ * complaint about producing one — `src/test-utils/cli.ts` carried a comment
18
+ * saying exactly that, twice, and merged both streams to cope.
19
+ *
20
+ * Spawns the real CLI rather than using `CLIContext`: that harness runs the CLI
21
+ * in `CLI_SERVER_MODE`, which returns `result.message` over a protocol and
22
+ * never reaches the stdout writer under test here.
23
+ */
24
+
25
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
26
+ import { spawnSync } from 'node:child_process';
27
+ import { type IntegrationTestContext, setupIntegrationTest } from '@/test-utils/integration';
28
+
29
+ let ctx: IntegrationTestContext;
30
+
31
+ function celilo(command: string): { stdout: string; stderr: string; status: number | null } {
32
+ const result = spawnSync('bun', ['run', 'src/cli/index.ts', ...command.split(' ')], {
33
+ encoding: 'utf8',
34
+ env: {
35
+ ...process.env,
36
+ CELILO_DB_PATH: ctx.dbPath,
37
+ CELILO_DATA_DIR: ctx.dataDir,
38
+ CELILO_SUPPRESS_DEPRECATION: '1',
39
+ },
40
+ stdio: ['ignore', 'pipe', 'pipe'],
41
+ timeout: 60_000,
42
+ });
43
+ return { stdout: result.stdout, stderr: result.stderr, status: result.status };
44
+ }
45
+
46
+ /** The `│` clack used to open every rendered line with. */
47
+ const BOX_DRAWING = /[│┌└├─]/;
48
+ const ANSI = /\x1b\[[0-9;]*m/;
49
+
50
+ describe('celilo#699 — stdout is undecorated', () => {
51
+ beforeAll(async () => {
52
+ ctx = await setupIntegrationTest();
53
+ // A module has to exist for `module list` to print a roster line at all —
54
+ // an empty roster would pass every assertion below without testing them.
55
+ const imported = celilo('module import ../../modules/celilo-mgmt');
56
+ expect(imported.status, `module import failed:\n${imported.stderr}`).toBe(0);
57
+ });
58
+
59
+ afterAll(async () => {
60
+ await ctx.cleanup();
61
+ });
62
+
63
+ test('module list lines start with the module id, with no preprocessing', () => {
64
+ const { stdout, status, stderr } = celilo('module list');
65
+ expect(status, `module list failed:\n${stderr}`).toBe(0);
66
+
67
+ // The exact shape celilo#695 tried and failed to match. No ANSI stripping,
68
+ // no prefix trimming — if this needs either, the bug is back.
69
+ const line = stdout.split('\n').find((l) => l.startsWith('celilo-mgmt '));
70
+
71
+ expect(
72
+ line,
73
+ `No line began with "celilo-mgmt ". stdout was:\n${JSON.stringify(stdout)}`,
74
+ ).toBeDefined();
75
+ });
76
+
77
+ test('module list stdout carries no box-drawing or ANSI', () => {
78
+ const { stdout } = celilo('module list');
79
+ expect(BOX_DRAWING.test(stdout), `box-drawing in stdout:\n${JSON.stringify(stdout)}`).toBe(
80
+ false,
81
+ );
82
+ expect(ANSI.test(stdout), `ANSI in stdout:\n${JSON.stringify(stdout)}`).toBe(false);
83
+ });
84
+
85
+ test('a failing command writes its diagnostic to stderr, not stdout', () => {
86
+ const { stdout, stderr, status } = celilo('module where no-such-module-exists');
87
+
88
+ expect(status).not.toBe(0);
89
+ expect(stderr).toContain('Error');
90
+ // The whole point: stdout stays empty so a caller parsing it is not handed
91
+ // an error message where a result belongs.
92
+ expect(stdout.trim(), `error text leaked to stdout: ${JSON.stringify(stdout)}`).toBe('');
93
+ });
94
+ });
package/src/cli/types.ts CHANGED
@@ -20,8 +20,13 @@ export interface CommandSuccess {
20
20
  message: string;
21
21
  data?: unknown;
22
22
  /**
23
- * When true, the CLI writes `message` directly to stdout (no clack formatting).
24
- * Use for commands designed to be consumed by scripts (e.g. `module secret get`).
23
+ * Marks a command whose `message` is a machine-readable payload (JSON, a
24
+ * unit file, a secret) rather than prose for a human.
25
+ *
26
+ * Every successful message now reaches stdout verbatim, so this no longer
27
+ * selects a different destination. It is kept as the contract: these are the
28
+ * commands a script parses, and nothing may ever decorate them (celilo#698,
29
+ * where ten JSON payloads were rendered unparseable by the formatter).
25
30
  */
26
31
  rawOutput?: boolean;
27
32
  }