@celilo/cli 0.1.4 → 0.1.6
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 +9 -8
- package/src/ansible/inventory.ts +9 -7
- package/src/capabilities/public-web-helpers.test.ts +2 -2
- package/src/capabilities/public-web-publish.test.ts +45 -12
- package/src/capabilities/registration.test.ts +6 -6
- package/src/capabilities/well-known.test.ts +2 -2
- package/src/capabilities/well-known.ts +5 -5
- 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-show.ts +1 -1
- 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.test.ts +3 -3
- package/src/db/schema.ts +26 -17
- package/src/hooks/capability-loader.ts +135 -72
- package/src/hooks/define-hook.test.ts +11 -3
- 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/manifest/template-validator.test.ts +1 -1
- package/src/manifest/template-validator.ts +1 -1
- package/src/manifest/validate.test.ts +1 -1
- package/src/module/import.ts +20 -12
- package/src/module/packaging/build.ts +121 -25
- 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-planner.ts +5 -5
- 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/dns-auto-register.ts +4 -4
- package/src/services/ensure-interview.test.ts +245 -0
- package/src/services/health-runner.ts +110 -38
- package/src/services/infrastructure-variable-resolver.test.ts +1 -1
- package/src/services/infrastructure-variable-resolver.ts +3 -3
- package/src/services/module-build.ts +11 -13
- package/src/services/module-deploy.ts +372 -61
- package/src/services/proxmox-state-recovery.ts +6 -6
- 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 +3 -3
- package/src/templates/generator.ts +43 -2
- 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.test.ts +31 -31
- package/src/variables/context.ts +65 -17
- package/src/variables/declarative-derivation.test.ts +306 -0
- package/src/variables/declarative-derivation.ts +4 -2
- package/src/variables/parser.test.ts +64 -9
- package/src/variables/parser.ts +47 -6
- package/src/variables/resolver.test.ts +14 -14
- package/src/variables/resolver.ts +27 -9
- package/src/variables/types.ts +1 -1
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { DriftFinding, SystemAuditReport } from '../../services/audit/types';
|
|
3
|
+
import { COMMAND_LOG_MAX_LINES, initState, reducer } from './audit-state';
|
|
4
|
+
|
|
5
|
+
function finding(over: Partial<DriftFinding>): DriftFinding {
|
|
6
|
+
return {
|
|
7
|
+
category: 'module_configs',
|
|
8
|
+
severity: 'drift',
|
|
9
|
+
code: 'cfg_unset',
|
|
10
|
+
subject: 'caddy',
|
|
11
|
+
message: 'config "acme_ca" is unset',
|
|
12
|
+
remediation: 'celilo module config set caddy acme_ca <value>',
|
|
13
|
+
...over,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function report(findings: DriftFinding[]): SystemAuditReport {
|
|
18
|
+
return {
|
|
19
|
+
version: 1,
|
|
20
|
+
verdict: findings.length > 0 ? 'DRIFT' : 'READY',
|
|
21
|
+
generatedAt: '2026-04-25T12:00:00.000Z',
|
|
22
|
+
findings,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('modal lifecycle', () => {
|
|
27
|
+
test('open-remediate stores findingId + command', () => {
|
|
28
|
+
const s = initState(report([finding({ code: 'a' })]));
|
|
29
|
+
const next = reducer(s, {
|
|
30
|
+
type: 'open-remediate',
|
|
31
|
+
findingId: 'caddy/a',
|
|
32
|
+
command: 'celilo module config set caddy acme_ca <value>',
|
|
33
|
+
});
|
|
34
|
+
expect(next.modal).toEqual({
|
|
35
|
+
kind: 'remediate',
|
|
36
|
+
findingId: 'caddy/a',
|
|
37
|
+
command: 'celilo module config set caddy acme_ca <value>',
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('set-modal-command updates the command in the modal', () => {
|
|
42
|
+
const s = initState(report([finding({ code: 'a' })]));
|
|
43
|
+
let st = reducer(s, {
|
|
44
|
+
type: 'open-remediate',
|
|
45
|
+
findingId: 'caddy/a',
|
|
46
|
+
command: 'celilo x',
|
|
47
|
+
});
|
|
48
|
+
st = reducer(st, { type: 'set-modal-command', command: 'celilo y' });
|
|
49
|
+
expect(st.modal).toMatchObject({ kind: 'remediate', command: 'celilo y' });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('set-modal-command is a no-op when no remediate modal is open', () => {
|
|
53
|
+
const s = initState(report([finding({ code: 'a' })]));
|
|
54
|
+
const next = reducer(s, { type: 'set-modal-command', command: 'noop' });
|
|
55
|
+
expect(next.modal).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('open-reaudit-prompt switches modal', () => {
|
|
59
|
+
const s = initState(report([finding({ code: 'a' })]));
|
|
60
|
+
const opened = reducer(s, {
|
|
61
|
+
type: 'open-remediate',
|
|
62
|
+
findingId: 'caddy/a',
|
|
63
|
+
command: 'celilo x',
|
|
64
|
+
});
|
|
65
|
+
const next = reducer(opened, { type: 'open-reaudit-prompt' });
|
|
66
|
+
expect(next.modal).toEqual({ kind: 'reaudit-prompt' });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('close-modal clears the modal', () => {
|
|
70
|
+
const s = initState(report([finding({ code: 'a' })]));
|
|
71
|
+
const opened = reducer(s, {
|
|
72
|
+
type: 'open-remediate',
|
|
73
|
+
findingId: 'caddy/a',
|
|
74
|
+
command: 'celilo x',
|
|
75
|
+
});
|
|
76
|
+
const next = reducer(opened, { type: 'close-modal' });
|
|
77
|
+
expect(next.modal).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('command log lifecycle', () => {
|
|
82
|
+
test('log-start appends a running entry', () => {
|
|
83
|
+
const s = initState(report([]));
|
|
84
|
+
const next = reducer(s, { type: 'log-start', startedAt: 1000, cmd: 'celilo x' });
|
|
85
|
+
expect(next.commandLog).toHaveLength(1);
|
|
86
|
+
expect(next.commandLog[0]).toEqual({
|
|
87
|
+
startedAt: 1000,
|
|
88
|
+
cmd: 'celilo x',
|
|
89
|
+
lines: [],
|
|
90
|
+
exitCode: null,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('log-line appends a line to the matching entry', () => {
|
|
95
|
+
let st = initState(report([]));
|
|
96
|
+
st = reducer(st, { type: 'log-start', startedAt: 1000, cmd: 'celilo x' });
|
|
97
|
+
st = reducer(st, { type: 'log-line', startedAt: 1000, stream: 'stdout', text: 'hello' });
|
|
98
|
+
st = reducer(st, { type: 'log-line', startedAt: 1000, stream: 'stderr', text: 'oh no' });
|
|
99
|
+
expect(st.commandLog[0].lines).toEqual([
|
|
100
|
+
{ stream: 'stdout', text: 'hello' },
|
|
101
|
+
{ stream: 'stderr', text: 'oh no' },
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('log-finish sets exit code on matching entry', () => {
|
|
106
|
+
let st = initState(report([]));
|
|
107
|
+
st = reducer(st, { type: 'log-start', startedAt: 1000, cmd: 'celilo x' });
|
|
108
|
+
st = reducer(st, { type: 'log-finish', startedAt: 1000, exitCode: 0 });
|
|
109
|
+
expect(st.commandLog[0].exitCode).toBe(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('log entries with mismatched startedAt are not affected', () => {
|
|
113
|
+
let st = initState(report([]));
|
|
114
|
+
st = reducer(st, { type: 'log-start', startedAt: 1000, cmd: 'a' });
|
|
115
|
+
st = reducer(st, { type: 'log-start', startedAt: 2000, cmd: 'b' });
|
|
116
|
+
st = reducer(st, { type: 'log-line', startedAt: 1000, stream: 'stdout', text: 'hi' });
|
|
117
|
+
expect(st.commandLog[0].lines).toHaveLength(1);
|
|
118
|
+
expect(st.commandLog[1].lines).toHaveLength(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('log evicts oldest entries when total lines exceed cap', () => {
|
|
122
|
+
let st = initState(report([]));
|
|
123
|
+
// First entry — fills almost the whole cap
|
|
124
|
+
st = reducer(st, { type: 'log-start', startedAt: 1, cmd: 'big' });
|
|
125
|
+
for (let i = 0; i < COMMAND_LOG_MAX_LINES - 5; i++) {
|
|
126
|
+
st = reducer(st, { type: 'log-line', startedAt: 1, stream: 'stdout', text: `line ${i}` });
|
|
127
|
+
}
|
|
128
|
+
// Second entry — pushes us over
|
|
129
|
+
st = reducer(st, { type: 'log-start', startedAt: 2, cmd: 'small' });
|
|
130
|
+
for (let i = 0; i < 50; i++) {
|
|
131
|
+
st = reducer(st, { type: 'log-line', startedAt: 2, stream: 'stdout', text: `t ${i}` });
|
|
132
|
+
}
|
|
133
|
+
// Oldest (entry 1) should have been evicted
|
|
134
|
+
expect(st.commandLog).toHaveLength(1);
|
|
135
|
+
expect(st.commandLog[0].cmd).toBe('small');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { paneAt, parseMouseEvent } from './mouse';
|
|
3
|
+
|
|
4
|
+
describe('parseMouseEvent', () => {
|
|
5
|
+
test('parses raw SGR press event', () => {
|
|
6
|
+
expect(parseMouseEvent('\x1b[<0;5;3M')).toEqual({
|
|
7
|
+
button: 0,
|
|
8
|
+
col: 4,
|
|
9
|
+
row: 2,
|
|
10
|
+
pressed: true,
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('parses Ink-relayed form (no leading ESC+CSI)', () => {
|
|
15
|
+
expect(parseMouseEvent('[<2;15;7M')).toEqual({
|
|
16
|
+
button: 2,
|
|
17
|
+
col: 14,
|
|
18
|
+
row: 6,
|
|
19
|
+
pressed: true,
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('press vs release', () => {
|
|
24
|
+
expect(parseMouseEvent('[<0;1;1M')?.pressed).toBe(true);
|
|
25
|
+
expect(parseMouseEvent('[<0;1;1m')?.pressed).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('returns null for non-mouse input', () => {
|
|
29
|
+
expect(parseMouseEvent('q')).toBeNull();
|
|
30
|
+
expect(parseMouseEvent('\x1b[A')).toBeNull(); // arrow key, not mouse
|
|
31
|
+
expect(parseMouseEvent('')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const REGIONS = {
|
|
36
|
+
bodyHeight: 30,
|
|
37
|
+
leftColWidth: 40,
|
|
38
|
+
summaryHeight: 6,
|
|
39
|
+
categoriesHeight: 8,
|
|
40
|
+
detailHeight: 15,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe('paneAt', () => {
|
|
44
|
+
test('top-left of left column → summary', () => {
|
|
45
|
+
expect(paneAt(0, 0, REGIONS)).toEqual({ pane: 'summary', rowInPane: 0 });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('mid-left below summary → categories with row offset', () => {
|
|
49
|
+
expect(paneAt(10, 8, REGIONS)).toEqual({ pane: 'categories', rowInPane: 2 });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('bottom of left column → findings with row offset', () => {
|
|
53
|
+
expect(paneAt(20, 25, REGIONS)).toEqual({
|
|
54
|
+
pane: 'findings',
|
|
55
|
+
rowInPane: 25 - REGIONS.summaryHeight - REGIONS.categoriesHeight,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('right column top → detail', () => {
|
|
60
|
+
expect(paneAt(50, 5, REGIONS)).toEqual({ pane: 'detail', rowInPane: 5 });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('right column bottom → log with offset', () => {
|
|
64
|
+
expect(paneAt(60, 20, REGIONS)).toEqual({
|
|
65
|
+
pane: 'log',
|
|
66
|
+
rowInPane: 20 - REGIONS.detailHeight,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('keybar row (out of body) → null', () => {
|
|
71
|
+
expect(paneAt(10, 30, REGIONS)).toBeNull();
|
|
72
|
+
expect(paneAt(10, 50, REGIONS)).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('negative col returns null', () => {
|
|
76
|
+
expect(paneAt(-1, 5, REGIONS)).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mouse-click support via SGR mouse reporting.
|
|
3
|
+
*
|
|
4
|
+
* SGR mode (`CSI ? 1006 h`) is the modern, well-supported encoding —
|
|
5
|
+
* iTerm2, kitty, Alacritty, WezTerm, modern Windows Terminal all
|
|
6
|
+
* speak it. Format on press: `ESC [ < button ; col ; row M`; on
|
|
7
|
+
* release the trailing M becomes m.
|
|
8
|
+
*
|
|
9
|
+
* Buttons in SGR mode:
|
|
10
|
+
* 0 = left, 1 = middle, 2 = right
|
|
11
|
+
* 64 = wheel up, 65 = wheel down
|
|
12
|
+
* modifiers (shift/alt/ctrl) get OR'd into the button code (4/8/16)
|
|
13
|
+
*
|
|
14
|
+
* `CSI ? 1000 h` enables button-event reporting (presses + releases,
|
|
15
|
+
* no motion). We don't need motion or any-event mode for click-to-
|
|
16
|
+
* focus.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const ESC = '\x1b';
|
|
20
|
+
|
|
21
|
+
export const MOUSE_ON = `${ESC}[?1000h${ESC}[?1006h`;
|
|
22
|
+
export const MOUSE_OFF = `${ESC}[?1000l${ESC}[?1006l`;
|
|
23
|
+
|
|
24
|
+
export interface MouseEvent {
|
|
25
|
+
button: number;
|
|
26
|
+
/** 0-based terminal column. SGR reports 1-based; we normalize. */
|
|
27
|
+
col: number;
|
|
28
|
+
/** 0-based terminal row. */
|
|
29
|
+
row: number;
|
|
30
|
+
pressed: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Match an SGR mouse sequence in a chunk of input. Ink's `useInput`
|
|
35
|
+
* sees mouse events as `key.escape: true` + an `input` like
|
|
36
|
+
* `[<0;5;3M`, so we accept both that form (Ink-relayed) and the raw
|
|
37
|
+
* form including the leading ESC + CSI. Built from a string at
|
|
38
|
+
* runtime so the `\x1b` control byte doesn't trip biome's "control
|
|
39
|
+
* character in regex" lint.
|
|
40
|
+
*/
|
|
41
|
+
const SGR_PATTERN = new RegExp(`^(?:${ESC}\\[|\\[)<(\\d+);(\\d+);(\\d+)([Mm])`);
|
|
42
|
+
|
|
43
|
+
export function parseMouseEvent(input: string): MouseEvent | null {
|
|
44
|
+
const match = input.match(SGR_PATTERN);
|
|
45
|
+
if (!match) return null;
|
|
46
|
+
return {
|
|
47
|
+
button: Number.parseInt(match[1], 10),
|
|
48
|
+
// SGR coordinates are 1-based; our layout math is 0-based.
|
|
49
|
+
col: Number.parseInt(match[2], 10) - 1,
|
|
50
|
+
row: Number.parseInt(match[3], 10) - 1,
|
|
51
|
+
pressed: match[4] === 'M',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ClickRegions {
|
|
56
|
+
/** Width and height of the body (everything above the keybar). */
|
|
57
|
+
bodyHeight: number;
|
|
58
|
+
leftColWidth: number;
|
|
59
|
+
/** rightColWidth is implied by total columns; not needed for hit-testing. */
|
|
60
|
+
summaryHeight: number;
|
|
61
|
+
categoriesHeight: number;
|
|
62
|
+
// findingsHeight is the remainder of the left column.
|
|
63
|
+
detailHeight: number;
|
|
64
|
+
// commandLogHeight is the remainder of the right column.
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type PaneId = 'summary' | 'categories' | 'findings' | 'detail' | 'log';
|
|
68
|
+
|
|
69
|
+
export interface PaneHit {
|
|
70
|
+
pane: PaneId;
|
|
71
|
+
/**
|
|
72
|
+
* 0-based row offset relative to the pane's top edge (border
|
|
73
|
+
* inclusive). Useful for translating a click into a list index in
|
|
74
|
+
* scrollable panes.
|
|
75
|
+
*/
|
|
76
|
+
rowInPane: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Hit-test a click against the pane layout. Returns the pane the
|
|
81
|
+
* click landed in (with the row offset within that pane), or null
|
|
82
|
+
* if the click is outside the body (e.g. keybar row).
|
|
83
|
+
*/
|
|
84
|
+
export function paneAt(col: number, row: number, regions: ClickRegions): PaneHit | null {
|
|
85
|
+
if (row < 0 || row >= regions.bodyHeight) return null;
|
|
86
|
+
if (col < 0) return null;
|
|
87
|
+
|
|
88
|
+
if (col < regions.leftColWidth) {
|
|
89
|
+
if (row < regions.summaryHeight) {
|
|
90
|
+
return { pane: 'summary', rowInPane: row };
|
|
91
|
+
}
|
|
92
|
+
if (row < regions.summaryHeight + regions.categoriesHeight) {
|
|
93
|
+
return { pane: 'categories', rowInPane: row - regions.summaryHeight };
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
pane: 'findings',
|
|
97
|
+
rowInPane: row - regions.summaryHeight - regions.categoriesHeight,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Right column.
|
|
102
|
+
if (row < regions.detailHeight) {
|
|
103
|
+
return { pane: 'detail', rowInPane: row };
|
|
104
|
+
}
|
|
105
|
+
return { pane: 'log', rowInPane: row - regions.detailHeight };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Each pane's "chrome" — non-content rows above the first list item.
|
|
110
|
+
* Categories: top border (1) + title (1) + marginTop spacer (1) = 3.
|
|
111
|
+
* Findings: same. (Bottom border doesn't matter; we only care
|
|
112
|
+
* about the top offset.)
|
|
113
|
+
*/
|
|
114
|
+
export const LIST_PANE_CHROME = 3;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import type { AuditTuiState } from '../audit-state';
|
|
3
|
+
import { InboxZeroCelebration } from '../celebration';
|
|
4
|
+
import { SEVERITY_VISUALS } from '../icons';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
state: AuditTuiState;
|
|
8
|
+
focused: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function CategoriesPane({ state, focused }: Props) {
|
|
12
|
+
const { groups, selectedCategory } = state;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<Box
|
|
16
|
+
borderStyle="round"
|
|
17
|
+
borderColor={focused ? 'cyan' : 'gray'}
|
|
18
|
+
flexDirection="column"
|
|
19
|
+
paddingX={1}
|
|
20
|
+
width="100%"
|
|
21
|
+
height="100%"
|
|
22
|
+
overflow="hidden"
|
|
23
|
+
>
|
|
24
|
+
<Text dimColor>2 Categories</Text>
|
|
25
|
+
<Box flexDirection="column" marginTop={1}>
|
|
26
|
+
{groups.length === 0 ? (
|
|
27
|
+
// Don't celebrate while the audit is still running — the
|
|
28
|
+
// empty report is a placeholder, not a verdict.
|
|
29
|
+
state.auditing ? (
|
|
30
|
+
<Text dimColor>(checking…)</Text>
|
|
31
|
+
) : (
|
|
32
|
+
<InboxZeroCelebration compact />
|
|
33
|
+
)
|
|
34
|
+
) : (
|
|
35
|
+
groups.map((g) => {
|
|
36
|
+
const v = SEVERITY_VISUALS[g.severity];
|
|
37
|
+
const isSelected = focused && g.category === selectedCategory;
|
|
38
|
+
// When selected, render the whole row as a single inverse
|
|
39
|
+
// Text — nested <Text color> children override the parent's
|
|
40
|
+
// inverse styling in Ink's renderer, so we flatten.
|
|
41
|
+
if (isSelected) {
|
|
42
|
+
return (
|
|
43
|
+
<Text key={g.category} inverse wrap="truncate-end">
|
|
44
|
+
{`▸ ${v.icon} ${v.label} · ${g.category} (${g.findings.length})`}
|
|
45
|
+
</Text>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return (
|
|
49
|
+
<Text key={g.category} wrap="truncate-end">
|
|
50
|
+
{' '}
|
|
51
|
+
<Text color={v.color}>
|
|
52
|
+
{v.icon} {v.label}
|
|
53
|
+
</Text>
|
|
54
|
+
{` · ${g.category} (${g.findings.length})`}
|
|
55
|
+
</Text>
|
|
56
|
+
);
|
|
57
|
+
})
|
|
58
|
+
)}
|
|
59
|
+
</Box>
|
|
60
|
+
</Box>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import type { AuditTuiState, CommandLogEntry } from '../audit-state';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
state: AuditTuiState;
|
|
6
|
+
focused: boolean;
|
|
7
|
+
/** Total height the pane will occupy. Used to slice to the latest visible lines. */
|
|
8
|
+
height: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Line {
|
|
12
|
+
key: string;
|
|
13
|
+
text: string;
|
|
14
|
+
color?: string;
|
|
15
|
+
dim?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Flatten the command log into renderable lines: a separator
|
|
20
|
+
* header + stdout/stderr lines + an exit-code footer per entry.
|
|
21
|
+
* Returned in chronological order (oldest first).
|
|
22
|
+
*/
|
|
23
|
+
function flattenLog(log: CommandLogEntry[]): Line[] {
|
|
24
|
+
const lines: Line[] = [];
|
|
25
|
+
for (const entry of log) {
|
|
26
|
+
const time = new Date(entry.startedAt).toLocaleTimeString();
|
|
27
|
+
lines.push({
|
|
28
|
+
key: `${entry.startedAt}-hdr`,
|
|
29
|
+
text: `─── ${time} · ${entry.cmd} ───`,
|
|
30
|
+
dim: true,
|
|
31
|
+
});
|
|
32
|
+
for (let i = 0; i < entry.lines.length; i++) {
|
|
33
|
+
const ln = entry.lines[i];
|
|
34
|
+
lines.push({
|
|
35
|
+
key: `${entry.startedAt}-${i}`,
|
|
36
|
+
text: ln.text,
|
|
37
|
+
color: ln.stream === 'stderr' ? 'red' : undefined,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
lines.push({
|
|
41
|
+
key: `${entry.startedAt}-exit`,
|
|
42
|
+
text: entry.exitCode === null ? 'running…' : `exit ${entry.exitCode}`,
|
|
43
|
+
color: entry.exitCode === 0 ? 'green' : entry.exitCode === null ? undefined : 'red',
|
|
44
|
+
dim: true,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return lines;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function CommandLogPane({ state, focused, height }: Props) {
|
|
51
|
+
const all = flattenLog(state.commandLog);
|
|
52
|
+
// chrome = 2 border rows + 1 title + 1 spacer = 4. Show the last
|
|
53
|
+
// `height - chrome` lines so the most recent output is always
|
|
54
|
+
// visible (terminal-tail behavior).
|
|
55
|
+
const chrome = 4;
|
|
56
|
+
const visible = Math.max(height - chrome, 0);
|
|
57
|
+
const slice = all.length <= visible ? all : all.slice(all.length - visible);
|
|
58
|
+
const hidden = all.length - slice.length;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Box
|
|
62
|
+
borderStyle="round"
|
|
63
|
+
borderColor={focused ? 'cyan' : 'gray'}
|
|
64
|
+
flexDirection="column"
|
|
65
|
+
paddingX={1}
|
|
66
|
+
width="100%"
|
|
67
|
+
height="100%"
|
|
68
|
+
overflow="hidden"
|
|
69
|
+
>
|
|
70
|
+
<Text dimColor wrap="truncate-end">
|
|
71
|
+
5 Command log
|
|
72
|
+
{hidden > 0 ? ` (${hidden} earlier line${hidden === 1 ? '' : 's'} hidden)` : ''}
|
|
73
|
+
</Text>
|
|
74
|
+
<Box flexDirection="column" marginTop={1}>
|
|
75
|
+
{state.commandLog.length === 0 ? (
|
|
76
|
+
<Text dimColor>(empty — press r on a finding to remediate)</Text>
|
|
77
|
+
) : (
|
|
78
|
+
slice.map((ln) => (
|
|
79
|
+
<Text key={ln.key} color={ln.color} dimColor={ln.dim} wrap="truncate-end">
|
|
80
|
+
{ln.text}
|
|
81
|
+
</Text>
|
|
82
|
+
))
|
|
83
|
+
)}
|
|
84
|
+
</Box>
|
|
85
|
+
</Box>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import type { AuditTuiState } from '../audit-state';
|
|
3
|
+
import { selectedFinding } from '../audit-state';
|
|
4
|
+
import { InboxZeroCelebration } from '../celebration';
|
|
5
|
+
import { SEVERITY_VISUALS } from '../icons';
|
|
6
|
+
import { wrapText } from '../wrap';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
state: AuditTuiState;
|
|
10
|
+
focused: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* The pane's full width in columns. Used to pre-wrap multi-line
|
|
13
|
+
* content (details, non-actionable guidance) so each rendered line
|
|
14
|
+
* fits within the pane — Ink's column layout assumes one Text =
|
|
15
|
+
* one row, so a wrapping Text would overlap the next sibling.
|
|
16
|
+
*/
|
|
17
|
+
width: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* ASCII-art "celilo" wordmark in the figlet "Standard" font. 4 rows
|
|
22
|
+
* tall, ~30 cols wide. Rendered cyan to pop against the pane chrome.
|
|
23
|
+
*
|
|
24
|
+
* Hard-coded as a string array rather than a runtime figlet
|
|
25
|
+
* dependency — the wordmark is stable and the dep would be overkill.
|
|
26
|
+
*/
|
|
27
|
+
const CELILO_ASCII = [
|
|
28
|
+
' ___ ___ _ ___ _ ___ ',
|
|
29
|
+
' / __| __| | |_ _| | / _ \\ ',
|
|
30
|
+
' | (__| _|| |__ | || |_| (_) |',
|
|
31
|
+
' \\___|___|____|___|____\\___/ ',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Banner shown in this pane when the user is focused on Summary
|
|
36
|
+
* (pane 1). Lazygit-style orientation: ASCII wordmark, what celilo
|
|
37
|
+
* is, where to learn more, where to find help. The full keymap
|
|
38
|
+
* lives behind `?` and is suppressed here to keep the banner clean.
|
|
39
|
+
*/
|
|
40
|
+
function CeliloBanner({ contentWidth }: { contentWidth: number }) {
|
|
41
|
+
const description = wrapText(
|
|
42
|
+
'Home-lab orchestration: Terraform + Ansible + a module registry, wrapped in a CLI that knows your topology.',
|
|
43
|
+
contentWidth,
|
|
44
|
+
);
|
|
45
|
+
const screenDesc = wrapText(
|
|
46
|
+
'Reports drift across CLI version, schema, capability ABI, terraform plan, module versions, configs, health, backups, undeployed modules, and unconfigured modules.',
|
|
47
|
+
contentWidth,
|
|
48
|
+
);
|
|
49
|
+
// Show ASCII art only when the pane is wide enough to fit it
|
|
50
|
+
// without ugly wrap. Falls back to a plain "celilo" title on
|
|
51
|
+
// narrow terminals.
|
|
52
|
+
const fitsAscii = contentWidth >= CELILO_ASCII[0].length;
|
|
53
|
+
return (
|
|
54
|
+
<Box flexDirection="column">
|
|
55
|
+
{fitsAscii ? (
|
|
56
|
+
<Box flexDirection="column">
|
|
57
|
+
{CELILO_ASCII.map((line, i) => (
|
|
58
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static ASCII art
|
|
59
|
+
<Text key={`a-${i}`} color="cyan" bold>
|
|
60
|
+
{line}
|
|
61
|
+
</Text>
|
|
62
|
+
))}
|
|
63
|
+
</Box>
|
|
64
|
+
) : (
|
|
65
|
+
<Text bold color="cyan">
|
|
66
|
+
celilo
|
|
67
|
+
</Text>
|
|
68
|
+
)}
|
|
69
|
+
<Box marginTop={1} flexDirection="column">
|
|
70
|
+
{description.map((line, i) => (
|
|
71
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: pre-wrapped static lines
|
|
72
|
+
<Text key={`d-${i}`}>{line}</Text>
|
|
73
|
+
))}
|
|
74
|
+
</Box>
|
|
75
|
+
<Box marginTop={1}>
|
|
76
|
+
<Text dimColor>https://celilo.computer</Text>
|
|
77
|
+
</Box>
|
|
78
|
+
<Box marginTop={1} flexDirection="column">
|
|
79
|
+
<Text bold>This screen — `system audit`</Text>
|
|
80
|
+
{screenDesc.map((line, i) => (
|
|
81
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: pre-wrapped static lines
|
|
82
|
+
<Text key={`s-${i}`}>{line}</Text>
|
|
83
|
+
))}
|
|
84
|
+
</Box>
|
|
85
|
+
<Box marginTop={1}>
|
|
86
|
+
<Text dimColor>Press `?` for the full keymap.</Text>
|
|
87
|
+
</Box>
|
|
88
|
+
<Box marginTop={1}>
|
|
89
|
+
<Text dimColor>Copyright 2026 Peter Banka</Text>
|
|
90
|
+
</Box>
|
|
91
|
+
</Box>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function DetailPane({ state, focused, width }: Props) {
|
|
96
|
+
const finding = selectedFinding(state);
|
|
97
|
+
const showBanner = state.focusedPane === 'summary';
|
|
98
|
+
const inboxZero = !state.auditing && state.report.findings.length === 0;
|
|
99
|
+
// Content width = pane width - 2 border cols - 2 padding cols (paddingX={1}).
|
|
100
|
+
const contentWidth = Math.max(width - 4, 10);
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<Box
|
|
104
|
+
borderStyle="round"
|
|
105
|
+
borderColor={focused ? 'cyan' : 'gray'}
|
|
106
|
+
flexDirection="column"
|
|
107
|
+
paddingX={1}
|
|
108
|
+
width="100%"
|
|
109
|
+
height="100%"
|
|
110
|
+
overflow="hidden"
|
|
111
|
+
>
|
|
112
|
+
<Text dimColor>4 Detail</Text>
|
|
113
|
+
<Box flexDirection="column" marginTop={1}>
|
|
114
|
+
{showBanner ? (
|
|
115
|
+
<CeliloBanner contentWidth={contentWidth} />
|
|
116
|
+
) : inboxZero ? (
|
|
117
|
+
<InboxZeroCelebration />
|
|
118
|
+
) : finding === null ? (
|
|
119
|
+
<Text dimColor>(no finding selected)</Text>
|
|
120
|
+
) : (
|
|
121
|
+
<>
|
|
122
|
+
{wrapText(finding.message, contentWidth).map((line, i) => (
|
|
123
|
+
<Text
|
|
124
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: pre-wrapped static lines
|
|
125
|
+
key={`m-${i}`}
|
|
126
|
+
bold
|
|
127
|
+
color={SEVERITY_VISUALS[finding.severity].color}
|
|
128
|
+
>
|
|
129
|
+
{line}
|
|
130
|
+
</Text>
|
|
131
|
+
))}
|
|
132
|
+
{finding.details && (
|
|
133
|
+
<Box marginTop={1} flexDirection="column">
|
|
134
|
+
{wrapText(finding.details, contentWidth).map((line, i) => (
|
|
135
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: pre-wrapped static lines
|
|
136
|
+
<Text key={`line-${i}`}>{line}</Text>
|
|
137
|
+
))}
|
|
138
|
+
</Box>
|
|
139
|
+
)}
|
|
140
|
+
{finding.remediation && finding.actionable && (
|
|
141
|
+
<Box marginTop={1} flexDirection="column">
|
|
142
|
+
{wrapText(`→ ${finding.remediation}`, contentWidth).map((line, i) => (
|
|
143
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: pre-wrapped static lines
|
|
144
|
+
<Text key={`rem-${i}`} color="cyan">
|
|
145
|
+
{line}
|
|
146
|
+
</Text>
|
|
147
|
+
))}
|
|
148
|
+
</Box>
|
|
149
|
+
)}
|
|
150
|
+
{finding.remediation && !finding.actionable && (
|
|
151
|
+
<Box marginTop={1} flexDirection="column">
|
|
152
|
+
{/*
|
|
153
|
+
* Non-runnable remediation — render as plain guidance
|
|
154
|
+
* (no `→` arrow, no command-style cyan) so the user
|
|
155
|
+
* doesn't expect to hit `r` and get a one-shot fix.
|
|
156
|
+
*/}
|
|
157
|
+
{wrapText(finding.remediation, contentWidth).map((line, i) => (
|
|
158
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: pre-wrapped static lines
|
|
159
|
+
<Text key={`rem-${i}`} dimColor>
|
|
160
|
+
{line}
|
|
161
|
+
</Text>
|
|
162
|
+
))}
|
|
163
|
+
</Box>
|
|
164
|
+
)}
|
|
165
|
+
<Box marginTop={1}>
|
|
166
|
+
<Text dimColor>
|
|
167
|
+
{finding.subject} · {finding.code}
|
|
168
|
+
</Text>
|
|
169
|
+
</Box>
|
|
170
|
+
</>
|
|
171
|
+
)}
|
|
172
|
+
</Box>
|
|
173
|
+
</Box>
|
|
174
|
+
);
|
|
175
|
+
}
|