@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
@@ -0,0 +1,254 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { useRegisterHandler } from '@matthesketh/ink-input-dispatcher';
5
+ import TextInput from 'ink-text-input';
6
+ import { RoutineSchema } from '../../../core/routines/schema.js';
7
+ function toDraft(r) {
8
+ if (!r) {
9
+ return {
10
+ id: '',
11
+ name: '',
12
+ description: '',
13
+ scheduleKind: 'calendar',
14
+ onCalendar: '*-*-* 02:00:00',
15
+ taskKind: 'claude-cli',
16
+ prompt: '',
17
+ argv: '',
18
+ tool: '',
19
+ tokenCap: '100000',
20
+ maxUsd: '2',
21
+ enabled: false,
22
+ };
23
+ }
24
+ return {
25
+ id: r.id,
26
+ name: r.name,
27
+ description: r.description,
28
+ scheduleKind: r.schedule.kind === 'calendar' ? 'calendar' : 'manual',
29
+ onCalendar: r.schedule.kind === 'calendar' ? r.schedule.onCalendar : '*-*-* 02:00:00',
30
+ taskKind: r.task.kind,
31
+ prompt: r.task.kind === 'claude-cli' ? r.task.prompt : '',
32
+ argv: r.task.kind === 'shell' ? r.task.argv.join(' ') : '',
33
+ tool: r.task.kind === 'mcp-call' ? r.task.tool : '',
34
+ tokenCap: r.task.kind === 'claude-cli' ? String(r.task.tokenCap) : '100000',
35
+ maxUsd: r.task.kind === 'claude-cli' ? String(r.task.maxUsd) : '2',
36
+ enabled: r.enabled,
37
+ };
38
+ }
39
+ function buildRoutine(draft) {
40
+ const errors = [];
41
+ const trimmed = {
42
+ id: draft.id.trim(),
43
+ name: draft.name.trim() || draft.id.trim(),
44
+ description: draft.description.trim(),
45
+ onCalendar: draft.onCalendar.trim(),
46
+ prompt: draft.prompt.trim(),
47
+ argv: draft.argv.trim(),
48
+ tool: draft.tool.trim(),
49
+ tokenCap: parseInt(draft.tokenCap, 10),
50
+ maxUsd: parseFloat(draft.maxUsd),
51
+ };
52
+ if (!trimmed.id)
53
+ errors.push('id is required');
54
+ if (draft.scheduleKind === 'calendar' && !trimmed.onCalendar)
55
+ errors.push('OnCalendar is required for calendar schedule');
56
+ let task;
57
+ if (draft.taskKind === 'claude-cli') {
58
+ if (!trimmed.prompt)
59
+ errors.push('prompt is required for claude-cli task');
60
+ if (!Number.isFinite(trimmed.tokenCap) || trimmed.tokenCap <= 0)
61
+ errors.push('tokenCap must be a positive integer');
62
+ if (!Number.isFinite(trimmed.maxUsd) || trimmed.maxUsd <= 0)
63
+ errors.push('maxUsd must be positive');
64
+ task = {
65
+ kind: 'claude-cli',
66
+ prompt: trimmed.prompt,
67
+ outputFormat: 'json',
68
+ tokenCap: trimmed.tokenCap,
69
+ maxUsd: trimmed.maxUsd,
70
+ wallClockMs: 15 * 60 * 1000,
71
+ };
72
+ }
73
+ else if (draft.taskKind === 'shell') {
74
+ const argv = trimmed.argv.length > 0 ? trimmed.argv.split(/\s+/) : [];
75
+ if (argv.length === 0)
76
+ errors.push('argv is required for shell task');
77
+ task = { kind: 'shell', argv, wallClockMs: 15 * 60 * 1000 };
78
+ }
79
+ else {
80
+ if (!trimmed.tool)
81
+ errors.push('tool is required for mcp-call task');
82
+ task = { kind: 'mcp-call', tool: trimmed.tool, args: {}, wallClockMs: 60_000 };
83
+ }
84
+ if (errors.length > 0)
85
+ return { ok: false, errors };
86
+ const candidate = {
87
+ id: trimmed.id,
88
+ name: trimmed.name || trimmed.id,
89
+ description: trimmed.description,
90
+ schedule: draft.scheduleKind === 'manual'
91
+ ? { kind: 'manual' }
92
+ : { kind: 'calendar', onCalendar: trimmed.onCalendar, randomizedDelaySec: 300, persistent: true },
93
+ enabled: draft.enabled,
94
+ targets: [],
95
+ perTarget: false,
96
+ task,
97
+ notify: [],
98
+ tags: [],
99
+ };
100
+ const parsed = RoutineSchema.safeParse(candidate);
101
+ if (!parsed.success) {
102
+ return { ok: false, errors: parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`) };
103
+ }
104
+ return { ok: true, routine: parsed.data };
105
+ }
106
+ const FIELD_ORDER_ALL = [
107
+ 'id', 'name', 'description',
108
+ 'scheduleKind', 'onCalendar',
109
+ 'taskKind', 'prompt', 'argv', 'tool', 'tokenCap', 'maxUsd',
110
+ 'enabled',
111
+ ];
112
+ function visibleFields(draft) {
113
+ return FIELD_ORDER_ALL.filter(f => {
114
+ if (f === 'onCalendar' && draft.scheduleKind !== 'calendar')
115
+ return false;
116
+ if (f === 'prompt' && draft.taskKind !== 'claude-cli')
117
+ return false;
118
+ if (f === 'tokenCap' && draft.taskKind !== 'claude-cli')
119
+ return false;
120
+ if (f === 'maxUsd' && draft.taskKind !== 'claude-cli')
121
+ return false;
122
+ if (f === 'argv' && draft.taskKind !== 'shell')
123
+ return false;
124
+ if (f === 'tool' && draft.taskKind !== 'mcp-call')
125
+ return false;
126
+ return true;
127
+ });
128
+ }
129
+ const FIELD_LABEL = {
130
+ id: 'id',
131
+ name: 'name',
132
+ description: 'description',
133
+ scheduleKind: 'schedule',
134
+ onCalendar: 'OnCalendar',
135
+ taskKind: 'task kind',
136
+ prompt: 'prompt',
137
+ argv: 'argv',
138
+ tool: 'MCP tool',
139
+ tokenCap: 'token cap',
140
+ maxUsd: 'max USD',
141
+ enabled: 'enabled',
142
+ };
143
+ export function RoutineForm({ initial, onSubmit, onCancel }) {
144
+ const [draft, setDraft] = useState(() => toDraft(initial));
145
+ const [cursor, setCursor] = useState(0);
146
+ const [textValue, setTextValue] = useState(() => {
147
+ const fields = visibleFields(draft);
148
+ return draft[fields[0]];
149
+ });
150
+ const [errors, setErrors] = useState([]);
151
+ const [editing, setEditing] = useState(true);
152
+ const fields = visibleFields(draft);
153
+ const currentField = fields[cursor];
154
+ const isDisabledId = !!initial;
155
+ useRegisterHandler((input, key) => {
156
+ if (editing && (currentField === 'scheduleKind' || currentField === 'taskKind' || currentField === 'enabled')) {
157
+ return false;
158
+ }
159
+ if (key.escape) {
160
+ onCancel();
161
+ return true;
162
+ }
163
+ if (!editing) {
164
+ if (key.return) {
165
+ const result = buildRoutine(draft);
166
+ if (!result.ok) {
167
+ setErrors(result.errors);
168
+ return true;
169
+ }
170
+ onSubmit(result.routine);
171
+ return true;
172
+ }
173
+ if (input === 'e') {
174
+ setEditing(true);
175
+ return true;
176
+ }
177
+ if (input === 'j' || key.downArrow) {
178
+ const next = Math.min(cursor + 1, fields.length - 1);
179
+ setCursor(next);
180
+ const nextField = fields[next];
181
+ if (nextField !== 'scheduleKind' && nextField !== 'taskKind' && nextField !== 'enabled') {
182
+ setTextValue(String(draft[nextField] ?? ''));
183
+ }
184
+ return true;
185
+ }
186
+ if (input === 'k' || key.upArrow) {
187
+ const next = Math.max(cursor - 1, 0);
188
+ setCursor(next);
189
+ const nextField = fields[next];
190
+ if (nextField !== 'scheduleKind' && nextField !== 'taskKind' && nextField !== 'enabled') {
191
+ setTextValue(String(draft[nextField] ?? ''));
192
+ }
193
+ return true;
194
+ }
195
+ return false;
196
+ }
197
+ if (currentField === 'scheduleKind') {
198
+ if (input === ' ' || key.return) {
199
+ setDraft(d => ({ ...d, scheduleKind: d.scheduleKind === 'manual' ? 'calendar' : 'manual' }));
200
+ return true;
201
+ }
202
+ }
203
+ if (currentField === 'taskKind') {
204
+ if (input === ' ' || key.return) {
205
+ setDraft(d => {
206
+ const next = {
207
+ 'claude-cli': 'shell',
208
+ 'shell': 'mcp-call',
209
+ 'mcp-call': 'claude-cli',
210
+ };
211
+ return { ...d, taskKind: next[d.taskKind] };
212
+ });
213
+ return true;
214
+ }
215
+ }
216
+ if (currentField === 'enabled') {
217
+ if (input === ' ' || key.return) {
218
+ setDraft(d => ({ ...d, enabled: !d.enabled }));
219
+ return true;
220
+ }
221
+ }
222
+ return false;
223
+ });
224
+ const renderField = (f, selected) => {
225
+ const marker = selected ? '▶' : ' ';
226
+ const label = FIELD_LABEL[f];
227
+ const editable = editing && selected && f !== 'scheduleKind' && f !== 'taskKind' && f !== 'enabled';
228
+ const d = draft;
229
+ const valueNode = (() => {
230
+ if (editable && typeof d[f] === 'string') {
231
+ return (_jsx(TextInput, { value: textValue, onChange: setTextValue, onSubmit: () => {
232
+ setDraft(prev => ({ ...prev, [f]: textValue }));
233
+ setEditing(false);
234
+ } }));
235
+ }
236
+ if (f === 'enabled')
237
+ return _jsx(Text, { color: draft.enabled ? 'green' : 'gray', children: draft.enabled ? 'yes' : 'no' });
238
+ if (f === 'scheduleKind')
239
+ return _jsx(Text, { color: "cyan", children: draft.scheduleKind });
240
+ if (f === 'taskKind')
241
+ return _jsx(Text, { color: "cyan", children: draft.taskKind });
242
+ if (f === 'id' && isDisabledId)
243
+ return _jsxs(Text, { color: "gray", children: [String(d[f]), " (locked)"] });
244
+ return _jsx(Text, { children: String(d[f]) });
245
+ })();
246
+ return (_jsxs(Box, { children: [_jsx(Box, { width: 2, children: _jsx(Text, { color: selected ? 'cyan' : undefined, children: marker }) }), _jsx(Box, { width: 16, children: _jsx(Text, { color: selected ? 'cyan' : 'gray', children: label }) }), _jsx(Box, { children: valueNode })] }, f));
247
+ };
248
+ const hint = editing && currentField !== 'scheduleKind' && currentField !== 'taskKind' && currentField !== 'enabled'
249
+ ? 'type to edit · Enter to confirm field · Esc cancel'
250
+ : editing
251
+ ? 'Space/Enter to toggle · Esc cancel'
252
+ : 'j/k move · e edit · Enter submit · Esc cancel';
253
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, gap: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: initial ? `edit ${initial.id}` : 'new routine' }), _jsx(Box, { flexDirection: "column", children: fields.map((f, i) => renderField(f, i === cursor)) }), errors.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", bold: true, children: "errors" }), errors.map((e, i) => _jsxs(Text, { color: "red", children: [" \u00B7 ", e] }, i))] })), _jsx(Text, { color: "gray", children: hint })] }));
254
+ }
@@ -0,0 +1,13 @@
1
+ import React from 'react';
2
+ import type { Signal, SignalKind } from '../../../core/routines/schema.js';
3
+ export interface SignalsGridRow {
4
+ repo: string;
5
+ signals: Signal[];
6
+ }
7
+ export interface SignalsGridProps {
8
+ rows: SignalsGridRow[];
9
+ selectedIndex: number;
10
+ kinds: SignalKind[];
11
+ nameWidth?: number;
12
+ }
13
+ export declare function SignalsGrid({ rows, selectedIndex, kinds, nameWidth }: SignalsGridProps): React.JSX.Element;
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { signalStateColor, signalStateGlyph, truncate } from '../format.js';
5
+ const KIND_LABEL = {
6
+ 'git-clean': 'GIT',
7
+ 'git-ahead': 'AHEAD',
8
+ 'git-behind': 'BEHIND',
9
+ 'open-prs': 'PRS',
10
+ 'pr-age-max': 'PR-AGE',
11
+ 'deps-outdated': 'DEPS',
12
+ 'deps-vulns': 'VULNS',
13
+ 'build-ok': 'BUILD',
14
+ 'tests-ok': 'TESTS',
15
+ 'env-schema-ok': 'ENV',
16
+ 'container-up': 'CTRS',
17
+ 'ci-status': 'CI',
18
+ 'cache-age': 'CACHE',
19
+ };
20
+ function Cell({ signal }) {
21
+ if (!signal)
22
+ return _jsx(Text, { color: "gray", children: " \u00B7 " });
23
+ const color = signalStateColor[signal.state];
24
+ const glyph = signalStateGlyph[signal.state];
25
+ return _jsxs(Text, { color: color, children: [" ", glyph, " "] });
26
+ }
27
+ export function SignalsGrid({ rows, selectedIndex, kinds, nameWidth = 22 }) {
28
+ const header = useMemo(() => kinds.map(k => KIND_LABEL[k].padEnd(5).slice(0, 5)).join(''), [kinds]);
29
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: nameWidth + 2, children: _jsx(Text, { bold: true, children: "REPO" }) }), _jsx(Text, { bold: true, children: header })] }), rows.length === 0 && (_jsx(Text, { color: "gray", children: " no repos registered \u2014 run `fleet add`" })), rows.map((row, idx) => {
30
+ const byKind = new Map(row.signals.map(s => [s.kind, s]));
31
+ const selected = idx === selectedIndex;
32
+ return (_jsxs(Box, { children: [_jsx(Box, { width: nameWidth + 2, children: _jsxs(Text, { color: selected ? 'cyan' : undefined, bold: selected, children: [selected ? '▶ ' : ' ', truncate(row.repo, nameWidth)] }) }), kinds.map(kind => _jsx(Cell, { signal: byKind.get(kind) }, kind))] }, row.repo));
33
+ })] }));
34
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
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 { SignalsGrid } from './SignalsGrid.js';
5
+ const mkSignal = (kind, state, detail = '') => ({
6
+ repo: 'demo',
7
+ kind,
8
+ state,
9
+ value: state === 'ok',
10
+ detail,
11
+ collectedAt: new Date().toISOString(),
12
+ ttlMs: 10_000,
13
+ });
14
+ describe('SignalsGrid', () => {
15
+ it('renders header row with column labels', () => {
16
+ const { lastFrame } = render(_jsx(SignalsGrid, { rows: [], selectedIndex: 0, kinds: ['git-clean', 'container-up', 'ci-status'] }));
17
+ const frame = lastFrame();
18
+ expect(frame).toContain('REPO');
19
+ expect(frame).toContain('GIT');
20
+ expect(frame).toContain('CTRS');
21
+ expect(frame).toContain('CI');
22
+ });
23
+ it('renders a row with repo name when signals present', () => {
24
+ const rows = [{ repo: 'abmanandvan', signals: [mkSignal('git-clean', 'ok')] }];
25
+ const { lastFrame } = render(_jsx(SignalsGrid, { rows: rows, selectedIndex: 0, kinds: ['git-clean'] }));
26
+ expect(lastFrame()).toContain('abmanandvan');
27
+ });
28
+ it('shows empty-state message with no repos', () => {
29
+ const { lastFrame } = render(_jsx(SignalsGrid, { rows: [], selectedIndex: 0, kinds: ['git-clean'] }));
30
+ expect(lastFrame()).toContain('no repos registered');
31
+ });
32
+ it('marks the selected row with an arrow', () => {
33
+ const rows = [
34
+ { repo: 'first', signals: [] },
35
+ { repo: 'second', signals: [] },
36
+ ];
37
+ const { lastFrame } = render(_jsx(SignalsGrid, { rows: rows, selectedIndex: 1, kinds: ['git-clean'] }));
38
+ const frame = lastFrame();
39
+ const lines = frame.split('\n');
40
+ const secondLine = lines.find(l => l.includes('second'));
41
+ expect(secondLine).toContain('▶');
42
+ });
43
+ });
@@ -0,0 +1,7 @@
1
+ import type { SignalState } from '../../core/routines/schema.js';
2
+ export declare const signalStateColor: Record<SignalState, string>;
3
+ export declare const signalStateGlyph: Record<SignalState, string>;
4
+ export declare function formatRelative(iso: string | null, now?: number): string;
5
+ export declare function formatDuration(ms: number | null): string;
6
+ export declare function formatUsd(usd: number | null): string;
7
+ export declare function truncate(s: string, max: number): string;
@@ -0,0 +1,51 @@
1
+ export const signalStateColor = {
2
+ ok: 'green',
3
+ warn: 'yellow',
4
+ error: 'red',
5
+ unknown: 'gray',
6
+ };
7
+ export const signalStateGlyph = {
8
+ ok: '●',
9
+ warn: '◐',
10
+ error: '●',
11
+ unknown: '○',
12
+ };
13
+ export function formatRelative(iso, now = Date.now()) {
14
+ if (!iso)
15
+ return '—';
16
+ const ms = now - new Date(iso).getTime();
17
+ const abs = Math.abs(ms);
18
+ const suffix = ms >= 0 ? 'ago' : 'from now';
19
+ if (abs < 10_000)
20
+ return 'just now';
21
+ if (abs < 60_000)
22
+ return `${Math.round(abs / 1000)}s ${suffix}`;
23
+ if (abs < 3_600_000)
24
+ return `${Math.round(abs / 60_000)}m ${suffix}`;
25
+ if (abs < 86_400_000)
26
+ return `${Math.round(abs / 3_600_000)}h ${suffix}`;
27
+ return `${Math.round(abs / 86_400_000)}d ${suffix}`;
28
+ }
29
+ export function formatDuration(ms) {
30
+ if (ms == null)
31
+ return '—';
32
+ if (ms < 1000)
33
+ return `${ms}ms`;
34
+ if (ms < 60_000)
35
+ return `${(ms / 1000).toFixed(1)}s`;
36
+ return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
37
+ }
38
+ export function formatUsd(usd) {
39
+ if (usd == null)
40
+ return '—';
41
+ if (usd < 0.01)
42
+ return `<$0.01`;
43
+ if (usd < 1)
44
+ return `$${usd.toFixed(3)}`;
45
+ return `$${usd.toFixed(2)}`;
46
+ }
47
+ export function truncate(s, max) {
48
+ if (s.length <= max)
49
+ return s;
50
+ return `${s.slice(0, max - 1)}…`;
51
+ }
@@ -0,0 +1,33 @@
1
+ import type { AppEntry } from '../../../core/registry.js';
2
+ export interface FleetPr {
3
+ repo: string;
4
+ number: number;
5
+ title: string;
6
+ author: string;
7
+ updatedAt: string;
8
+ url: string;
9
+ isDraft: boolean;
10
+ reviewDecision: string | null;
11
+ }
12
+ export interface FleetBranchState {
13
+ repo: string;
14
+ branch: string;
15
+ ahead: number;
16
+ behind: number;
17
+ clean: boolean;
18
+ dirtyCount: number;
19
+ releasePending: number;
20
+ }
21
+ export interface GitFleetSnapshot {
22
+ loading: boolean;
23
+ prs: FleetPr[];
24
+ branchStates: FleetBranchState[];
25
+ refreshedAt: number;
26
+ errors: {
27
+ repo: string;
28
+ message: string;
29
+ }[];
30
+ }
31
+ export declare function useGitFleet(apps: AppEntry[]): GitFleetSnapshot & {
32
+ refresh(): void;
33
+ };
@@ -0,0 +1,82 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { execSafe } from '../../../core/exec.js';
3
+ import { getGitStatus } from '../../../core/git.js';
4
+ function listPrsForRepo(cwd, repo) {
5
+ const res = execSafe('gh', [
6
+ 'pr', 'list', '--state', 'open',
7
+ '--json', 'number,title,author,updatedAt,url,isDraft,reviewDecision',
8
+ '--limit', '20',
9
+ ], { cwd, timeout: 8000 });
10
+ if (!res.ok)
11
+ return [];
12
+ try {
13
+ const raw = JSON.parse(res.stdout);
14
+ return raw.map(p => ({
15
+ repo,
16
+ number: p.number,
17
+ title: p.title,
18
+ author: p.author?.login ?? 'unknown',
19
+ updatedAt: p.updatedAt,
20
+ url: p.url,
21
+ isDraft: p.isDraft,
22
+ reviewDecision: p.reviewDecision,
23
+ }));
24
+ }
25
+ catch {
26
+ return [];
27
+ }
28
+ }
29
+ function countReleasePending(cwd) {
30
+ const res = execSafe('git', ['-C', cwd, 'rev-list', '--count', 'origin/main..origin/develop'], { timeout: 5000 });
31
+ if (!res.ok)
32
+ return 0;
33
+ return parseInt(res.stdout.trim() || '0', 10) || 0;
34
+ }
35
+ export function useGitFleet(apps) {
36
+ const [state, setState] = useState({
37
+ loading: false,
38
+ prs: [],
39
+ branchStates: [],
40
+ refreshedAt: 0,
41
+ errors: [],
42
+ });
43
+ const load = () => {
44
+ setState(s => ({ ...s, loading: true }));
45
+ const prs = [];
46
+ const branchStates = [];
47
+ const errors = [];
48
+ for (const app of apps) {
49
+ const cwd = app.composePath ?? '';
50
+ if (!cwd)
51
+ continue;
52
+ try {
53
+ const git = getGitStatus(cwd);
54
+ if (git.initialised) {
55
+ branchStates.push({
56
+ repo: app.name,
57
+ branch: git.branch,
58
+ ahead: git.ahead,
59
+ behind: git.behind,
60
+ clean: git.clean,
61
+ dirtyCount: git.modified + git.staged + git.untracked,
62
+ releasePending: countReleasePending(cwd),
63
+ });
64
+ }
65
+ prs.push(...listPrsForRepo(cwd, app.name));
66
+ }
67
+ catch (err) {
68
+ errors.push({ repo: app.name, message: err.message });
69
+ }
70
+ }
71
+ prs.sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime());
72
+ setState({ loading: false, prs, branchStates, refreshedAt: Date.now(), errors });
73
+ };
74
+ useEffect(() => {
75
+ if (apps.length === 0)
76
+ return;
77
+ load();
78
+ const id = setInterval(load, 60_000);
79
+ return () => clearInterval(id);
80
+ }, [apps.map(a => a.name).join('|')]);
81
+ return { ...state, refresh: load };
82
+ }
@@ -0,0 +1,13 @@
1
+ import type { LogLine } from '@matthesketh/ink-log-viewer';
2
+ export interface LogsStreamOptions {
3
+ command: string;
4
+ args: string[];
5
+ bufferSize?: number;
6
+ }
7
+ export interface LogsStream {
8
+ lines: LogLine[];
9
+ running: boolean;
10
+ error: string | null;
11
+ restart(): void;
12
+ }
13
+ export declare function useLogsStream(opts: LogsStreamOptions | null): LogsStream;
@@ -0,0 +1,64 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ const LEVEL_PATTERNS = [
4
+ [/\b(error|err|failed|fatal)\b/i, 'error'],
5
+ [/\b(warn|warning)\b/i, 'warn'],
6
+ [/\b(debug|trace)\b/i, 'debug'],
7
+ ];
8
+ function classify(line) {
9
+ for (const [pattern, level] of LEVEL_PATTERNS) {
10
+ if (pattern.test(line))
11
+ return level;
12
+ }
13
+ return 'info';
14
+ }
15
+ export function useLogsStream(opts) {
16
+ const [lines, setLines] = useState([]);
17
+ const [running, setRunning] = useState(false);
18
+ const [error, setError] = useState(null);
19
+ const [version, setVersion] = useState(0);
20
+ const bufferSize = opts?.bufferSize ?? 500;
21
+ const lineBufferRef = useRef('');
22
+ useEffect(() => {
23
+ if (!opts) {
24
+ setLines([]);
25
+ setRunning(false);
26
+ return;
27
+ }
28
+ setLines([]);
29
+ setError(null);
30
+ setRunning(true);
31
+ lineBufferRef.current = '';
32
+ const child = spawn(opts.command, opts.args, { stdio: ['ignore', 'pipe', 'pipe'] });
33
+ const append = (chunk) => {
34
+ lineBufferRef.current += chunk;
35
+ let idx;
36
+ const newLines = [];
37
+ while ((idx = lineBufferRef.current.indexOf('\n')) >= 0) {
38
+ const text = lineBufferRef.current.slice(0, idx);
39
+ lineBufferRef.current = lineBufferRef.current.slice(idx + 1);
40
+ if (!text.trim())
41
+ continue;
42
+ newLines.push({ text, level: classify(text), timestamp: new Date() });
43
+ }
44
+ if (newLines.length > 0) {
45
+ setLines(prev => {
46
+ const combined = [...prev, ...newLines];
47
+ return combined.length > bufferSize ? combined.slice(-bufferSize) : combined;
48
+ });
49
+ }
50
+ };
51
+ child.stdout?.setEncoding('utf-8');
52
+ child.stderr?.setEncoding('utf-8');
53
+ child.stdout?.on('data', append);
54
+ child.stderr?.on('data', append);
55
+ child.on('error', err => { setError(err.message); setRunning(false); });
56
+ child.on('close', () => setRunning(false));
57
+ return () => {
58
+ child.kill('SIGTERM');
59
+ setTimeout(() => { if (!child.killed)
60
+ child.kill('SIGKILL'); }, 2000);
61
+ };
62
+ }, [opts?.command, opts?.args.join(' '), bufferSize, version]);
63
+ return { lines, running, error, restart: () => setVersion(v => v + 1) };
64
+ }
@@ -0,0 +1,20 @@
1
+ import type { AppEntry } from '../../../core/registry.js';
2
+ import { type ServiceStatus } from '../../../core/systemd.js';
3
+ export interface OpsRepoState {
4
+ name: string;
5
+ service: ServiceStatus | null;
6
+ runningContainers: number;
7
+ totalContainers: number;
8
+ }
9
+ export interface OpsSnapshot {
10
+ loading: boolean;
11
+ repos: OpsRepoState[];
12
+ nginxSites: number | null;
13
+ nginxOk: boolean | null;
14
+ dockerDatabasesActive: boolean | null;
15
+ diskPercent: number | null;
16
+ refreshedAt: number;
17
+ }
18
+ export declare function useOpsFleet(apps: AppEntry[]): OpsSnapshot & {
19
+ refresh(): void;
20
+ };