@celilo/cli 0.1.7 → 0.1.8

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.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,7 +47,7 @@
47
47
  "dependencies": {
48
48
  "@aws-sdk/client-s3": "^3.1024.0",
49
49
  "@celilo/capabilities": "^0.1.4",
50
- "@celilo/cli-display": "0.1.0",
50
+ "@celilo/cli-display": "0.1.4",
51
51
  "@clack/prompts": "^1.1.0",
52
52
  "ajv": "^8.18.0",
53
53
  "drizzle-orm": "^0.36.4",
@@ -283,6 +283,12 @@ export const COMMANDS: CommandDef[] = [
283
283
  description: 'Run pre-flight validation only (no deployment)',
284
284
  takesValue: false,
285
285
  },
286
+ {
287
+ name: 'verbose',
288
+ description:
289
+ 'Keep all sub-events visible (no collapse-on-success); useful for debugging slow steps',
290
+ takesValue: false,
291
+ },
286
292
  ],
287
293
  },
288
294
  {
@@ -14,7 +14,7 @@ import type { CommandResult } from '../types';
14
14
  * Handle module deploy command
15
15
  *
16
16
  * Usage:
17
- * celilo module deploy <module-id> [--no-interactive] [--debug] [--preflight]
17
+ * celilo module deploy <module-id> [--no-interactive] [--debug] [--preflight] [--verbose]
18
18
  *
19
19
  * @param args - Command arguments
20
20
  * @param flags - Command flags
@@ -52,8 +52,9 @@ export async function handleModuleDeploy(
52
52
 
53
53
  const noInteractive = hasFlag(flags, 'no-interactive');
54
54
  const debug = hasFlag(flags, 'debug');
55
+ const verbose = hasFlag(flags, 'verbose');
55
56
 
56
- const result = await deployModule(moduleId, db, { noInteractive, debug });
57
+ const result = await deployModule(moduleId, db, { noInteractive, debug, verbose });
57
58
 
58
59
  if (!result.success) {
59
60
  return {
@@ -197,12 +197,10 @@ export class FuelGauge {
197
197
  if (success) {
198
198
  display.doneStep();
199
199
  } else {
200
+ // failStep no longer collapses sub-events, so the lines we'd
201
+ // re-print as "last output" are already visible above the
202
+ // "✗ msg" line. Just call failStep and let them stand.
200
203
  display.failStep(this.title);
201
- // Surface the last few output lines so the user can see what broke.
202
- const errorLines = this.outputLines.slice(-this.errorDisplayLines);
203
- for (const line of errorLines) {
204
- if (line.trim()) display.subEvent(line);
205
- }
206
204
  }
207
205
  return;
208
206
  }
@@ -3,23 +3,14 @@
3
3
  * Beautiful interactive prompts using @clack/prompts
4
4
  */
5
5
 
6
- import type { ProgressDisplay } from '@celilo/cli-display';
6
+ import { getActiveDisplay, setActiveDisplay } from '@celilo/cli-display';
7
7
  import * as p from '@clack/prompts';
8
8
 
9
- let activeDisplay: ProgressDisplay | null = null;
10
-
11
- /**
12
- * When set, log.* calls route through the ProgressDisplay instead of @clack/prompts.
13
- * Callers (e.g. module deploy) set this for the duration of a long-running operation
14
- * so all the sub-services they call emit through one coherent display.
15
- */
16
- export function setActiveDisplay(display: ProgressDisplay | null): void {
17
- activeDisplay = display;
18
- }
19
-
20
- export function getActiveDisplay(): ProgressDisplay | null {
21
- return activeDisplay;
22
- }
9
+ // Re-export so existing imports from `../cli/prompts` keep working.
10
+ // The singleton itself lives in @celilo/cli-display so module code
11
+ // running in-process (capability functions, hook scripts) can reach
12
+ // it via @celilo/capabilities's re-export.
13
+ export { getActiveDisplay, setActiveDisplay };
23
14
 
24
15
  /**
25
16
  * Interactive prompt wrapper with celilo branding
@@ -130,23 +121,28 @@ export function showNote(message: string, title?: string): void {
130
121
  */
131
122
  export const log = {
132
123
  success: (message: string) => {
133
- if (activeDisplay) return activeDisplay.subEvent(`\x1b[32m✓\x1b[0m ${message}`);
124
+ const d = getActiveDisplay();
125
+ if (d) return d.subEvent(`\x1b[32m✓\x1b[0m ${message}`);
134
126
  p.log.success(message);
135
127
  },
136
128
  error: (message: string) => {
137
- if (activeDisplay) return activeDisplay.subEvent(`\x1b[31m✗\x1b[0m ${message}`);
129
+ const d = getActiveDisplay();
130
+ if (d) return d.subEvent(`\x1b[31m✗\x1b[0m ${message}`);
138
131
  p.log.error(message);
139
132
  },
140
133
  warn: (message: string) => {
141
- if (activeDisplay) return activeDisplay.subEvent(`\x1b[33m⚠\x1b[0m ${message}`);
134
+ const d = getActiveDisplay();
135
+ if (d) return d.subEvent(`\x1b[33m⚠\x1b[0m ${message}`);
142
136
  p.log.warn(message);
143
137
  },
144
138
  info: (message: string) => {
145
- if (activeDisplay) return activeDisplay.instantEvent(message);
139
+ const d = getActiveDisplay();
140
+ if (d) return d.instantEvent(message);
146
141
  p.log.info(message);
147
142
  },
148
143
  message: (message: string) => {
149
- if (activeDisplay) return activeDisplay.subEvent(message);
144
+ const d = getActiveDisplay();
145
+ if (d) return d.subEvent(message);
150
146
  p.log.message(message);
151
147
  },
152
148
  };
@@ -82,6 +82,8 @@ const FRAMEWORK_OWNED_PATHS = new Set(['celilo/types.d.ts']);
82
82
  * hooks aren't silently broken by a runtime that ships a different
83
83
  * version of `@celilo/capabilities` than the one on npm.
84
84
  */
85
+ const BUNDLED_CELILO_PACKAGES = new Set(['capabilities', 'cli-display']);
86
+
85
87
  function shouldExclude(filePath: string): boolean {
86
88
  if (FRAMEWORK_OWNED_PATHS.has(filePath)) return true;
87
89
 
@@ -93,15 +95,18 @@ function shouldExclude(filePath: string): boolean {
93
95
 
94
96
  const nmIdx = segments.indexOf('node_modules');
95
97
  if (nmIdx >= 0) {
96
- // `node_modules` itself: descend so we can pick out @celilo/capabilities.
97
- // The capabilities scope is the only thing we ship — it's the framework
98
- // SDK the module was authored against. Everything else is regular npm
99
- // cruft we'd just re-install on the target (or test-only deps inside
100
- // packages like `@celilo/e2e` we don't want to drag in).
98
+ // `node_modules` itself: descend so we can pick out @celilo/* SDK
99
+ // packages. capabilities is the public surface the module was
100
+ // authored against; cli-display is its transitive dep (the
101
+ // ProgressDisplay singleton that capability functions reach for to
102
+ // route output through the parent celilo's display). Everything
103
+ // else is regular npm cruft we'd just re-install on the target
104
+ // (or test-only deps inside packages like `@celilo/e2e` we don't
105
+ // want to drag in).
101
106
  if (nmIdx + 1 >= segments.length) return false; // node_modules dir itself
102
107
  if (segments[nmIdx + 1] !== '@celilo') return true;
103
108
  if (nmIdx + 2 >= segments.length) return false; // node_modules/@celilo dir itself
104
- return segments[nmIdx + 2] !== 'capabilities';
109
+ return !BUNDLED_CELILO_PACKAGES.has(segments[nmIdx + 2]);
105
110
  }
106
111
 
107
112
  const name = basename(filePath);
@@ -7,7 +7,7 @@
7
7
  import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
- import { log } from '../cli/prompts';
10
+ import { getActiveDisplay, log } from '../cli/prompts';
11
11
  import { getVaultPassword } from '../secrets/vault';
12
12
  import { shellEscape } from '../utils/shell';
13
13
  import { executeBuildWithProgress } from './build-stream';
@@ -29,8 +29,15 @@ const ANSI_ESCAPE = /\x1b\[[0-9;]*m/g;
29
29
  * Emit structured progress markers to stdout for each Ansible play/task.
30
30
  * Uses console.log (same channel as log.info) so the e2e runner sees them
31
31
  * in real-time. The runner matches [ansible:play] / [ansible:task] patterns.
32
+ *
33
+ * When an active ProgressDisplay is handling the output, skip emission
34
+ * entirely: parseAnsibleLine is already routing the same play/task lines
35
+ * through display.subEvent (which emits [progress:sub] markers for
36
+ * cele2e or renders directly for an interactive terminal). Emitting
37
+ * both would duplicate every task on the user's screen.
32
38
  */
33
39
  function emitAnsibleProgress(rawChunk: string): void {
40
+ if (getActiveDisplay()) return;
34
41
  // In non-TTY mode (e.g. inside a Docker exec) write to stdout, which is
35
42
  // line-buffered by stdbuf. stderr may arrive batched or out-of-order through
36
43
  // docker exec -T, so stdout gives the e2e runner reliable real-time delivery.
@@ -52,6 +59,41 @@ function emitAnsibleProgress(rawChunk: string): void {
52
59
  }
53
60
  }
54
61
 
62
+ /**
63
+ * Reformat an Ansible PLAY RECAP host line into a compact summary.
64
+ *
65
+ * Input (raw, ~100 columns):
66
+ * www : ok=11 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
67
+ *
68
+ * Output (compact, ~40 columns, ✓ on success / ✗ on any failure or
69
+ * unreachable host, zero-value fields dropped):
70
+ * ✓ www ok=11 changed=2
71
+ */
72
+ function formatAnsibleRecap(stripped: string): string {
73
+ const match = stripped.match(/^(\S+)\s*:\s*(.+)$/);
74
+ if (!match) return ` ${stripped}`;
75
+ const host = match[1];
76
+ const stats = match[2];
77
+
78
+ const counts: Record<string, number> = {};
79
+ for (const m of stats.matchAll(/(\w+)=(\d+)/g)) {
80
+ counts[m[1]] = Number.parseInt(m[2], 10);
81
+ }
82
+
83
+ const failures = (counts.failed ?? 0) + (counts.unreachable ?? 0);
84
+ const icon = failures > 0 ? '✗' : '✓';
85
+
86
+ // Order matters: ok / changed first (good news), then anything bad.
87
+ // Skipped is dropped — it's noise from `when:` clauses.
88
+ const fieldOrder = ['ok', 'changed', 'failed', 'unreachable', 'rescued', 'ignored'];
89
+ const parts: string[] = [];
90
+ for (const f of fieldOrder) {
91
+ if (counts[f]) parts.push(`${f}=${counts[f]}`);
92
+ }
93
+
94
+ return ` ${icon} ${host} ${parts.join(' ')}`;
95
+ }
96
+
55
97
  function parseAnsibleLine(line: string): string | null {
56
98
  const stripped = line.replace(ANSI_ESCAPE, '').trim();
57
99
 
@@ -67,13 +109,20 @@ function parseAnsibleLine(line: string): string | null {
67
109
  const name = stripped.replace(/^RUNNING HANDLER \[/, '').replace(/\].*$/, '');
68
110
  return ` ↺ handler: ${name}`;
69
111
  }
70
- if (/^ok:/.test(stripped)) return ' ok';
112
+ // `ok:` and `skipping:` are pure noise — every successful task emits one
113
+ // and the cele2e runner already drops them. Keep `changed`/`failed`/`fatal`
114
+ // because they're the lines a user actually wants to see scrolling by.
115
+ if (/^ok:/.test(stripped)) return null;
116
+ if (/^skipping:/.test(stripped)) return null;
71
117
  if (/^changed:/.test(stripped)) return ' changed';
72
- if (/^skipping:/.test(stripped)) return ' skipped';
73
118
  if (/^failed:/.test(stripped)) return ' FAILED';
74
119
  if (/^fatal:/.test(stripped)) return ` FATAL: ${stripped.slice(7)}`;
75
- if (/^PLAY RECAP/.test(stripped)) return ' recap:';
76
- if (/^\w[\w.-]+ *:/.test(stripped) && stripped.includes('ok=')) return ` ${stripped}`;
120
+ // The "PLAY RECAP" header is redundant — the per-host summary line
121
+ // that follows already names the host and the counts.
122
+ if (/^PLAY RECAP/.test(stripped)) return null;
123
+ if (/^\w[\w.-]+ *:/.test(stripped) && stripped.includes('ok=')) {
124
+ return formatAnsibleRecap(stripped);
125
+ }
77
126
 
78
127
  // Suppress decorative separator lines (all * or = chars)
79
128
  if (/^[*=\s]+$/.test(stripped)) return null;
@@ -432,6 +432,10 @@ export async function parseTerraformOutputs(
432
432
  cwd: terraformDir,
433
433
  title: 'Reading Terraform outputs',
434
434
  env: { TF_IN_AUTOMATION: '1' },
435
+ // The raw JSON is for parsing into a Record, not for display.
436
+ // Suppress every line from the gauge/display while still capturing
437
+ // result.output for JSON.parse below.
438
+ filterOutput: () => null,
435
439
  });
436
440
 
437
441
  if (!result.success) {
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { mkdir, rm, writeFile } from 'node:fs/promises';
4
4
  import { eq } from 'drizzle-orm';
5
+ import { log } from '../cli/prompts';
5
6
  import { type DbClient, createDbClient } from '../db/client';
6
7
  import { moduleBuilds, modules } from '../db/schema';
7
8
  import { buildModuleFromSource, getModuleBuildStatus, verifyArtifactsExist } from './module-build';
@@ -131,21 +132,25 @@ describe('Module Build Service', () => {
131
132
  })
132
133
  .run();
133
134
 
134
- // Mock console.log to capture output
135
- const consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {});
135
+ // Mock log.info / log.warn to capture output (module-build.ts now
136
+ // routes through these instead of console.log so output flows through
137
+ // the active ProgressDisplay during a real deploy).
138
+ const logInfoSpy = spyOn(log, 'info').mockImplementation(() => {});
139
+ const logWarnSpy = spyOn(log, 'warn').mockImplementation(() => {});
136
140
 
137
141
  const _result = await buildModuleFromSource('nix-module', db);
138
142
 
139
143
  // Verify Nix detection or fallback message was logged
140
- // If nix is available: "Entering Nix environment"
141
- // If nix not available: "⚠️ flake.nix detected but nix command not available"
142
- const logCalls = consoleLogSpy.mock.calls.map((call) => call[0]);
143
- const hasNixMessage = logCalls.some(
144
+ // If nix is available: "Entering Nix environment"
145
+ // If nix not available: "flake.nix detected but nix command not available"
146
+ const calls = [...logInfoSpy.mock.calls, ...logWarnSpy.mock.calls].map((c) => c[0]);
147
+ const hasNixMessage = calls.some(
144
148
  (msg) => msg.includes('Nix environment') || msg.includes('flake.nix detected'),
145
149
  );
146
150
  expect(hasNixMessage).toBe(true);
147
151
 
148
- consoleLogSpy.mockRestore();
152
+ logInfoSpy.mockRestore();
153
+ logWarnSpy.mockRestore();
149
154
 
150
155
  // Build result depends on whether Nix is installed
151
156
  // We're just testing that detection happens (message logged)
@@ -269,9 +269,9 @@ export async function buildModuleFromSource(moduleId: string, db: DbClient): Pro
269
269
  const environment: 'nix' | 'system' = useNix ? 'nix' : 'system';
270
270
 
271
271
  if (useNix) {
272
- console.log('Entering Nix environment (flake.nix detected)...');
272
+ log.info('Entering Nix environment (flake.nix detected)...');
273
273
  } else if (nixDetected && !nixAvailable) {
274
- console.log('⚠️ flake.nix detected but nix command not available - using system Ansible');
274
+ log.warn('flake.nix detected but nix command not available - using system Ansible');
275
275
  }
276
276
 
277
277
  try {
@@ -91,6 +91,14 @@ async function updateMachineAssignment(
91
91
  export interface DeployOptions {
92
92
  noInteractive?: boolean;
93
93
  debug?: boolean;
94
+ /**
95
+ * Keep all sub-events visible during the deploy. With this off (the
96
+ * default), the ProgressDisplay collapses each "… doing" block into
97
+ * a single "✔ done" line on completion. Verbose mode disables that
98
+ * collapse so the user can see every line that flowed through —
99
+ * useful for debugging slow or hanging steps.
100
+ */
101
+ verbose?: boolean;
94
102
  }
95
103
 
96
104
  /**
@@ -243,8 +251,19 @@ export async function deployModule(
243
251
  ): Promise<DeployResult> {
244
252
  const phases: DeployResult['phases'] = {};
245
253
 
254
+ // --no-interactive emits structured [progress:*] markers (for cele2e
255
+ // to parse). --verbose forces render mode but with isTTY=false, which
256
+ // disables cursor magic — every sub-event stays visible after each
257
+ // step completes (no collapse-on-success). When neither is set, mode
258
+ // is 'auto' and the display picks based on the real stdout's isTTY.
259
+ // --no-interactive wins if both are set (protocol output is what
260
+ // cele2e expects regardless of verbosity).
261
+ const displayMode = options.noInteractive ? 'protocol' : options.verbose ? 'render' : 'auto';
246
262
  const display = new ProgressDisplay({
247
- mode: options.noInteractive ? 'protocol' : 'auto',
263
+ mode: displayMode,
264
+ out: options.verbose
265
+ ? { write: process.stdout.write.bind(process.stdout), isTTY: false }
266
+ : undefined,
248
267
  });
249
268
  setActiveDisplay(display);
250
269