@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.
Files changed (145) hide show
  1. package/drizzle/0004_caddy_hostname_list.sql +25 -0
  2. package/drizzle/meta/_journal.json +14 -0
  3. package/package.json +9 -2
  4. package/src/ansible/inventory.test.ts +3 -2
  5. package/src/ansible/inventory.ts +5 -1
  6. package/src/capabilities/public-web-helpers.test.ts +2 -2
  7. package/src/capabilities/public-web-publish.test.ts +34 -1
  8. package/src/cli/cli.test.ts +2 -2
  9. package/src/cli/command-registry.ts +146 -3
  10. package/src/cli/command-tree-parser.test.ts +1 -1
  11. package/src/cli/command-tree-parser.ts +9 -8
  12. package/src/cli/commands/hook-run.ts +15 -66
  13. package/src/cli/commands/module-audit.ts +14 -44
  14. package/src/cli/commands/module-deploy.ts +4 -1
  15. package/src/cli/commands/module-import-registry.test.ts +115 -0
  16. package/src/cli/commands/module-import.ts +106 -22
  17. package/src/cli/commands/module-publish.test.ts +235 -0
  18. package/src/cli/commands/module-publish.ts +234 -0
  19. package/src/cli/commands/module-remove.ts +82 -2
  20. package/src/cli/commands/module-search.ts +57 -0
  21. package/src/cli/commands/module-secret-get.ts +59 -0
  22. package/src/cli/commands/module-terraform-unlock.ts +57 -0
  23. package/src/cli/commands/module-verify.test.ts +59 -0
  24. package/src/cli/commands/module-verify.ts +53 -0
  25. package/src/cli/commands/status.ts +30 -20
  26. package/src/cli/commands/system-audit.test.ts +138 -0
  27. package/src/cli/commands/system-audit.ts +571 -0
  28. package/src/cli/commands/system-update.ts +391 -0
  29. package/src/cli/completion.ts +15 -1
  30. package/src/cli/fuel-gauge.ts +68 -3
  31. package/src/cli/generate-zsh-completion.ts +13 -3
  32. package/src/cli/index.ts +112 -5
  33. package/src/cli/parser.ts +11 -0
  34. package/src/cli/prompts.ts +36 -5
  35. package/src/cli/tui/audit-state.test.ts +246 -0
  36. package/src/cli/tui/audit-state.ts +525 -0
  37. package/src/cli/tui/audit-tui.test.tsx +135 -0
  38. package/src/cli/tui/audit-tui.tsx +624 -0
  39. package/src/cli/tui/celebration.tsx +29 -0
  40. package/src/cli/tui/clipboard.test.ts +94 -0
  41. package/src/cli/tui/clipboard.ts +101 -0
  42. package/src/cli/tui/icons.ts +22 -0
  43. package/src/cli/tui/keybar.tsx +65 -0
  44. package/src/cli/tui/keymap.test.ts +105 -0
  45. package/src/cli/tui/keymap.ts +70 -0
  46. package/src/cli/tui/modals/analyzing.tsx +75 -0
  47. package/src/cli/tui/modals/celebration.tsx +44 -0
  48. package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
  49. package/src/cli/tui/modals/remediate.tsx +44 -0
  50. package/src/cli/tui/modals.test.ts +137 -0
  51. package/src/cli/tui/mouse.test.ts +78 -0
  52. package/src/cli/tui/mouse.ts +114 -0
  53. package/src/cli/tui/panes/categories.tsx +62 -0
  54. package/src/cli/tui/panes/command-log.tsx +87 -0
  55. package/src/cli/tui/panes/detail.tsx +175 -0
  56. package/src/cli/tui/panes/findings.tsx +97 -0
  57. package/src/cli/tui/panes/summary.tsx +64 -0
  58. package/src/cli/tui/spawn.ts +130 -0
  59. package/src/cli/tui/theme.ts +42 -0
  60. package/src/cli/tui/wrap.test.ts +43 -0
  61. package/src/cli/tui/wrap.ts +45 -0
  62. package/src/cli/types.ts +5 -0
  63. package/src/db/client.ts +55 -2
  64. package/src/db/schema.ts +26 -17
  65. package/src/hooks/capability-loader.ts +133 -73
  66. package/src/hooks/define-hook.test.ts +9 -1
  67. package/src/hooks/executor.ts +22 -1
  68. package/src/hooks/load-hook-config.test.ts +165 -0
  69. package/src/hooks/load-hook-config.ts +60 -0
  70. package/src/hooks/logger.ts +42 -12
  71. package/src/hooks/run-named-hook.ts +128 -0
  72. package/src/hooks/types.ts +19 -0
  73. package/src/manifest/ensure-schema.test.ts +115 -0
  74. package/src/manifest/schema.ts +76 -0
  75. package/src/module/import.ts +20 -12
  76. package/src/module/packaging/build.ts +85 -16
  77. package/src/module/packaging/release-metadata.test.ts +103 -0
  78. package/src/module/packaging/release-metadata.ts +145 -0
  79. package/src/registry/client.test.ts +228 -0
  80. package/src/registry/client.ts +157 -0
  81. package/src/services/audit/backups.test.ts +233 -0
  82. package/src/services/audit/backups.ts +128 -0
  83. package/src/services/audit/capability-abi.test.ts +153 -0
  84. package/src/services/audit/capability-abi.ts +204 -0
  85. package/src/services/audit/cli-version.test.ts +60 -0
  86. package/src/services/audit/cli-version.ts +87 -0
  87. package/src/services/audit/health.test.ts +84 -0
  88. package/src/services/audit/health.ts +43 -0
  89. package/src/services/audit/index.test.ts +99 -0
  90. package/src/services/audit/index.ts +118 -0
  91. package/src/services/audit/machines-reachable.test.ts +87 -0
  92. package/src/services/audit/machines-reachable.ts +87 -0
  93. package/src/services/audit/module-configs.test.ts +131 -0
  94. package/src/services/audit/module-configs.ts +80 -0
  95. package/src/services/audit/module-versions.test.ts +99 -0
  96. package/src/services/audit/module-versions.ts +154 -0
  97. package/src/services/audit/schema.test.ts +68 -0
  98. package/src/services/audit/schema.ts +115 -0
  99. package/src/services/audit/secrets-decryptable.test.ts +82 -0
  100. package/src/services/audit/secrets-decryptable.ts +97 -0
  101. package/src/services/audit/services-credentials.test.ts +54 -0
  102. package/src/services/audit/services-credentials.ts +64 -0
  103. package/src/services/audit/services-reachable.test.ts +60 -0
  104. package/src/services/audit/services-reachable.ts +64 -0
  105. package/src/services/audit/terraform-plan.test.ts +127 -0
  106. package/src/services/audit/terraform-plan.ts +153 -0
  107. package/src/services/audit/types.test.ts +36 -0
  108. package/src/services/audit/types.ts +90 -0
  109. package/src/services/audit/unconfigured-modules.test.ts +48 -0
  110. package/src/services/audit/unconfigured-modules.ts +71 -0
  111. package/src/services/audit/undeployed-modules.test.ts +66 -0
  112. package/src/services/audit/undeployed-modules.ts +72 -0
  113. package/src/services/build-stream.ts +122 -122
  114. package/src/services/config-interview.ts +407 -2
  115. package/src/services/deploy-ansible.ts +73 -7
  116. package/src/services/deploy-preflight.ts +45 -4
  117. package/src/services/deploy-terraform.ts +31 -24
  118. package/src/services/deploy-validation.ts +167 -23
  119. package/src/services/ensure-interview.test.ts +245 -0
  120. package/src/services/health-runner.ts +110 -38
  121. package/src/services/module-build.ts +11 -13
  122. package/src/services/module-deploy.ts +370 -59
  123. package/src/services/ssh-key-manager.test.ts +1 -1
  124. package/src/services/ssh-key-manager.ts +3 -2
  125. package/src/services/terraform-env.ts +62 -0
  126. package/src/services/update/dep-graph.test.ts +214 -0
  127. package/src/services/update/dep-graph.ts +215 -0
  128. package/src/services/update/orchestrator.test.ts +463 -0
  129. package/src/services/update/orchestrator.ts +359 -0
  130. package/src/services/update/progress.ts +49 -0
  131. package/src/services/update/self-update.test.ts +68 -0
  132. package/src/services/update/self-update.ts +57 -0
  133. package/src/services/update/types.ts +94 -0
  134. package/src/templates/generator.test.ts +1 -1
  135. package/src/templates/generator.ts +42 -1
  136. package/src/test-utils/completion-harness.test.ts +1 -1
  137. package/src/test-utils/completion-harness.ts +4 -4
  138. package/src/variables/capability-self-ref.test.ts +203 -0
  139. package/src/variables/context.ts +49 -1
  140. package/src/variables/declarative-derivation.test.ts +306 -0
  141. package/src/variables/declarative-derivation.ts +4 -2
  142. package/src/variables/parser.test.ts +56 -1
  143. package/src/variables/parser.ts +47 -6
  144. package/src/variables/resolver.ts +27 -9
  145. 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 (works in both Bun and Node/vitest)
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
- 'ALTER TABLE web_routes ADD subdomain text',
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.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('web_routes', {
330
- id: integer('id').primaryKey({ autoIncrement: true }),
331
- slug: text('slug').notNull(),
332
- moduleId: text('module_id')
333
- .notNull()
334
- .references(() => modules.id, { onDelete: 'cascade' }),
335
- type: text('type').$type<'static' | 'reverse_proxy'>().notNull(),
336
- path: text('path').notNull().unique(),
337
- targetHost: text('target_host'),
338
- targetPort: integer('target_port'),
339
- subdomain: text('subdomain'),
340
- websocket: integer('websocket', { mode: 'boolean' }).notNull().default(false),
341
- contentHash: text('content_hash'),
342
- createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
343
- updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
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