@matthesketh/fleet 1.1.0 → 1.6.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 (217) 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 +43 -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/logs.d.ts +1 -1
  39. package/dist/commands/logs.js +237 -8
  40. package/dist/commands/patch-systemd.d.ts +1 -0
  41. package/dist/commands/patch-systemd.js +126 -0
  42. package/dist/commands/rollback.d.ts +1 -0
  43. package/dist/commands/rollback.js +58 -0
  44. package/dist/commands/routine-run.d.ts +1 -0
  45. package/dist/commands/routine-run.js +122 -0
  46. package/dist/commands/routines.d.ts +1 -0
  47. package/dist/commands/routines.js +25 -0
  48. package/dist/commands/secrets.js +449 -16
  49. package/dist/commands/status.js +7 -3
  50. package/dist/commands/watchdog.d.ts +1 -1
  51. package/dist/commands/watchdog.js +16 -40
  52. package/dist/core/boot-refresh.d.ts +57 -0
  53. package/dist/core/boot-refresh.js +116 -0
  54. package/dist/core/deps/actors/pr-creator.js +11 -9
  55. package/dist/core/deps/collectors/docker-running.js +2 -2
  56. package/dist/core/deps/collectors/github-pr.js +5 -2
  57. package/dist/core/deps/collectors/npm.js +10 -5
  58. package/dist/core/deps/collectors/vulnerability.js +10 -6
  59. package/dist/core/deps/reporters/motd.js +1 -1
  60. package/dist/core/deps/reporters/telegram.js +2 -29
  61. package/dist/core/docker.js +45 -15
  62. package/dist/core/egress.d.ts +41 -0
  63. package/dist/core/egress.js +161 -0
  64. package/dist/core/exec.d.ts +7 -1
  65. package/dist/core/exec.js +25 -17
  66. package/dist/core/git.d.ts +1 -0
  67. package/dist/core/git.js +36 -23
  68. package/dist/core/github.js +27 -8
  69. package/dist/core/health.d.ts +3 -0
  70. package/dist/core/health.js +15 -3
  71. package/dist/core/logs-multi.d.ts +73 -0
  72. package/dist/core/logs-multi.js +163 -0
  73. package/dist/core/logs-policy.d.ts +55 -0
  74. package/dist/core/logs-policy.js +148 -0
  75. package/dist/core/nginx.js +8 -4
  76. package/dist/core/notify.d.ts +15 -0
  77. package/dist/core/notify.js +55 -0
  78. package/dist/core/registry.d.ts +25 -0
  79. package/dist/core/registry.js +57 -10
  80. package/dist/core/routines/cost-queries.d.ts +24 -0
  81. package/dist/core/routines/cost-queries.js +65 -0
  82. package/dist/core/routines/db.d.ts +9 -0
  83. package/dist/core/routines/db.js +126 -0
  84. package/dist/core/routines/defaults.d.ts +2 -0
  85. package/dist/core/routines/defaults.js +72 -0
  86. package/dist/core/routines/engine.d.ts +59 -0
  87. package/dist/core/routines/engine.js +175 -0
  88. package/dist/core/routines/incidents.d.ts +13 -0
  89. package/dist/core/routines/incidents.js +35 -0
  90. package/dist/core/routines/schema.d.ts +418 -0
  91. package/dist/core/routines/schema.js +113 -0
  92. package/dist/core/routines/signals-collector.d.ts +35 -0
  93. package/dist/core/routines/signals-collector.js +114 -0
  94. package/dist/core/routines/store.d.ts +316 -0
  95. package/dist/core/routines/store.js +99 -0
  96. package/dist/core/routines/test-utils.d.ts +2 -0
  97. package/dist/core/routines/test-utils.js +13 -0
  98. package/dist/core/secrets-audit.d.ts +21 -0
  99. package/dist/core/secrets-audit.js +60 -0
  100. package/dist/core/secrets-metadata.d.ts +39 -0
  101. package/dist/core/secrets-metadata.js +82 -0
  102. package/dist/core/secrets-motd.d.ts +20 -0
  103. package/dist/core/secrets-motd.js +72 -0
  104. package/dist/core/secrets-ops.d.ts +3 -1
  105. package/dist/core/secrets-ops.js +78 -13
  106. package/dist/core/secrets-providers.d.ts +50 -0
  107. package/dist/core/secrets-providers.js +291 -0
  108. package/dist/core/secrets-rotation.d.ts +52 -0
  109. package/dist/core/secrets-rotation.js +165 -0
  110. package/dist/core/secrets-snapshots.d.ts +26 -0
  111. package/dist/core/secrets-snapshots.js +95 -0
  112. package/dist/core/secrets-validate.js +2 -1
  113. package/dist/core/secrets.d.ts +12 -1
  114. package/dist/core/secrets.js +35 -24
  115. package/dist/core/self-update.d.ts +41 -0
  116. package/dist/core/self-update.js +73 -0
  117. package/dist/core/systemd.js +29 -12
  118. package/dist/core/telegram.d.ts +6 -0
  119. package/dist/core/telegram.js +32 -0
  120. package/dist/core/validate.d.ts +7 -0
  121. package/dist/core/validate.js +42 -0
  122. package/dist/index.js +0 -4
  123. package/dist/mcp/deps-tools.js +9 -1
  124. package/dist/mcp/git-tools.js +4 -4
  125. package/dist/mcp/server.js +193 -8
  126. package/dist/templates/systemd.js +3 -3
  127. package/dist/templates/unseal.js +5 -1
  128. package/dist/tui/components/Confirm.js +3 -4
  129. package/dist/tui/components/Header.js +37 -8
  130. package/dist/tui/components/KeyHint.js +14 -5
  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/hooks/use-terminal-size.d.ts +1 -0
  135. package/dist/tui/hooks/use-terminal-size.js +1 -0
  136. package/dist/tui/router.js +133 -8
  137. package/dist/tui/routines/RoutinesApp.d.ts +8 -0
  138. package/dist/tui/routines/RoutinesApp.js +277 -0
  139. package/dist/tui/routines/components/AlertsPanel.d.ts +7 -0
  140. package/dist/tui/routines/components/AlertsPanel.js +22 -0
  141. package/dist/tui/routines/components/AlertsPanel.test.d.ts +1 -0
  142. package/dist/tui/routines/components/AlertsPanel.test.js +52 -0
  143. package/dist/tui/routines/components/CommandPalette.d.ts +12 -0
  144. package/dist/tui/routines/components/CommandPalette.js +21 -0
  145. package/dist/tui/routines/components/LiveRunPanel.d.ts +12 -0
  146. package/dist/tui/routines/components/LiveRunPanel.js +107 -0
  147. package/dist/tui/routines/components/RoutineForm.d.ts +8 -0
  148. package/dist/tui/routines/components/RoutineForm.js +254 -0
  149. package/dist/tui/routines/components/SignalsGrid.d.ts +13 -0
  150. package/dist/tui/routines/components/SignalsGrid.js +34 -0
  151. package/dist/tui/routines/components/SignalsGrid.test.d.ts +1 -0
  152. package/dist/tui/routines/components/SignalsGrid.test.js +43 -0
  153. package/dist/tui/routines/format.d.ts +7 -0
  154. package/dist/tui/routines/format.js +51 -0
  155. package/dist/tui/routines/hooks/use-git-fleet.d.ts +33 -0
  156. package/dist/tui/routines/hooks/use-git-fleet.js +82 -0
  157. package/dist/tui/routines/hooks/use-logs-stream.d.ts +13 -0
  158. package/dist/tui/routines/hooks/use-logs-stream.js +64 -0
  159. package/dist/tui/routines/hooks/use-ops-fleet.d.ts +20 -0
  160. package/dist/tui/routines/hooks/use-ops-fleet.js +70 -0
  161. package/dist/tui/routines/hooks/use-repo-detail.d.ts +31 -0
  162. package/dist/tui/routines/hooks/use-repo-detail.js +104 -0
  163. package/dist/tui/routines/hooks/use-security.d.ts +33 -0
  164. package/dist/tui/routines/hooks/use-security.js +110 -0
  165. package/dist/tui/routines/hooks/use-signals.d.ts +9 -0
  166. package/dist/tui/routines/hooks/use-signals.js +60 -0
  167. package/dist/tui/routines/runtime.d.ts +20 -0
  168. package/dist/tui/routines/runtime.js +40 -0
  169. package/dist/tui/routines/tabs/CostTab.d.ts +7 -0
  170. package/dist/tui/routines/tabs/CostTab.js +24 -0
  171. package/dist/tui/routines/tabs/DashboardTab.d.ts +15 -0
  172. package/dist/tui/routines/tabs/DashboardTab.js +10 -0
  173. package/dist/tui/routines/tabs/GitTab.d.ts +6 -0
  174. package/dist/tui/routines/tabs/GitTab.js +39 -0
  175. package/dist/tui/routines/tabs/LogsTab.d.ts +6 -0
  176. package/dist/tui/routines/tabs/LogsTab.js +58 -0
  177. package/dist/tui/routines/tabs/OpsTab.d.ts +6 -0
  178. package/dist/tui/routines/tabs/OpsTab.js +34 -0
  179. package/dist/tui/routines/tabs/RepoDetailView.d.ts +6 -0
  180. package/dist/tui/routines/tabs/RepoDetailView.js +12 -0
  181. package/dist/tui/routines/tabs/RoutinesTab.d.ts +10 -0
  182. package/dist/tui/routines/tabs/RoutinesTab.js +58 -0
  183. package/dist/tui/routines/tabs/ScaffoldTab.d.ts +2 -0
  184. package/dist/tui/routines/tabs/ScaffoldTab.js +127 -0
  185. package/dist/tui/routines/tabs/SecurityTab.d.ts +6 -0
  186. package/dist/tui/routines/tabs/SecurityTab.js +31 -0
  187. package/dist/tui/routines/tabs/SettingsTab.d.ts +6 -0
  188. package/dist/tui/routines/tabs/SettingsTab.js +61 -0
  189. package/dist/tui/routines/tabs/TimelineTab.d.ts +7 -0
  190. package/dist/tui/routines/tabs/TimelineTab.js +26 -0
  191. package/dist/tui/state.js +16 -1
  192. package/dist/tui/tests/flicker.test.d.ts +1 -0
  193. package/dist/tui/tests/flicker.test.js +105 -0
  194. package/dist/tui/tests/keyboard-integration.test.d.ts +1 -0
  195. package/dist/tui/tests/keyboard-integration.test.js +120 -0
  196. package/dist/tui/tests/test-app.d.ts +4 -0
  197. package/dist/tui/tests/test-app.js +79 -0
  198. package/dist/tui/types.d.ts +14 -1
  199. package/dist/tui/views/AppDetail.js +40 -26
  200. package/dist/tui/views/Dashboard.js +34 -9
  201. package/dist/tui/views/HealthView.js +42 -12
  202. package/dist/tui/views/LogsView.js +38 -10
  203. package/dist/tui/views/MultiLogsView.d.ts +2 -0
  204. package/dist/tui/views/MultiLogsView.js +165 -0
  205. package/dist/tui/views/SecretEdit.js +18 -7
  206. package/dist/tui/views/SecretsView.js +55 -39
  207. package/dist/ui/prompt.d.ts +52 -0
  208. package/dist/ui/prompt.js +169 -0
  209. package/package.json +33 -5
  210. package/dist/commands/motd.d.ts +0 -1
  211. package/dist/commands/motd.js +0 -10
  212. package/dist/templates/motd.d.ts +0 -1
  213. package/dist/templates/motd.js +0 -7
  214. package/dist/tui/components/AppList.d.ts +0 -12
  215. package/dist/tui/components/AppList.js +0 -32
  216. package/dist/tui/hooks/use-keyboard.d.ts +0 -1
  217. package/dist/tui/hooks/use-keyboard.js +0 -44
@@ -0,0 +1,107 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import Spinner from 'ink-spinner';
5
+ import { useRegisterHandler } from '@matthesketh/ink-input-dispatcher';
6
+ import { formatDuration, formatUsd, truncate } from '../format.js';
7
+ export function LiveRunPanel({ engine, routineId, target, onClose }) {
8
+ const [feed, setFeed] = useState([]);
9
+ const [status, setStatus] = useState('starting');
10
+ const [startedAt, setStartedAt] = useState(null);
11
+ const [endedAt, setEndedAt] = useState(null);
12
+ const [cost, setCost] = useState(null);
13
+ const [error, setError] = useState(null);
14
+ const abortRef = useRef(null);
15
+ useEffect(() => {
16
+ const ac = new AbortController();
17
+ abortRef.current = ac;
18
+ const append = (line) => {
19
+ setFeed(prev => (prev.length < 200 ? [...prev, line] : [...prev.slice(-199), line]));
20
+ };
21
+ void (async () => {
22
+ try {
23
+ for await (const ev of engine.runOnce(routineId, target ?? { repo: null, repoPath: null }, 'manual', ac.signal)) {
24
+ switch (ev.kind) {
25
+ case 'start':
26
+ setStartedAt(Date.now());
27
+ setStatus('running');
28
+ append({ kind: 'info', text: `▶ start ${routineId}${ev.target ? ` · ${ev.target}` : ''}` });
29
+ break;
30
+ case 'stdout':
31
+ for (const l of ev.chunk.split('\n'))
32
+ if (l.trim())
33
+ append({ kind: 'stdout', text: l });
34
+ break;
35
+ case 'stderr':
36
+ for (const l of ev.chunk.split('\n'))
37
+ if (l.trim())
38
+ append({ kind: 'stderr', text: l });
39
+ break;
40
+ case 'tool-call':
41
+ append({ kind: 'tool-call', text: `${ev.name}${ev.argsPreview ? ` ${ev.argsPreview}` : ''}` });
42
+ break;
43
+ case 'cost':
44
+ setCost(ev);
45
+ break;
46
+ case 'end':
47
+ setEndedAt(Date.now());
48
+ setStatus(ev.status);
49
+ if (ev.error)
50
+ setError(ev.error);
51
+ append({ kind: 'info', text: `◼ ${ev.status} exit=${ev.exitCode} (${formatDuration(ev.durationMs)})` });
52
+ break;
53
+ }
54
+ }
55
+ }
56
+ catch (err) {
57
+ setStatus('failed');
58
+ setEndedAt(Date.now());
59
+ setError(err.message);
60
+ }
61
+ })();
62
+ return () => {
63
+ ac.abort();
64
+ };
65
+ }, [engine, routineId, target?.repo, target?.repoPath]);
66
+ useRegisterHandler((input, key) => {
67
+ if (status === 'running' || status === 'starting') {
68
+ if (input === 'a') {
69
+ abortRef.current?.abort();
70
+ return true;
71
+ }
72
+ return false;
73
+ }
74
+ if (key.escape || key.return || input === 'q') {
75
+ onClose();
76
+ return true;
77
+ }
78
+ return false;
79
+ });
80
+ const running = status === 'starting' || status === 'running';
81
+ const statusColor = (() => {
82
+ switch (status) {
83
+ case 'ok': return 'green';
84
+ case 'failed': return 'red';
85
+ case 'timeout': return 'yellow';
86
+ case 'aborted': return 'gray';
87
+ case 'running': return 'cyan';
88
+ case 'starting': return 'cyan';
89
+ default: return 'gray';
90
+ }
91
+ })();
92
+ const elapsed = startedAt
93
+ ? (endedAt ?? Date.now()) - startedAt
94
+ : 0;
95
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: statusColor, paddingX: 1, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, color: statusColor, children: running ? _jsxs(_Fragment, { children: [_jsx(Spinner, { type: "dots" }), " ", status] }) : _jsxs(_Fragment, { children: ["\u25FC ", status] }) }), _jsx(Text, { color: "gray", children: routineId }), target?.repo && _jsxs(Text, { color: "yellow", children: ["\u00B7 ", target.repo] }), _jsxs(Text, { color: "gray", children: ["\u00B7 ", formatDuration(elapsed)] }), cost && _jsxs(Text, { color: "magenta", children: ["\u00B7 ", formatUsd(cost.usd)] })] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [feed.slice(-18).map((line, i) => {
96
+ const color = line.kind === 'stderr' ? 'red'
97
+ : line.kind === 'tool-call' ? 'magenta'
98
+ : line.kind === 'info' ? 'cyan'
99
+ : undefined;
100
+ const prefix = line.kind === 'tool-call' ? '↳ '
101
+ : line.kind === 'stderr' ? '✖ '
102
+ : line.kind === 'info' ? '' : ' ';
103
+ return (_jsxs(Text, { color: color, children: [prefix, truncate(line.text, 160)] }, i));
104
+ }), feed.length === 0 && running && _jsx(Text, { color: "gray", children: " (no output yet)" })] }), error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ["\u2716 ", error] }) })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: running
105
+ ? 'a abort · q cancel and close'
106
+ : 'Enter / Esc / q close' }) })] }));
107
+ }
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ import { type Routine } from '../../../core/routines/schema.js';
3
+ export interface RoutineFormProps {
4
+ initial?: Routine;
5
+ onSubmit(r: Routine): void;
6
+ onCancel(): void;
7
+ }
8
+ export declare function RoutineForm({ initial, onSubmit, onCancel }: RoutineFormProps): React.JSX.Element;
@@ -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;