@matthesketh/fleet 1.8.1 → 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 +9 -6
  228. package/dist/tui/views/HealthView.js +9 -4
  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 +8 -5
@@ -48,9 +48,9 @@ export function useSecrets() {
48
48
  }));
49
49
  }
50
50
  }, []);
51
- const saveSecret = useCallback((app, key, value) => {
51
+ const saveSecret = useCallback(async (app, key, value) => {
52
52
  try {
53
- setSecret(app, key, value);
53
+ await setSecret(app, key, value);
54
54
  // Re-unseal to update runtime
55
55
  try {
56
56
  unsealAll();
@@ -62,7 +62,7 @@ export function useSecrets() {
62
62
  return { ok: false, error: err instanceof Error ? err.message : 'Failed to save secret' };
63
63
  }
64
64
  }, []);
65
- const deleteSecret = useCallback((app, key) => {
65
+ const deleteSecret = useCallback(async (app, key) => {
66
66
  try {
67
67
  const plaintext = decryptApp(app);
68
68
  const manifest = loadManifest();
@@ -114,9 +114,9 @@ export function useSecrets() {
114
114
  return { ok: false, error: err instanceof Error ? err.message : 'Failed to unseal' };
115
115
  }
116
116
  }, []);
117
- const seal = useCallback(() => {
117
+ const seal = useCallback(async () => {
118
118
  try {
119
- sealFromRuntime();
119
+ await sealFromRuntime();
120
120
  setState(prev => ({ ...prev, sealed: true }));
121
121
  return { ok: true };
122
122
  }
@@ -124,9 +124,9 @@ export function useSecrets() {
124
124
  return { ok: false, error: err instanceof Error ? err.message : 'Failed to seal' };
125
125
  }
126
126
  }, []);
127
- const importEnv = useCallback((app, path) => {
127
+ const importEnv = useCallback(async (app, path) => {
128
128
  try {
129
- importEnvFile(app, path);
129
+ await importEnvFile(app, path);
130
130
  try {
131
131
  unsealAll();
132
132
  }
@@ -1,2 +1,3 @@
1
1
  import React from 'react';
2
+ export declare function ViewRouter(): React.JSX.Element;
2
3
  export declare function App(): React.JSX.Element;
@@ -17,6 +17,7 @@ import { SecretsView } from './views/SecretsView.js';
17
17
  import { SecretEdit } from './views/SecretEdit.js';
18
18
  import { HealthView } from './views/HealthView.js';
19
19
  import { LogsView } from './views/LogsView.js';
20
+ import { CommandPalette } from './views/CommandPalette.js';
20
21
  import { isSealed, isInitialized } from '../core/secrets.js';
21
22
  const HELP_GROUPS = [
22
23
  {
@@ -47,8 +48,9 @@ const HELP_GROUPS = [
47
48
  ],
48
49
  },
49
50
  ];
50
- function ViewRouter() {
51
+ export function ViewRouter() {
51
52
  const state = React.useContext(AppStateContext);
53
+ const dispatch = React.useContext(AppDispatchContext);
52
54
  switch (state.currentView) {
53
55
  case 'dashboard':
54
56
  return _jsx(Dashboard, {});
@@ -62,6 +64,8 @@ function ViewRouter() {
62
64
  return _jsx(SecretEdit, {});
63
65
  case 'logs':
64
66
  return _jsx(LogsView, {});
67
+ case 'command-palette':
68
+ return (_jsx(CommandPalette, { onClose: () => dispatch({ type: 'GO_BACK' }), onOpenView: view => dispatch({ type: 'NAVIGATE', view: view }) }));
65
69
  default:
66
70
  return _jsx(Dashboard, {});
67
71
  }
@@ -75,7 +79,10 @@ function UpdateBanner({ info, inProgress }) {
75
79
  }
76
80
  const ahead = info.behind;
77
81
  const subject = info.latestSubject ? ` — ${info.latestSubject}` : '';
78
- return (_jsx(Box, { paddingX: 1, children: _jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Text, { color: "cyan", children: ["\u2191 Update available: ", ahead, " commit", ahead === 1 ? '' : 's', " ahead", subject, ". Press "] }), _jsx(Text, { color: "cyan", bold: true, children: "U" }), _jsx(Text, { color: "cyan", children: " to install." })] }) }));
82
+ // channel label only surfaces on prerelease so the stable case stays
83
+ // visually identical to what operators have seen for several releases.
84
+ const channelLabel = info.channel === 'prerelease' ? ' (prerelease)' : '';
85
+ return (_jsx(Box, { paddingX: 1, children: _jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Text, { color: "cyan", children: ["\u2191 Update available", channelLabel, ": ", ahead, " commit", ahead === 1 ? '' : 's', " ahead", subject, ". Press "] }), _jsx(Text, { color: "cyan", bold: true, children: "U" }), _jsx(Text, { color: "cyan", children: " to install." })] }) }));
79
86
  }
80
87
  export function App() {
81
88
  const [state, dispatch] = useReducer(reducer, initialState);
@@ -141,27 +148,37 @@ export function App() {
141
148
  }
142
149
  return true;
143
150
  }
144
- if (input === '?' && state.currentView !== 'secret-edit') {
151
+ // command-palette and secret-edit capture raw text input — the global
152
+ // single-key shortcuts must not fire while either is open.
153
+ const isInputView = state.currentView === 'secret-edit' || state.currentView === 'command-palette';
154
+ if (input === '?' && !isInputView) {
145
155
  setShowHelp(true);
146
156
  return true;
147
157
  }
148
- if (input === 'q' && state.currentView !== 'secret-edit') {
158
+ if (input === ':' && !isInputView) {
159
+ dispatch({ type: 'NAVIGATE', view: 'command-palette' });
160
+ return true;
161
+ }
162
+ if (input === 'q' && !isInputView) {
149
163
  process.exit(0);
150
164
  return true;
151
165
  }
152
- if (input === 'x' && state.currentView !== 'secret-edit') {
166
+ if (input === 'x' && !isInputView) {
153
167
  dispatch({ type: 'TOGGLE_REDACT' });
154
168
  return true;
155
169
  }
156
170
  // U → apply pending update. Only fires when one is actually available.
157
- if ((input === 'U' || input === 'u') && state.currentView !== 'secret-edit') {
171
+ if ((input === 'U' || input === 'u') && !isInputView) {
158
172
  const info = updateInfoRef.current;
159
173
  if (info?.available && !updateInProgressRef.current) {
160
174
  setUpdateInProgress(true);
161
175
  applyUpdate().then(result => {
162
176
  setUpdateInProgress(false);
163
177
  if (result.ok) {
164
- setUpdateInfo({ available: false, behind: 0, latestSubject: '', branch: info.branch });
178
+ setUpdateInfo({
179
+ available: false, behind: 0, latestSubject: '',
180
+ branch: info.branch, remoteBranch: info.remoteBranch, channel: info.channel,
181
+ });
165
182
  }
166
183
  // Result reported via UpdateBanner below.
167
184
  App.__lastUpdateOutput = result.output;
@@ -172,7 +189,7 @@ export function App() {
172
189
  return true;
173
190
  }
174
191
  }
175
- if (key.tab) {
192
+ if (key.tab && state.currentView !== 'command-palette') {
176
193
  const topViews = ['dashboard', 'health', 'secrets', 'logs-multi'];
177
194
  const base = topViews.includes(state.currentView)
178
195
  ? state.currentView
@@ -180,7 +197,7 @@ export function App() {
180
197
  dispatch({ type: 'NAVIGATE', view: nextTopView(base) });
181
198
  return true;
182
199
  }
183
- if (key.escape && state.previousView) {
200
+ if (key.escape && state.previousView && state.currentView !== 'command-palette') {
184
201
  dispatch({ type: 'GO_BACK' });
185
202
  return true;
186
203
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from 'ink-testing-library';
3
+ import { describe, it, expect } from 'vitest';
4
+ import { InputDispatcher } from '@matthesketh/ink-input-dispatcher';
5
+ import { ViewRouter } from './router.js';
6
+ import { AppStateContext, AppDispatchContext, initialState } from './state.js';
7
+ describe('command palette routing', () => {
8
+ it('renders the command palette for the command-palette view', async () => {
9
+ const { lastFrame } = render(_jsx(InputDispatcher, { globalHandler: () => false, children: _jsx(AppStateContext.Provider, { value: { ...initialState, currentView: 'command-palette' }, children: _jsx(AppDispatchContext.Provider, { value: () => { }, children: _jsx(ViewRouter, {}) }) }) }));
10
+ await new Promise(r => setTimeout(r, 30));
11
+ expect(lastFrame() ?? '').toContain('Command palette');
12
+ });
13
+ });
@@ -21,9 +21,9 @@ describe('SignalsGrid', () => {
21
21
  expect(frame).toContain('CI');
22
22
  });
23
23
  it('renders a row with repo name when signals present', () => {
24
- const rows = [{ repo: 'abmanandvan', signals: [mkSignal('git-clean', 'ok')] }];
24
+ const rows = [{ repo: 'movers-co', signals: [mkSignal('git-clean', 'ok')] }];
25
25
  const { lastFrame } = render(_jsx(SignalsGrid, { rows: rows, selectedIndex: 0, kinds: ['git-clean'] }));
26
- expect(lastFrame()).toContain('abmanandvan');
26
+ expect(lastFrame()).toContain('movers-co');
27
27
  });
28
28
  it('shows empty-state message with no repos', () => {
29
29
  const { lastFrame } = render(_jsx(SignalsGrid, { rows: [], selectedIndex: 0, kinds: ['git-clean'] }));
@@ -69,7 +69,7 @@ function buildPlan(draft) {
69
69
  export function ScaffoldTab() {
70
70
  const [draft, setDraft] = useState({
71
71
  name: '',
72
- composePath: '/home/matt/',
72
+ composePath: '/home/operator/',
73
73
  port: '3000',
74
74
  domain: '',
75
75
  usesSharedDb: true,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from 'ink-testing-library';
3
+ import { describe, it, expect, vi } from 'vitest';
4
+ import { InputDispatcher } from '@matthesketh/ink-input-dispatcher';
5
+ // stable mock fleet status. the reference must stay constant across renders:
6
+ // dashboard memoises its list items, so a changing status object would rebuild
7
+ // them anyway and mask the redaction-staleness bug this test guards.
8
+ const { MOCK_STATUS } = vi.hoisted(() => ({
9
+ MOCK_STATUS: {
10
+ totalApps: 3,
11
+ healthy: 3,
12
+ unhealthy: 0,
13
+ apps: [
14
+ { name: 'alpha-service', systemd: 'active', containers: '1/1', health: 'healthy' },
15
+ { name: 'bravo-service', systemd: 'active', containers: '1/1', health: 'healthy' },
16
+ { name: 'charlie-service', systemd: 'active', containers: '1/1', health: 'healthy' },
17
+ ],
18
+ },
19
+ }));
20
+ vi.mock('../hooks/use-fleet-data', () => ({
21
+ useFleetData: () => ({ status: MOCK_STATUS, loading: false, error: null }),
22
+ }));
23
+ // fixed available height so the list renders every row under a test stdout.
24
+ vi.mock('@matthesketh/ink-viewport', () => ({
25
+ useAvailableHeight: () => 20,
26
+ Viewport: ({ children }) => children,
27
+ }));
28
+ import { Dashboard } from '../views/Dashboard.js';
29
+ import { AppStateContext, AppDispatchContext, initialState } from '../state.js';
30
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
31
+ function Harness({ redacted }) {
32
+ return (_jsx(AppStateContext.Provider, { value: { ...initialState, redacted }, children: _jsx(AppDispatchContext.Provider, { value: () => { }, children: _jsx(InputDispatcher, { globalHandler: () => false, children: _jsx(Dashboard, {}) }) }) }));
33
+ }
34
+ describe('redaction re-renders every visible row', () => {
35
+ it('toggling redaction updates all app rows without scrolling', async () => {
36
+ const { lastFrame, rerender } = render(_jsx(Harness, { redacted: false }));
37
+ await delay(50);
38
+ const plain = lastFrame() ?? '';
39
+ expect(plain).toContain('alpha-service');
40
+ expect(plain).toContain('bravo-service');
41
+ expect(plain).toContain('charlie-service');
42
+ // flip redaction — equivalent to pressing 'x'. no j/k/arrow keys are sent,
43
+ // so nothing scrolls; every visible row must still pick up the new label.
44
+ rerender(_jsx(Harness, { redacted: true }));
45
+ await delay(50);
46
+ const out = lastFrame() ?? '';
47
+ expect(out).not.toContain('alpha-service');
48
+ expect(out).not.toContain('bravo-service');
49
+ expect(out).not.toContain('charlie-service');
50
+ // redacted labels follow the app-NN pattern from redactName()
51
+ expect(out).toMatch(/app-\d\d/);
52
+ });
53
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,145 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useReducer } from 'react';
3
+ import { EventEmitter } from 'node:events';
4
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
5
+ // Force Ink's real (non-CI) render path so the flicker branch is reachable.
6
+ // ink-testing-library renders in debug mode, where Ink's onRender returns
7
+ // before the clear-screen branch — so it cannot observe the flicker at all.
8
+ // This proof drives Ink's genuine production render() instead.
9
+ vi.mock('is-in-ci', () => ({ default: false }));
10
+ // eslint-disable-next-line import/first
11
+ import { render, Box, Text } from 'ink';
12
+ // eslint-disable-next-line import/first
13
+ import { Viewport, useAvailableHeight } from '@matthesketh/ink-viewport';
14
+ // eslint-disable-next-line import/first
15
+ import { ScrollableList } from '@matthesketh/ink-scrollable-list';
16
+ // eslint-disable-next-line import/first
17
+ import { InputDispatcher, useRegisterHandler } from '@matthesketh/ink-input-dispatcher';
18
+ // ESC (0x1B) built without a control-char literal in source.
19
+ const ESC = String.fromCharCode(27);
20
+ // Ink writes this exact sequence (ansiEscapes.clearTerminal) before a frame
21
+ // whenever the frame height is >= stdout.rows — a full-screen wipe on every
22
+ // re-render. That wipe IS the flicker; counting it proves its presence.
23
+ const CLEAR_TERMINAL = `${ESC}[2J${ESC}[3J${ESC}[H`;
24
+ const DOWN_ARROW = `${ESC}[B`;
25
+ const ROWS = 24;
26
+ const COLUMNS = 120;
27
+ const SCROLL_STEPS = 20;
28
+ const APPS = Array.from({ length: 200 }, (_, i) => `app-${i}`);
29
+ /** A stdout that records every byte Ink writes and reports a fixed size. */
30
+ class RecordingStdout extends EventEmitter {
31
+ columns = COLUMNS;
32
+ rows = ROWS;
33
+ isTTY = true;
34
+ writes = [];
35
+ write = (data) => {
36
+ this.writes.push(data);
37
+ return true;
38
+ };
39
+ }
40
+ /** A TTY-like stdin mirroring ink-testing-library's: a keypress is delivered
41
+ * via both the 'readable'/read() and 'data' paths. */
42
+ class FakeStdin extends EventEmitter {
43
+ isTTY = true;
44
+ data = null;
45
+ press = (data) => {
46
+ this.data = data;
47
+ this.emit('readable');
48
+ this.emit('data', data);
49
+ };
50
+ setEncoding() { }
51
+ setRawMode() { }
52
+ resume() { }
53
+ pause() { }
54
+ ref() { }
55
+ unref() { }
56
+ read = () => {
57
+ const d = this.data;
58
+ this.data = null;
59
+ return d;
60
+ };
61
+ }
62
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
63
+ /** Mirrors Fleet's Dashboard: a windowed ScrollableList sized off
64
+ * useAvailableHeight(), driven by j / down-arrow via the input dispatcher. */
65
+ function MockDashboard() {
66
+ const [index, move] = useReducer((cur, delta) => Math.max(0, Math.min(APPS.length - 1, cur + delta)), 0);
67
+ const available = useAvailableHeight();
68
+ const listHeight = Math.max(5, available - 4); // same formula as Dashboard.tsx
69
+ const handler = (input, key) => {
70
+ if (input === 'j' || key.downArrow) {
71
+ move(1);
72
+ return true;
73
+ }
74
+ if (input === 'k' || key.upArrow) {
75
+ move(-1);
76
+ return true;
77
+ }
78
+ return false;
79
+ };
80
+ useRegisterHandler(handler);
81
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { bold: true, children: [APPS.length, " apps"] }), _jsx(ScrollableList, { items: APPS, selectedIndex: index, maxVisible: listHeight, renderItem: (item, selected) => (_jsxs(Text, { color: selected ? 'cyan' : undefined, children: [selected ? '> ' : ' ', item] })) })] }));
82
+ }
83
+ /** Fleet's real chrome: InputDispatcher outside, Viewport(chrome) inside. */
84
+ function FleetUI() {
85
+ return (_jsx(InputDispatcher, { globalHandler: () => false, children: _jsxs(Viewport, { chrome: 6, children: [_jsx(Text, { children: "fleet" }), _jsx(MockDashboard, {})] }) }));
86
+ }
87
+ /** The pre-fix viewport: a root box pinned to the FULL terminal height. */
88
+ function LegacyChrome() {
89
+ return (_jsx(InputDispatcher, { globalHandler: () => false, children: _jsxs(Box, { flexDirection: "column", height: ROWS, children: [_jsx(Text, { children: "fleet" }), _jsx(MockDashboard, {})] }) }));
90
+ }
91
+ /** Mount with Ink's production render path, scroll SCROLL_STEPS rows, and
92
+ * record every byte Ink writes to the terminal. */
93
+ async function scrollAndRecord(node) {
94
+ const stdout = new RecordingStdout();
95
+ const stdin = new FakeStdin();
96
+ const app = render(node, {
97
+ stdout: stdout,
98
+ stdin: stdin,
99
+ debug: false,
100
+ exitOnCtrlC: false,
101
+ patchConsole: false,
102
+ });
103
+ await sleep(40);
104
+ for (let i = 0; i < SCROLL_STEPS; i++) {
105
+ stdin.press(DOWN_ARROW);
106
+ await sleep(45); // > Ink's 32ms render throttle, so each scroll renders
107
+ }
108
+ await sleep(60);
109
+ app.unmount();
110
+ const output = stdout.writes.join('');
111
+ return { clears: output.split(CLEAR_TERMINAL).length - 1, output };
112
+ }
113
+ describe('Fleet TUI scroll flicker proof', () => {
114
+ beforeEach(() => {
115
+ process.setMaxListeners(0);
116
+ Object.defineProperty(process.stdout, 'rows', {
117
+ value: ROWS,
118
+ writable: true,
119
+ configurable: true,
120
+ });
121
+ Object.defineProperty(process.stdout, 'columns', {
122
+ value: COLUMNS,
123
+ writable: true,
124
+ configurable: true,
125
+ });
126
+ });
127
+ // skipped on ci: ink picks up github_actions / ci directly and gates the
128
+ // clearterminal branch on tty-ish heuristics that the in-process recorder
129
+ // can't fully spoof. the control passes locally where the production
130
+ // render path is reachable, so the proof below still has its anchor when
131
+ // run pre-commit; gh actions just doesn't reproduce the legacy bug.
132
+ it.skipIf(Boolean(process.env.CI))('CONTROL: a full-terminal-height frame flickers on every scroll render', async () => {
133
+ const { clears } = await scrollAndRecord(_jsx(LegacyChrome, {}));
134
+ expect(clears).toBeGreaterThan(0);
135
+ });
136
+ it('Fleet UI scrolls 20 rows with ZERO full-screen clears', async () => {
137
+ const { clears, output } = await scrollAndRecord(_jsx(FleetUI, {}));
138
+ // The scroll genuinely happened: ScrollableList only renders its
139
+ // "more above" indicator once the window has scrolled down.
140
+ expect(output).toContain('more above');
141
+ // ...and across every one of those re-renders, Ink never wiped the
142
+ // screen. Zero clearTerminal sequences => zero flicker.
143
+ expect(clears).toBe(0);
144
+ });
145
+ });
@@ -1,4 +1,4 @@
1
- export type View = 'dashboard' | 'app-detail' | 'health' | 'secrets' | 'secret-edit' | 'logs' | 'logs-multi';
1
+ export type View = 'dashboard' | 'app-detail' | 'health' | 'secrets' | 'secret-edit' | 'logs' | 'logs-multi' | 'command-palette';
2
2
  export type SecretsSubView = 'app-list' | 'secret-list';
3
3
  export interface TuiState {
4
4
  currentView: View;
@@ -0,0 +1,5 @@
1
+ import React from 'react';
2
+ export declare function CommandPalette(props: {
3
+ onOpenView: (view: string) => void;
4
+ onClose: () => void;
5
+ }): React.JSX.Element;
@@ -0,0 +1,90 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useMemo, useState } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { useRegisterHandler } from '@matthesketh/ink-input-dispatcher';
5
+ import { ScrollableList } from '@matthesketh/ink-scrollable-list';
6
+ import { loadRegistry } from '../../registry/index.js';
7
+ import { allCommands } from '../../registry/registry.js';
8
+ import { ArgForm } from '../components/ArgForm.js';
9
+ import { runFleetCommand } from '../exec-bridge.js';
10
+ import { colors } from '../theme.js';
11
+ export function CommandPalette(props) {
12
+ // load the registry once and snapshot the visible commands. a lazy useState
13
+ // initialiser runs exactly once — unlike a bare call in the render body.
14
+ const [commands] = useState(() => {
15
+ loadRegistry();
16
+ return allCommands().filter(c => !c.cliOnly);
17
+ });
18
+ const [query, setQuery] = useState('');
19
+ const [index, setIndex] = useState(0);
20
+ const [chosen, setChosen] = useState(null);
21
+ const [output, setOutput] = useState(null);
22
+ const filtered = useMemo(() => commands.filter(c => (c.name + ' ' + c.summary).toLowerCase().includes(query.toLowerCase())), [commands, query]);
23
+ const listHandler = (input, key) => {
24
+ if (chosen || output !== null)
25
+ return false;
26
+ if (key.escape) {
27
+ props.onClose();
28
+ return true;
29
+ }
30
+ if (key.downArrow) {
31
+ setIndex(i => Math.min(i + 1, filtered.length - 1));
32
+ return true;
33
+ }
34
+ if (key.upArrow) {
35
+ setIndex(i => Math.max(i - 1, 0));
36
+ return true;
37
+ }
38
+ if (key.return) {
39
+ const cmd = filtered[index];
40
+ if (!cmd)
41
+ return true;
42
+ if (cmd.tui && typeof cmd.tui === 'object') {
43
+ props.onOpenView(cmd.tui.view);
44
+ return true;
45
+ }
46
+ setChosen(cmd);
47
+ return true;
48
+ }
49
+ if (key.backspace || key.delete) {
50
+ setQuery(q => q.slice(0, -1));
51
+ setIndex(0);
52
+ return true;
53
+ }
54
+ if (input && !key.ctrl && !key.meta) {
55
+ setQuery(q => q + input);
56
+ setIndex(0);
57
+ return true;
58
+ }
59
+ return false;
60
+ };
61
+ useRegisterHandler(listHandler);
62
+ if (output !== null) {
63
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { bold: true, color: colors.primary, children: [chosen?.name, " result"] }), _jsx(Text, { children: output }), _jsx(Text, { color: colors.muted, children: "esc to close" }), _jsx(CloseOnEscape, { onClose: () => { setOutput(null); setChosen(null); } })] }));
64
+ }
65
+ if (chosen) {
66
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: colors.primary, children: chosen.name }), _jsx(ArgForm, { schema: chosen.args, onCancel: () => setChosen(null), onSubmit: async (values) => {
67
+ const argv = [chosen.name];
68
+ for (const [k, v] of Object.entries(values)) {
69
+ if (v === true)
70
+ argv.push(`--${k}`);
71
+ else if (v !== false && v !== '' && v != null)
72
+ argv.push(`--${k}`, String(v));
73
+ }
74
+ const r = await runFleetCommand(argv);
75
+ setOutput(r.output);
76
+ } })] }));
77
+ }
78
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: colors.primary, children: "Command palette" }), _jsxs(Text, { color: colors.muted, children: ["filter: ", query || '(type to filter)'] }), _jsx(ScrollableList, { items: filtered, selectedIndex: Math.min(index, Math.max(0, filtered.length - 1)), maxVisible: 12, emptyText: " no matching commands", renderItem: (cmd, selected) => (_jsxs(Box, { children: [_jsx(Text, { color: selected ? colors.primary : colors.muted, children: selected ? '> ' : ' ' }), _jsx(Box, { width: 20, children: _jsx(Text, { bold: selected, children: cmd.name }) }), _jsx(Text, { color: colors.muted, children: cmd.summary })] })) })] }));
79
+ }
80
+ function CloseOnEscape(props) {
81
+ const handler = (_input, key) => {
82
+ if (key.escape) {
83
+ props.onClose();
84
+ return true;
85
+ }
86
+ return false;
87
+ };
88
+ useRegisterHandler(handler);
89
+ return _jsx(_Fragment, {});
90
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,117 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from 'ink-testing-library';
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4
+ import { z } from 'zod';
5
+ import { InputDispatcher } from '@matthesketh/ink-input-dispatcher';
6
+ vi.mock('../exec-bridge', () => ({
7
+ runFleetCommand: vi.fn(async () => ({ ok: true, output: 'done' })),
8
+ }));
9
+ import { runFleetCommand } from '../exec-bridge.js';
10
+ import { register, defineCommand } from '../../registry/registry.js';
11
+ import { loadRegistry, _resetLoader } from '../../registry/index.js';
12
+ import { CommandPalette } from './CommandPalette.js';
13
+ /** flush ink's render queue after a keystroke */
14
+ const flush = () => new Promise(r => setTimeout(r, 30));
15
+ beforeEach(() => _resetLoader());
16
+ afterEach(() => _resetLoader());
17
+ describe('CommandPalette', () => {
18
+ it('lists registry commands', async () => {
19
+ const { lastFrame } = render(_jsx(InputDispatcher, { globalHandler: () => false, children: _jsx(CommandPalette, { onOpenView: () => { }, onClose: () => { } }) }));
20
+ await flush();
21
+ // assert against the first alphabetical command — the palette caps
22
+ // visible items at 12 so commands later in the list (status, stop,
23
+ // whoami, ...) may scroll off when the registry grows.
24
+ expect(lastFrame() ?? '').toContain('add');
25
+ });
26
+ it('hides non-matching commands when a query is typed', async () => {
27
+ const { lastFrame, stdin } = render(_jsx(InputDispatcher, { globalHandler: () => false, children: _jsx(CommandPalette, { onOpenView: () => { }, onClose: () => { } }) }));
28
+ await flush();
29
+ // sanity: at least the first command is visible before filtering
30
+ expect(lastFrame() ?? '').toContain('add');
31
+ // type a query that matches nothing
32
+ stdin.write('z');
33
+ await flush();
34
+ stdin.write('z');
35
+ await flush();
36
+ stdin.write('z');
37
+ await flush();
38
+ stdin.write('z');
39
+ await flush();
40
+ const frame = lastFrame() ?? '';
41
+ expect(frame).not.toContain('status');
42
+ expect(frame).toContain('no matching commands');
43
+ });
44
+ it('calls onOpenView when enter is pressed on a command with a tui view', async () => {
45
+ // type 'stat' to filter to only status, then press enter.
46
+ const onOpenView = vi.fn();
47
+ const { stdin } = render(_jsx(InputDispatcher, { globalHandler: () => false, children: _jsx(CommandPalette, { onOpenView: onOpenView, onClose: () => { } }) }));
48
+ await flush();
49
+ stdin.write('s');
50
+ await flush();
51
+ stdin.write('t');
52
+ await flush();
53
+ stdin.write('a');
54
+ await flush();
55
+ stdin.write('t');
56
+ await flush();
57
+ stdin.write('\r');
58
+ await flush();
59
+ expect(onOpenView).toHaveBeenCalledWith('dashboard');
60
+ });
61
+ it('builds argv from an empty-args command and calls runFleetCommand', async () => {
62
+ // register an ad-hoc command with no tui and an empty args schema.
63
+ // loadRegistry() first so the loaded flag is set and the component's own
64
+ // call does not wipe the ad-hoc registration.
65
+ loadRegistry();
66
+ register(defineCommand({
67
+ name: 'demo-run',
68
+ summary: 'a demo command',
69
+ args: z.object({}),
70
+ async run() { return { ok: true, summary: 'ok', data: null }; },
71
+ }));
72
+ const { stdin } = render(_jsx(InputDispatcher, { globalHandler: () => false, children: _jsx(CommandPalette, { onOpenView: () => { }, onClose: () => { } }) }));
73
+ await flush();
74
+ // type 'demo-run' to filter to exactly this command so the test is
75
+ // stable regardless of how many other commands are in the registry.
76
+ for (const ch of 'demo-run') {
77
+ stdin.write(ch);
78
+ await flush();
79
+ }
80
+ // press enter on demo-run → ArgForm shown (no tui field).
81
+ stdin.write('\r');
82
+ await flush();
83
+ // ArgForm with no fields: press enter → onSubmit({}) → runFleetCommand.
84
+ stdin.write('\r');
85
+ await flush();
86
+ expect(vi.mocked(runFleetCommand)).toHaveBeenCalledWith(['demo-run']);
87
+ });
88
+ it('builds --flag argv when a boolean field is toggled before submit', async () => {
89
+ // register a command with a boolean arg.
90
+ loadRegistry();
91
+ register(defineCommand({
92
+ name: 'demo-flag',
93
+ summary: 'a flag demo',
94
+ args: z.object({ force: z.boolean().default(false) }),
95
+ async run() { return { ok: true, summary: 'ok', data: null }; },
96
+ }));
97
+ const { stdin } = render(_jsx(InputDispatcher, { globalHandler: () => false, children: _jsx(CommandPalette, { onOpenView: () => { }, onClose: () => { } }) }));
98
+ await flush();
99
+ // type 'demo-flag' to filter to exactly this command so the test is
100
+ // stable regardless of how many other commands are in the registry.
101
+ for (const ch of 'demo-flag') {
102
+ stdin.write(ch);
103
+ await flush();
104
+ }
105
+ // press enter on demo-flag.
106
+ stdin.write('\r');
107
+ await flush();
108
+ // ArgForm is now showing the 'force' boolean field (cursor on it).
109
+ // press space to toggle force → true.
110
+ stdin.write(' ');
111
+ await flush();
112
+ // press enter to submit { force: true }.
113
+ stdin.write('\r');
114
+ await flush();
115
+ expect(vi.mocked(runFleetCommand)).toHaveBeenCalledWith(['demo-flag', '--force']);
116
+ });
117
+ });