@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,97 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import type { AuditTuiState } from '../audit-state';
|
|
3
|
+
import { findingId, findingsForCategory } from '../audit-state';
|
|
4
|
+
import { InboxZeroCelebration } from '../celebration';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
state: AuditTuiState;
|
|
8
|
+
focused: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Total height the pane will occupy. Used to compute the scroll
|
|
11
|
+
* window so the selected finding stays visible without the pane
|
|
12
|
+
* resizing.
|
|
13
|
+
*/
|
|
14
|
+
height: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Compute a scroll window that keeps `selectedIndex` visible inside a
|
|
19
|
+
* window of `windowSize` rows. Anchors selection at the top when it
|
|
20
|
+
* would otherwise scroll off above; at the bottom when it would
|
|
21
|
+
* scroll off below. Exported so the mouse-click hit-tester can apply
|
|
22
|
+
* the same math when translating clicks into list indices.
|
|
23
|
+
*/
|
|
24
|
+
export function computeScrollWindow(
|
|
25
|
+
selectedIndex: number,
|
|
26
|
+
totalCount: number,
|
|
27
|
+
windowSize: number,
|
|
28
|
+
): { start: number; end: number } {
|
|
29
|
+
if (totalCount <= windowSize) return { start: 0, end: totalCount };
|
|
30
|
+
let start = Math.max(0, selectedIndex - Math.floor(windowSize / 2));
|
|
31
|
+
if (start + windowSize > totalCount) {
|
|
32
|
+
start = totalCount - windowSize;
|
|
33
|
+
}
|
|
34
|
+
return { start, end: start + windowSize };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Chrome (border+title+spacer) above the first finding row. */
|
|
38
|
+
export const FINDINGS_CHROME = 4;
|
|
39
|
+
|
|
40
|
+
export function FindingsPane({ state, focused, height }: Props) {
|
|
41
|
+
const findings = findingsForCategory(state.groups, state.selectedCategory);
|
|
42
|
+
// Pane height accounts for: 2 border rows + 1 title row + 1 spacer
|
|
43
|
+
// = 4 chrome rows. Reserve one bottom row for the "(N more)" hint
|
|
44
|
+
// when the list overflows, so the visible window is height - 5.
|
|
45
|
+
const chrome = 4;
|
|
46
|
+
const overflowRow = findings.length > Math.max(height - chrome, 0) ? 1 : 0;
|
|
47
|
+
const visible = Math.max(height - chrome - overflowRow, 0);
|
|
48
|
+
|
|
49
|
+
const selectedIndex = Math.max(
|
|
50
|
+
findings.findIndex((f) => findingId(f) === state.selectedFindingId),
|
|
51
|
+
0,
|
|
52
|
+
);
|
|
53
|
+
const { start, end } = computeScrollWindow(selectedIndex, findings.length, visible);
|
|
54
|
+
const slice = findings.slice(start, end);
|
|
55
|
+
const hidden = findings.length - end;
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Box
|
|
59
|
+
borderStyle="round"
|
|
60
|
+
borderColor={focused ? 'cyan' : 'gray'}
|
|
61
|
+
flexDirection="column"
|
|
62
|
+
paddingX={1}
|
|
63
|
+
width="100%"
|
|
64
|
+
height="100%"
|
|
65
|
+
overflow="hidden"
|
|
66
|
+
>
|
|
67
|
+
<Text dimColor wrap="truncate-end">
|
|
68
|
+
3 Findings · {state.selectedCategory ?? '—'}
|
|
69
|
+
{findings.length > 0 ? ` (${selectedIndex + 1}/${findings.length})` : ''}
|
|
70
|
+
</Text>
|
|
71
|
+
<Box flexDirection="column" marginTop={1}>
|
|
72
|
+
{findings.length === 0 ? (
|
|
73
|
+
state.auditing ? (
|
|
74
|
+
<Text dimColor>(checking…)</Text>
|
|
75
|
+
) : state.report.findings.length === 0 ? (
|
|
76
|
+
<InboxZeroCelebration compact />
|
|
77
|
+
) : (
|
|
78
|
+
<Text dimColor>(none)</Text>
|
|
79
|
+
)
|
|
80
|
+
) : (
|
|
81
|
+
slice.map((f) => {
|
|
82
|
+
const id = findingId(f);
|
|
83
|
+
const isSelected = focused && id === state.selectedFindingId;
|
|
84
|
+
const marker = isSelected ? '▸ ' : ' ';
|
|
85
|
+
return (
|
|
86
|
+
<Text key={id} inverse={isSelected} wrap="truncate-end">
|
|
87
|
+
{marker}
|
|
88
|
+
{f.message}
|
|
89
|
+
</Text>
|
|
90
|
+
);
|
|
91
|
+
})
|
|
92
|
+
)}
|
|
93
|
+
{hidden > 0 && <Text dimColor>↓ {hidden} more</Text>}
|
|
94
|
+
</Box>
|
|
95
|
+
</Box>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import Spinner from 'ink-spinner';
|
|
3
|
+
import type { AuditTuiState } from '../audit-state';
|
|
4
|
+
import { VERDICT_VISUALS } from '../icons';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
state: AuditTuiState;
|
|
8
|
+
focused: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function formatTimestamp(iso: string): string {
|
|
12
|
+
// Local-time HH:MM:SS — full ISO is dense and the UTC component is
|
|
13
|
+
// rarely useful for an interactive operator at the keyboard.
|
|
14
|
+
try {
|
|
15
|
+
return new Date(iso).toLocaleTimeString();
|
|
16
|
+
} catch {
|
|
17
|
+
return iso;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function SummaryPane({ state, focused }: Props) {
|
|
22
|
+
const { report, auditing, progressMessage } = state;
|
|
23
|
+
const verdict = VERDICT_VISUALS[report.verdict];
|
|
24
|
+
const blockedCount = report.findings.filter((f) => f.severity === 'blocked').length;
|
|
25
|
+
const driftCount = report.findings.filter((f) => f.severity === 'drift').length;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Box
|
|
29
|
+
borderStyle="round"
|
|
30
|
+
borderColor={focused ? 'cyan' : 'gray'}
|
|
31
|
+
flexDirection="column"
|
|
32
|
+
paddingX={1}
|
|
33
|
+
width="100%"
|
|
34
|
+
height="100%"
|
|
35
|
+
overflow="hidden"
|
|
36
|
+
>
|
|
37
|
+
<Text dimColor>1 Summary</Text>
|
|
38
|
+
<Box marginTop={1}>
|
|
39
|
+
{auditing ? (
|
|
40
|
+
<Text>
|
|
41
|
+
<Spinner type="dots" />
|
|
42
|
+
<Text> auditing…</Text>
|
|
43
|
+
</Text>
|
|
44
|
+
) : (
|
|
45
|
+
<Text color={verdict.color} bold>
|
|
46
|
+
{verdict.icon} {verdict.label}
|
|
47
|
+
</Text>
|
|
48
|
+
)}
|
|
49
|
+
</Box>
|
|
50
|
+
{auditing ? (
|
|
51
|
+
<Text dimColor wrap="truncate-end">
|
|
52
|
+
{progressMessage ?? 'starting…'}
|
|
53
|
+
</Text>
|
|
54
|
+
) : (
|
|
55
|
+
<>
|
|
56
|
+
<Text>
|
|
57
|
+
{blockedCount} blocked, {driftCount} drift
|
|
58
|
+
</Text>
|
|
59
|
+
<Text dimColor>at {formatTimestamp(report.generatedAt)}</Text>
|
|
60
|
+
</>
|
|
61
|
+
)}
|
|
62
|
+
</Box>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a `celilo …` subcommand and stream its stdout/stderr through
|
|
3
|
+
* the supplied callbacks. Used by the remediation modal — when the
|
|
4
|
+
* user submits, we spawn the same celilo entry point with the edited
|
|
5
|
+
* args and let the TUI's reducer absorb the output line-by-line.
|
|
6
|
+
*
|
|
7
|
+
* The same Bun/Node binary that's running the TUI is reused
|
|
8
|
+
* (`process.argv[0]` + `process.argv[1]`) so we don't depend on
|
|
9
|
+
* `celilo` being on PATH or worry about which build is "the" build.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface RunCeliloHandlers {
|
|
13
|
+
onLine: (stream: 'stdout' | 'stderr', text: string) => void;
|
|
14
|
+
onExit: (exitCode: number) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a shell-ish command string into argv. Honors `"..."` and
|
|
19
|
+
* `'...'` quoting and `\` escapes. Not a full shell parser — it
|
|
20
|
+
* does not expand variables, run subshells, or honor pipes. The
|
|
21
|
+
* remediation modal pre-fills a single command, and that's what we
|
|
22
|
+
* support.
|
|
23
|
+
*/
|
|
24
|
+
export function parseCommand(input: string): string[] {
|
|
25
|
+
const args: string[] = [];
|
|
26
|
+
let current = '';
|
|
27
|
+
let inQuote: '"' | "'" | null = null;
|
|
28
|
+
let escaped = false;
|
|
29
|
+
for (const c of input) {
|
|
30
|
+
if (escaped) {
|
|
31
|
+
current += c;
|
|
32
|
+
escaped = false;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (c === '\\') {
|
|
36
|
+
escaped = true;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (inQuote === c) {
|
|
40
|
+
inQuote = null;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (inQuote === null && (c === '"' || c === "'")) {
|
|
44
|
+
inQuote = c;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (inQuote === null && /\s/.test(c)) {
|
|
48
|
+
if (current) args.push(current);
|
|
49
|
+
current = '';
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
current += c;
|
|
53
|
+
}
|
|
54
|
+
if (current) args.push(current);
|
|
55
|
+
return args;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Strip a leading `celilo` token if the user typed one. The runner
|
|
60
|
+
* always invokes the celilo entry point itself, so the `celilo`
|
|
61
|
+
* prefix is implicit.
|
|
62
|
+
*/
|
|
63
|
+
function stripCeliloPrefix(args: string[]): string[] {
|
|
64
|
+
if (args.length > 0 && (args[0] === 'celilo' || args[0].endsWith('/celilo'))) {
|
|
65
|
+
return args.slice(1);
|
|
66
|
+
}
|
|
67
|
+
return args;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run `celilo <args>` and stream its output. Returns a function that
|
|
72
|
+
* can be called to terminate the running process (best-effort kill).
|
|
73
|
+
*/
|
|
74
|
+
export function runCelilo(commandString: string, handlers: RunCeliloHandlers): () => void {
|
|
75
|
+
const parsed = stripCeliloPrefix(parseCommand(commandString));
|
|
76
|
+
// Reuse the parent process's invocation. argv[0] is bun/node,
|
|
77
|
+
// argv[1] is the entry script (or the bin path for compiled use).
|
|
78
|
+
const exe = process.argv[0];
|
|
79
|
+
const entry = process.argv[1];
|
|
80
|
+
const proc = Bun.spawn([exe, entry, ...parsed], {
|
|
81
|
+
stdout: 'pipe',
|
|
82
|
+
stderr: 'pipe',
|
|
83
|
+
stdin: 'ignore',
|
|
84
|
+
env: { ...process.env },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
void streamLines(proc.stdout, (line) => handlers.onLine('stdout', line));
|
|
88
|
+
void streamLines(proc.stderr, (line) => handlers.onLine('stderr', line));
|
|
89
|
+
|
|
90
|
+
proc.exited.then((code) => {
|
|
91
|
+
handlers.onExit(typeof code === 'number' ? code : -1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return () => {
|
|
95
|
+
try {
|
|
96
|
+
proc.kill();
|
|
97
|
+
} catch {
|
|
98
|
+
// Process may already have exited; ignore.
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read a ReadableStream of bytes and call `cb` for each newline-
|
|
105
|
+
* delimited line. Trailing partial lines are flushed when the
|
|
106
|
+
* stream closes.
|
|
107
|
+
*/
|
|
108
|
+
async function streamLines(
|
|
109
|
+
stream: ReadableStream<Uint8Array> | undefined,
|
|
110
|
+
cb: (line: string) => void,
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
if (!stream) return;
|
|
113
|
+
const reader = stream.getReader();
|
|
114
|
+
const decoder = new TextDecoder();
|
|
115
|
+
let buffer = '';
|
|
116
|
+
try {
|
|
117
|
+
while (true) {
|
|
118
|
+
const { value, done } = await reader.read();
|
|
119
|
+
if (done) break;
|
|
120
|
+
buffer += decoder.decode(value, { stream: true });
|
|
121
|
+
const lines = buffer.split('\n');
|
|
122
|
+
buffer = lines.pop() ?? '';
|
|
123
|
+
for (const line of lines) cb(line);
|
|
124
|
+
}
|
|
125
|
+
buffer += decoder.decode();
|
|
126
|
+
if (buffer.length > 0) cb(buffer);
|
|
127
|
+
} finally {
|
|
128
|
+
reader.releaseLock();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI theme — colors that vary between dark and light terminal
|
|
3
|
+
* backgrounds. Pulled into one place so a future theme picker, env
|
|
4
|
+
* var, or auto-detection only needs to swap the active object.
|
|
5
|
+
*
|
|
6
|
+
* Severity colors (red/yellow/green) are kept theme-agnostic — those
|
|
7
|
+
* are ANSI named colors that respect the user's terminal palette and
|
|
8
|
+
* read correctly on both dark and light backgrounds.
|
|
9
|
+
*
|
|
10
|
+
* Selected via `--theme=dark|light` on `celilo system audit --tui`.
|
|
11
|
+
* Default: `dark`. We don't auto-detect terminal background — the
|
|
12
|
+
* standard probe (`COLORFGBG` env var, or OSC 11) is unreliable
|
|
13
|
+
* across terminals, and an explicit flag is more honest than a guess
|
|
14
|
+
* that's right 70% of the time.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type ThemeName = 'dark' | 'light';
|
|
18
|
+
|
|
19
|
+
export interface Theme {
|
|
20
|
+
/**
|
|
21
|
+
* Dialog background. Should contrast strongly with the typical
|
|
22
|
+
* terminal background on this theme — pure black on dark themes,
|
|
23
|
+
* pure white on light themes.
|
|
24
|
+
*/
|
|
25
|
+
dialogBg: 'black' | 'white';
|
|
26
|
+
/** Foreground text color inside dialogs. Inverse of dialogBg. */
|
|
27
|
+
dialogFg: 'white' | 'black';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DARK: Theme = {
|
|
31
|
+
dialogBg: 'black',
|
|
32
|
+
dialogFg: 'white',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const LIGHT: Theme = {
|
|
36
|
+
dialogBg: 'white',
|
|
37
|
+
dialogFg: 'black',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function getTheme(name: ThemeName | undefined): Theme {
|
|
41
|
+
return name === 'light' ? LIGHT : DARK;
|
|
42
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { wrapText } from './wrap';
|
|
3
|
+
|
|
4
|
+
describe('wrapText', () => {
|
|
5
|
+
test('preserves short lines unchanged', () => {
|
|
6
|
+
expect(wrapText('hello world', 80)).toEqual(['hello world']);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('preserves existing newlines as line breaks', () => {
|
|
10
|
+
expect(wrapText('a\nb\nc', 80)).toEqual(['a', 'b', 'c']);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('empty input lines render as single space (visible blank row)', () => {
|
|
14
|
+
expect(wrapText('a\n\nb', 80)).toEqual(['a', ' ', 'b']);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('word-wraps long lines on whitespace', () => {
|
|
18
|
+
const result = wrapText('the quick brown fox jumps over the lazy dog', 15);
|
|
19
|
+
expect(result.every((line) => line.length <= 15)).toBe(true);
|
|
20
|
+
expect(result.join(' ').replace(/\s+/g, ' ')).toBe(
|
|
21
|
+
'the quick brown fox jumps over the lazy dog',
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('hard-breaks words longer than the width', () => {
|
|
26
|
+
const result = wrapText('xxxxxxxxxxxxxxxxxxxx', 5);
|
|
27
|
+
expect(result.every((line) => line.length <= 5)).toBe(true);
|
|
28
|
+
expect(result.join('')).toBe('xxxxxxxxxxxxxxxxxxxx');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('mix of short, long, and blank lines', () => {
|
|
32
|
+
const text = ['short', '', 'a much longer sentence that needs to wrap'].join('\n');
|
|
33
|
+
const result = wrapText(text, 20);
|
|
34
|
+
// First line: short stays as-is
|
|
35
|
+
expect(result[0]).toBe('short');
|
|
36
|
+
// Second line: blank → space
|
|
37
|
+
expect(result[1]).toBe(' ');
|
|
38
|
+
// Subsequent lines: each <= 20
|
|
39
|
+
for (const line of result.slice(2)) {
|
|
40
|
+
expect(line.length).toBeLessThanOrEqual(20);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-wrap text to a known column width, returning an array of
|
|
3
|
+
* lines no longer than `width` characters. Used by the Detail pane
|
|
4
|
+
* to render multi-line content (capability_abi guidance, terraform
|
|
5
|
+
* stderr, etc.) without falling into Ink's "Text sibling wraps and
|
|
6
|
+
* its remainder overlaps the next sibling" trap.
|
|
7
|
+
*
|
|
8
|
+
* Rules:
|
|
9
|
+
* - Existing newlines (`\n`) are preserved as line breaks.
|
|
10
|
+
* - Empty input lines render as a single-space output line — a true
|
|
11
|
+
* empty Text would measure as zero height and let siblings
|
|
12
|
+
* collapse together; one space keeps the row visible.
|
|
13
|
+
* - Long lines word-wrap on whitespace when possible. A word longer
|
|
14
|
+
* than `width` (rare — long URLs, base64) is hard-broken.
|
|
15
|
+
*/
|
|
16
|
+
export function wrapText(text: string, width: number): string[] {
|
|
17
|
+
if (width <= 0) return [text];
|
|
18
|
+
const out: string[] = [];
|
|
19
|
+
for (const line of text.split('\n')) {
|
|
20
|
+
if (line === '') {
|
|
21
|
+
out.push(' '); // visible blank row
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (line.length <= width) {
|
|
25
|
+
out.push(line);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
let remaining = line;
|
|
29
|
+
while (remaining.length > width) {
|
|
30
|
+
// Prefer breaking at the last whitespace within the window.
|
|
31
|
+
let breakAt = remaining.lastIndexOf(' ', width);
|
|
32
|
+
if (breakAt <= 0) {
|
|
33
|
+
// No whitespace fits — hard-break at the width boundary so a
|
|
34
|
+
// long URL or base64 string doesn't blow out the layout.
|
|
35
|
+
breakAt = width;
|
|
36
|
+
}
|
|
37
|
+
out.push(remaining.slice(0, breakAt));
|
|
38
|
+
// Drop the leading whitespace introduced by the wrap point so
|
|
39
|
+
// the next line doesn't begin with a stray space.
|
|
40
|
+
remaining = remaining.slice(breakAt).replace(/^\s+/, '');
|
|
41
|
+
}
|
|
42
|
+
if (remaining) out.push(remaining);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
package/src/cli/types.ts
CHANGED
|
@@ -19,6 +19,11 @@ export interface CommandSuccess {
|
|
|
19
19
|
success: true;
|
|
20
20
|
message: string;
|
|
21
21
|
data?: unknown;
|
|
22
|
+
/**
|
|
23
|
+
* When true, the CLI writes `message` directly to stdout (no clack formatting).
|
|
24
|
+
* Use for commands designed to be consumed by scripts (e.g. `module secret get`).
|
|
25
|
+
*/
|
|
26
|
+
rawOutput?: boolean;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export interface CommandError {
|
package/src/db/client.ts
CHANGED
|
@@ -19,7 +19,7 @@ interface DatabaseConfig {
|
|
|
19
19
|
* Find migrations folder relative to current working directory
|
|
20
20
|
*/
|
|
21
21
|
export function findMigrationsFolder(): string {
|
|
22
|
-
// Get directory of current file
|
|
22
|
+
// Get directory of current file
|
|
23
23
|
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
24
24
|
|
|
25
25
|
// Try common locations
|
|
@@ -57,7 +57,9 @@ function needsMigration(sqlite: Database): boolean {
|
|
|
57
57
|
const alterStatements = [
|
|
58
58
|
'ALTER TABLE capabilities ADD zones text',
|
|
59
59
|
'ALTER TABLE machines ADD earmarked_module text',
|
|
60
|
-
'
|
|
60
|
+
// web_routes' subdomain/custom_domain columns were folded into a
|
|
61
|
+
// single `hostname` field by migration 0004. Don't re-add them
|
|
62
|
+
// here — the migration drops and recreates the table.
|
|
61
63
|
// Backup system tables (Phase 1)
|
|
62
64
|
`CREATE TABLE IF NOT EXISTS backup_storages (
|
|
63
65
|
id text PRIMARY KEY NOT NULL,
|
|
@@ -100,6 +102,57 @@ function needsMigration(sqlite: Database): boolean {
|
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
// web_routes hostname migration (CADDY_HOSTNAME_LIST design).
|
|
106
|
+
// Drizzle's auto-migrate path (`migrate()`) only runs for fresh
|
|
107
|
+
// databases — for existing celilo installs we apply the schema
|
|
108
|
+
// change here. Phase 0 + no production users = destructive
|
|
109
|
+
// rebuild; modules repopulate routes on their next deploy.
|
|
110
|
+
try {
|
|
111
|
+
const cols = sqlite.query("SELECT name FROM pragma_table_info('web_routes')").all() as Array<{
|
|
112
|
+
name: string;
|
|
113
|
+
}>;
|
|
114
|
+
const hasHostname = cols.some((c) => c.name === 'hostname');
|
|
115
|
+
const hasOldColumns = cols.some((c) => c.name === 'subdomain' || c.name === 'custom_domain');
|
|
116
|
+
if (!hasHostname && cols.length > 0) {
|
|
117
|
+
// Old shape detected (or missing hostname) — drop and recreate.
|
|
118
|
+
// Wrap in a transaction so the table is never half-migrated.
|
|
119
|
+
sqlite.exec('BEGIN');
|
|
120
|
+
try {
|
|
121
|
+
sqlite.exec('DROP TABLE web_routes');
|
|
122
|
+
sqlite.exec(`CREATE TABLE web_routes (
|
|
123
|
+
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
124
|
+
slug text NOT NULL,
|
|
125
|
+
module_id text NOT NULL,
|
|
126
|
+
type text NOT NULL,
|
|
127
|
+
path text NOT NULL,
|
|
128
|
+
hostname text NOT NULL,
|
|
129
|
+
target_host text,
|
|
130
|
+
target_port integer,
|
|
131
|
+
websocket integer DEFAULT false NOT NULL,
|
|
132
|
+
content_hash text,
|
|
133
|
+
created_at integer DEFAULT (unixepoch()) NOT NULL,
|
|
134
|
+
updated_at integer DEFAULT (unixepoch()) NOT NULL,
|
|
135
|
+
FOREIGN KEY (module_id) REFERENCES modules(id) ON UPDATE no action ON DELETE cascade
|
|
136
|
+
)`);
|
|
137
|
+
sqlite.exec(
|
|
138
|
+
'CREATE UNIQUE INDEX web_routes_hostname_path_idx ON web_routes (hostname, path)',
|
|
139
|
+
);
|
|
140
|
+
sqlite.exec('COMMIT');
|
|
141
|
+
if (hasOldColumns) {
|
|
142
|
+
console.log(
|
|
143
|
+
'web_routes migrated to hostname-based schema. Modules will repopulate their routes on next deploy.',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
sqlite.exec('ROLLBACK');
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
console.error('Failed to migrate web_routes schema:', err);
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
|
|
103
156
|
return false; // Schema is up to date
|
|
104
157
|
} catch {
|
|
105
158
|
return true;
|
package/src/db/schema.test.ts
CHANGED
|
@@ -121,7 +121,7 @@ describe('Database Schema', () => {
|
|
|
121
121
|
// Insert config
|
|
122
122
|
const newConfig: NewModuleConfig = {
|
|
123
123
|
moduleId: 'homebridge',
|
|
124
|
-
key: '
|
|
124
|
+
key: 'target_ip',
|
|
125
125
|
value: '192.168.0.50',
|
|
126
126
|
};
|
|
127
127
|
|
|
@@ -129,7 +129,7 @@ describe('Database Schema', () => {
|
|
|
129
129
|
|
|
130
130
|
expect(result).toBeDefined();
|
|
131
131
|
expect(result.moduleId).toBe('homebridge');
|
|
132
|
-
expect(result.key).toBe('
|
|
132
|
+
expect(result.key).toBe('target_ip');
|
|
133
133
|
expect(result.value).toBe('192.168.0.50');
|
|
134
134
|
});
|
|
135
135
|
|
|
@@ -206,7 +206,7 @@ describe('Database Schema', () => {
|
|
|
206
206
|
// Insert config
|
|
207
207
|
const newConfig: NewModuleConfig = {
|
|
208
208
|
moduleId: 'homebridge',
|
|
209
|
-
key: '
|
|
209
|
+
key: 'target_ip',
|
|
210
210
|
value: '192.168.0.50',
|
|
211
211
|
};
|
|
212
212
|
db.insert(moduleConfigs).values(newConfig).run();
|
package/src/db/schema.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { sql } from 'drizzle-orm';
|
|
2
|
-
import { integer, sqliteTable, text, unique } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
import { integer, sqliteTable, text, unique, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Module lifecycle states
|
|
@@ -326,22 +326,31 @@ export const moduleInfrastructure = sqliteTable('module_infrastructure', {
|
|
|
326
326
|
* Routes are registered during on_install hooks and removed during on_uninstall.
|
|
327
327
|
* The public_web provider (Caddy) uses these to generate its configuration.
|
|
328
328
|
*/
|
|
329
|
-
export const webRoutes = sqliteTable(
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
.notNull()
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
329
|
+
export const webRoutes = sqliteTable(
|
|
330
|
+
'web_routes',
|
|
331
|
+
{
|
|
332
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
333
|
+
slug: text('slug').notNull(),
|
|
334
|
+
moduleId: text('module_id')
|
|
335
|
+
.notNull()
|
|
336
|
+
.references(() => modules.id, { onDelete: 'cascade' }),
|
|
337
|
+
type: text('type').$type<'static' | 'reverse_proxy'>().notNull(),
|
|
338
|
+
path: text('path').notNull(),
|
|
339
|
+
/** FQDN this route serves under — must be in caddy's hostnames list. */
|
|
340
|
+
hostname: text('hostname').notNull(),
|
|
341
|
+
targetHost: text('target_host'),
|
|
342
|
+
targetPort: integer('target_port'),
|
|
343
|
+
websocket: integer('websocket', { mode: 'boolean' }).notNull().default(false),
|
|
344
|
+
contentHash: text('content_hash'),
|
|
345
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
346
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
347
|
+
},
|
|
348
|
+
(table) => ({
|
|
349
|
+
// Path uniqueness is scoped to hostname — two modules can both own
|
|
350
|
+
// "/" as long as they're on different hostnames.
|
|
351
|
+
hostnamePathUnique: uniqueIndex('web_routes_hostname_path_idx').on(table.hostname, table.path),
|
|
352
|
+
}),
|
|
353
|
+
);
|
|
345
354
|
|
|
346
355
|
/**
|
|
347
356
|
* Backup storage providers - destinations for backup archives
|