@matthesketh/fleet 1.8.0 → 1.11.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 (233) hide show
  1. package/README.md +186 -16
  2. package/dist/bin/fleet-agent.d.ts +2 -0
  3. package/dist/bin/fleet-agent.js +7 -0
  4. package/dist/cli.d.ts +5 -0
  5. package/dist/cli.js +73 -31
  6. package/dist/commands/add.d.ts +2 -1
  7. package/dist/commands/add.js +66 -59
  8. package/dist/commands/audit.d.ts +1 -0
  9. package/dist/commands/audit.js +144 -0
  10. package/dist/commands/backup.d.ts +1 -0
  11. package/dist/commands/backup.js +510 -0
  12. package/dist/commands/boot-start.d.ts +3 -1
  13. package/dist/commands/boot-start.js +39 -47
  14. package/dist/commands/completions.d.ts +6 -0
  15. package/dist/commands/completions.js +83 -0
  16. package/dist/commands/config.d.ts +16 -0
  17. package/dist/commands/config.js +96 -0
  18. package/dist/commands/deploy.js +3 -2
  19. package/dist/commands/deps.js +5 -1
  20. package/dist/commands/doctor.d.ts +32 -0
  21. package/dist/commands/doctor.js +186 -0
  22. package/dist/commands/egress.d.ts +1 -1
  23. package/dist/commands/egress.js +13 -10
  24. package/dist/commands/freeze.d.ts +8 -4
  25. package/dist/commands/freeze.js +77 -59
  26. package/dist/commands/git.js +2 -2
  27. package/dist/commands/health.d.ts +2 -1
  28. package/dist/commands/health.js +38 -56
  29. package/dist/commands/init.d.ts +2 -1
  30. package/dist/commands/init.js +83 -73
  31. package/dist/commands/install-mcp.d.ts +3 -1
  32. package/dist/commands/install-mcp.js +53 -34
  33. package/dist/commands/list.d.ts +2 -1
  34. package/dist/commands/list.js +22 -19
  35. package/dist/commands/logs.js +1 -1
  36. package/dist/commands/notify.d.ts +1 -0
  37. package/dist/commands/notify.js +51 -0
  38. package/dist/commands/patch-systemd.d.ts +7 -1
  39. package/dist/commands/patch-systemd.js +71 -31
  40. package/dist/commands/remove.d.ts +3 -1
  41. package/dist/commands/remove.js +37 -26
  42. package/dist/commands/restart.d.ts +4 -1
  43. package/dist/commands/restart.js +17 -20
  44. package/dist/commands/rollback.d.ts +4 -1
  45. package/dist/commands/rollback.js +33 -42
  46. package/dist/commands/secrets.js +157 -9
  47. package/dist/commands/start.d.ts +4 -1
  48. package/dist/commands/start.js +17 -20
  49. package/dist/commands/status.d.ts +1 -1
  50. package/dist/commands/status.js +21 -26
  51. package/dist/commands/stop.d.ts +4 -1
  52. package/dist/commands/stop.js +17 -20
  53. package/dist/commands/testflight.d.ts +1 -0
  54. package/dist/commands/testflight.js +193 -0
  55. package/dist/commands/update.d.ts +16 -0
  56. package/dist/commands/update.js +95 -0
  57. package/dist/core/audit/cache.d.ts +4 -0
  58. package/dist/core/audit/cache.js +37 -0
  59. package/dist/core/audit/config.d.ts +5 -0
  60. package/dist/core/audit/config.js +35 -0
  61. package/dist/core/audit/greenlight.d.ts +11 -0
  62. package/dist/core/audit/greenlight.js +81 -0
  63. package/dist/core/audit/reporters/cli.d.ts +3 -0
  64. package/dist/core/audit/reporters/cli.js +68 -0
  65. package/dist/core/audit/suppress.d.ts +6 -0
  66. package/dist/core/audit/suppress.js +37 -0
  67. package/dist/core/audit/target.d.ts +5 -0
  68. package/dist/core/audit/target.js +26 -0
  69. package/dist/core/audit/types.d.ts +54 -0
  70. package/dist/core/audit/types.js +5 -0
  71. package/dist/core/backup/browser-api.d.ts +66 -0
  72. package/dist/core/backup/browser-api.js +197 -0
  73. package/dist/core/backup/browser-server.d.ts +11 -0
  74. package/dist/core/backup/browser-server.js +241 -0
  75. package/dist/core/backup/browser-ui.d.ts +5 -0
  76. package/dist/core/backup/browser-ui.js +268 -0
  77. package/dist/core/backup/cloudflare.d.ts +7 -0
  78. package/dist/core/backup/cloudflare.js +82 -0
  79. package/dist/core/backup/config.d.ts +9 -0
  80. package/dist/core/backup/config.js +80 -0
  81. package/dist/core/backup/detect.d.ts +11 -0
  82. package/dist/core/backup/detect.js +71 -0
  83. package/dist/core/backup/dump.d.ts +11 -0
  84. package/dist/core/backup/dump.js +82 -0
  85. package/dist/core/backup/index.d.ts +9 -0
  86. package/dist/core/backup/index.js +9 -0
  87. package/dist/core/backup/repo.d.ts +71 -0
  88. package/dist/core/backup/repo.js +256 -0
  89. package/dist/core/backup/schedule.d.ts +17 -0
  90. package/dist/core/backup/schedule.js +90 -0
  91. package/dist/core/backup/sensitive.d.ts +5 -0
  92. package/dist/core/backup/sensitive.js +37 -0
  93. package/dist/core/backup/status.d.ts +3 -0
  94. package/dist/core/backup/status.js +29 -0
  95. package/dist/core/backup/statuspage.d.ts +23 -0
  96. package/dist/core/backup/statuspage.js +145 -0
  97. package/dist/core/backup/system.d.ts +24 -0
  98. package/dist/core/backup/system.js +209 -0
  99. package/dist/core/backup/totp.d.ts +16 -0
  100. package/dist/core/backup/totp.js +116 -0
  101. package/dist/core/backup/types.d.ts +70 -0
  102. package/dist/core/backup/types.js +7 -0
  103. package/dist/core/backup/unlock.d.ts +19 -0
  104. package/dist/core/backup/unlock.js +69 -0
  105. package/dist/core/boot-refresh.d.ts +1 -1
  106. package/dist/core/boot-refresh.js +10 -9
  107. package/dist/core/deps/actors/pr-creator.d.ts +5 -3
  108. package/dist/core/deps/actors/pr-creator.js +71 -18
  109. package/dist/core/deps/collectors/fetch-with-timeout.d.ts +7 -0
  110. package/dist/core/deps/collectors/fetch-with-timeout.js +16 -0
  111. package/dist/core/deps/collectors/npm.js +3 -1
  112. package/dist/core/deps/collectors/vulnerability.d.ts +8 -0
  113. package/dist/core/deps/collectors/vulnerability.js +31 -2
  114. package/dist/core/deps/config.js +6 -0
  115. package/dist/core/deps/scanner.js +1 -1
  116. package/dist/core/deps/types.d.ts +8 -0
  117. package/dist/core/env.d.ts +3 -0
  118. package/dist/core/env.js +11 -0
  119. package/dist/core/exec.d.ts +1 -0
  120. package/dist/core/exec.js +4 -0
  121. package/dist/core/file-lock.d.ts +18 -0
  122. package/dist/core/file-lock.js +44 -0
  123. package/dist/core/git-onboard.js +10 -13
  124. package/dist/core/github.d.ts +3 -1
  125. package/dist/core/github.js +10 -7
  126. package/dist/core/logs-policy.d.ts +5 -0
  127. package/dist/core/logs-policy.js +20 -1
  128. package/dist/core/operator.d.ts +21 -0
  129. package/dist/core/operator.js +54 -0
  130. package/dist/core/registry.d.ts +18 -0
  131. package/dist/core/registry.js +26 -0
  132. package/dist/core/routines/schema.d.ts +11 -11
  133. package/dist/core/routines/schema.js +14 -3
  134. package/dist/core/routines/store.d.ts +8 -8
  135. package/dist/core/secrets-ops.d.ts +31 -6
  136. package/dist/core/secrets-ops.js +208 -102
  137. package/dist/core/secrets-providers.js +2 -2
  138. package/dist/core/secrets-rotation.d.ts +1 -1
  139. package/dist/core/secrets-rotation.js +58 -52
  140. package/dist/core/secrets-v2-cleanup.d.ts +19 -0
  141. package/dist/core/secrets-v2-cleanup.js +94 -0
  142. package/dist/core/secrets-v2-creds.d.ts +9 -0
  143. package/dist/core/secrets-v2-creds.js +44 -0
  144. package/dist/core/secrets-v2-install.d.ts +13 -0
  145. package/dist/core/secrets-v2-install.js +76 -0
  146. package/dist/core/secrets-v2-keypair.d.ts +10 -0
  147. package/dist/core/secrets-v2-keypair.js +31 -0
  148. package/dist/core/secrets-v2-migrate.d.ts +29 -0
  149. package/dist/core/secrets-v2-migrate.js +395 -0
  150. package/dist/core/secrets-v2-ops.d.ts +36 -0
  151. package/dist/core/secrets-v2-ops.js +184 -0
  152. package/dist/core/secrets-v2-protocol.d.ts +19 -0
  153. package/dist/core/secrets-v2-protocol.js +60 -0
  154. package/dist/core/secrets-v2-snapshot.d.ts +36 -0
  155. package/dist/core/secrets-v2-snapshot.js +115 -0
  156. package/dist/core/secrets-v2.d.ts +21 -0
  157. package/dist/core/secrets-v2.js +249 -0
  158. package/dist/core/secrets.d.ts +39 -4
  159. package/dist/core/secrets.js +91 -11
  160. package/dist/core/self-update.d.ts +32 -11
  161. package/dist/core/self-update.js +52 -14
  162. package/dist/core/testflight/asc.d.ts +12 -0
  163. package/dist/core/testflight/asc.js +101 -0
  164. package/dist/core/testflight/credentials.d.ts +3 -0
  165. package/dist/core/testflight/credentials.js +35 -0
  166. package/dist/core/testflight/eas.d.ts +4 -0
  167. package/dist/core/testflight/eas.js +38 -0
  168. package/dist/core/testflight/resolve.d.ts +6 -0
  169. package/dist/core/testflight/resolve.js +44 -0
  170. package/dist/core/testflight/types.d.ts +13 -0
  171. package/dist/core/testflight/types.js +3 -0
  172. package/dist/core/testflight/workflow.d.ts +17 -0
  173. package/dist/core/testflight/workflow.js +65 -0
  174. package/dist/core/validate.d.ts +1 -0
  175. package/dist/core/validate.js +8 -0
  176. package/dist/mcp/audit-tools.d.ts +2 -0
  177. package/dist/mcp/audit-tools.js +94 -0
  178. package/dist/mcp/git-tools.js +1 -1
  179. package/dist/mcp/registry-bridge.d.ts +10 -0
  180. package/dist/mcp/registry-bridge.js +65 -0
  181. package/dist/mcp/secrets-tools.js +2 -2
  182. package/dist/mcp/server.js +16 -82
  183. package/dist/mcp/testflight-tools.d.ts +2 -0
  184. package/dist/mcp/testflight-tools.js +52 -0
  185. package/dist/registry/context.d.ts +7 -0
  186. package/dist/registry/context.js +37 -0
  187. package/dist/registry/index.d.ts +5 -0
  188. package/dist/registry/index.js +44 -0
  189. package/dist/registry/parse-args.d.ts +13 -0
  190. package/dist/registry/parse-args.js +74 -0
  191. package/dist/registry/registry.d.ts +24 -0
  192. package/dist/registry/registry.js +26 -0
  193. package/dist/registry/render.d.ts +3 -0
  194. package/dist/registry/render.js +29 -0
  195. package/dist/registry/types.d.ts +50 -0
  196. package/dist/registry/types.js +1 -0
  197. package/dist/templates/agent-unit.d.ts +5 -0
  198. package/dist/templates/agent-unit.js +40 -0
  199. package/dist/templates/app-unit-edit.d.ts +2 -0
  200. package/dist/templates/app-unit-edit.js +46 -0
  201. package/dist/templates/compose-edit.d.ts +2 -0
  202. package/dist/templates/compose-edit.js +156 -0
  203. package/dist/templates/nginx.js +11 -0
  204. package/dist/templates/systemd.js +6 -0
  205. package/dist/tui/components/ArgForm.d.ts +7 -0
  206. package/dist/tui/components/ArgForm.js +64 -0
  207. package/dist/tui/components/ArgForm.test.d.ts +1 -0
  208. package/dist/tui/components/ArgForm.test.js +19 -0
  209. package/dist/tui/components/KeyHint.js +5 -0
  210. package/dist/tui/hooks/use-secrets.d.ts +8 -8
  211. package/dist/tui/hooks/use-secrets.js +7 -7
  212. package/dist/tui/router.d.ts +1 -0
  213. package/dist/tui/router.js +26 -9
  214. package/dist/tui/router.test.d.ts +1 -0
  215. package/dist/tui/router.test.js +13 -0
  216. package/dist/tui/routines/components/SignalsGrid.test.js +2 -2
  217. package/dist/tui/routines/tabs/ScaffoldTab.js +1 -1
  218. package/dist/tui/tests/redaction-rerender.test.d.ts +1 -0
  219. package/dist/tui/tests/redaction-rerender.test.js +53 -0
  220. package/dist/tui/tests/scroll-flicker-proof.test.d.ts +1 -0
  221. package/dist/tui/tests/scroll-flicker-proof.test.js +145 -0
  222. package/dist/tui/types.d.ts +1 -1
  223. package/dist/tui/views/CommandPalette.d.ts +5 -0
  224. package/dist/tui/views/CommandPalette.js +90 -0
  225. package/dist/tui/views/CommandPalette.test.d.ts +1 -0
  226. package/dist/tui/views/CommandPalette.test.js +117 -0
  227. package/dist/tui/views/Dashboard.js +10 -7
  228. package/dist/tui/views/HealthView.js +14 -5
  229. package/dist/tui/views/SecretEdit.js +15 -16
  230. package/dist/tui/views/SecretEdit.test.d.ts +1 -0
  231. package/dist/tui/views/SecretEdit.test.js +82 -0
  232. package/dist/tui/views/SecretsView.js +26 -16
  233. package/package.json +9 -6
@@ -5,16 +5,20 @@ import Spinner from 'ink-spinner';
5
5
  import { useRegisterHandler } from '@matthesketh/ink-input-dispatcher';
6
6
  import { ScrollableList } from '@matthesketh/ink-scrollable-list';
7
7
  import { useAvailableHeight } from '@matthesketh/ink-viewport';
8
- import { useAppState, useAppDispatch, useRedact } from '../state.js';
8
+ import { useAppState, useAppDispatch, redactName } from '../state.js';
9
9
  import { useFleetData } from '../hooks/use-fleet-data.js';
10
10
  import { colors } from '../theme.js';
11
11
  export function Dashboard() {
12
12
  const state = useAppState();
13
13
  const dispatch = useAppDispatch();
14
14
  const { status, loading, error } = useFleetData();
15
- const redact = useRedact();
16
15
  const availableHeight = useAvailableHeight();
17
- const items = useMemo(() => status?.apps.map(app => ({ ...app, name: app.name })) ?? [], [status]);
16
+ const items = useMemo(() => (status?.apps ?? []).map(app => ({
17
+ ...app,
18
+ // bake the redacted label onto the item: ScrollableList memoises rows by
19
+ // item identity, so a redaction toggle must yield fresh item objects.
20
+ displayLabel: state.redacted ? redactName(app.name) : app.name,
21
+ })), [status, state.redacted]);
18
22
  const handler = (input, key) => {
19
23
  if (items.length === 0)
20
24
  return false;
@@ -46,9 +50,8 @@ export function Dashboard() {
46
50
  if (!status)
47
51
  return _jsx(Text, { color: colors.muted, children: "No data" });
48
52
  const listHeight = Math.max(5, availableHeight - 4);
49
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsxs(Text, { bold: true, children: [status.totalApps, " apps"] }), _jsxs(Text, { color: colors.success, children: [status.healthy, " healthy"] }), status.unhealthy > 0 && (_jsxs(Text, { color: colors.error, children: [status.unhealthy, " unhealthy"] })), loading && _jsx(Text, { color: colors.muted, children: _jsx(Spinner, { type: "dots" }) })] }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, children: ['APP'.padEnd(24), 'SYSTEMD'.padEnd(14), 'CONTAINERS'.padEnd(14), 'HEALTH'.padEnd(12)] }) }), _jsx(ScrollableList, { items: items, selectedIndex: Math.min(state.dashboardIndex, items.length - 1), maxVisible: listHeight, renderItem: (item, selected) => {
50
- const app = status.apps.find(a => a.name === item.name);
51
- const displayName = redact(app.name);
52
- return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: selected ? colors.primary : colors.muted, children: selected ? '> ' : ' ' }), _jsx(Box, { width: 24, children: _jsx(Text, { bold: selected, color: selected ? colors.primary : colors.text, children: displayName.length > 22 ? displayName.slice(0, 19) + '...' : displayName }) }), _jsx(Box, { width: 14, children: _jsx(Text, { children: app.systemd.slice(0, 12) }) }), _jsx(Box, { width: 14, children: _jsx(Text, { children: app.containers }) }), _jsx(Box, { width: 12, children: _jsx(Text, { children: app.health.slice(0, 10) }) })] }));
53
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsxs(Text, { bold: true, children: [status.totalApps, " apps"] }), _jsxs(Text, { color: colors.success, children: [status.healthy, " healthy"] }), status.unhealthy > 0 && (_jsxs(Text, { color: colors.error, children: [status.unhealthy, " unhealthy"] }))] }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, children: ['APP'.padEnd(24), 'SYSTEMD'.padEnd(14), 'CONTAINERS'.padEnd(14), 'HEALTH'.padEnd(12)] }) }), _jsx(ScrollableList, { items: items, selectedIndex: Math.min(state.dashboardIndex, items.length - 1), maxVisible: listHeight, renderItem: (item, selected) => {
54
+ const label = item.displayLabel;
55
+ return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: selected ? colors.primary : colors.muted, children: selected ? '> ' : ' ' }), _jsx(Box, { width: 24, children: _jsx(Text, { bold: selected, color: selected ? colors.primary : colors.text, children: label.length > 22 ? label.slice(0, 19) + '...' : label }) }), _jsx(Box, { width: 14, children: _jsx(Text, { children: item.systemd.slice(0, 12) }) }), _jsx(Box, { width: 14, children: _jsx(Text, { children: item.containers }) }), _jsx(Box, { width: 12, children: _jsx(Text, { children: item.health.slice(0, 10) }) })] }));
53
56
  } })] }));
54
57
  }
@@ -7,19 +7,28 @@ import { ScrollableList } from '@matthesketh/ink-scrollable-list';
7
7
  import { useAvailableHeight } from '@matthesketh/ink-viewport';
8
8
  import { useHealth } from '../hooks/use-health.js';
9
9
  import { StatusBadge } from '../components/StatusBadge.js';
10
- import { useAppState, useAppDispatch, useRedact } from '../state.js';
10
+ import { useAppState, useAppDispatch, redactName } from '../state.js';
11
11
  import { colors } from '../theme.js';
12
12
  export function HealthView() {
13
13
  const state = useAppState();
14
14
  const dispatch = useAppDispatch();
15
15
  const { results, loading, error } = useHealth();
16
- const redact = useRedact();
16
+ // only show spinner during the very first load. background polls (every
17
+ // 15s) flip `loading` true/false too, but the data is already on screen —
18
+ // ticking a spinner there causes the whole table to redraw at frame rate.
19
+ const initialLoad = loading && results.length === 0;
17
20
  const availableHeight = useAvailableHeight();
18
21
  const counts = useMemo(() => ({
19
22
  healthy: results.filter(r => r.overall === 'healthy').length,
20
23
  degraded: results.filter(r => r.overall === 'degraded').length,
21
24
  down: results.filter(r => r.overall === 'down').length,
22
25
  }), [results]);
26
+ const items = useMemo(() => results.map(r => ({
27
+ ...r,
28
+ // same reasoning as the dashboard view — ScrollableList memoises rows by
29
+ // item identity, so the redacted label must live on the item itself.
30
+ displayApp: state.redacted ? redactName(r.app) : r.app,
31
+ })), [results, state.redacted]);
23
32
  const handler = (input, key) => {
24
33
  if (results.length === 0)
25
34
  return false;
@@ -34,14 +43,14 @@ export function HealthView() {
34
43
  return false;
35
44
  };
36
45
  useRegisterHandler(handler);
37
- if (loading && results.length === 0) {
46
+ if (initialLoad) {
38
47
  return (_jsx(Box, { padding: 1, children: _jsxs(Text, { children: [_jsx(Spinner, { type: "dots" }), " Running health checks..."] }) }));
39
48
  }
40
49
  if (error && results.length === 0) {
41
50
  return (_jsx(Box, { padding: 1, children: _jsxs(Text, { color: colors.error, children: ["Error: ", error] }) }));
42
51
  }
43
52
  const listHeight = Math.max(5, availableHeight - 4);
44
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsx(Text, { bold: true, children: "Health Monitor" }), _jsxs(Text, { color: colors.success, children: [counts.healthy, " healthy"] }), counts.degraded > 0 && _jsxs(Text, { color: colors.warning, children: [counts.degraded, " degraded"] }), counts.down > 0 && _jsxs(Text, { color: colors.error, children: [counts.down, " down"] }), loading && _jsx(Text, { color: colors.muted, children: _jsx(Spinner, { type: "dots" }) })] }), _jsxs(Text, { bold: true, children: [' APP'.padEnd(26), 'SYSTEMD'.padEnd(12), 'CONTAINERS'.padEnd(20), 'HTTP'.padEnd(10), "OVERALL"] }), _jsx(ScrollableList, { items: results, selectedIndex: Math.min(state.healthIndex, results.length - 1), maxVisible: listHeight, renderItem: (result, selected) => {
53
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsx(Text, { bold: true, children: "Health Monitor" }), _jsxs(Text, { color: colors.success, children: [counts.healthy, " healthy"] }), counts.degraded > 0 && _jsxs(Text, { color: colors.warning, children: [counts.degraded, " degraded"] }), counts.down > 0 && _jsxs(Text, { color: colors.error, children: [counts.down, " down"] })] }), _jsxs(Text, { bold: true, children: [' APP'.padEnd(26), 'SYSTEMD'.padEnd(12), 'CONTAINERS'.padEnd(20), 'HTTP'.padEnd(10), "OVERALL"] }), _jsx(ScrollableList, { items: items, selectedIndex: Math.min(state.healthIndex, items.length - 1), maxVisible: listHeight, renderItem: (result, selected) => {
45
54
  const runningCount = result.containers.filter(c => c.running).length;
46
55
  const containerStr = `${runningCount}/${result.containers.length}`;
47
56
  // 404 → "no /health" (app never implemented one — distinct from a real failure).
@@ -53,6 +62,6 @@ export function HealthView() {
53
62
  ? 'no /health'
54
63
  : `${result.http.status ?? 'err'}`
55
64
  : 'n/a';
56
- return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: selected ? colors.primary : colors.muted, children: selected ? '> ' : ' ' }), _jsx(Text, { children: redact(result.app).padEnd(24) }), _jsx(Box, { width: 12, children: _jsx(StatusBadge, { value: result.systemd.state, type: "systemd" }) }), _jsx(Text, { children: containerStr.padEnd(20) }), _jsx(Box, { width: 10, children: _jsx(Text, { color: result.http?.ok ? colors.success : result.http ? colors.error : colors.muted, children: httpStr }) }), _jsx(StatusBadge, { value: result.overall, type: "health" })] }));
65
+ return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: selected ? colors.primary : colors.muted, children: selected ? '> ' : ' ' }), _jsx(Text, { children: result.displayApp.padEnd(24) }), _jsx(Box, { width: 12, children: _jsx(StatusBadge, { value: result.systemd.state, type: "systemd" }) }), _jsx(Text, { children: containerStr.padEnd(20) }), _jsx(Box, { width: 10, children: _jsx(Text, { color: result.http?.ok ? colors.success : result.http ? colors.error : colors.muted, children: httpStr }) }), _jsx(StatusBadge, { value: result.overall, type: "health" })] }));
57
66
  } })] }));
58
67
  }
@@ -5,7 +5,6 @@ import TextInput from 'ink-text-input';
5
5
  import { useRegisterHandler } from '@matthesketh/ink-input-dispatcher';
6
6
  import { useAppState, useAppDispatch } from '../state.js';
7
7
  import { useSecrets } from '../hooks/use-secrets.js';
8
- import { getSecret as getCoreSecret } from '../../core/secrets-ops.js';
9
8
  import { colors } from '../theme.js';
10
9
  export function SecretEdit() {
11
10
  const { selectedApp, selectedSecret } = useAppState();
@@ -23,22 +22,20 @@ export function SecretEdit() {
23
22
  clearTimeout(timerRef.current);
24
23
  };
25
24
  }, []);
26
- useEffect(() => {
27
- if (!isNew && selectedApp && selectedSecret) {
28
- try {
29
- const existing = getCoreSecret(selectedApp, selectedSecret);
30
- if (existing)
31
- setValue(existing);
32
- }
33
- catch {
34
- // ignore
35
- }
36
- }
37
- }, [isNew, selectedApp, selectedSecret]);
38
- const save = () => {
25
+ // SECURITY: existing secret values are NEVER preloaded into editor state.
26
+ // The TextInput's `mask="*"` only changes the rendered glyph — the
27
+ // underlying React state would still hold plaintext, exposing it to
28
+ // DevTools dumps, error boundary captures, and process memory dumps.
29
+ // Editing requires re-typing the value, matching the CLI posture in
30
+ // `src/commands/secrets.ts` which rejects argv values for the same reason.
31
+ const save = async () => {
39
32
  if (!selectedApp || !keyName)
40
33
  return;
41
- const result = secrets.saveSecret(selectedApp, keyName, value);
34
+ const result = await secrets.saveSecret(selectedApp, keyName, value);
35
+ // Clear the local plaintext from React state immediately, regardless of
36
+ // success/failure. The state holds plaintext only for the duration of
37
+ // the save call.
38
+ setValue('');
42
39
  if (result.ok) {
43
40
  setStatus('Saved and re-sealed');
44
41
  timerRef.current = setTimeout(() => {
@@ -60,5 +57,7 @@ export function SecretEdit() {
60
57
  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: () => {
61
58
  if (keyName)
62
59
  setPhase('value');
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" }) })] }));
60
+ } })) : (_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: isNew
61
+ ? 'Adding new secret. Type the key name, then the value.'
62
+ : `Editing ${keyName} - paste new value to replace. (Current value not displayed.)` }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.muted, children: "Enter to save | Esc to cancel" }) })] }));
64
63
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,82 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from 'ink-testing-library';
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+ import { InputDispatcher } from '@matthesketh/ink-input-dispatcher';
5
+ import { AppStateContext, AppDispatchContext, initialState } from '../state.js';
6
+ import { SecretEdit } from './SecretEdit.js';
7
+ // Mock useSecrets so the component doesn't try to touch the real vault.
8
+ vi.mock('../hooks/use-secrets.js', () => ({
9
+ useSecrets: () => ({
10
+ initialized: true,
11
+ sealed: false,
12
+ apps: [],
13
+ secrets: [],
14
+ revealedValues: {},
15
+ loading: false,
16
+ error: null,
17
+ refresh: () => { },
18
+ loadAppSecrets: () => { },
19
+ saveSecret: () => ({ ok: true }),
20
+ deleteSecret: () => ({ ok: true }),
21
+ revealSecret: () => { },
22
+ hideSecret: () => { },
23
+ unseal: () => ({ ok: true }),
24
+ seal: () => ({ ok: true }),
25
+ importEnv: () => ({ ok: true }),
26
+ }),
27
+ }));
28
+ function renderWithState(state) {
29
+ const dispatch = () => { };
30
+ return render(_jsx(AppStateContext.Provider, { value: state, children: _jsx(AppDispatchContext.Provider, { value: dispatch, children: _jsx(InputDispatcher, { globalHandler: () => false, children: _jsx(SecretEdit, {}) }) }) }));
31
+ }
32
+ describe('SecretEdit (security policy: never preload secret values)', () => {
33
+ beforeEach(() => {
34
+ vi.clearAllMocks();
35
+ });
36
+ it('Edit case: shows the existing key but the value field starts empty', () => {
37
+ // Policy: editing an existing secret means re-typing the value.
38
+ // The TUI must NOT decrypt the existing value into React state because
39
+ // mask="*" only affects rendering, not the underlying string.
40
+ const state = {
41
+ ...initialState,
42
+ selectedApp: 'my-app',
43
+ selectedSecret: 'API_KEY',
44
+ };
45
+ const { lastFrame } = renderWithState(state);
46
+ const frame = lastFrame();
47
+ expect(frame).toContain('Edit Secret');
48
+ expect(frame).toContain('API_KEY');
49
+ // Helper text must announce the policy.
50
+ expect(frame).toContain('Current value not displayed');
51
+ // Even though TextInput is rendered for the value, no plaintext or
52
+ // mask glyphs should appear for the value (it is empty).
53
+ // The "Value:" label is present, and any content after it on that
54
+ // line must not contain '*' characters from a preloaded masked value.
55
+ const valueLine = frame.split('\n').find(l => l.includes('Value:'));
56
+ expect(valueLine).not.toMatch(/\*/);
57
+ });
58
+ it('New case: prompts for key first and shows the new-secret helper text', () => {
59
+ const state = {
60
+ ...initialState,
61
+ selectedApp: 'my-app',
62
+ selectedSecret: null,
63
+ };
64
+ const { lastFrame } = renderWithState(state);
65
+ const frame = lastFrame();
66
+ expect(frame).toContain('Add Secret');
67
+ expect(frame).toContain('Adding new secret');
68
+ // Until the user enters a key, the value field shows the placeholder
69
+ // and is NOT yet active.
70
+ expect(frame).toContain('press Enter on key first');
71
+ });
72
+ it('does not import getSecret (no preload code path remains)', async () => {
73
+ // Static guard: the SecretEdit module must not import getCoreSecret
74
+ // from secrets-ops. If a future change re-introduces the preload,
75
+ // this test fails.
76
+ const fs = await import('node:fs');
77
+ const url = await import('node:url');
78
+ const source = fs.readFileSync(url.fileURLToPath(new URL('./SecretEdit.tsx', import.meta.url)), 'utf8');
79
+ expect(source).not.toMatch(/getSecret as getCoreSecret/);
80
+ expect(source).not.toMatch(/from '\.\.\/\.\.\/core\/secrets-ops/);
81
+ });
82
+ });
@@ -1,10 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useCallback } from 'react';
2
+ import { useEffect, useCallback, useMemo } from 'react';
3
3
  import { Box, Text } from 'ink';
4
4
  import { useRegisterHandler } from '@matthesketh/ink-input-dispatcher';
5
5
  import { ScrollableList } from '@matthesketh/ink-scrollable-list';
6
6
  import { useAvailableHeight } from '@matthesketh/ink-viewport';
7
- import { useAppState, useAppDispatch, useRedact } from '../state.js';
7
+ import { useAppState, useAppDispatch, useRedact, redactName } from '../state.js';
8
8
  import { useSecrets } from '../hooks/use-secrets.js';
9
9
  import { colors } from '../theme.js';
10
10
  export function SecretsView() {
@@ -14,6 +14,12 @@ export function SecretsView() {
14
14
  const secrets = useSecrets();
15
15
  const availableHeight = useAvailableHeight();
16
16
  const { secretsSubView: subView, secretsIndex: selectedIndex, selectedApp } = state;
17
+ const appItems = useMemo(() => secrets.apps.map(a => ({
18
+ ...a,
19
+ // same reasoning as the dashboard view — ScrollableList memoises rows by
20
+ // item identity, so the redacted label must live on the item itself.
21
+ displayApp: state.redacted ? redactName(a.app) : a.app,
22
+ })), [secrets.apps, state.redacted]);
17
23
  const refresh = secrets.refresh;
18
24
  useEffect(() => {
19
25
  refresh();
@@ -47,11 +53,14 @@ export function SecretsView() {
47
53
  return true;
48
54
  }
49
55
  if (input === 'l') {
50
- const result = secrets.seal();
51
- if (!result.ok) {
52
- dispatch({ type: 'SET_ERROR', error: result.error ?? 'Seal failed' });
53
- }
54
- secrets.refresh();
56
+ // Seal acquires a manifest lock and may take a moment; fire-and-forget
57
+ // from the input handler, dispatch errors / refresh on completion.
58
+ secrets.seal().then(result => {
59
+ if (!result.ok) {
60
+ dispatch({ type: 'SET_ERROR', error: result.error ?? 'Seal failed' });
61
+ }
62
+ secrets.refresh();
63
+ });
55
64
  return true;
56
65
  }
57
66
  }
@@ -89,14 +98,15 @@ export function SecretsView() {
89
98
  label: `Delete secret "${secretKey}"?`,
90
99
  description: `This will remove ${secretKey} from ${redact(appName)}'s vault.`,
91
100
  onConfirm: () => {
92
- const result = secrets.deleteSecret(appName, secretKey);
93
- if (result.ok) {
94
- secrets.loadAppSecrets(appName);
95
- secrets.refresh();
96
- }
97
- else {
98
- dispatch({ type: 'SET_ERROR', error: result.error ?? 'Delete failed' });
99
- }
101
+ secrets.deleteSecret(appName, secretKey).then(result => {
102
+ if (result.ok) {
103
+ secrets.loadAppSecrets(appName);
104
+ secrets.refresh();
105
+ }
106
+ else {
107
+ dispatch({ type: 'SET_ERROR', error: result.error ?? 'Delete failed' });
108
+ }
109
+ });
100
110
  },
101
111
  },
102
112
  });
@@ -117,7 +127,7 @@ export function SecretsView() {
117
127
  }, [subView, selectedIndex, selectedApp, secrets, dispatch, redact]);
118
128
  useRegisterHandler(handler);
119
129
  const listHeight = Math.max(5, availableHeight - 5);
120
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, paddingX: 1, gap: 2, children: [_jsx(Text, { bold: true, children: "Vault:" }), !secrets.initialized ? (_jsx(Text, { color: colors.error, children: "Not initialized" })) : secrets.sealed ? (_jsx(Text, { color: colors.warning, bold: true, children: "SEALED" })) : (_jsx(Text, { color: colors.success, bold: true, children: "UNSEALED" })), _jsxs(Text, { color: colors.muted, children: [secrets.apps.length, " apps | ", secrets.apps.reduce((sum, a) => sum + a.keyCount, 0), " keys"] })] }), secrets.error && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.error, children: secrets.error }) })), subView === 'app-list' ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Apps with secrets:" }), _jsx(ScrollableList, { items: secrets.apps, selectedIndex: Math.min(selectedIndex, secrets.apps.length - 1), maxVisible: listHeight, emptyText: " No secrets managed", renderItem: (app, selected) => (_jsxs(Box, { children: [_jsx(Text, { color: colors.primary, children: selected ? '> ' : ' ' }), _jsx(Text, { bold: selected, color: selected ? colors.primary : colors.text, children: redact(app.app).padEnd(24) }), _jsx(Text, { color: colors.muted, children: app.type.padEnd(14) }), _jsxs(Text, { children: [String(app.keyCount).padEnd(8), " keys"] })] })) })] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: colors.primary, children: redact(selectedApp ?? '') }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(ScrollableList, { items: secrets.secrets, selectedIndex: Math.min(selectedIndex, secrets.secrets.length - 1), maxVisible: listHeight, emptyText: " No secrets found", renderItem: (secret, selected) => {
130
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, paddingX: 1, gap: 2, children: [_jsx(Text, { bold: true, children: "Vault:" }), !secrets.initialized ? (_jsx(Text, { color: colors.error, children: "Not initialized" })) : secrets.sealed ? (_jsx(Text, { color: colors.warning, bold: true, children: "SEALED" })) : (_jsx(Text, { color: colors.success, bold: true, children: "UNSEALED" })), _jsxs(Text, { color: colors.muted, children: [secrets.apps.length, " apps | ", secrets.apps.reduce((sum, a) => sum + a.keyCount, 0), " keys"] })] }), secrets.error && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.error, children: secrets.error }) })), subView === 'app-list' ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Apps with secrets:" }), _jsx(ScrollableList, { items: appItems, selectedIndex: Math.min(selectedIndex, appItems.length - 1), maxVisible: listHeight, emptyText: " No secrets managed", renderItem: (app, selected) => (_jsxs(Box, { children: [_jsx(Text, { color: colors.primary, children: selected ? '> ' : ' ' }), _jsx(Text, { bold: selected, color: selected ? colors.primary : colors.text, children: app.displayApp.padEnd(24) }), _jsx(Text, { color: colors.muted, children: app.type.padEnd(14) }), _jsxs(Text, { children: [String(app.keyCount).padEnd(8), " keys"] })] })) })] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: colors.primary, children: redact(selectedApp ?? '') }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(ScrollableList, { items: secrets.secrets, selectedIndex: Math.min(selectedIndex, secrets.secrets.length - 1), maxVisible: listHeight, emptyText: " No secrets found", renderItem: (secret, selected) => {
121
131
  const revealed = secrets.revealedValues[secret.key];
122
132
  return (_jsxs(Box, { children: [_jsx(Text, { color: colors.primary, children: selected ? '> ' : ' ' }), _jsx(Text, { bold: selected, color: selected ? colors.primary : colors.text, children: secret.key.padEnd(30) }), _jsx(Text, { color: revealed ? colors.warning : colors.muted, children: revealed ?? secret.maskedValue })] }));
123
133
  } }) })] }))] }));
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@matthesketh/fleet",
3
- "version": "1.8.0",
3
+ "version": "1.11.0",
4
4
  "description": "Docker production management CLI + MCP server for Claude Code",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
8
- "fleet": "dist/index.js"
8
+ "fleet": "dist/index.js",
9
+ "fleet-agent": "dist/bin/fleet-agent.js"
9
10
  },
10
11
  "files": [
11
12
  "dist/",
@@ -15,9 +16,10 @@
15
16
  "README.md"
16
17
  ],
17
18
  "scripts": {
18
- "build": "tsc && tsc-alias",
19
+ "build": "tsc && tsc-alias --resolve-full-paths",
19
20
  "dev": "tsx src/index.ts",
20
21
  "test": "vitest run",
22
+ "changelog": "node scripts/gen-changelog.mjs > CHANGELOG.md",
21
23
  "prepublishOnly": "npm run build"
22
24
  },
23
25
  "keywords": [
@@ -59,7 +61,7 @@
59
61
  "@matthesketh/ink-modal": "^0.1.0",
60
62
  "@matthesketh/ink-pipeline": "^0.1.0",
61
63
  "@matthesketh/ink-rule": "^0.1.0",
62
- "@matthesketh/ink-scrollable-list": "^0.1.1",
64
+ "@matthesketh/ink-scrollable-list": "0.2.0",
63
65
  "@matthesketh/ink-split-pane": "^0.1.0",
64
66
  "@matthesketh/ink-stable-state": "^0.1.0",
65
67
  "@matthesketh/ink-status-bar": "^0.1.0",
@@ -68,7 +70,7 @@
68
70
  "@matthesketh/ink-task-list": "0.1.0",
69
71
  "@matthesketh/ink-timeline": "0.1.0",
70
72
  "@matthesketh/ink-toast": "^0.1.0",
71
- "@matthesketh/ink-viewport": "^0.1.0",
73
+ "@matthesketh/ink-viewport": "^0.1.1",
72
74
  "@modelcontextprotocol/sdk": "1.29.0",
73
75
  "better-sqlite3": "12.9.0",
74
76
  "chokidar": "5.0.0",
@@ -77,6 +79,7 @@
77
79
  "ink-text-input": "^6.0.0",
78
80
  "proper-lockfile": "4.1.2",
79
81
  "react": "^18.3.1",
82
+ "yaml": "2.8.4",
80
83
  "zod": "^3.24.0"
81
84
  },
82
85
  "devDependencies": {
@@ -90,4 +93,4 @@
90
93
  "typescript": "5.6.3",
91
94
  "vitest": "4.0.18"
92
95
  }
93
- }
96
+ }