@matthesketh/fleet 1.2.0 → 1.7.0

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 (218) hide show
  1. package/README.md +183 -251
  2. package/dist/adapters/detector/index.d.ts +8 -0
  3. package/dist/adapters/detector/index.js +54 -0
  4. package/dist/adapters/notifier/index.d.ts +2 -0
  5. package/dist/adapters/notifier/index.js +2 -0
  6. package/dist/adapters/notifier/stdout.d.ts +2 -0
  7. package/dist/adapters/notifier/stdout.js +8 -0
  8. package/dist/adapters/notifier/webhook.d.ts +9 -0
  9. package/dist/adapters/notifier/webhook.js +38 -0
  10. package/dist/adapters/runner/claude-cli.d.ts +7 -0
  11. package/dist/adapters/runner/claude-cli.js +231 -0
  12. package/dist/adapters/runner/mcp-call.d.ts +8 -0
  13. package/dist/adapters/runner/mcp-call.js +82 -0
  14. package/dist/adapters/runner/shell.d.ts +2 -0
  15. package/dist/adapters/runner/shell.js +103 -0
  16. package/dist/adapters/scheduler/systemd-timer.d.ts +17 -0
  17. package/dist/adapters/scheduler/systemd-timer.js +149 -0
  18. package/dist/adapters/signals/ci-status.d.ts +2 -0
  19. package/dist/adapters/signals/ci-status.js +79 -0
  20. package/dist/adapters/signals/container-up.d.ts +5 -0
  21. package/dist/adapters/signals/container-up.js +54 -0
  22. package/dist/adapters/signals/git-clean.d.ts +2 -0
  23. package/dist/adapters/signals/git-clean.js +55 -0
  24. package/dist/adapters/signals/index.d.ts +6 -0
  25. package/dist/adapters/signals/index.js +7 -0
  26. package/dist/adapters/types.d.ts +52 -0
  27. package/dist/adapters/types.js +1 -0
  28. package/dist/cli.js +46 -2
  29. package/dist/commands/add.js +0 -6
  30. package/dist/commands/boot-start.d.ts +1 -0
  31. package/dist/commands/boot-start.js +51 -0
  32. package/dist/commands/deploy.js +13 -0
  33. package/dist/commands/deps.js +5 -0
  34. package/dist/commands/egress.d.ts +1 -0
  35. package/dist/commands/egress.js +106 -0
  36. package/dist/commands/freeze.d.ts +4 -0
  37. package/dist/commands/freeze.js +64 -0
  38. package/dist/commands/guard.d.ts +1 -0
  39. package/dist/commands/guard.js +144 -0
  40. package/dist/commands/logs.d.ts +1 -1
  41. package/dist/commands/logs.js +237 -8
  42. package/dist/commands/patch-systemd.d.ts +1 -0
  43. package/dist/commands/patch-systemd.js +126 -0
  44. package/dist/commands/rollback.d.ts +1 -0
  45. package/dist/commands/rollback.js +58 -0
  46. package/dist/commands/routine-run.d.ts +1 -0
  47. package/dist/commands/routine-run.js +122 -0
  48. package/dist/commands/routines.d.ts +1 -0
  49. package/dist/commands/routines.js +25 -0
  50. package/dist/commands/secrets.js +449 -16
  51. package/dist/commands/status.js +7 -3
  52. package/dist/commands/watchdog.d.ts +1 -1
  53. package/dist/commands/watchdog.js +16 -40
  54. package/dist/core/boot-refresh.d.ts +57 -0
  55. package/dist/core/boot-refresh.js +116 -0
  56. package/dist/core/deps/actors/pr-creator.js +11 -9
  57. package/dist/core/deps/collectors/docker-running.js +2 -2
  58. package/dist/core/deps/collectors/github-pr.js +5 -2
  59. package/dist/core/deps/collectors/npm.js +10 -5
  60. package/dist/core/deps/collectors/vulnerability.js +10 -6
  61. package/dist/core/deps/reporters/motd.js +1 -1
  62. package/dist/core/deps/reporters/telegram.js +2 -29
  63. package/dist/core/docker.js +45 -15
  64. package/dist/core/egress.d.ts +41 -0
  65. package/dist/core/egress.js +161 -0
  66. package/dist/core/exec.d.ts +7 -1
  67. package/dist/core/exec.js +25 -17
  68. package/dist/core/git.d.ts +1 -0
  69. package/dist/core/git.js +36 -23
  70. package/dist/core/github.js +27 -8
  71. package/dist/core/health.d.ts +3 -0
  72. package/dist/core/health.js +15 -3
  73. package/dist/core/logs-multi.d.ts +73 -0
  74. package/dist/core/logs-multi.js +163 -0
  75. package/dist/core/logs-policy.d.ts +55 -0
  76. package/dist/core/logs-policy.js +148 -0
  77. package/dist/core/nginx.js +8 -4
  78. package/dist/core/notify.d.ts +15 -0
  79. package/dist/core/notify.js +55 -0
  80. package/dist/core/registry.d.ts +25 -0
  81. package/dist/core/registry.js +57 -10
  82. package/dist/core/routines/cost-queries.d.ts +24 -0
  83. package/dist/core/routines/cost-queries.js +65 -0
  84. package/dist/core/routines/db.d.ts +9 -0
  85. package/dist/core/routines/db.js +126 -0
  86. package/dist/core/routines/defaults.d.ts +2 -0
  87. package/dist/core/routines/defaults.js +72 -0
  88. package/dist/core/routines/engine.d.ts +59 -0
  89. package/dist/core/routines/engine.js +175 -0
  90. package/dist/core/routines/incidents.d.ts +13 -0
  91. package/dist/core/routines/incidents.js +35 -0
  92. package/dist/core/routines/schema.d.ts +418 -0
  93. package/dist/core/routines/schema.js +113 -0
  94. package/dist/core/routines/signals-collector.d.ts +35 -0
  95. package/dist/core/routines/signals-collector.js +114 -0
  96. package/dist/core/routines/store.d.ts +316 -0
  97. package/dist/core/routines/store.js +99 -0
  98. package/dist/core/routines/test-utils.d.ts +2 -0
  99. package/dist/core/routines/test-utils.js +13 -0
  100. package/dist/core/secrets-audit.d.ts +21 -0
  101. package/dist/core/secrets-audit.js +60 -0
  102. package/dist/core/secrets-metadata.d.ts +39 -0
  103. package/dist/core/secrets-metadata.js +82 -0
  104. package/dist/core/secrets-motd.d.ts +20 -0
  105. package/dist/core/secrets-motd.js +72 -0
  106. package/dist/core/secrets-ops.d.ts +3 -1
  107. package/dist/core/secrets-ops.js +78 -13
  108. package/dist/core/secrets-providers.d.ts +50 -0
  109. package/dist/core/secrets-providers.js +291 -0
  110. package/dist/core/secrets-rotation.d.ts +52 -0
  111. package/dist/core/secrets-rotation.js +165 -0
  112. package/dist/core/secrets-snapshots.d.ts +26 -0
  113. package/dist/core/secrets-snapshots.js +95 -0
  114. package/dist/core/secrets-validate.js +2 -1
  115. package/dist/core/secrets.d.ts +12 -1
  116. package/dist/core/secrets.js +35 -24
  117. package/dist/core/self-update.d.ts +41 -0
  118. package/dist/core/self-update.js +73 -0
  119. package/dist/core/systemd.js +29 -12
  120. package/dist/core/telegram.d.ts +6 -0
  121. package/dist/core/telegram.js +32 -0
  122. package/dist/core/validate.d.ts +7 -0
  123. package/dist/core/validate.js +42 -0
  124. package/dist/index.js +0 -4
  125. package/dist/mcp/deps-tools.js +9 -1
  126. package/dist/mcp/git-tools.js +4 -4
  127. package/dist/mcp/server.js +193 -8
  128. package/dist/templates/systemd.js +3 -3
  129. package/dist/templates/unseal.js +5 -1
  130. package/dist/tui/components/KeyHint.js +10 -0
  131. package/dist/tui/exec-bridge.js +26 -12
  132. package/dist/tui/hooks/use-fleet-data.js +5 -2
  133. package/dist/tui/hooks/use-health.js +5 -2
  134. package/dist/tui/router.js +60 -7
  135. package/dist/tui/routines/RoutinesApp.d.ts +8 -0
  136. package/dist/tui/routines/RoutinesApp.js +277 -0
  137. package/dist/tui/routines/components/AlertsPanel.d.ts +7 -0
  138. package/dist/tui/routines/components/AlertsPanel.js +22 -0
  139. package/dist/tui/routines/components/AlertsPanel.test.d.ts +1 -0
  140. package/dist/tui/routines/components/AlertsPanel.test.js +52 -0
  141. package/dist/tui/routines/components/CommandPalette.d.ts +12 -0
  142. package/dist/tui/routines/components/CommandPalette.js +21 -0
  143. package/dist/tui/routines/components/LiveRunPanel.d.ts +12 -0
  144. package/dist/tui/routines/components/LiveRunPanel.js +107 -0
  145. package/dist/tui/routines/components/RoutineForm.d.ts +8 -0
  146. package/dist/tui/routines/components/RoutineForm.js +254 -0
  147. package/dist/tui/routines/components/SignalsGrid.d.ts +13 -0
  148. package/dist/tui/routines/components/SignalsGrid.js +34 -0
  149. package/dist/tui/routines/components/SignalsGrid.test.d.ts +1 -0
  150. package/dist/tui/routines/components/SignalsGrid.test.js +43 -0
  151. package/dist/tui/routines/format.d.ts +7 -0
  152. package/dist/tui/routines/format.js +51 -0
  153. package/dist/tui/routines/hooks/use-git-fleet.d.ts +33 -0
  154. package/dist/tui/routines/hooks/use-git-fleet.js +82 -0
  155. package/dist/tui/routines/hooks/use-logs-stream.d.ts +13 -0
  156. package/dist/tui/routines/hooks/use-logs-stream.js +64 -0
  157. package/dist/tui/routines/hooks/use-ops-fleet.d.ts +20 -0
  158. package/dist/tui/routines/hooks/use-ops-fleet.js +70 -0
  159. package/dist/tui/routines/hooks/use-repo-detail.d.ts +31 -0
  160. package/dist/tui/routines/hooks/use-repo-detail.js +104 -0
  161. package/dist/tui/routines/hooks/use-security.d.ts +33 -0
  162. package/dist/tui/routines/hooks/use-security.js +110 -0
  163. package/dist/tui/routines/hooks/use-signals.d.ts +9 -0
  164. package/dist/tui/routines/hooks/use-signals.js +60 -0
  165. package/dist/tui/routines/runtime.d.ts +20 -0
  166. package/dist/tui/routines/runtime.js +40 -0
  167. package/dist/tui/routines/tabs/CostTab.d.ts +7 -0
  168. package/dist/tui/routines/tabs/CostTab.js +24 -0
  169. package/dist/tui/routines/tabs/DashboardTab.d.ts +15 -0
  170. package/dist/tui/routines/tabs/DashboardTab.js +10 -0
  171. package/dist/tui/routines/tabs/GitTab.d.ts +6 -0
  172. package/dist/tui/routines/tabs/GitTab.js +39 -0
  173. package/dist/tui/routines/tabs/LogsTab.d.ts +6 -0
  174. package/dist/tui/routines/tabs/LogsTab.js +58 -0
  175. package/dist/tui/routines/tabs/OpsTab.d.ts +6 -0
  176. package/dist/tui/routines/tabs/OpsTab.js +34 -0
  177. package/dist/tui/routines/tabs/RepoDetailView.d.ts +6 -0
  178. package/dist/tui/routines/tabs/RepoDetailView.js +12 -0
  179. package/dist/tui/routines/tabs/RoutinesTab.d.ts +10 -0
  180. package/dist/tui/routines/tabs/RoutinesTab.js +58 -0
  181. package/dist/tui/routines/tabs/ScaffoldTab.d.ts +2 -0
  182. package/dist/tui/routines/tabs/ScaffoldTab.js +127 -0
  183. package/dist/tui/routines/tabs/SecurityTab.d.ts +6 -0
  184. package/dist/tui/routines/tabs/SecurityTab.js +31 -0
  185. package/dist/tui/routines/tabs/SettingsTab.d.ts +6 -0
  186. package/dist/tui/routines/tabs/SettingsTab.js +61 -0
  187. package/dist/tui/routines/tabs/TimelineTab.d.ts +7 -0
  188. package/dist/tui/routines/tabs/TimelineTab.js +26 -0
  189. package/dist/tui/state.js +1 -1
  190. package/dist/tui/tests/keyboard-integration.test.js +3 -0
  191. package/dist/tui/tests/test-app.js +1 -1
  192. package/dist/tui/types.d.ts +2 -2
  193. package/dist/tui/views/AppDetail.js +3 -4
  194. package/dist/tui/views/HealthView.js +7 -1
  195. package/dist/tui/views/LogsView.js +24 -1
  196. package/dist/tui/views/MultiLogsView.d.ts +2 -0
  197. package/dist/tui/views/MultiLogsView.js +165 -0
  198. package/dist/tui/views/SecretEdit.js +10 -3
  199. package/dist/tui/views/SecretsView.js +6 -3
  200. package/dist/ui/prompt.d.ts +52 -0
  201. package/dist/ui/prompt.js +169 -0
  202. package/package.json +34 -21
  203. package/scripts/guard/cert-expiry-watch +109 -0
  204. package/scripts/guard/cf-audit-monitor +169 -0
  205. package/scripts/guard/cf-snapshot +124 -0
  206. package/scripts/guard/cron.d-cf-protect +11 -0
  207. package/scripts/guard/dns-drift-watch +138 -0
  208. package/scripts/guard/fleet-guard +282 -0
  209. package/scripts/guard/fleet-guard-execute +197 -0
  210. package/scripts/guard/notify +108 -0
  211. package/dist/commands/motd.d.ts +0 -1
  212. package/dist/commands/motd.js +0 -10
  213. package/dist/templates/motd.d.ts +0 -1
  214. package/dist/templates/motd.js +0 -7
  215. package/dist/tui/components/AppList.d.ts +0 -12
  216. package/dist/tui/components/AppList.js +0 -32
  217. package/dist/tui/hooks/use-keyboard.d.ts +0 -1
  218. package/dist/tui/hooks/use-keyboard.js +0 -44
@@ -17,6 +17,8 @@ export function LogsView() {
17
17
  const [following, setFollowing] = useState(false);
18
18
  const [loading, setLoading] = useState(true);
19
19
  const streamRef = useRef(null);
20
+ const lineBufferRef = useRef([]);
21
+ const flushTimerRef = useRef(null);
20
22
  useEffect(() => {
21
23
  if (!selectedApp)
22
24
  return;
@@ -35,6 +37,10 @@ export function LogsView() {
35
37
  streamRef.current.kill();
36
38
  streamRef.current = null;
37
39
  }
40
+ if (flushTimerRef.current) {
41
+ clearInterval(flushTimerRef.current);
42
+ flushTimerRef.current = null;
43
+ }
38
44
  };
39
45
  }, [selectedApp]);
40
46
  const handler = (input, key) => {
@@ -44,6 +50,16 @@ export function LogsView() {
44
50
  streamRef.current.kill();
45
51
  streamRef.current = null;
46
52
  }
53
+ if (flushTimerRef.current) {
54
+ clearInterval(flushTimerRef.current);
55
+ flushTimerRef.current = null;
56
+ }
57
+ // flush any remaining buffered lines
58
+ if (lineBufferRef.current.length > 0) {
59
+ const buf = lineBufferRef.current;
60
+ lineBufferRef.current = [];
61
+ setLines(prev => [...prev, ...buf].slice(-MAX_LINES));
62
+ }
47
63
  setFollowing(false);
48
64
  }
49
65
  else if (selectedApp) {
@@ -51,8 +67,15 @@ export function LogsView() {
51
67
  const handle = streamFleetCommand(['logs', selectedApp, '-f']);
52
68
  streamRef.current = handle;
53
69
  handle.onData((line) => {
54
- setLines(prev => [...prev.slice(-MAX_LINES + 1), line]);
70
+ lineBufferRef.current.push(line);
55
71
  });
72
+ flushTimerRef.current = setInterval(() => {
73
+ const buf = lineBufferRef.current;
74
+ if (buf.length === 0)
75
+ return;
76
+ lineBufferRef.current = [];
77
+ setLines(prev => [...prev, ...buf].slice(-MAX_LINES));
78
+ }, 50);
56
79
  }
57
80
  return true;
58
81
  }
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export declare function MultiLogsView(): React.JSX.Element;
@@ -0,0 +1,165 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import Spinner from 'ink-spinner';
5
+ import { useRegisterHandler } from '@matthesketh/ink-input-dispatcher';
6
+ import { useAvailableHeight } from '@matthesketh/ink-viewport';
7
+ import { colors } from '../theme.js';
8
+ import { useRedact } from '../state.js';
9
+ import { load } from '../../core/registry.js';
10
+ import { startMultiTail, resolveSources, } from '../../core/logs-multi.js';
11
+ const MAX_LINES = 500;
12
+ const LEVEL_RANKS = ['debug', 'info', 'warn', 'error', 'all'];
13
+ const LEVEL_RANK_NUMBER = {
14
+ debug: 0, info: 1, warn: 2, error: 3,
15
+ };
16
+ const SOURCE_PALETTE = [colors.primary, colors.success, colors.warning, colors.muted, 'cyan', 'magenta'];
17
+ function colourForSource(name) {
18
+ let h = 0;
19
+ for (let i = 0; i < name.length; i++)
20
+ h = (h * 31 + name.charCodeAt(i)) | 0;
21
+ return SOURCE_PALETTE[Math.abs(h) % SOURCE_PALETTE.length];
22
+ }
23
+ export function MultiLogsView() {
24
+ const redact = useRedact();
25
+ const availableHeight = useAvailableHeight();
26
+ const allSources = useMemo(() => {
27
+ try {
28
+ return resolveSources(load().apps);
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ }, []);
34
+ // Selection: by default, every container. User toggles with Space.
35
+ const [selected, setSelected] = useState(() => new Set(allSources.map(s => `${s.app}/${s.container}`)));
36
+ const [pickerIndex, setPickerIndex] = useState(0);
37
+ const [paused, setPaused] = useState(false);
38
+ const [level, setLevel] = useState('all');
39
+ const [lines, setLines] = useState([]);
40
+ const [pickerFocused, setPickerFocused] = useState(true);
41
+ const handleRef = useRef(null);
42
+ const lineIdRef = useRef(0);
43
+ // Buffer lines between renders so we batch React updates and don't flicker
44
+ // when bursts of output land. flushed every 100ms.
45
+ const pendingRef = useRef([]);
46
+ // (Re)start tailers whenever the selection changes.
47
+ useEffect(() => {
48
+ if (handleRef.current) {
49
+ void handleRef.current.stop();
50
+ handleRef.current = null;
51
+ }
52
+ pendingRef.current = [];
53
+ setLines([]);
54
+ const sources = allSources.filter(s => selected.has(`${s.app}/${s.container}`));
55
+ if (sources.length === 0)
56
+ return;
57
+ const handle = startMultiTail(sources, { tail: 30, follow: true }, line => {
58
+ pendingRef.current.push({ ...line, id: ++lineIdRef.current });
59
+ });
60
+ handleRef.current = handle;
61
+ return () => { void handle.stop(); };
62
+ }, [allSources, selected]);
63
+ // Flush buffered lines into state on a 100ms tick (batched to avoid flicker).
64
+ useEffect(() => {
65
+ const t = setInterval(() => {
66
+ if (paused)
67
+ return;
68
+ if (pendingRef.current.length === 0)
69
+ return;
70
+ const batch = pendingRef.current;
71
+ pendingRef.current = [];
72
+ setLines(prev => {
73
+ const merged = prev.length + batch.length > MAX_LINES
74
+ ? [...prev, ...batch].slice(-MAX_LINES)
75
+ : [...prev, ...batch];
76
+ return merged;
77
+ });
78
+ }, 100);
79
+ return () => clearInterval(t);
80
+ }, [paused]);
81
+ const handler = (input, key) => {
82
+ // Tab toggles focus between picker and viewport (so j/k goes to the right place).
83
+ if (key.tab) {
84
+ setPickerFocused(p => !p);
85
+ return true;
86
+ }
87
+ if (pickerFocused) {
88
+ if (input === 'j' || key.downArrow) {
89
+ setPickerIndex(i => Math.min(i + 1, allSources.length - 1));
90
+ return true;
91
+ }
92
+ if (input === 'k' || key.upArrow) {
93
+ setPickerIndex(i => Math.max(i - 1, 0));
94
+ return true;
95
+ }
96
+ if (input === ' ') {
97
+ const src = allSources[pickerIndex];
98
+ if (!src)
99
+ return true;
100
+ const k = `${src.app}/${src.container}`;
101
+ setSelected(prev => {
102
+ const next = new Set(prev);
103
+ if (next.has(k))
104
+ next.delete(k);
105
+ else
106
+ next.add(k);
107
+ return next;
108
+ });
109
+ return true;
110
+ }
111
+ if (input === 'a') {
112
+ // Select / deselect all
113
+ setSelected(prev => prev.size === allSources.length
114
+ ? new Set()
115
+ : new Set(allSources.map(s => `${s.app}/${s.container}`)));
116
+ return true;
117
+ }
118
+ }
119
+ if (input === 'p') {
120
+ setPaused(p => !p);
121
+ return true;
122
+ }
123
+ if (input === 'c') {
124
+ setLines([]);
125
+ pendingRef.current = [];
126
+ return true;
127
+ }
128
+ if (input === 'L') {
129
+ // Cycle level filter
130
+ setLevel(l => LEVEL_RANKS[(LEVEL_RANKS.indexOf(l) + 1) % LEVEL_RANKS.length]);
131
+ return true;
132
+ }
133
+ return false;
134
+ };
135
+ useRegisterHandler(handler);
136
+ // Apply level filter at render time so the buffer keeps everything (cheap to
137
+ // change filter back and forth without losing history).
138
+ const filteredLines = useMemo(() => {
139
+ if (level === 'all')
140
+ return lines;
141
+ const minRank = LEVEL_RANK_NUMBER[level];
142
+ return lines.filter(l => {
143
+ if (l.level === 'unknown')
144
+ return false;
145
+ return LEVEL_RANK_NUMBER[l.level] >= minRank;
146
+ });
147
+ }, [lines, level]);
148
+ // Rough split of the viewport: 30% picker, 70% logs (min 5 rows each).
149
+ const totalH = Math.max(10, availableHeight - 4);
150
+ const pickerH = Math.max(5, Math.floor(totalH * 0.3));
151
+ const logsH = Math.max(5, totalH - pickerH - 1);
152
+ const visibleLogs = filteredLines.slice(-logsH);
153
+ const maxLabelLen = useMemo(() => allSources.reduce((m, s) => Math.max(m, `${s.app}/${s.container}`.length), 0), [allSources]);
154
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsx(Text, { bold: true, color: colors.primary, children: "Multi-source Logs" }), _jsxs(Text, { color: colors.muted, children: [selected.size, "/", allSources.length, " sources \u00B7 level:", level, " \u00B7 ", paused ? 'PAUSED' : 'live'] }), !paused && handleRef.current && handleRef.current.active() > 0 && (_jsxs(Text, { color: colors.success, children: [_jsx(Spinner, { type: "dots" }), " tailing"] }))] }), _jsxs(Box, { flexDirection: "column", height: pickerH, marginBottom: 1, borderStyle: pickerFocused ? 'round' : 'single', borderColor: pickerFocused ? colors.primary : colors.muted, children: [_jsx(Box, { paddingX: 1, children: _jsx(Text, { bold: true, color: colors.muted, children: "Sources [Tab to switch focus, Space toggle, a all/none]" }) }), _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [allSources.slice(0, pickerH - 2).map((src, i) => {
155
+ const k = `${src.app}/${src.container}`;
156
+ const checked = selected.has(k);
157
+ const cursor = pickerFocused && i === pickerIndex ? '>' : ' ';
158
+ return (_jsxs(Text, { color: checked ? colors.success : colors.muted, children: [cursor, " ", checked ? '☑' : '☐', " ", redact(src.app), "/", src.container] }, k));
159
+ }), allSources.length > pickerH - 2 && (_jsxs(Text, { color: colors.muted, children: ["\u2026 ", allSources.length - (pickerH - 2), " more"] }))] })] }), _jsxs(Box, { flexDirection: "column", height: logsH, borderStyle: !pickerFocused ? 'round' : 'single', borderColor: !pickerFocused ? colors.primary : colors.muted, children: [_jsx(Box, { paddingX: 1, children: _jsxs(Text, { bold: true, color: colors.muted, children: ["Logs [p pause \u00B7 c clear \u00B7 L level cycle \u00B7 last ", visibleLogs.length, "/", filteredLines.length, "]"] }) }), _jsx(Box, { flexDirection: "column", paddingX: 1, children: visibleLogs.map(line => {
160
+ const label = `${line.app}/${line.container}`.padEnd(maxLabelLen);
161
+ const colour = colourForSource(`${line.app}/${line.container}`);
162
+ const ts = line.ts.toISOString().slice(11, 19); // HH:MM:SS
163
+ return (_jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: colors.muted, children: [ts, " "] }), _jsx(Text, { color: colour, children: label }), _jsxs(Text, { children: [" ", line.text] })] }, line.id));
164
+ }) })] })] }));
165
+ }
@@ -1,5 +1,5 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { useState, useEffect } from 'react';
2
+ import { useState, useEffect, useRef } from 'react';
3
3
  import { Box, Text } from 'ink';
4
4
  import TextInput from 'ink-text-input';
5
5
  import { useRegisterHandler } from '@matthesketh/ink-input-dispatcher';
@@ -16,6 +16,13 @@ export function SecretEdit() {
16
16
  const [value, setValue] = useState('');
17
17
  const [phase, setPhase] = useState(isNew ? 'key' : 'value');
18
18
  const [status, setStatus] = useState(null);
19
+ const timerRef = useRef(null);
20
+ useEffect(() => {
21
+ return () => {
22
+ if (timerRef.current)
23
+ clearTimeout(timerRef.current);
24
+ };
25
+ }, []);
19
26
  useEffect(() => {
20
27
  if (!isNew && selectedApp && selectedSecret) {
21
28
  try {
@@ -34,7 +41,7 @@ export function SecretEdit() {
34
41
  const result = secrets.saveSecret(selectedApp, keyName, value);
35
42
  if (result.ok) {
36
43
  setStatus('Saved and re-sealed');
37
- setTimeout(() => {
44
+ timerRef.current = setTimeout(() => {
38
45
  dispatch({ type: 'GO_BACK' });
39
46
  }, 500);
40
47
  }
@@ -53,5 +60,5 @@ export function SecretEdit() {
53
60
  return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { bold: true, color: colors.primary, children: [isNew ? 'Add Secret' : 'Edit Secret', " - ", selectedApp] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", gap: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: colors.muted, children: "Key: " }), isNew && phase === 'key' ? (_jsx(TextInput, { value: keyName, onChange: setKeyName, onSubmit: () => {
54
61
  if (keyName)
55
62
  setPhase('value');
56
- } })) : (_jsx(Text, { bold: true, children: keyName }))] }), _jsxs(Box, { children: [_jsx(Text, { color: colors.muted, children: "Value: " }), phase === 'value' ? (_jsx(TextInput, { value: value, onChange: setValue, onSubmit: save })) : (_jsx(Text, { color: colors.muted, children: "(press Enter on key first)" }))] })] }), status && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: status.startsWith('Error') ? colors.error : colors.success, children: status }) })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.muted, children: "Enter to save | Esc to cancel" }) })] }));
63
+ } })) : (_jsx(Text, { bold: true, children: keyName }))] }), _jsxs(Box, { children: [_jsx(Text, { color: colors.muted, children: "Value: " }), phase === 'value' ? (_jsx(TextInput, { value: value, onChange: setValue, onSubmit: save, mask: "*" })) : (_jsx(Text, { color: colors.muted, children: "(press Enter on key first)" }))] })] }), status && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: status.startsWith('Error') ? colors.error : colors.success, children: status }) })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.muted, children: "Enter to save | Esc to cancel" }) })] }));
57
64
  }
@@ -56,6 +56,8 @@ export function SecretsView() {
56
56
  }
57
57
  }
58
58
  else if (subView === 'secret-list') {
59
+ if (secrets.secrets.length === 0)
60
+ return false;
59
61
  if (input === 'j' || key.downArrow) {
60
62
  dispatch({ type: 'SET_INDEX', view: 'secrets', index: Math.min(selectedIndex + 1, secrets.secrets.length - 1) });
61
63
  return true;
@@ -80,15 +82,16 @@ export function SecretsView() {
80
82
  }
81
83
  if (input === 'd' && selectedApp && secrets.secrets[selectedIndex]) {
82
84
  const secretKey = secrets.secrets[selectedIndex].key;
85
+ const appName = selectedApp;
83
86
  dispatch({
84
87
  type: 'CONFIRM',
85
88
  action: {
86
89
  label: `Delete secret "${secretKey}"?`,
87
- description: `This will remove ${secretKey} from ${redact(selectedApp)}'s vault.`,
90
+ description: `This will remove ${secretKey} from ${redact(appName)}'s vault.`,
88
91
  onConfirm: () => {
89
- const result = secrets.deleteSecret(selectedApp, secretKey);
92
+ const result = secrets.deleteSecret(appName, secretKey);
90
93
  if (result.ok) {
91
- secrets.loadAppSecrets(selectedApp);
94
+ secrets.loadAppSecrets(appName);
92
95
  secrets.refresh();
93
96
  }
94
97
  else {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Plain-text and hidden-input prompts, no external deps.
3
+ *
4
+ * promptHidden uses raw mode + manual char-by-char read so the echoed value
5
+ * never appears on the terminal — important for pasting secrets and for
6
+ * tools like `script` / asciinema.
7
+ */
8
+ export declare function prompt(message: string, defaultValue?: string): Promise<string>;
9
+ /**
10
+ * String variant — convenience wrapper around the Buffer variant. Use the
11
+ * Buffer variant directly if you want the strongest in-memory guarantees;
12
+ * this one converts to string and the result lives in the V8 heap until GC.
13
+ */
14
+ export declare function promptHidden(message: string): Promise<string>;
15
+ /**
16
+ * Buffer-based hidden input. Returns the raw input bytes in a Buffer the
17
+ * caller is expected to zero out (`buf.fill(0)`) when finished — see
18
+ * `withSecretBuffer` for an automated pattern.
19
+ *
20
+ * Why Buffer (not string)?
21
+ * - Node strings are immutable + interned in V8 heap; you can't zero them.
22
+ * Once a secret string exists, it sits in the heap until GC.
23
+ * - Buffer is a writable byte array. Calling `buf.fill(0)` overwrites the
24
+ * bytes in-place; subsequent heap dumps and core dumps contain zeros.
25
+ *
26
+ * Hardening:
27
+ * - Buffer is grown by `Buffer.concat` and the intermediate buffers are
28
+ * zeroed before being released.
29
+ * - Non-TTY fallback uses `terminal: false` so readline can never promote
30
+ * stdout to terminal mode and echo the value.
31
+ * - End/error/SIGINT all reject + restore terminal state and zero the
32
+ * in-flight buffer so a death never leaves bytes behind.
33
+ *
34
+ * KNOWN LIMITATION: any string copy made downstream (e.g. `buf.toString()`
35
+ * for regex validation) lives in V8 heap until GC. Convert as late as
36
+ * possible and let the string go out of scope ASAP.
37
+ */
38
+ export declare function promptHiddenBuffer(message: string): Promise<Buffer>;
39
+ /**
40
+ * Recommended pattern for handling a secret in memory: read into a Buffer,
41
+ * pass to your callback, zero on exit (success or throw). Use this instead
42
+ * of `promptHidden` when the value will be processed by code under your
43
+ * control end-to-end.
44
+ *
45
+ * Example:
46
+ * await withSecretBuffer('Paste new STRIPE_SECRET_KEY', async (buf) => {
47
+ * const asString = buf.toString('utf8'); // brief string copy
48
+ * await sealApp(app, applyRotation(...)); // age-encrypt happens here
49
+ * // String copy is unreferenced from this point; buf is zeroed on exit.
50
+ * });
51
+ */
52
+ export declare function withSecretBuffer<T>(message: string, fn: (value: Buffer) => Promise<T>): Promise<T>;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Plain-text and hidden-input prompts, no external deps.
3
+ *
4
+ * promptHidden uses raw mode + manual char-by-char read so the echoed value
5
+ * never appears on the terminal — important for pasting secrets and for
6
+ * tools like `script` / asciinema.
7
+ */
8
+ import * as readline from 'node:readline';
9
+ export async function prompt(message, defaultValue) {
10
+ const hint = defaultValue !== undefined ? ` [${defaultValue}]` : '';
11
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
12
+ return new Promise(resolve => {
13
+ rl.question(`${message}${hint}: `, answer => {
14
+ rl.close();
15
+ const trimmed = answer.trim();
16
+ resolve(trimmed === '' && defaultValue !== undefined ? defaultValue : trimmed);
17
+ });
18
+ });
19
+ }
20
+ /**
21
+ * String variant — convenience wrapper around the Buffer variant. Use the
22
+ * Buffer variant directly if you want the strongest in-memory guarantees;
23
+ * this one converts to string and the result lives in the V8 heap until GC.
24
+ */
25
+ export async function promptHidden(message) {
26
+ const buf = await promptHiddenBuffer(message);
27
+ try {
28
+ return buf.toString('utf8');
29
+ }
30
+ finally {
31
+ buf.fill(0);
32
+ }
33
+ }
34
+ /**
35
+ * Buffer-based hidden input. Returns the raw input bytes in a Buffer the
36
+ * caller is expected to zero out (`buf.fill(0)`) when finished — see
37
+ * `withSecretBuffer` for an automated pattern.
38
+ *
39
+ * Why Buffer (not string)?
40
+ * - Node strings are immutable + interned in V8 heap; you can't zero them.
41
+ * Once a secret string exists, it sits in the heap until GC.
42
+ * - Buffer is a writable byte array. Calling `buf.fill(0)` overwrites the
43
+ * bytes in-place; subsequent heap dumps and core dumps contain zeros.
44
+ *
45
+ * Hardening:
46
+ * - Buffer is grown by `Buffer.concat` and the intermediate buffers are
47
+ * zeroed before being released.
48
+ * - Non-TTY fallback uses `terminal: false` so readline can never promote
49
+ * stdout to terminal mode and echo the value.
50
+ * - End/error/SIGINT all reject + restore terminal state and zero the
51
+ * in-flight buffer so a death never leaves bytes behind.
52
+ *
53
+ * KNOWN LIMITATION: any string copy made downstream (e.g. `buf.toString()`
54
+ * for regex validation) lives in V8 heap until GC. Convert as late as
55
+ * possible and let the string go out of scope ASAP.
56
+ */
57
+ export function promptHiddenBuffer(message) {
58
+ if (!process.stdin.isTTY) {
59
+ return new Promise((resolve, reject) => {
60
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
61
+ process.stdout.write(message + ': ');
62
+ let done = false;
63
+ const finish = (cb) => { if (done)
64
+ return; done = true; rl.close(); cb(); };
65
+ rl.once('line', line => finish(() => resolve(Buffer.from(line, 'utf8'))));
66
+ rl.once('close', () => finish(() => reject(new Error('Cancelled (stdin closed)'))));
67
+ rl.once('error', err => finish(() => reject(err)));
68
+ });
69
+ }
70
+ return new Promise((resolve, reject) => {
71
+ const stdin = process.stdin;
72
+ process.stdout.write(message + ': ');
73
+ // Pre-allocate; grow geometrically to avoid quadratic Buffer.concat.
74
+ let buf = Buffer.alloc(64);
75
+ let len = 0;
76
+ let settled = false;
77
+ const wasRaw = stdin.isRaw;
78
+ const grow = (need) => {
79
+ if (len + need <= buf.length)
80
+ return;
81
+ const next = Buffer.alloc(Math.max(buf.length * 2, len + need));
82
+ buf.copy(next);
83
+ buf.fill(0); // zero the old buffer before releasing
84
+ buf = next;
85
+ };
86
+ const cleanup = () => {
87
+ stdin.removeListener('data', onData);
88
+ stdin.removeListener('end', onEnd);
89
+ stdin.removeListener('error', onError);
90
+ process.removeListener('SIGINT', onSigint);
91
+ try {
92
+ stdin.setRawMode(wasRaw);
93
+ }
94
+ catch { /* terminal may be gone */ }
95
+ stdin.pause();
96
+ };
97
+ const settle = (fn) => {
98
+ if (settled)
99
+ return;
100
+ settled = true;
101
+ cleanup();
102
+ fn();
103
+ };
104
+ const wipeAndReject = (err) => {
105
+ buf.fill(0);
106
+ settle(() => reject(err));
107
+ };
108
+ const onData = (chunk) => {
109
+ for (let i = 0; i < chunk.length; i++) {
110
+ const c = chunk[i];
111
+ if (c === 0x0d || c === 0x0a) { // \r \n
112
+ process.stdout.write('\n');
113
+ const out = Buffer.alloc(len);
114
+ buf.copy(out, 0, 0, len);
115
+ buf.fill(0);
116
+ settle(() => resolve(out));
117
+ return;
118
+ }
119
+ else if (c === 0x03) { // Ctrl-C
120
+ process.stdout.write('\n');
121
+ wipeAndReject(new Error('Cancelled'));
122
+ return;
123
+ }
124
+ else if (c === 0x7f || c === 0x08) { // backspace
125
+ if (len > 0) {
126
+ len -= 1;
127
+ buf[len] = 0;
128
+ }
129
+ }
130
+ else if (c >= 0x20) { // printable
131
+ grow(1);
132
+ buf[len++] = c;
133
+ }
134
+ }
135
+ };
136
+ const onEnd = () => wipeAndReject(new Error('Cancelled (stdin ended)'));
137
+ const onError = (err) => wipeAndReject(err);
138
+ const onSigint = () => { process.stdout.write('\n'); wipeAndReject(new Error('Cancelled (SIGINT)')); };
139
+ stdin.setRawMode(true);
140
+ stdin.resume();
141
+ // Note: do NOT setEncoding — we want raw bytes, not auto-decoded strings.
142
+ stdin.on('data', onData);
143
+ stdin.on('end', onEnd);
144
+ stdin.on('error', onError);
145
+ process.on('SIGINT', onSigint);
146
+ });
147
+ }
148
+ /**
149
+ * Recommended pattern for handling a secret in memory: read into a Buffer,
150
+ * pass to your callback, zero on exit (success or throw). Use this instead
151
+ * of `promptHidden` when the value will be processed by code under your
152
+ * control end-to-end.
153
+ *
154
+ * Example:
155
+ * await withSecretBuffer('Paste new STRIPE_SECRET_KEY', async (buf) => {
156
+ * const asString = buf.toString('utf8'); // brief string copy
157
+ * await sealApp(app, applyRotation(...)); // age-encrypt happens here
158
+ * // String copy is unreferenced from this point; buf is zeroed on exit.
159
+ * });
160
+ */
161
+ export async function withSecretBuffer(message, fn) {
162
+ const buf = await promptHiddenBuffer(message);
163
+ try {
164
+ return await fn(buf);
165
+ }
166
+ finally {
167
+ buf.fill(0);
168
+ }
169
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matthesketh/fleet",
3
- "version": "1.2.0",
3
+ "version": "1.7.0",
4
4
  "description": "Docker production management CLI + MCP server for Claude Code",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -10,12 +10,12 @@
10
10
  "files": [
11
11
  "dist/",
12
12
  "data/registry.example.json",
13
+ "scripts/guard/",
13
14
  "LICENSE",
14
15
  "README.md"
15
16
  ],
16
- "workspaces": ["packages/*"],
17
17
  "scripts": {
18
- "build": "tsc",
18
+ "build": "tsc && tsc-alias",
19
19
  "dev": "tsx src/index.ts",
20
20
  "test": "vitest run",
21
21
  "prepublishOnly": "npm run build"
@@ -48,33 +48,46 @@
48
48
  "node": ">=20"
49
49
  },
50
50
  "dependencies": {
51
- "@modelcontextprotocol/sdk": "1.8.0",
52
- "@matthesketh/ink-breadcrumb": "*",
53
- "@matthesketh/ink-gauge": "*",
54
- "@matthesketh/ink-input-dispatcher": "*",
55
- "@matthesketh/ink-keybinding-help": "*",
56
- "@matthesketh/ink-log-viewer": "*",
57
- "@matthesketh/ink-modal": "*",
58
- "@matthesketh/ink-pipeline": "*",
59
- "@matthesketh/ink-rule": "*",
60
- "@matthesketh/ink-scrollable-list": "*",
61
- "@matthesketh/ink-split-pane": "*",
62
- "@matthesketh/ink-status-bar": "*",
63
- "@matthesketh/ink-table": "*",
64
- "@matthesketh/ink-tabs": "*",
65
- "@matthesketh/ink-toast": "*",
66
- "@matthesketh/ink-viewport": "*",
51
+ "@matthesketh/ink-breadcrumb": "^0.1.0",
52
+ "@matthesketh/ink-chart": "0.1.0",
53
+ "@matthesketh/ink-form": "0.1.0",
54
+ "@matthesketh/ink-fuzzy-select": "0.1.0",
55
+ "@matthesketh/ink-gauge": "^0.1.0",
56
+ "@matthesketh/ink-input-dispatcher": "^0.1.0",
57
+ "@matthesketh/ink-keybinding-help": "^0.1.0",
58
+ "@matthesketh/ink-log-viewer": "^0.1.0",
59
+ "@matthesketh/ink-modal": "^0.1.0",
60
+ "@matthesketh/ink-pipeline": "^0.1.0",
61
+ "@matthesketh/ink-rule": "^0.1.0",
62
+ "@matthesketh/ink-scrollable-list": "^0.1.1",
63
+ "@matthesketh/ink-split-pane": "^0.1.0",
64
+ "@matthesketh/ink-stable-state": "^0.1.0",
65
+ "@matthesketh/ink-status-bar": "^0.1.0",
66
+ "@matthesketh/ink-table": "^0.1.0",
67
+ "@matthesketh/ink-tabs": "^0.1.0",
68
+ "@matthesketh/ink-task-list": "0.1.0",
69
+ "@matthesketh/ink-timeline": "0.1.0",
70
+ "@matthesketh/ink-toast": "^0.1.0",
71
+ "@matthesketh/ink-viewport": "^0.1.0",
72
+ "@modelcontextprotocol/sdk": "1.29.0",
73
+ "better-sqlite3": "12.9.0",
74
+ "chokidar": "5.0.0",
67
75
  "ink": "^5.2.1",
68
76
  "ink-spinner": "^5.0.0",
69
77
  "ink-text-input": "^6.0.0",
78
+ "proper-lockfile": "4.1.2",
70
79
  "react": "^18.3.1",
71
80
  "zod": "^3.24.0"
72
81
  },
73
82
  "devDependencies": {
83
+ "@types/better-sqlite3": "7.6.13",
74
84
  "@types/node": "20.17.0",
85
+ "@types/proper-lockfile": "4.1.4",
75
86
  "@types/react": "^18.3.28",
76
- "tsx": "4.19.2",
87
+ "ink-testing-library": "^4.0.0",
88
+ "tsc-alias": "1.8.16",
89
+ "tsx": "4.21.0",
77
90
  "typescript": "5.6.3",
78
91
  "vitest": "4.0.18"
79
92
  }
80
- }
93
+ }