@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 +2 -2
- package/src/cli/command-registry.ts +6 -0
- package/src/cli/commands/module-deploy.ts +3 -2
- package/src/cli/fuel-gauge.ts +3 -5
- package/src/cli/prompts.ts +16 -20
- package/src/module/packaging/build.ts +11 -6
- package/src/services/deploy-ansible.ts +54 -5
- package/src/services/deploy-terraform.ts +4 -0
- package/src/services/module-build.test.ts +12 -7
- package/src/services/module-build.ts +2 -2
- package/src/services/module-deploy.ts +20 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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 {
|
package/src/cli/fuel-gauge.ts
CHANGED
|
@@ -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
|
}
|
package/src/cli/prompts.ts
CHANGED
|
@@ -3,23 +3,14 @@
|
|
|
3
3
|
* Beautiful interactive prompts using @clack/prompts
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import
|
|
6
|
+
import { getActiveDisplay, setActiveDisplay } from '@celilo/cli-display';
|
|
7
7
|
import * as p from '@clack/prompts';
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
135
|
-
|
|
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: "
|
|
141
|
-
// If nix not available: "
|
|
142
|
-
const
|
|
143
|
-
const hasNixMessage =
|
|
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
|
-
|
|
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
|
-
|
|
272
|
+
log.info('Entering Nix environment (flake.nix detected)...');
|
|
273
273
|
} else if (nixDetected && !nixAvailable) {
|
|
274
|
-
|
|
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:
|
|
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
|
|