@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,79 @@
1
+ import { execSafe } from '../../core/exec.js';
2
+ function ghRunToState(run) {
3
+ if (run.status === 'in_progress' || run.status === 'queued' || run.status === 'waiting')
4
+ return 'warn';
5
+ switch (run.conclusion) {
6
+ case 'success': return 'ok';
7
+ case 'failure':
8
+ case 'timed_out':
9
+ case 'startup_failure':
10
+ return 'error';
11
+ case 'cancelled':
12
+ case 'skipped':
13
+ case 'stale':
14
+ case 'neutral':
15
+ return 'warn';
16
+ default: return 'unknown';
17
+ }
18
+ }
19
+ export const ciStatusProvider = {
20
+ kind: 'ci-status',
21
+ ttlMs: 60_000,
22
+ strategy: 'event',
23
+ async collect(repoPath, repoName) {
24
+ const collectedAt = new Date().toISOString();
25
+ const result = execSafe('gh', [
26
+ 'run', 'list',
27
+ '--limit', '1',
28
+ '--json', 'status,conclusion,name,headBranch,event,createdAt,url',
29
+ ], { cwd: repoPath, timeout: 8_000 });
30
+ if (!result.ok) {
31
+ return {
32
+ repo: repoName,
33
+ kind: 'ci-status',
34
+ state: 'unknown',
35
+ value: null,
36
+ detail: result.stderr || 'gh run list failed',
37
+ collectedAt,
38
+ ttlMs: this.ttlMs,
39
+ };
40
+ }
41
+ let runs = [];
42
+ try {
43
+ runs = JSON.parse(result.stdout);
44
+ }
45
+ catch {
46
+ return {
47
+ repo: repoName,
48
+ kind: 'ci-status',
49
+ state: 'unknown',
50
+ value: null,
51
+ detail: 'gh output not JSON',
52
+ collectedAt,
53
+ ttlMs: this.ttlMs,
54
+ };
55
+ }
56
+ if (runs.length === 0) {
57
+ return {
58
+ repo: repoName,
59
+ kind: 'ci-status',
60
+ state: 'unknown',
61
+ value: null,
62
+ detail: 'no runs yet',
63
+ collectedAt,
64
+ ttlMs: this.ttlMs,
65
+ };
66
+ }
67
+ const latest = runs[0];
68
+ const state = ghRunToState(latest);
69
+ return {
70
+ repo: repoName,
71
+ kind: 'ci-status',
72
+ state,
73
+ value: latest.conclusion ?? latest.status,
74
+ detail: `${latest.name} · ${latest.headBranch} · ${latest.conclusion ?? latest.status}`,
75
+ collectedAt,
76
+ ttlMs: this.ttlMs,
77
+ };
78
+ },
79
+ };
@@ -0,0 +1,5 @@
1
+ import type { SignalProvider } from '../types.js';
2
+ export interface ContainerUpOptions {
3
+ projectForRepo?(repoName: string): string;
4
+ }
5
+ export declare function createContainerUpProvider(opts?: ContainerUpOptions): SignalProvider;
@@ -0,0 +1,54 @@
1
+ import { execSafe } from '../../core/exec.js';
2
+ const COMPOSE_LABEL = 'com.docker.compose.project';
3
+ export function createContainerUpProvider(opts = {}) {
4
+ const projectForRepo = opts.projectForRepo ?? ((name) => name);
5
+ return {
6
+ kind: 'container-up',
7
+ ttlMs: 15_000,
8
+ strategy: 'pull',
9
+ async collect(_repoPath, repoName) {
10
+ const project = projectForRepo(repoName);
11
+ const collectedAt = new Date().toISOString();
12
+ const result = execSafe('docker', [
13
+ 'ps', '--all',
14
+ '--filter', `label=${COMPOSE_LABEL}=${project}`,
15
+ '--format', '{{.State}}',
16
+ ], { timeout: 5_000 });
17
+ if (!result.ok) {
18
+ return {
19
+ repo: repoName,
20
+ kind: 'container-up',
21
+ state: 'unknown',
22
+ value: null,
23
+ detail: result.stderr || 'docker ps failed',
24
+ collectedAt,
25
+ ttlMs: this.ttlMs,
26
+ };
27
+ }
28
+ const states = result.stdout.split('\n').map(s => s.trim()).filter(Boolean);
29
+ if (states.length === 0) {
30
+ return {
31
+ repo: repoName,
32
+ kind: 'container-up',
33
+ state: 'warn',
34
+ value: 0,
35
+ detail: 'no containers for project',
36
+ collectedAt,
37
+ ttlMs: this.ttlMs,
38
+ };
39
+ }
40
+ const running = states.filter(s => s === 'running').length;
41
+ const total = states.length;
42
+ const allRunning = running === total;
43
+ return {
44
+ repo: repoName,
45
+ kind: 'container-up',
46
+ state: allRunning ? 'ok' : running > 0 ? 'warn' : 'error',
47
+ value: running,
48
+ detail: `${running}/${total} running`,
49
+ collectedAt,
50
+ ttlMs: this.ttlMs,
51
+ };
52
+ },
53
+ };
54
+ }
@@ -0,0 +1,2 @@
1
+ import type { SignalProvider } from '../types.js';
2
+ export declare const gitCleanProvider: SignalProvider;
@@ -0,0 +1,55 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { execSafe } from '../../core/exec.js';
3
+ function resolveSafe(path) {
4
+ try {
5
+ return realpathSync(path);
6
+ }
7
+ catch {
8
+ return null;
9
+ }
10
+ }
11
+ export const gitCleanProvider = {
12
+ kind: 'git-clean',
13
+ ttlMs: 5_000,
14
+ strategy: 'pull',
15
+ async collect(repoPath, repoName) {
16
+ const collectedAt = new Date().toISOString();
17
+ const toplevel = execSafe('git', ['-C', repoPath, 'rev-parse', '--show-toplevel'], { timeout: 5_000 });
18
+ const expected = resolveSafe(repoPath);
19
+ const actual = toplevel.ok ? resolveSafe(toplevel.stdout.trim()) : null;
20
+ if (!toplevel.ok || !expected || !actual || expected !== actual) {
21
+ return {
22
+ repo: repoName,
23
+ kind: 'git-clean',
24
+ state: 'unknown',
25
+ value: null,
26
+ detail: !toplevel.ok ? (toplevel.stderr || 'not a git repo') : 'path is not a git repo root',
27
+ collectedAt,
28
+ ttlMs: this.ttlMs,
29
+ };
30
+ }
31
+ const status = execSafe('git', ['-C', repoPath, 'status', '--porcelain=1'], { timeout: 5_000 });
32
+ if (!status.ok) {
33
+ return {
34
+ repo: repoName,
35
+ kind: 'git-clean',
36
+ state: 'unknown',
37
+ value: null,
38
+ detail: status.stderr || 'git status failed',
39
+ collectedAt,
40
+ ttlMs: this.ttlMs,
41
+ };
42
+ }
43
+ const dirty = status.stdout.trim().length > 0;
44
+ const changeCount = dirty ? status.stdout.split('\n').filter(Boolean).length : 0;
45
+ return {
46
+ repo: repoName,
47
+ kind: 'git-clean',
48
+ state: dirty ? 'warn' : 'ok',
49
+ value: !dirty,
50
+ detail: dirty ? `${changeCount} uncommitted change${changeCount === 1 ? '' : 's'}` : '',
51
+ collectedAt,
52
+ ttlMs: this.ttlMs,
53
+ };
54
+ },
55
+ };
@@ -0,0 +1,6 @@
1
+ import type { SignalProvider } from '../types.js';
2
+ import { ciStatusProvider } from './ci-status.js';
3
+ import { createContainerUpProvider } from './container-up.js';
4
+ import { gitCleanProvider } from './git-clean.js';
5
+ export { ciStatusProvider, createContainerUpProvider, gitCleanProvider };
6
+ export declare function builtInSignalProviders(): SignalProvider[];
@@ -0,0 +1,7 @@
1
+ import { ciStatusProvider } from './ci-status.js';
2
+ import { createContainerUpProvider } from './container-up.js';
3
+ import { gitCleanProvider } from './git-clean.js';
4
+ export { ciStatusProvider, createContainerUpProvider, gitCleanProvider };
5
+ export function builtInSignalProviders() {
6
+ return [gitCleanProvider, createContainerUpProvider(), ciStatusProvider];
7
+ }
@@ -0,0 +1,52 @@
1
+ import type { Routine, RoutineTask, RunEvent, Signal, SignalKind } from '../core/routines/schema.js';
2
+ export interface ScheduledEntry {
3
+ routineId: string;
4
+ unitName: string;
5
+ nextRunAt: string | null;
6
+ lastRunAt: string | null;
7
+ lastStatus: 'ok' | 'failed' | 'unknown';
8
+ active: boolean;
9
+ persistent: boolean;
10
+ }
11
+ export interface SchedulerAdapter {
12
+ readonly id: 'systemd-timer';
13
+ readonly available: () => boolean;
14
+ upsert(routine: Routine): Promise<void>;
15
+ remove(routineId: string): Promise<void>;
16
+ list(): Promise<ScheduledEntry[]>;
17
+ get(routineId: string): Promise<ScheduledEntry | null>;
18
+ }
19
+ export interface RunContext {
20
+ repo: string | null;
21
+ repoPath: string | null;
22
+ runId: string;
23
+ routineId: string;
24
+ startedAt: string;
25
+ logsDir: string;
26
+ env: Record<string, string>;
27
+ }
28
+ export interface RunnerAdapter {
29
+ readonly id: RoutineTask['kind'];
30
+ supports(task: RoutineTask): boolean;
31
+ run(task: RoutineTask, ctx: RunContext, signal: AbortSignal): AsyncIterable<RunEvent>;
32
+ }
33
+ export interface SignalProvider {
34
+ readonly kind: SignalKind;
35
+ readonly ttlMs: number;
36
+ readonly strategy: 'push' | 'pull' | 'event';
37
+ collect(repoPath: string, repoName: string): Promise<Signal>;
38
+ watch?(repoPath: string, repoName: string, emit: (s: Signal) => void): () => void;
39
+ }
40
+ export interface NotifierAdapter {
41
+ readonly id: 'stdout' | 'webhook' | 'slack' | 'email';
42
+ notify(subject: string, body: string, meta: {
43
+ routineId: string;
44
+ runId: string;
45
+ status: string;
46
+ }): Promise<void>;
47
+ }
48
+ export interface StackDetector {
49
+ readonly id: 'node' | 'python' | 'rust' | 'docker' | 'generic';
50
+ detect(repoPath: string): boolean;
51
+ priority: number;
52
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.js CHANGED
@@ -1,9 +1,13 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
1
4
  import { statusCommand } from './commands/status.js';
2
5
  import { listCommand } from './commands/list.js';
3
6
  import { startCommand } from './commands/start.js';
4
7
  import { stopCommand } from './commands/stop.js';
5
8
  import { restartCommand } from './commands/restart.js';
6
9
  import { logsCommand } from './commands/logs.js';
10
+ import { egressCommand } from './commands/egress.js';
7
11
  import { healthCommand } from './commands/health.js';
8
12
  import { addCommand } from './commands/add.js';
9
13
  import { removeCommand } from './commands/remove.js';
@@ -15,9 +19,18 @@ import { initCommand } from './commands/init.js';
15
19
  import { depsCommand } from './commands/deps.js';
16
20
  import { watchdogCommand } from './commands/watchdog.js';
17
21
  import { installMcpCommand } from './commands/install-mcp.js';
22
+ import { patchSystemdCommand } from './commands/patch-systemd.js';
23
+ import { freezeCommand, unfreezeCommand } from './commands/freeze.js';
24
+ import { guardCommand } from './commands/guard.js';
25
+ import { bootStartCommand } from './commands/boot-start.js';
26
+ import { rollbackCommand } from './commands/rollback.js';
27
+ import { routineRunCommand } from './commands/routine-run.js';
28
+ import { routinesCommand } from './commands/routines.js';
18
29
  import { startMcpServer } from './mcp/server.js';
19
30
  import { error } from './ui/output.js';
20
- const VERSION = '1.0.0';
31
+ const __dirname = dirname(fileURLToPath(import.meta.url));
32
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
33
+ const VERSION = pkg.version;
21
34
  const HELP = `fleet v${VERSION} - Docker production management CLI
22
35
 
23
36
  Usage: fleet <command> [options]
@@ -65,10 +78,19 @@ Commands:
65
78
  git pr list <app> List open PRs
66
79
  git release <app> Create develop->main PR
67
80
  tui, dashboard Interactive terminal dashboard
81
+ routines Fleet-wide routines TUI (signals grid + routine history)
82
+ routine-run --id <id> [--target <repo>] [--trigger scheduled]
83
+ Headless entrypoint for systemd-timer units. JSON mode: --json.
68
84
  init Auto-discover all existing apps
69
85
  watchdog Health check all services, alert on failure
70
86
  install-mcp Install fleet as Claude Code MCP server
71
87
  mcp Start as MCP server
88
+ patch-systemd Add StartLimitBurst/StartLimitIntervalSec to all service files
89
+ boot-start <app> Start app respecting boot-order dependencies
90
+ freeze <app> Freeze a crash-looping service (stop + disable)
91
+ rollback <app> Roll back app to previous image
92
+ unfreeze <app> Unfreeze and restart a frozen service
93
+ guard <subcommand> Cloudflare protection layer (install/status/approve/reject/...)
72
94
 
73
95
  Global flags:
74
96
  --json Output as JSON
@@ -85,10 +107,23 @@ export async function run(argv) {
85
107
  process.stdout.write(VERSION + '\n');
86
108
  return;
87
109
  }
88
- if (!command || args.includes('-h') || args.includes('--help')) {
110
+ if (args.includes('-h') || args.includes('--help')) {
89
111
  process.stdout.write(HELP);
90
112
  return;
91
113
  }
114
+ if (!command) {
115
+ const { launchTui } = await import('./tui/app.js');
116
+ return launchTui();
117
+ }
118
+ // Commands that require root privileges
119
+ const ROOT_COMMANDS = new Set([
120
+ 'start', 'stop', 'restart', 'deploy', 'freeze', 'unfreeze',
121
+ 'nginx', 'secrets', 'patch-systemd', 'init', 'watchdog',
122
+ ]);
123
+ if (ROOT_COMMANDS.has(command) && process.getuid && process.getuid() !== 0) {
124
+ error(`'fleet ${command}' requires root privileges. Run with sudo.`);
125
+ process.exit(1);
126
+ }
92
127
  switch (command) {
93
128
  case 'status': return statusCommand(rest);
94
129
  case 'list': return listCommand(rest);
@@ -96,6 +131,7 @@ export async function run(argv) {
96
131
  case 'stop': return stopCommand(rest);
97
132
  case 'restart': return restartCommand(rest);
98
133
  case 'logs': return logsCommand(rest);
134
+ case 'egress': return egressCommand(rest);
99
135
  case 'health': return healthCommand(rest);
100
136
  case 'deps': return depsCommand(rest);
101
137
  case 'add': return addCommand(rest);
@@ -107,12 +143,20 @@ export async function run(argv) {
107
143
  case 'init': return initCommand(rest);
108
144
  case 'watchdog': return watchdogCommand(rest);
109
145
  case 'install-mcp': return installMcpCommand(rest);
146
+ case 'patch-systemd': return patchSystemdCommand(rest);
147
+ case 'boot-start': return bootStartCommand(rest);
148
+ case 'freeze': return freezeCommand(rest);
149
+ case 'rollback': return rollbackCommand(rest);
150
+ case 'unfreeze': return unfreezeCommand(rest);
151
+ case 'guard': return guardCommand(rest);
110
152
  case 'mcp': return startMcpServer();
111
153
  case 'tui':
112
154
  case 'dashboard': {
113
155
  const { launchTui } = await import('./tui/app.js');
114
156
  return launchTui();
115
157
  }
158
+ case 'routines': return routinesCommand(rest);
159
+ case 'routine-run': return routineRunCommand(rest);
116
160
  default:
117
161
  error(`Unknown command: ${command}`);
118
162
  process.stdout.write(HELP);
@@ -85,11 +85,5 @@ function findComposePath(dir) {
85
85
  if (existsSync(`${dir}/server/docker-compose.yaml`)) {
86
86
  return { path: `${dir}/server`, file: null };
87
87
  }
88
- const customFiles = ['docker-compose.imagemerger.yml'];
89
- for (const f of customFiles) {
90
- if (existsSync(`${dir}/${f}`)) {
91
- return { path: dir, file: f };
92
- }
93
- }
94
88
  return { path: '', file: null };
95
89
  }
@@ -0,0 +1 @@
1
+ export declare function bootStartCommand(args: string[]): Promise<void>;
@@ -0,0 +1,51 @@
1
+ import { load, findApp } from '../core/registry.js';
2
+ import { refresh } from '../core/boot-refresh.js';
3
+ import { composeUp } from '../core/docker.js';
4
+ function log(msg) {
5
+ process.stdout.write(`[boot-start] ${msg}\n`);
6
+ }
7
+ function logErr(msg) {
8
+ process.stderr.write(`[boot-start] ${msg}\n`);
9
+ }
10
+ export async function bootStartCommand(args) {
11
+ const appName = args[0];
12
+ if (!appName) {
13
+ logErr('Usage: fleet boot-start <app>');
14
+ process.exit(1);
15
+ }
16
+ const reg = load();
17
+ const app = findApp(reg, appName);
18
+ if (!app) {
19
+ logErr(`app not found: ${appName}`);
20
+ process.exit(1);
21
+ }
22
+ // Refresh is best-effort. Any error — sync or async — is caught here and logged,
23
+ // then compose up ALWAYS runs. This is the fail-safe contract for boot.
24
+ try {
25
+ const result = await refresh(app);
26
+ switch (result.kind) {
27
+ case 'refreshed':
28
+ log(`refreshed ${app.name} head=${result.head} built=${result.built}`);
29
+ break;
30
+ case 'no-change':
31
+ log(`no-change ${app.name} head=${result.head}`);
32
+ break;
33
+ case 'skipped':
34
+ log(`skipped ${app.name} reason=${result.reason}`);
35
+ break;
36
+ case 'failed-safe':
37
+ log(`failed-safe ${app.name} step=${result.step} detail=${result.detail}`);
38
+ break;
39
+ }
40
+ }
41
+ catch (err) {
42
+ log(`failed-safe ${app.name} step=outer-catch detail=${err instanceof Error ? err.message : String(err)}`);
43
+ }
44
+ // compose up — the only step whose exit code matters
45
+ const ok = composeUp(app.composePath, app.composeFile);
46
+ if (!ok) {
47
+ logErr(`compose up failed for ${app.name}`);
48
+ process.exit(1);
49
+ }
50
+ log(`up ${app.name}`);
51
+ }
@@ -6,6 +6,9 @@ import { startService, restartService, getServiceStatus } from '../core/systemd.
6
6
  import { FleetError } from '../core/errors.js';
7
7
  import { success, error, info, warn, heading } from '../ui/output.js';
8
8
  import { addCommand } from './add.js';
9
+ import { execGit } from '../core/exec.js';
10
+ import { getProjectRoot } from '../core/git.js';
11
+ import { recordBuiltCommit } from '../core/boot-refresh.js';
9
12
  export async function deployCommand(args) {
10
13
  const dryRun = args.includes('--dry-run');
11
14
  const yes = args.includes('-y') || args.includes('--yes');
@@ -40,6 +43,16 @@ export async function deployCommand(args) {
40
43
  process.exit(1);
41
44
  }
42
45
  success('Build complete');
46
+ try {
47
+ const root = getProjectRoot(app.composePath);
48
+ const head = execGit(['rev-parse', 'HEAD'], { cwd: root, timeout: 10_000 });
49
+ if (head.ok && head.stdout.trim()) {
50
+ recordBuiltCommit(app.name, head.stdout.trim());
51
+ }
52
+ }
53
+ catch {
54
+ // Non-fatal: deploy already succeeded
55
+ }
43
56
  info(`Starting ${app.name}...`);
44
57
  const svc = getServiceStatus(app.serviceName);
45
58
  const started = svc.active
@@ -151,9 +151,14 @@ async function depsConfig(args) {
151
151
  process.stdout.write(JSON.stringify(config, null, 2) + '\n');
152
152
  return;
153
153
  }
154
+ const ALLOWED_KEYS = new Set(['scanIntervalHours', 'concurrency']);
154
155
  if (args[0] === 'set' && args.length >= 3) {
155
156
  const key = args[1];
156
157
  const value = args[2];
158
+ if (!ALLOWED_KEYS.has(key)) {
159
+ error(`Cannot set key: ${key}. Allowed: ${[...ALLOWED_KEYS].join(', ')}`);
160
+ process.exit(1);
161
+ }
157
162
  const parsed = value === 'true' ? true : value === 'false' ? false : isNaN(Number(value)) ? value : Number(value);
158
163
  config[key] = parsed;
159
164
  saveConfig(config);
@@ -0,0 +1 @@
1
+ export declare function egressCommand(args: string[]): void;
@@ -0,0 +1,106 @@
1
+ import { load, save, findApp } from '../core/registry.js';
2
+ import { snapshotEgress, addEgressAllow } from '../core/egress.js';
3
+ import { AppNotFoundError } from '../core/errors.js';
4
+ import { c, error, heading, info, success, table, warn } from '../ui/output.js';
5
+ export function egressCommand(args) {
6
+ const sub = args[0];
7
+ switch (sub) {
8
+ case 'observe': return egressObserve(args.slice(1));
9
+ case 'show': return egressShow(args.slice(1));
10
+ case 'allow': return egressAllow(args.slice(1));
11
+ default:
12
+ error('Usage: fleet egress <observe|show|allow> ...');
13
+ error(' observe <app> take a snapshot of current outbound flows');
14
+ error(' show <app> show configured allowlist + observed flows');
15
+ error(' allow <app> <host> add a host to the allowlist');
16
+ error('Note: enforce mode (actual drop) is deferred to Phase E. v1 is observe-only.');
17
+ process.exit(1);
18
+ }
19
+ }
20
+ function egressObserve(args) {
21
+ const json = args.includes('--json');
22
+ const appName = args.find(a => !a.startsWith('-'));
23
+ if (!appName) {
24
+ error('Usage: fleet egress observe <app>');
25
+ process.exit(1);
26
+ }
27
+ const reg = load();
28
+ const app = findApp(reg, appName);
29
+ if (!app)
30
+ throw new AppNotFoundError(appName);
31
+ const snap = snapshotEgress(app);
32
+ if (json) {
33
+ process.stdout.write(JSON.stringify(snap, null, 2) + '\n');
34
+ return;
35
+ }
36
+ heading(`Egress snapshot: ${app.name}`);
37
+ info(`Taken: ${snap.takenAt}`);
38
+ info(`Distinct remote endpoints: ${snap.uniqueRemotes.length}`);
39
+ if (snap.uniqueRemotes.length === 0) {
40
+ info('No outbound flows visible right now (containers may be idle).');
41
+ return;
42
+ }
43
+ // Dedupe per (container, remote)
44
+ const seen = new Set();
45
+ const rows = [];
46
+ for (const f of snap.flows) {
47
+ const key = `${f.container}|${f.remote}`;
48
+ if (seen.has(key))
49
+ continue;
50
+ seen.add(key);
51
+ const status = f.allowed
52
+ ? `${c.green}allowed${c.reset}`
53
+ : `${c.yellow}not in allowlist${c.reset}`;
54
+ rows.push([f.container, f.remote, status]);
55
+ }
56
+ table(['CONTAINER', 'REMOTE', 'STATUS'], rows);
57
+ process.stdout.write('\n');
58
+ if (snap.violations.length > 0) {
59
+ warn(`${snap.violations.length} non-private destination(s) NOT in allowlist:`);
60
+ for (const v of snap.violations)
61
+ process.stdout.write(` - ${v}\n`);
62
+ info(`Add to allowlist: fleet egress allow ${app.name} <host>`);
63
+ }
64
+ else {
65
+ success('All non-private destinations are allowed (or allowlist not yet seeded).');
66
+ }
67
+ }
68
+ function egressShow(args) {
69
+ const appName = args.find(a => !a.startsWith('-'));
70
+ if (!appName) {
71
+ error('Usage: fleet egress show <app>');
72
+ process.exit(1);
73
+ }
74
+ const reg = load();
75
+ const app = findApp(reg, appName);
76
+ if (!app)
77
+ throw new AppNotFoundError(appName);
78
+ heading(`Egress config: ${app.name}`);
79
+ info(`Mode: ${app.egress?.mode ?? 'observe (default)'}`);
80
+ const allow = app.egress?.allow ?? [];
81
+ if (allow.length === 0) {
82
+ info('Allowlist: (empty — every external destination would be flagged)');
83
+ info(`Seed it: fleet egress observe ${app.name}, then fleet egress allow ${app.name} <host>`);
84
+ }
85
+ else {
86
+ info(`Allowlist (${allow.length}):`);
87
+ for (const a of allow)
88
+ process.stdout.write(` - ${a}\n`);
89
+ }
90
+ info('Note: v1 is observe/shadow only — no packets are actually dropped.');
91
+ }
92
+ function egressAllow(args) {
93
+ const positional = args.filter(a => !a.startsWith('-'));
94
+ const [appName, host] = positional;
95
+ if (!appName || !host) {
96
+ error('Usage: fleet egress allow <app> <host[:port] | *.host | cidr>');
97
+ process.exit(1);
98
+ }
99
+ const reg = load();
100
+ const app = findApp(reg, appName);
101
+ if (!app)
102
+ throw new AppNotFoundError(appName);
103
+ const updated = addEgressAllow(app, host);
104
+ save(reg);
105
+ success(`${appName} allow → ${host} (now ${updated.length} entries)`);
106
+ }
@@ -0,0 +1,4 @@
1
+ export declare function freezeApp(appName: string, reason?: string): void;
2
+ export declare function unfreezeApp(appName: string): void;
3
+ export declare function freezeCommand(args: string[]): void;
4
+ export declare function unfreezeCommand(args: string[]): void;
@@ -0,0 +1,64 @@
1
+ import { load, save, findApp } from '../core/registry.js';
2
+ import { stopService, disableService, enableService, startService } from '../core/systemd.js';
3
+ import { AppNotFoundError } from '../core/errors.js';
4
+ import { success, error } from '../ui/output.js';
5
+ export function freezeApp(appName, reason) {
6
+ const reg = load();
7
+ const app = findApp(reg, appName);
8
+ if (!app)
9
+ throw new AppNotFoundError(appName);
10
+ if (app.frozenAt) {
11
+ throw new Error(`App "${appName}" is already frozen (since ${app.frozenAt})`);
12
+ }
13
+ stopService(app.serviceName);
14
+ disableService(app.serviceName);
15
+ app.frozenAt = new Date().toISOString();
16
+ if (reason)
17
+ app.frozenReason = reason;
18
+ save(reg);
19
+ }
20
+ export function unfreezeApp(appName) {
21
+ const reg = load();
22
+ const app = findApp(reg, appName);
23
+ if (!app)
24
+ throw new AppNotFoundError(appName);
25
+ if (!app.frozenAt) {
26
+ throw new Error(`App "${appName}" is not frozen`);
27
+ }
28
+ delete app.frozenAt;
29
+ delete app.frozenReason;
30
+ save(reg);
31
+ enableService(app.serviceName);
32
+ startService(app.serviceName);
33
+ }
34
+ export function freezeCommand(args) {
35
+ const appName = args[0];
36
+ if (!appName) {
37
+ error('Usage: fleet freeze <app> [reason]');
38
+ process.exit(1);
39
+ }
40
+ const reason = args.slice(1).join(' ') || undefined;
41
+ try {
42
+ freezeApp(appName, reason);
43
+ success(`Frozen ${appName}${reason ? `: ${reason}` : ''}`);
44
+ }
45
+ catch (err) {
46
+ error(err.message);
47
+ process.exit(1);
48
+ }
49
+ }
50
+ export function unfreezeCommand(args) {
51
+ const appName = args[0];
52
+ if (!appName) {
53
+ error('Usage: fleet unfreeze <app>');
54
+ process.exit(1);
55
+ }
56
+ try {
57
+ unfreezeApp(appName);
58
+ success(`Unfrozen ${appName} — service enabled and started`);
59
+ }
60
+ catch (err) {
61
+ error(err.message);
62
+ process.exit(1);
63
+ }
64
+ }
@@ -0,0 +1 @@
1
+ export declare function guardCommand(args: string[]): void;