@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,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
+ };
@@ -0,0 +1,70 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { execSafe } from '../../../core/exec.js';
3
+ import { getMultipleServiceStatuses } from '../../../core/systemd.js';
4
+ function countContainersByProject(project) {
5
+ const res = execSafe('docker', [
6
+ 'ps', '--all',
7
+ '--filter', `label=com.docker.compose.project=${project}`,
8
+ '--format', '{{.State}}',
9
+ ], { timeout: 5000 });
10
+ if (!res.ok)
11
+ return { running: 0, total: 0 };
12
+ const states = res.stdout.split('\n').map(s => s.trim()).filter(Boolean);
13
+ return { running: states.filter(s => s === 'running').length, total: states.length };
14
+ }
15
+ function nginxHealth() {
16
+ const test = execSafe('nginx', ['-t'], { timeout: 4000 });
17
+ const list = execSafe('bash', ['-c', "ls /etc/nginx/sites-enabled/ 2>/dev/null | wc -l"], { timeout: 3000 });
18
+ const count = list.ok ? parseInt(list.stdout.trim(), 10) : null;
19
+ return { sites: Number.isFinite(count) ? count : null, ok: test.ok };
20
+ }
21
+ function diskPercent(path) {
22
+ const res = execSafe('bash', ['-c', `df -P ${path} | awk 'NR==2 {gsub("%",""); print $5}'`], { timeout: 3000 });
23
+ if (!res.ok)
24
+ return null;
25
+ const v = parseInt(res.stdout.trim(), 10);
26
+ return Number.isFinite(v) ? v : null;
27
+ }
28
+ export function useOpsFleet(apps) {
29
+ const [state, setState] = useState({
30
+ loading: false,
31
+ repos: [],
32
+ nginxSites: null,
33
+ nginxOk: null,
34
+ dockerDatabasesActive: null,
35
+ diskPercent: null,
36
+ refreshedAt: 0,
37
+ });
38
+ const load = () => {
39
+ setState(s => ({ ...s, loading: true }));
40
+ const serviceNames = apps.map(a => a.serviceName).filter(Boolean);
41
+ const allServiceNames = Array.from(new Set([...serviceNames, 'docker-databases']));
42
+ const serviceMap = getMultipleServiceStatuses(allServiceNames);
43
+ const repos = apps.map(app => {
44
+ const counts = countContainersByProject(app.name);
45
+ return {
46
+ name: app.name,
47
+ service: serviceMap.get(app.serviceName) ?? null,
48
+ runningContainers: counts.running,
49
+ totalContainers: counts.total,
50
+ };
51
+ });
52
+ const nginx = nginxHealth();
53
+ const disk = diskPercent('/home');
54
+ setState({
55
+ loading: false,
56
+ repos,
57
+ nginxSites: nginx.sites,
58
+ nginxOk: nginx.ok,
59
+ dockerDatabasesActive: serviceMap.get('docker-databases')?.active ?? null,
60
+ diskPercent: disk,
61
+ refreshedAt: Date.now(),
62
+ });
63
+ };
64
+ useEffect(() => {
65
+ load();
66
+ const id = setInterval(load, 20_000);
67
+ return () => clearInterval(id);
68
+ }, [apps.map(a => a.name).join('|')]);
69
+ return { ...state, refresh: load };
70
+ }
@@ -0,0 +1,31 @@
1
+ import type { AppEntry } from '../../../core/registry.js';
2
+ import { type GitStatus } from '../../../core/git.js';
3
+ import { type ServiceStatus } from '../../../core/systemd.js';
4
+ export interface OpenPr {
5
+ number: number;
6
+ title: string;
7
+ author: string;
8
+ updatedAt: string;
9
+ url: string;
10
+ isDraft: boolean;
11
+ }
12
+ export interface LastCommit {
13
+ hash: string;
14
+ subject: string;
15
+ date: string;
16
+ author: string;
17
+ }
18
+ export interface RepoDetailSnapshot {
19
+ loading: boolean;
20
+ error: string | null;
21
+ git: GitStatus | null;
22
+ lastCommit: LastCommit | null;
23
+ openPrs: OpenPr[] | null;
24
+ service: ServiceStatus | null;
25
+ runningContainers: number | null;
26
+ totalContainers: number | null;
27
+ refreshedAt: number;
28
+ }
29
+ export declare function useRepoDetail(app: AppEntry | null): RepoDetailSnapshot & {
30
+ refresh(): void;
31
+ };
@@ -0,0 +1,104 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { execSafe } from '../../../core/exec.js';
3
+ import { getGitStatus } from '../../../core/git.js';
4
+ import { getMultipleServiceStatuses } from '../../../core/systemd.js';
5
+ function fetchLastCommit(cwd) {
6
+ const res = execSafe('git', ['-C', cwd, 'log', '-1', '--format=%H%x09%s%x09%ad%x09%an', '--date=iso-strict'], { timeout: 5000 });
7
+ if (!res.ok || !res.stdout)
8
+ return null;
9
+ const [hash, subject, date, author] = res.stdout.split('\t');
10
+ if (!hash || !subject)
11
+ return null;
12
+ return { hash: hash.slice(0, 8), subject, date, author };
13
+ }
14
+ function fetchOpenPrs(cwd) {
15
+ const res = execSafe('gh', [
16
+ 'pr', 'list', '--state', 'open',
17
+ '--json', 'number,title,author,updatedAt,url,isDraft',
18
+ '--limit', '20',
19
+ ], { cwd, timeout: 8000 });
20
+ if (!res.ok)
21
+ return null;
22
+ try {
23
+ const raw = JSON.parse(res.stdout);
24
+ return raw.map(p => ({
25
+ number: p.number,
26
+ title: p.title,
27
+ author: p.author?.login ?? 'unknown',
28
+ updatedAt: p.updatedAt,
29
+ url: p.url,
30
+ isDraft: p.isDraft,
31
+ }));
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ function fetchContainerCounts(project) {
38
+ const res = execSafe('docker', [
39
+ 'ps', '--all',
40
+ '--filter', `label=com.docker.compose.project=${project}`,
41
+ '--format', '{{.State}}',
42
+ ], { timeout: 5000 });
43
+ if (!res.ok)
44
+ return null;
45
+ const states = res.stdout.split('\n').map(s => s.trim()).filter(Boolean);
46
+ return { running: states.filter(s => s === 'running').length, total: states.length };
47
+ }
48
+ export function useRepoDetail(app) {
49
+ const [snapshot, setSnapshot] = useState({
50
+ loading: false,
51
+ error: null,
52
+ git: null,
53
+ lastCommit: null,
54
+ openPrs: null,
55
+ service: null,
56
+ runningContainers: null,
57
+ totalContainers: null,
58
+ refreshedAt: 0,
59
+ });
60
+ const mounted = useRef(true);
61
+ useEffect(() => {
62
+ mounted.current = true;
63
+ return () => { mounted.current = false; };
64
+ }, []);
65
+ const load = () => {
66
+ if (!app)
67
+ return;
68
+ setSnapshot(s => ({ ...s, loading: true, error: null }));
69
+ try {
70
+ const cwd = app.composePath ?? '';
71
+ const git = cwd ? getGitStatus(cwd) : null;
72
+ const lastCommit = cwd ? fetchLastCommit(cwd) : null;
73
+ const openPrs = cwd ? fetchOpenPrs(cwd) : null;
74
+ const service = app.serviceName ? getMultipleServiceStatuses([app.serviceName]).get(app.serviceName) ?? null : null;
75
+ const containers = fetchContainerCounts(app.name);
76
+ if (!mounted.current)
77
+ return;
78
+ setSnapshot({
79
+ loading: false,
80
+ error: null,
81
+ git,
82
+ lastCommit,
83
+ openPrs,
84
+ service,
85
+ runningContainers: containers?.running ?? null,
86
+ totalContainers: containers?.total ?? null,
87
+ refreshedAt: Date.now(),
88
+ });
89
+ }
90
+ catch (err) {
91
+ if (!mounted.current)
92
+ return;
93
+ setSnapshot(s => ({ ...s, loading: false, error: err.message }));
94
+ }
95
+ };
96
+ useEffect(() => {
97
+ if (!app)
98
+ return;
99
+ load();
100
+ const id = setInterval(load, 30_000);
101
+ return () => clearInterval(id);
102
+ }, [app?.name]);
103
+ return { ...snapshot, refresh: load };
104
+ }
@@ -0,0 +1,33 @@
1
+ import type { AppEntry } from '../../../core/registry.js';
2
+ export interface GuardianStatus {
3
+ binaryInstalled: boolean;
4
+ whitelistExists: boolean;
5
+ whitelistLines: number | null;
6
+ runcWhitelisted: boolean | null;
7
+ }
8
+ export interface SshAgentStatus {
9
+ socketExists: boolean;
10
+ keyLoaded: boolean | null;
11
+ keyFingerprint: string | null;
12
+ }
13
+ export interface CertExpiry {
14
+ domain: string;
15
+ expiresAt: string | null;
16
+ daysUntil: number | null;
17
+ }
18
+ export interface SecretAge {
19
+ app: string;
20
+ ageDays: number | null;
21
+ error: string | null;
22
+ }
23
+ export interface SecuritySnapshot {
24
+ loading: boolean;
25
+ guardian: GuardianStatus | null;
26
+ ssh: SshAgentStatus | null;
27
+ certs: CertExpiry[];
28
+ secretAges: SecretAge[];
29
+ refreshedAt: number;
30
+ }
31
+ export declare function useSecurity(apps: AppEntry[]): SecuritySnapshot & {
32
+ refresh(): void;
33
+ };
@@ -0,0 +1,110 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { useEffect, useState } from 'react';
3
+ import { execSafe } from '../../../core/exec.js';
4
+ const SSH_AUTH_SOCK_PATH = '/tmp/fleet-ssh-agent.sock';
5
+ const GUARDIAN_WHITELIST = '/etc/guardian/whitelist';
6
+ function checkGuardian() {
7
+ const binary = existsSync('/usr/local/bin/guardiand');
8
+ const whitelistExists = existsSync(GUARDIAN_WHITELIST);
9
+ let whitelistLines = null;
10
+ let runcWhitelisted = null;
11
+ if (whitelistExists) {
12
+ const res = execSafe('bash', ['-c', `wc -l < ${GUARDIAN_WHITELIST} && grep -c '^/runc$' ${GUARDIAN_WHITELIST}`], { timeout: 3000 });
13
+ if (res.ok) {
14
+ const lines = res.stdout.split('\n').map(s => parseInt(s.trim(), 10));
15
+ whitelistLines = Number.isFinite(lines[0]) ? lines[0] : null;
16
+ runcWhitelisted = lines[1] ? lines[1] > 0 : false;
17
+ }
18
+ }
19
+ return { binaryInstalled: binary, whitelistExists, whitelistLines, runcWhitelisted };
20
+ }
21
+ function checkSshAgent() {
22
+ const socketExists = existsSync(SSH_AUTH_SOCK_PATH);
23
+ if (!socketExists)
24
+ return { socketExists: false, keyLoaded: null, keyFingerprint: null };
25
+ const res = execSafe('ssh-add', ['-l'], {
26
+ env: { SSH_AUTH_SOCK: SSH_AUTH_SOCK_PATH },
27
+ timeout: 3000,
28
+ });
29
+ if (!res.ok || !res.stdout)
30
+ return { socketExists: true, keyLoaded: false, keyFingerprint: null };
31
+ const line = res.stdout.split('\n')[0] ?? '';
32
+ const fp = line.split(/\s+/)[1] ?? null;
33
+ return { socketExists: true, keyLoaded: true, keyFingerprint: fp };
34
+ }
35
+ function certExpiryFor(domain) {
36
+ const paths = [
37
+ `/etc/letsencrypt/live/${domain}/fullchain.pem`,
38
+ `/etc/nginx/ssl/${domain}/fullchain.pem`,
39
+ ];
40
+ for (const path of paths) {
41
+ if (!existsSync(path))
42
+ continue;
43
+ const res = execSafe('openssl', ['x509', '-enddate', '-noout', '-in', path], { timeout: 3000 });
44
+ if (!res.ok)
45
+ continue;
46
+ const m = res.stdout.match(/notAfter=(.+)/);
47
+ if (!m)
48
+ continue;
49
+ const expiresAt = new Date(m[1]);
50
+ if (isNaN(expiresAt.getTime()))
51
+ continue;
52
+ const daysUntil = Math.floor((expiresAt.getTime() - Date.now()) / 86_400_000);
53
+ return { domain, expiresAt: expiresAt.toISOString(), daysUntil };
54
+ }
55
+ return { domain, expiresAt: null, daysUntil: null };
56
+ }
57
+ function secretAgeFor(app) {
58
+ const secretsPath = `${app.composePath}/secrets/vault.age`;
59
+ if (!existsSync(secretsPath))
60
+ return { app: app.name, ageDays: null, error: 'no vault.age' };
61
+ try {
62
+ const stat = statSync(secretsPath);
63
+ const ageDays = Math.floor((Date.now() - stat.mtimeMs) / 86_400_000);
64
+ return { app: app.name, ageDays, error: null };
65
+ }
66
+ catch (err) {
67
+ return { app: app.name, ageDays: null, error: err.message };
68
+ }
69
+ }
70
+ export function useSecurity(apps) {
71
+ const [state, setState] = useState({
72
+ loading: false,
73
+ guardian: null,
74
+ ssh: null,
75
+ certs: [],
76
+ secretAges: [],
77
+ refreshedAt: 0,
78
+ });
79
+ const load = () => {
80
+ setState(s => ({ ...s, loading: true }));
81
+ const guardian = checkGuardian();
82
+ const ssh = checkSshAgent();
83
+ const domains = new Set();
84
+ for (const app of apps)
85
+ for (const d of app.domains)
86
+ domains.add(d);
87
+ const certs = [];
88
+ for (const d of domains)
89
+ certs.push(certExpiryFor(d));
90
+ certs.sort((a, b) => (a.daysUntil ?? Infinity) - (b.daysUntil ?? Infinity));
91
+ const secretAges = apps
92
+ .filter(a => a.secretsManaged)
93
+ .map(secretAgeFor)
94
+ .sort((a, b) => (b.ageDays ?? 0) - (a.ageDays ?? 0));
95
+ setState({
96
+ loading: false,
97
+ guardian,
98
+ ssh,
99
+ certs,
100
+ secretAges,
101
+ refreshedAt: Date.now(),
102
+ });
103
+ };
104
+ useEffect(() => {
105
+ load();
106
+ const id = setInterval(load, 60_000);
107
+ return () => clearInterval(id);
108
+ }, [apps.map(a => a.name).join('|')]);
109
+ return { ...state, refresh: load };
110
+ }
@@ -0,0 +1,9 @@
1
+ import type { Signal } from '../../../core/routines/schema.js';
2
+ import type { SignalCollector, SignalTarget } from '../../../core/routines/signals-collector.js';
3
+ export interface UseSignalsResult {
4
+ snapshot: Map<string, Signal[]>;
5
+ loading: boolean;
6
+ lastRefreshed: number;
7
+ refresh(force?: boolean): Promise<void>;
8
+ }
9
+ export declare function useSignals(collector: SignalCollector, targets: SignalTarget[], intervalMs?: number): UseSignalsResult;
@@ -0,0 +1,60 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ export function useSignals(collector, targets, intervalMs = 30_000) {
3
+ const [snapshot, setSnapshot] = useState(new Map());
4
+ const [loading, setLoading] = useState(false);
5
+ const [lastRefreshed, setLastRefreshed] = useState(0);
6
+ const mounted = useRef(true);
7
+ const targetsKey = targets.map(t => t.repoName).join('|');
8
+ useEffect(() => {
9
+ mounted.current = true;
10
+ return () => { mounted.current = false; };
11
+ }, []);
12
+ useEffect(() => {
13
+ let cancelled = false;
14
+ const run = async (force = false) => {
15
+ if (cancelled)
16
+ return;
17
+ setLoading(true);
18
+ try {
19
+ if (force) {
20
+ await collector.collect(targets.map(target => ({ target, force: true })));
21
+ }
22
+ else {
23
+ await collector.collect(targets.map(target => ({ target })));
24
+ }
25
+ if (cancelled || !mounted.current)
26
+ return;
27
+ const next = new Map();
28
+ for (const t of targets)
29
+ next.set(t.repoName, collector.readCached(t.repoName));
30
+ setSnapshot(next);
31
+ setLastRefreshed(Date.now());
32
+ }
33
+ finally {
34
+ if (mounted.current && !cancelled)
35
+ setLoading(false);
36
+ }
37
+ };
38
+ void run();
39
+ const id = setInterval(() => void run(), intervalMs);
40
+ return () => {
41
+ cancelled = true;
42
+ clearInterval(id);
43
+ };
44
+ }, [collector, targetsKey, intervalMs]);
45
+ const refresh = async (force = false) => {
46
+ setLoading(true);
47
+ try {
48
+ await collector.collect(targets.map(target => ({ target, force })));
49
+ const next = new Map();
50
+ for (const t of targets)
51
+ next.set(t.repoName, collector.readCached(t.repoName));
52
+ setSnapshot(next);
53
+ setLastRefreshed(Date.now());
54
+ }
55
+ finally {
56
+ setLoading(false);
57
+ }
58
+ };
59
+ return { snapshot, loading, lastRefreshed, refresh };
60
+ }
@@ -0,0 +1,20 @@
1
+ import type Database from 'better-sqlite3';
2
+ import type { RoutineEngine } from '../../core/routines/engine.js';
3
+ import { RoutineStore } from '../../core/routines/store.js';
4
+ import { SignalCollector } from '../../core/routines/signals-collector.js';
5
+ export interface RoutinesRuntime {
6
+ engine: RoutineEngine;
7
+ store: RoutineStore;
8
+ collector: SignalCollector;
9
+ db: Database.Database;
10
+ seeded: {
11
+ seeded: number;
12
+ skipped: number;
13
+ };
14
+ close(): void;
15
+ }
16
+ export interface RuntimeOptions {
17
+ dataDir?: string;
18
+ seedDefaults?: boolean;
19
+ }
20
+ export declare function createRuntime(opts?: RuntimeOptions): RoutinesRuntime;
@@ -0,0 +1,40 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { createStdoutNotifier } from '../../adapters/notifier/index.js';
4
+ import { builtInSignalProviders } from '../../adapters/signals/index.js';
5
+ import { closeDb, openDb } from '../../core/routines/db.js';
6
+ import { builtInDefaultRoutines } from '../../core/routines/defaults.js';
7
+ import { RoutineEngine as Engine } from '../../core/routines/engine.js';
8
+ import { createClaudeCliRunner } from '../../adapters/runner/claude-cli.js';
9
+ import { createMcpCallRunner } from '../../adapters/runner/mcp-call.js';
10
+ import { createShellRunner } from '../../adapters/runner/shell.js';
11
+ import { createSystemdTimerAdapter } from '../../adapters/scheduler/systemd-timer.js';
12
+ import { RoutineStore } from '../../core/routines/store.js';
13
+ import { SignalCollector } from '../../core/routines/signals-collector.js';
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const DEFAULT_DATA_DIR = join(__dirname, '..', '..', '..', 'data');
16
+ export function createRuntime(opts = {}) {
17
+ const dataDir = opts.dataDir ?? DEFAULT_DATA_DIR;
18
+ const db = openDb({ path: join(dataDir, 'fleet.db') });
19
+ const store = new RoutineStore(join(dataDir, 'routines.json'));
20
+ const seeded = opts.seedDefaults === false
21
+ ? { seeded: 0, skipped: 0 }
22
+ : store.seedDefaults(builtInDefaultRoutines());
23
+ const scheduler = createSystemdTimerAdapter();
24
+ const engine = new Engine({
25
+ store,
26
+ db,
27
+ runners: [createShellRunner(), createClaudeCliRunner(), createMcpCallRunner()],
28
+ scheduler: scheduler.available() ? scheduler : null,
29
+ notifiers: [createStdoutNotifier()],
30
+ });
31
+ const collector = new SignalCollector({ providers: builtInSignalProviders(), db, concurrency: 4 });
32
+ return {
33
+ engine,
34
+ store,
35
+ collector,
36
+ db,
37
+ seeded,
38
+ close: () => closeDb(),
39
+ };
40
+ }
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ import type { RoutineEngine } from '../../../core/routines/engine.js';
3
+ export interface CostTabProps {
4
+ engine: RoutineEngine;
5
+ dailyBudgetUsd?: number;
6
+ }
7
+ export declare function CostTab({ engine, dailyBudgetUsd }: CostTabProps): React.JSX.Element;
@@ -0,0 +1,24 @@
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 { LineChart } from '@matthesketh/ink-chart';
5
+ import { costByRoutine, costRollup, dailyCostSeries } from '../../../core/routines/cost-queries.js';
6
+ import { formatUsd, truncate } from '../../../tui/routines/format.js';
7
+ function usdColor(usd, soft = 1, hard = 5) {
8
+ if (usd >= hard)
9
+ return 'red';
10
+ if (usd >= soft)
11
+ return 'yellow';
12
+ return 'green';
13
+ }
14
+ export function CostTab({ engine, dailyBudgetUsd = 10 }) {
15
+ const { rollup, byRoutine, daily } = useMemo(() => ({
16
+ rollup: costRollup(engine.db),
17
+ byRoutine: costByRoutine(engine.db, 30, 10),
18
+ daily: dailyCostSeries(engine.db, 14),
19
+ }), [engine.db]);
20
+ const projectedDaily = rollup.usdToday;
21
+ const dailyBudgetExceeded = projectedDaily > dailyBudgetUsd;
22
+ const series = daily.map(d => d.usd);
23
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: "Cost / usage" }), _jsxs(Text, { color: usdColor(rollup.usdToday, dailyBudgetUsd / 2, dailyBudgetUsd), children: ["today ", formatUsd(rollup.usdToday)] }), _jsxs(Text, { color: "gray", children: ["week ", formatUsd(rollup.usdWeek)] }), _jsxs(Text, { color: "gray", children: ["month ", formatUsd(rollup.usdMonth)] }), dailyBudgetExceeded && (_jsxs(Text, { color: "red", bold: true, children: ["over daily budget (", formatUsd(dailyBudgetUsd), ")"] }))] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Daily spend (last 14 days)" }), series.every(v => v === 0) ? (_jsx(Text, { color: "gray", children: " no claude-cli runs with cost yet" })) : (_jsx(LineChart, { data: series, width: 60, height: 8, color: "cyan", showAxis: true, label: "USD / day" }))] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Top routines by spend (30d)" }), byRoutine.length === 0 && _jsx(Text, { color: "gray", children: " no runs yet" }), _jsxs(Box, { children: [_jsx(Box, { width: 24, children: _jsx(Text, { bold: true, children: "ROUTINE" }) }), _jsx(Box, { width: 10, children: _jsx(Text, { bold: true, children: "RUNS" }) }), _jsx(Box, { width: 12, children: _jsx(Text, { bold: true, children: "USD" }) }), _jsx(Box, { width: 14, children: _jsx(Text, { bold: true, children: "IN TOKENS" }) }), _jsx(Box, { width: 14, children: _jsx(Text, { bold: true, children: "OUT TOKENS" }) }), _jsx(Text, { bold: true, children: "AVG / RUN" })] }), byRoutine.map(row => (_jsxs(Box, { children: [_jsx(Box, { width: 24, children: _jsx(Text, { children: truncate(row.routineId, 22) }) }), _jsx(Box, { width: 10, children: _jsx(Text, { children: row.runs }) }), _jsx(Box, { width: 12, children: _jsx(Text, { color: usdColor(row.usd), children: formatUsd(row.usd) }) }), _jsx(Box, { width: 14, children: _jsx(Text, { color: "gray", children: row.inputTokens.toLocaleString() }) }), _jsx(Box, { width: 14, children: _jsx(Text, { color: "gray", children: row.outputTokens.toLocaleString() }) }), _jsx(Text, { color: "gray", children: row.runs > 0 ? formatUsd(row.usd / row.runs) : '—' })] }, row.routineId)))] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Last 14 days (buckets)" }), daily.map(bucket => (_jsxs(Box, { children: [_jsx(Box, { width: 12, children: _jsx(Text, { color: "gray", children: bucket.date }) }), _jsx(Box, { width: 12, children: _jsx(Text, { color: usdColor(bucket.usd), children: formatUsd(bucket.usd) }) }), _jsxs(Text, { color: "gray", children: [bucket.runs, " runs"] })] }, bucket.date)))] })] }));
24
+ }
@@ -0,0 +1,15 @@
1
+ import React from 'react';
2
+ import type { Signal } from '../../../core/routines/schema.js';
3
+ import { type SignalsGridRow } from '../components/SignalsGrid.js';
4
+ export interface DashboardTabProps {
5
+ rows: SignalsGridRow[];
6
+ selectedIndex: number;
7
+ loading: boolean;
8
+ lastRefreshed: number;
9
+ signalsByRepo: Map<string, Signal[]>;
10
+ seededNotice: {
11
+ seeded: number;
12
+ skipped: number;
13
+ };
14
+ }
15
+ export declare function DashboardTab({ rows, selectedIndex, loading, lastRefreshed, signalsByRepo, seededNotice, }: DashboardTabProps): React.JSX.Element;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ import { AlertsPanel } from '../components/AlertsPanel.js';
5
+ import { SignalsGrid } from '../components/SignalsGrid.js';
6
+ import { formatRelative } from '../format.js';
7
+ const SLICE_ONE_KINDS = ['git-clean', 'container-up', 'ci-status'];
8
+ export function DashboardTab({ rows, selectedIndex, loading, lastRefreshed, signalsByRepo, seededNotice, }) {
9
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: "Fleet dashboard" }), _jsxs(Text, { color: "gray", children: [rows.length, " repos"] }), loading ? (_jsxs(Text, { color: "cyan", children: [_jsx(Spinner, { type: "dots" }), " refreshing"] })) : (_jsxs(Text, { color: "gray", children: ["updated ", formatRelative(new Date(lastRefreshed).toISOString())] })), seededNotice.seeded > 0 && (_jsxs(Text, { color: "magenta", children: ["seeded ", seededNotice.seeded, " default routine", seededNotice.seeded === 1 ? '' : 's'] }))] }), _jsx(SignalsGrid, { rows: rows, selectedIndex: selectedIndex, kinds: SLICE_ONE_KINDS }), _jsx(AlertsPanel, { signals: signalsByRepo })] }));
10
+ }
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ import type { AppEntry } from '../../../core/registry.js';
3
+ export interface GitTabProps {
4
+ apps: AppEntry[];
5
+ }
6
+ export declare function GitTab({ apps }: GitTabProps): React.JSX.Element;