@celilo/cli 0.1.5 → 0.1.7
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/drizzle/0004_caddy_hostname_list.sql +25 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +9 -2
- package/src/ansible/inventory.test.ts +3 -2
- package/src/ansible/inventory.ts +5 -1
- package/src/capabilities/public-web-helpers.test.ts +2 -2
- package/src/capabilities/public-web-publish.test.ts +34 -1
- package/src/cli/cli.test.ts +2 -2
- package/src/cli/command-registry.ts +146 -3
- package/src/cli/command-tree-parser.test.ts +1 -1
- package/src/cli/command-tree-parser.ts +9 -8
- package/src/cli/commands/hook-run.ts +15 -66
- package/src/cli/commands/module-audit.ts +14 -44
- package/src/cli/commands/module-deploy.ts +4 -1
- package/src/cli/commands/module-import-registry.test.ts +115 -0
- package/src/cli/commands/module-import.ts +106 -22
- package/src/cli/commands/module-publish.test.ts +235 -0
- package/src/cli/commands/module-publish.ts +234 -0
- package/src/cli/commands/module-remove.ts +82 -2
- package/src/cli/commands/module-search.ts +57 -0
- package/src/cli/commands/module-secret-get.ts +59 -0
- package/src/cli/commands/module-terraform-unlock.ts +57 -0
- package/src/cli/commands/module-verify.test.ts +59 -0
- package/src/cli/commands/module-verify.ts +53 -0
- package/src/cli/commands/status.ts +30 -20
- package/src/cli/commands/system-audit.test.ts +138 -0
- package/src/cli/commands/system-audit.ts +571 -0
- package/src/cli/commands/system-update.ts +391 -0
- package/src/cli/completion.ts +15 -1
- package/src/cli/fuel-gauge.ts +68 -3
- package/src/cli/generate-zsh-completion.ts +13 -3
- package/src/cli/index.ts +112 -5
- package/src/cli/parser.ts +11 -0
- package/src/cli/prompts.ts +36 -5
- package/src/cli/tui/audit-state.test.ts +246 -0
- package/src/cli/tui/audit-state.ts +525 -0
- package/src/cli/tui/audit-tui.test.tsx +135 -0
- package/src/cli/tui/audit-tui.tsx +624 -0
- package/src/cli/tui/celebration.tsx +29 -0
- package/src/cli/tui/clipboard.test.ts +94 -0
- package/src/cli/tui/clipboard.ts +101 -0
- package/src/cli/tui/icons.ts +22 -0
- package/src/cli/tui/keybar.tsx +65 -0
- package/src/cli/tui/keymap.test.ts +105 -0
- package/src/cli/tui/keymap.ts +70 -0
- package/src/cli/tui/modals/analyzing.tsx +75 -0
- package/src/cli/tui/modals/celebration.tsx +44 -0
- package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
- package/src/cli/tui/modals/remediate.tsx +44 -0
- package/src/cli/tui/modals.test.ts +137 -0
- package/src/cli/tui/mouse.test.ts +78 -0
- package/src/cli/tui/mouse.ts +114 -0
- package/src/cli/tui/panes/categories.tsx +62 -0
- package/src/cli/tui/panes/command-log.tsx +87 -0
- package/src/cli/tui/panes/detail.tsx +175 -0
- package/src/cli/tui/panes/findings.tsx +97 -0
- package/src/cli/tui/panes/summary.tsx +64 -0
- package/src/cli/tui/spawn.ts +130 -0
- package/src/cli/tui/theme.ts +42 -0
- package/src/cli/tui/wrap.test.ts +43 -0
- package/src/cli/tui/wrap.ts +45 -0
- package/src/cli/types.ts +5 -0
- package/src/db/client.ts +55 -2
- package/src/db/schema.ts +26 -17
- package/src/hooks/capability-loader.ts +133 -73
- package/src/hooks/define-hook.test.ts +9 -1
- package/src/hooks/executor.ts +22 -1
- package/src/hooks/load-hook-config.test.ts +165 -0
- package/src/hooks/load-hook-config.ts +60 -0
- package/src/hooks/logger.ts +42 -12
- package/src/hooks/run-named-hook.ts +128 -0
- package/src/hooks/types.ts +19 -0
- package/src/manifest/ensure-schema.test.ts +115 -0
- package/src/manifest/schema.ts +76 -0
- package/src/module/import.ts +20 -12
- package/src/module/packaging/build.ts +85 -16
- package/src/module/packaging/release-metadata.test.ts +103 -0
- package/src/module/packaging/release-metadata.ts +145 -0
- package/src/registry/client.test.ts +228 -0
- package/src/registry/client.ts +157 -0
- package/src/services/audit/backups.test.ts +233 -0
- package/src/services/audit/backups.ts +128 -0
- package/src/services/audit/capability-abi.test.ts +153 -0
- package/src/services/audit/capability-abi.ts +204 -0
- package/src/services/audit/cli-version.test.ts +60 -0
- package/src/services/audit/cli-version.ts +87 -0
- package/src/services/audit/health.test.ts +84 -0
- package/src/services/audit/health.ts +43 -0
- package/src/services/audit/index.test.ts +99 -0
- package/src/services/audit/index.ts +118 -0
- package/src/services/audit/machines-reachable.test.ts +87 -0
- package/src/services/audit/machines-reachable.ts +87 -0
- package/src/services/audit/module-configs.test.ts +131 -0
- package/src/services/audit/module-configs.ts +80 -0
- package/src/services/audit/module-versions.test.ts +99 -0
- package/src/services/audit/module-versions.ts +154 -0
- package/src/services/audit/schema.test.ts +68 -0
- package/src/services/audit/schema.ts +115 -0
- package/src/services/audit/secrets-decryptable.test.ts +82 -0
- package/src/services/audit/secrets-decryptable.ts +97 -0
- package/src/services/audit/services-credentials.test.ts +54 -0
- package/src/services/audit/services-credentials.ts +64 -0
- package/src/services/audit/services-reachable.test.ts +60 -0
- package/src/services/audit/services-reachable.ts +64 -0
- package/src/services/audit/terraform-plan.test.ts +127 -0
- package/src/services/audit/terraform-plan.ts +153 -0
- package/src/services/audit/types.test.ts +36 -0
- package/src/services/audit/types.ts +90 -0
- package/src/services/audit/unconfigured-modules.test.ts +48 -0
- package/src/services/audit/unconfigured-modules.ts +71 -0
- package/src/services/audit/undeployed-modules.test.ts +66 -0
- package/src/services/audit/undeployed-modules.ts +72 -0
- package/src/services/build-stream.ts +122 -122
- package/src/services/config-interview.ts +407 -2
- package/src/services/deploy-ansible.ts +73 -7
- package/src/services/deploy-preflight.ts +45 -4
- package/src/services/deploy-terraform.ts +31 -24
- package/src/services/deploy-validation.ts +167 -23
- package/src/services/ensure-interview.test.ts +245 -0
- package/src/services/health-runner.ts +110 -38
- package/src/services/module-build.ts +11 -13
- package/src/services/module-deploy.ts +370 -59
- package/src/services/ssh-key-manager.test.ts +1 -1
- package/src/services/ssh-key-manager.ts +3 -2
- package/src/services/terraform-env.ts +62 -0
- package/src/services/update/dep-graph.test.ts +214 -0
- package/src/services/update/dep-graph.ts +215 -0
- package/src/services/update/orchestrator.test.ts +463 -0
- package/src/services/update/orchestrator.ts +359 -0
- package/src/services/update/progress.ts +49 -0
- package/src/services/update/self-update.test.ts +68 -0
- package/src/services/update/self-update.ts +57 -0
- package/src/services/update/types.ts +94 -0
- package/src/templates/generator.test.ts +1 -1
- package/src/templates/generator.ts +42 -1
- package/src/test-utils/completion-harness.test.ts +1 -1
- package/src/test-utils/completion-harness.ts +4 -4
- package/src/variables/capability-self-ref.test.ts +203 -0
- package/src/variables/context.ts +49 -1
- package/src/variables/declarative-derivation.test.ts +306 -0
- package/src/variables/declarative-derivation.ts +4 -2
- package/src/variables/parser.test.ts +56 -1
- package/src/variables/parser.ts +47 -6
- package/src/variables/resolver.ts +27 -9
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { DriftFinding } from '../../services/audit/types';
|
|
3
|
+
import type { CommandLogEntry } from './audit-state';
|
|
4
|
+
import { formatCommandLogForClipboard, formatFindingForClipboard } from './clipboard';
|
|
5
|
+
|
|
6
|
+
const base: DriftFinding = {
|
|
7
|
+
category: 'module_configs',
|
|
8
|
+
severity: 'drift',
|
|
9
|
+
code: 'cfg_unset',
|
|
10
|
+
subject: 'caddy',
|
|
11
|
+
message: 'config "acme_ca" is unset (default: [])',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe('formatFindingForClipboard', () => {
|
|
15
|
+
test('minimal finding — message + subject/code footer', () => {
|
|
16
|
+
expect(formatFindingForClipboard(base)).toBe(
|
|
17
|
+
[
|
|
18
|
+
'config "acme_ca" is unset (default: [])',
|
|
19
|
+
'',
|
|
20
|
+
'caddy · cfg_unset (module_configs/drift)',
|
|
21
|
+
].join('\n'),
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('includes details and remediation when present', () => {
|
|
26
|
+
const out = formatFindingForClipboard({
|
|
27
|
+
...base,
|
|
28
|
+
details: 'User-configured ACME CA URL',
|
|
29
|
+
remediation: 'celilo module config set caddy acme_ca <value>',
|
|
30
|
+
});
|
|
31
|
+
expect(out).toContain('User-configured ACME CA URL');
|
|
32
|
+
expect(out).toContain('→ celilo module config set caddy acme_ca <value>');
|
|
33
|
+
expect(out).toContain('caddy · cfg_unset');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('strips ANSI escape codes from details', () => {
|
|
37
|
+
const out = formatFindingForClipboard({
|
|
38
|
+
...base,
|
|
39
|
+
details: '\x1b[34mError:\x1b[39m something went wrong',
|
|
40
|
+
});
|
|
41
|
+
expect(out).toContain('Error: something went wrong');
|
|
42
|
+
expect(out).not.toContain('\x1b');
|
|
43
|
+
expect(out).not.toContain('[34m');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('formatCommandLogForClipboard', () => {
|
|
48
|
+
function entry(over: Partial<CommandLogEntry> = {}): CommandLogEntry {
|
|
49
|
+
return {
|
|
50
|
+
startedAt: new Date('2026-04-25T12:00:00Z').getTime(),
|
|
51
|
+
cmd: 'celilo module deploy caddy',
|
|
52
|
+
lines: [],
|
|
53
|
+
exitCode: 0,
|
|
54
|
+
...over,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test('strips ANSI escape codes from streamed output', () => {
|
|
59
|
+
const out = formatCommandLogForClipboard([
|
|
60
|
+
entry({
|
|
61
|
+
lines: [
|
|
62
|
+
{ stream: 'stdout', text: '\x1b[90m│ \x1b[39m' },
|
|
63
|
+
{ stream: 'stdout', text: '\x1b[34m●\x1b[39m Deploying module: caddy' },
|
|
64
|
+
],
|
|
65
|
+
}),
|
|
66
|
+
]);
|
|
67
|
+
expect(out).not.toContain('\x1b');
|
|
68
|
+
expect(out).not.toContain('[34m');
|
|
69
|
+
expect(out).not.toContain('[39m');
|
|
70
|
+
expect(out).toContain('● Deploying module: caddy');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('marks stderr lines with [err] prefix', () => {
|
|
74
|
+
const out = formatCommandLogForClipboard([
|
|
75
|
+
entry({
|
|
76
|
+
lines: [
|
|
77
|
+
{ stream: 'stdout', text: 'normal output' },
|
|
78
|
+
{ stream: 'stderr', text: 'oh no' },
|
|
79
|
+
],
|
|
80
|
+
}),
|
|
81
|
+
]);
|
|
82
|
+
expect(out).toContain('normal output');
|
|
83
|
+
expect(out).toContain('[err] oh no');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('shows running… for an in-flight entry, exit N for finished', () => {
|
|
87
|
+
const out = formatCommandLogForClipboard([
|
|
88
|
+
entry({ exitCode: null, lines: [{ stream: 'stdout', text: 'working' }] }),
|
|
89
|
+
entry({ startedAt: 2, exitCode: 1, lines: [{ stream: 'stderr', text: 'failed' }] }),
|
|
90
|
+
]);
|
|
91
|
+
expect(out).toContain('running…');
|
|
92
|
+
expect(out).toContain('exit 1');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clipboard via OSC 52.
|
|
3
|
+
*
|
|
4
|
+
* OSC 52 (`\x1b]52;c;<base64>\x07`) is an ANSI sequence that asks
|
|
5
|
+
* the terminal emulator to set the system clipboard. It works in
|
|
6
|
+
* iTerm2, kitty, Alacritty, WezTerm, modern Windows Terminal, and
|
|
7
|
+
* tunnels naturally over SSH (the local terminal does the work, so
|
|
8
|
+
* we don't need a remote helper). macOS Terminal.app needs the
|
|
9
|
+
* "Allow applications on this Mac to access the clipboard" toggle.
|
|
10
|
+
*
|
|
11
|
+
* `c` selects the system clipboard (vs. `p` for primary selection
|
|
12
|
+
* on X11). BEL (`\x07`) terminates the sequence — works in every
|
|
13
|
+
* common terminal; some also accept ST (`\x1b\\`).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { DriftFinding } from '../../services/audit/types';
|
|
17
|
+
import type { CommandLogEntry } from './audit-state';
|
|
18
|
+
|
|
19
|
+
/** Base64-encode UTF-8 input — Node/Bun's Buffer is the simplest path. */
|
|
20
|
+
function base64(text: string): string {
|
|
21
|
+
return Buffer.from(text, 'utf8').toString('base64');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Strip ANSI escape sequences (CSI color/style codes, OSC strings,
|
|
26
|
+
* single-char escapes). Module output streamed into the command log
|
|
27
|
+
* keeps its colors so the in-TUI render stays pretty, but pasting
|
|
28
|
+
* those into a chat or issue tracker is just noise.
|
|
29
|
+
*
|
|
30
|
+
* Built from a string at runtime so biome's "control character in
|
|
31
|
+
* regex" lint doesn't fire on a literal `\x1b` in source.
|
|
32
|
+
*/
|
|
33
|
+
const ESC = '\x1b';
|
|
34
|
+
const ANSI_PATTERN = new RegExp(
|
|
35
|
+
// CSI: ESC [ ... letter, e.g. \x1b[34m, \x1b[2;3H
|
|
36
|
+
`${ESC}\\[[0-9;?]*[A-Za-z]|` +
|
|
37
|
+
// OSC: ESC ] ... BEL or ST
|
|
38
|
+
`${ESC}\\][^${ESC}\\x07]*(?:${ESC}\\\\|\\x07)|` +
|
|
39
|
+
// Single-char escapes
|
|
40
|
+
`${ESC}[@-Z\\\\-_]`,
|
|
41
|
+
'g',
|
|
42
|
+
);
|
|
43
|
+
function stripAnsi(text: string): string {
|
|
44
|
+
return text.replace(ANSI_PATTERN, '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Copy `text` to the system clipboard via OSC 52.
|
|
49
|
+
*
|
|
50
|
+
* Writes directly to stdout — Ink's renderer doesn't mind extra
|
|
51
|
+
* escape sequences interleaved between its frames. The terminal
|
|
52
|
+
* intercepts OSC 52 before it reaches the Ink output.
|
|
53
|
+
*/
|
|
54
|
+
export function copyToClipboard(text: string): void {
|
|
55
|
+
const seq = `\x1b]52;c;${base64(text)}\x07`;
|
|
56
|
+
process.stdout.write(seq);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Format a finding as plain text suitable for pasting into a chat,
|
|
61
|
+
* issue tracker, or notes app. Mirrors the Detail pane's structure.
|
|
62
|
+
* Strips ANSI codes from any field that might carry them (terraform
|
|
63
|
+
* stderr in `details`, etc.) so the paste is clean text.
|
|
64
|
+
*/
|
|
65
|
+
export function formatFindingForClipboard(f: DriftFinding): string {
|
|
66
|
+
const lines: string[] = [stripAnsi(f.message)];
|
|
67
|
+
if (f.details) {
|
|
68
|
+
lines.push('', stripAnsi(f.details));
|
|
69
|
+
}
|
|
70
|
+
if (f.remediation) {
|
|
71
|
+
lines.push('', `→ ${stripAnsi(f.remediation)}`);
|
|
72
|
+
}
|
|
73
|
+
lines.push('', `${f.subject} · ${f.code} (${f.category}/${f.severity})`);
|
|
74
|
+
return lines.join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Format the entire command log as plain text suitable for pasting.
|
|
79
|
+
* Mirrors the Command-log pane's rendering — separator headers,
|
|
80
|
+
* stdout/stderr lines (`[err]` prefix on stderr to disambiguate
|
|
81
|
+
* once colors are gone), and exit-code footers. ANSI escape codes
|
|
82
|
+
* in streamed module output are stripped — they're useful in the
|
|
83
|
+
* TUI but render as garbage when pasted elsewhere.
|
|
84
|
+
*/
|
|
85
|
+
export function formatCommandLogForClipboard(log: CommandLogEntry[]): string {
|
|
86
|
+
if (log.length === 0) return '(empty)';
|
|
87
|
+
const lines: string[] = [];
|
|
88
|
+
for (const entry of log) {
|
|
89
|
+
const time = new Date(entry.startedAt).toISOString();
|
|
90
|
+
lines.push(`─── ${time} · ${entry.cmd} ───`);
|
|
91
|
+
for (const ln of entry.lines) {
|
|
92
|
+
const clean = stripAnsi(ln.text);
|
|
93
|
+
lines.push(ln.stream === 'stderr' ? `[err] ${clean}` : clean);
|
|
94
|
+
}
|
|
95
|
+
lines.push(entry.exitCode === null ? 'running…' : `exit ${entry.exitCode}`);
|
|
96
|
+
lines.push('');
|
|
97
|
+
}
|
|
98
|
+
// Drop the trailing blank line.
|
|
99
|
+
if (lines[lines.length - 1] === '') lines.pop();
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AuditVerdict, DriftSeverity } from '../../services/audit/types';
|
|
2
|
+
|
|
3
|
+
export interface SeverityVisual {
|
|
4
|
+
icon: string;
|
|
5
|
+
color: 'red' | 'yellow' | 'green' | 'gray';
|
|
6
|
+
label: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const SEVERITY_VISUALS: Record<DriftSeverity, SeverityVisual> = {
|
|
10
|
+
blocked: { icon: '×', color: 'red', label: 'BLOCKED' },
|
|
11
|
+
drift: { icon: '▲', color: 'yellow', label: 'DRIFT' },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const VERDICT_VISUALS: Record<AuditVerdict, SeverityVisual> = {
|
|
15
|
+
BLOCKED: { icon: '×', color: 'red', label: 'BLOCKED' },
|
|
16
|
+
DRIFT: { icon: '▲', color: 'yellow', label: 'DRIFT' },
|
|
17
|
+
READY: { icon: '✓', color: 'green', label: 'READY' },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function severityRank(s: DriftSeverity): number {
|
|
21
|
+
return s === 'blocked' ? 0 : 1;
|
|
22
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import type { PaneId } from './audit-state';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
focused: PaneId;
|
|
6
|
+
modal: 'remediate' | 'reaudit-prompt' | 'celebration' | 'analyzing' | null;
|
|
7
|
+
/** Transient confirmation that takes over the left side when set. */
|
|
8
|
+
flashMessage?: string | null;
|
|
9
|
+
/**
|
|
10
|
+
* Whether the currently-selected finding has a runnable
|
|
11
|
+
* remediation. Drives whether "Remediate: r" appears in the
|
|
12
|
+
* findings/detail bindings — surfacing it on a non-actionable
|
|
13
|
+
* finding (e.g. capability_abi) misleads the user into thinking
|
|
14
|
+
* `r` will fix it.
|
|
15
|
+
*/
|
|
16
|
+
findingActionable?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function findingsBar(actionable: boolean): string {
|
|
20
|
+
return actionable
|
|
21
|
+
? 'Focus: <enter> · Remediate: r · Back: <esc>'
|
|
22
|
+
: 'Focus: <enter> · Back: <esc>';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function detailBar(actionable: boolean): string {
|
|
26
|
+
return actionable ? 'Remediate: r · Copy: y · Back: <esc>' : 'Copy: y · Back: <esc>';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function KeyBar({ focused, modal, flashMessage, findingActionable = false }: Props) {
|
|
30
|
+
let left: string;
|
|
31
|
+
if (flashMessage) {
|
|
32
|
+
left = flashMessage;
|
|
33
|
+
} else if (modal === 'remediate') {
|
|
34
|
+
left = 'Run: <enter> · Cancel: <esc>';
|
|
35
|
+
} else if (modal === 'reaudit-prompt') {
|
|
36
|
+
left = 'Yes: Y/<enter> · No: n/<esc>';
|
|
37
|
+
} else if (modal === 'celebration') {
|
|
38
|
+
left = 'Re-audit: R · Quit: q · Dismiss: <esc>';
|
|
39
|
+
} else if (modal === 'analyzing') {
|
|
40
|
+
left = 'Analyzing system… (q to quit)';
|
|
41
|
+
} else if (focused === 'findings') {
|
|
42
|
+
left = findingsBar(findingActionable);
|
|
43
|
+
} else if (focused === 'detail') {
|
|
44
|
+
left = detailBar(findingActionable);
|
|
45
|
+
} else if (focused === 'categories') {
|
|
46
|
+
left = 'Focus: <enter>';
|
|
47
|
+
} else if (focused === 'log') {
|
|
48
|
+
left = 'Scroll: ↑↓ · Copy: y · Back: <esc>';
|
|
49
|
+
} else {
|
|
50
|
+
left = '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// width="100%" + justifyContent="space-between" pins the two
|
|
54
|
+
// groups to opposite edges; without an explicit width the flexbox
|
|
55
|
+
// shrinks to content and the items end up adjacent.
|
|
56
|
+
// Right side hosts globals: re-audit + help + quit. They're
|
|
57
|
+
// always available regardless of focused pane / modal state, so
|
|
58
|
+
// the user can discover them anywhere.
|
|
59
|
+
return (
|
|
60
|
+
<Box width="100%" justifyContent="space-between" paddingX={1}>
|
|
61
|
+
<Text color={flashMessage ? 'green' : undefined}>{left}</Text>
|
|
62
|
+
<Text dimColor>R re-audit · t theme · ? help · q quit</Text>
|
|
63
|
+
</Box>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { Key } from 'ink';
|
|
3
|
+
import { handleKey } from './keymap';
|
|
4
|
+
|
|
5
|
+
function key(over: Partial<Key> = {}): Key {
|
|
6
|
+
return {
|
|
7
|
+
upArrow: false,
|
|
8
|
+
downArrow: false,
|
|
9
|
+
leftArrow: false,
|
|
10
|
+
rightArrow: false,
|
|
11
|
+
pageDown: false,
|
|
12
|
+
pageUp: false,
|
|
13
|
+
return: false,
|
|
14
|
+
escape: false,
|
|
15
|
+
ctrl: false,
|
|
16
|
+
shift: false,
|
|
17
|
+
tab: false,
|
|
18
|
+
backspace: false,
|
|
19
|
+
delete: false,
|
|
20
|
+
meta: false,
|
|
21
|
+
...over,
|
|
22
|
+
} as Key;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('handleKey — quit', () => {
|
|
26
|
+
test('q quits', () => {
|
|
27
|
+
expect(handleKey('q', key(), 'categories').signal).toBe('quit');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('Ctrl-C quits', () => {
|
|
31
|
+
expect(handleKey('c', key({ ctrl: true }), 'categories').signal).toBe('quit');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('Esc on summary quits', () => {
|
|
35
|
+
expect(handleKey('', key({ escape: true }), 'summary').signal).toBe('quit');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('Esc on categories quits', () => {
|
|
39
|
+
expect(handleKey('', key({ escape: true }), 'categories').signal).toBe('quit');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('Esc on findings dispatches escape (not quit)', () => {
|
|
43
|
+
const r = handleKey('', key({ escape: true }), 'findings');
|
|
44
|
+
expect(r.signal).toBeUndefined();
|
|
45
|
+
expect(r.actions).toEqual([{ type: 'escape' }]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('handleKey — pane jump', () => {
|
|
50
|
+
test.each(['1', '2', '3', '4', '5'] as const)('%s focuses corresponding pane', (n) => {
|
|
51
|
+
const r = handleKey(n, key(), 'summary');
|
|
52
|
+
expect(r.actions).toHaveLength(1);
|
|
53
|
+
expect(r.actions[0].type).toBe('focus');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('handleKey — movement', () => {
|
|
58
|
+
test('up arrow → move -1', () => {
|
|
59
|
+
expect(handleKey('', key({ upArrow: true }), 'categories').actions).toEqual([
|
|
60
|
+
{ type: 'move', direction: -1 },
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('k → move -1', () => {
|
|
65
|
+
expect(handleKey('k', key(), 'categories').actions).toEqual([{ type: 'move', direction: -1 }]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('j → move +1', () => {
|
|
69
|
+
expect(handleKey('j', key(), 'categories').actions).toEqual([{ type: 'move', direction: 1 }]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('handleKey — Enter / Tab', () => {
|
|
74
|
+
test('Enter dispatches enter', () => {
|
|
75
|
+
expect(handleKey('', key({ return: true }), 'categories').actions).toEqual([{ type: 'enter' }]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('Tab dispatches cycle +1', () => {
|
|
79
|
+
expect(handleKey('', key({ tab: true }), 'categories').actions).toEqual([
|
|
80
|
+
{ type: 'cycle', direction: 1 },
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('Shift-Tab dispatches cycle -1', () => {
|
|
85
|
+
expect(handleKey('', key({ tab: true, shift: true }), 'categories').actions).toEqual([
|
|
86
|
+
{ type: 'cycle', direction: -1 },
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('handleKey — Phase 3 hooks', () => {
|
|
92
|
+
test('r signals remediate', () => {
|
|
93
|
+
expect(handleKey('r', key(), 'findings').signal).toBe('remediate');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('R signals reaudit', () => {
|
|
97
|
+
expect(handleKey('R', key(), 'summary').signal).toBe('reaudit');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('handleKey — help', () => {
|
|
102
|
+
test('? toggles help', () => {
|
|
103
|
+
expect(handleKey('?', key(), 'summary').actions).toEqual([{ type: 'toggle-help' }]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Key } from 'ink';
|
|
2
|
+
import type { AuditTuiAction, PaneId } from './audit-state';
|
|
3
|
+
import { PANE_BY_NUMBER } from './audit-state';
|
|
4
|
+
|
|
5
|
+
export interface KeymapResult {
|
|
6
|
+
actions: AuditTuiAction[];
|
|
7
|
+
/** App-level signals the reducer can't express. */
|
|
8
|
+
signal?: 'quit' | 'help' | 'remediate' | 'reaudit' | 'copy' | 'toggle-theme';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Translate one keypress (input string + ink Key flags) into reducer
|
|
13
|
+
* actions and/or app-level signals. Pure function — no side effects,
|
|
14
|
+
* no React.
|
|
15
|
+
*
|
|
16
|
+
* Ink's `useInput(handler)` calls `handler(input, key)` for every
|
|
17
|
+
* keypress; this function is what `handler` delegates to.
|
|
18
|
+
*/
|
|
19
|
+
export function handleKey(input: string, key: Key, focused: PaneId): KeymapResult {
|
|
20
|
+
// Quit
|
|
21
|
+
if (key.ctrl && input === 'c') return { actions: [], signal: 'quit' };
|
|
22
|
+
if (input === 'q' && !key.ctrl && !key.meta) return { actions: [], signal: 'quit' };
|
|
23
|
+
|
|
24
|
+
// Help overlay
|
|
25
|
+
if (input === '?') return { actions: [{ type: 'toggle-help' }] };
|
|
26
|
+
|
|
27
|
+
// Pane jump (1-5)
|
|
28
|
+
if (input === '1' || input === '2' || input === '3' || input === '4' || input === '5') {
|
|
29
|
+
return { actions: [{ type: 'focus', pane: PANE_BY_NUMBER[input] }] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Tab / Shift-Tab: cycle focus
|
|
33
|
+
if (key.tab) {
|
|
34
|
+
return { actions: [{ type: 'cycle', direction: key.shift ? -1 : 1 }] };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Movement: arrows + j/k
|
|
38
|
+
if (key.upArrow || (input === 'k' && !key.ctrl)) {
|
|
39
|
+
return { actions: [{ type: 'move', direction: -1 }] };
|
|
40
|
+
}
|
|
41
|
+
if (key.downArrow || (input === 'j' && !key.ctrl)) {
|
|
42
|
+
return { actions: [{ type: 'move', direction: 1 }] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Enter: drill in
|
|
46
|
+
if (key.return) {
|
|
47
|
+
return { actions: [{ type: 'enter' }] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Escape: step back; on top-of-chain panes, signal quit
|
|
51
|
+
if (key.escape) {
|
|
52
|
+
if (focused === 'summary' || focused === 'categories') {
|
|
53
|
+
return { actions: [], signal: 'quit' };
|
|
54
|
+
}
|
|
55
|
+
return { actions: [{ type: 'escape' }] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Action hotkeys
|
|
59
|
+
if (input === 'r' && !key.shift) return { actions: [], signal: 'remediate' };
|
|
60
|
+
if (input === 'R' || (input === 'r' && key.shift)) return { actions: [], signal: 'reaudit' };
|
|
61
|
+
// Yank — clipboard copy. Parent decides what to copy based on the
|
|
62
|
+
// focused pane (detail content, command log, etc.).
|
|
63
|
+
if (input === 'y' && !key.shift) return { actions: [], signal: 'copy' };
|
|
64
|
+
// Theme toggle. Routed as a signal so the parent can also flash a
|
|
65
|
+
// confirmation in the keybar — the user otherwise has no visual cue
|
|
66
|
+
// that anything happened (theme currently only drives modal colors).
|
|
67
|
+
if (input === 't' && !key.shift) return { actions: [], signal: 'toggle-theme' };
|
|
68
|
+
|
|
69
|
+
return { actions: [] };
|
|
70
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import Spinner from 'ink-spinner';
|
|
3
|
+
import { ALL_CATEGORIES, CATEGORY_LABELS, type CategoryStatus } from '../audit-state';
|
|
4
|
+
import type { Theme } from '../theme';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
progress: Record<string, CategoryStatus>;
|
|
8
|
+
theme: Theme;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Right-padded label so all the verdict icons line up vertically
|
|
13
|
+
* regardless of category-name length. 21 = longest label
|
|
14
|
+
* ("Unconfigured modules") + a couple of cells of cushion.
|
|
15
|
+
*/
|
|
16
|
+
const LABEL_WIDTH = 22;
|
|
17
|
+
|
|
18
|
+
function StatusRow({ category, status }: { category: string; status: CategoryStatus }) {
|
|
19
|
+
const label = CATEGORY_LABELS[category as keyof typeof CATEGORY_LABELS] ?? category;
|
|
20
|
+
const padded = label.padEnd(LABEL_WIDTH, ' ');
|
|
21
|
+
|
|
22
|
+
if (status === 'pending') {
|
|
23
|
+
return <Text dimColor>· {padded} pending</Text>;
|
|
24
|
+
}
|
|
25
|
+
if (status === 'running') {
|
|
26
|
+
return (
|
|
27
|
+
<Text>
|
|
28
|
+
<Spinner type="dots" />
|
|
29
|
+
<Text> </Text>
|
|
30
|
+
<Text>{padded}</Text>
|
|
31
|
+
<Text dimColor>checking…</Text>
|
|
32
|
+
</Text>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
// done
|
|
36
|
+
if (status.verdict === 'clean') {
|
|
37
|
+
return <Text color="green">✓ {padded}clean</Text>;
|
|
38
|
+
}
|
|
39
|
+
if (status.verdict === 'drift') {
|
|
40
|
+
return <Text color="yellow">▲ {padded}drift</Text>;
|
|
41
|
+
}
|
|
42
|
+
return <Text color="red">× {padded}blocked</Text>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pops at the start of every audit run — one fuel-gauge per
|
|
47
|
+
* category. Auto-dismisses (via the reducer) when every category
|
|
48
|
+
* reports done. Non-interactive: nothing for the user to do here
|
|
49
|
+
* besides wait. Ctrl-C / q still quit at the AuditTui level.
|
|
50
|
+
*/
|
|
51
|
+
export function AnalyzingModal({ progress, theme }: Props) {
|
|
52
|
+
return (
|
|
53
|
+
<Box
|
|
54
|
+
borderStyle="round"
|
|
55
|
+
borderColor="cyan"
|
|
56
|
+
backgroundColor={theme.dialogBg}
|
|
57
|
+
flexDirection="column"
|
|
58
|
+
paddingX={3}
|
|
59
|
+
paddingY={1}
|
|
60
|
+
width={56}
|
|
61
|
+
>
|
|
62
|
+
<Text color={theme.dialogFg} bold>
|
|
63
|
+
Analyzing system…
|
|
64
|
+
</Text>
|
|
65
|
+
<Box marginTop={1} flexDirection="column">
|
|
66
|
+
{ALL_CATEGORIES.map((c) => (
|
|
67
|
+
<StatusRow key={c} category={c} status={progress[c] ?? 'pending'} />
|
|
68
|
+
))}
|
|
69
|
+
</Box>
|
|
70
|
+
<Box marginTop={1}>
|
|
71
|
+
<Text dimColor>q to quit</Text>
|
|
72
|
+
</Box>
|
|
73
|
+
</Box>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import type { Theme } from '../theme';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
theme: Theme;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Pops once an audit completes with zero findings — a moment of
|
|
10
|
+
* actual closure for the user, not a drab "(no findings)". Two
|
|
11
|
+
* options surface explicitly: re-audit (`R`) or quit (`q`); Esc
|
|
12
|
+
* dismisses to the panes for browsing.
|
|
13
|
+
*/
|
|
14
|
+
export function CelebrationModal({ theme }: Props) {
|
|
15
|
+
return (
|
|
16
|
+
<Box
|
|
17
|
+
borderStyle="round"
|
|
18
|
+
borderColor="green"
|
|
19
|
+
backgroundColor={theme.dialogBg}
|
|
20
|
+
flexDirection="column"
|
|
21
|
+
paddingX={3}
|
|
22
|
+
paddingY={1}
|
|
23
|
+
width={56}
|
|
24
|
+
>
|
|
25
|
+
<Text color="green" bold>
|
|
26
|
+
🎉 Everything is up-to-date in your system!
|
|
27
|
+
</Text>
|
|
28
|
+
<Box marginTop={1}>
|
|
29
|
+
<Text color={theme.dialogFg}>No drift detected across any audit category.</Text>
|
|
30
|
+
</Box>
|
|
31
|
+
<Box marginTop={1} flexDirection="column">
|
|
32
|
+
<Text color={theme.dialogFg}>
|
|
33
|
+
<Text color="cyan">R</Text> Re-audit
|
|
34
|
+
</Text>
|
|
35
|
+
<Text color={theme.dialogFg}>
|
|
36
|
+
<Text color="cyan">q</Text> Quit
|
|
37
|
+
</Text>
|
|
38
|
+
</Box>
|
|
39
|
+
<Box marginTop={1}>
|
|
40
|
+
<Text dimColor>Esc to dismiss</Text>
|
|
41
|
+
</Box>
|
|
42
|
+
</Box>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import type { Theme } from '../theme';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
theme: Theme;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Confirmation prompt shown after a remediation command exits 0.
|
|
10
|
+
* The TUI's input handler watches for Y/Enter (yes) and n/Esc (no);
|
|
11
|
+
* this component is purely presentational.
|
|
12
|
+
*/
|
|
13
|
+
export function ReauditPromptModal({ theme }: Props) {
|
|
14
|
+
return (
|
|
15
|
+
<Box
|
|
16
|
+
borderStyle="round"
|
|
17
|
+
borderColor="cyan"
|
|
18
|
+
backgroundColor={theme.dialogBg}
|
|
19
|
+
flexDirection="column"
|
|
20
|
+
paddingX={2}
|
|
21
|
+
paddingY={1}
|
|
22
|
+
width={60}
|
|
23
|
+
>
|
|
24
|
+
<Text color={theme.dialogFg} bold>
|
|
25
|
+
Audit may be stale
|
|
26
|
+
</Text>
|
|
27
|
+
<Box marginTop={1}>
|
|
28
|
+
<Text color={theme.dialogFg}>Re-run audit now?</Text>
|
|
29
|
+
</Box>
|
|
30
|
+
<Box marginTop={1}>
|
|
31
|
+
<Text color={theme.dialogFg}>Yes: Y/Enter · No: n/Esc</Text>
|
|
32
|
+
</Box>
|
|
33
|
+
</Box>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import TextInput from 'ink-text-input';
|
|
3
|
+
import type { Theme } from '../theme';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
command: string;
|
|
7
|
+
theme: Theme;
|
|
8
|
+
onChange: (value: string) => void;
|
|
9
|
+
onSubmit: (value: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Single-line input modal pre-filled with a finding's remediation
|
|
14
|
+
* command. Cursor lands on the first `<placeholder>` if one is
|
|
15
|
+
* present (resolved by the parent before `command` reaches us — we
|
|
16
|
+
* just render whatever we're given). Enter submits, Esc cancels at
|
|
17
|
+
* the parent level via the keymap.
|
|
18
|
+
*/
|
|
19
|
+
export function RemediateModal({ command, theme, onChange, onSubmit }: Props) {
|
|
20
|
+
return (
|
|
21
|
+
<Box
|
|
22
|
+
borderStyle="round"
|
|
23
|
+
borderColor="cyan"
|
|
24
|
+
backgroundColor={theme.dialogBg}
|
|
25
|
+
flexDirection="column"
|
|
26
|
+
paddingX={2}
|
|
27
|
+
paddingY={1}
|
|
28
|
+
width={80}
|
|
29
|
+
>
|
|
30
|
+
<Text color={theme.dialogFg} bold>
|
|
31
|
+
Remediate
|
|
32
|
+
</Text>
|
|
33
|
+
<Box marginTop={1}>
|
|
34
|
+
<Text color={theme.dialogFg}>
|
|
35
|
+
Edit the command, then press Enter to run (Esc to cancel).
|
|
36
|
+
</Text>
|
|
37
|
+
</Box>
|
|
38
|
+
<Box marginTop={1}>
|
|
39
|
+
<Text color="cyan">$ </Text>
|
|
40
|
+
<TextInput value={command} onChange={onChange} onSubmit={onSubmit} />
|
|
41
|
+
</Box>
|
|
42
|
+
</Box>
|
|
43
|
+
);
|
|
44
|
+
}
|