@matthesketh/fleet 1.2.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 (208) 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/KeyHint.js +10 -0
  129. package/dist/tui/exec-bridge.js +26 -12
  130. package/dist/tui/hooks/use-fleet-data.js +5 -2
  131. package/dist/tui/hooks/use-health.js +5 -2
  132. package/dist/tui/router.js +60 -7
  133. package/dist/tui/routines/RoutinesApp.d.ts +8 -0
  134. package/dist/tui/routines/RoutinesApp.js +277 -0
  135. package/dist/tui/routines/components/AlertsPanel.d.ts +7 -0
  136. package/dist/tui/routines/components/AlertsPanel.js +22 -0
  137. package/dist/tui/routines/components/AlertsPanel.test.d.ts +1 -0
  138. package/dist/tui/routines/components/AlertsPanel.test.js +52 -0
  139. package/dist/tui/routines/components/CommandPalette.d.ts +12 -0
  140. package/dist/tui/routines/components/CommandPalette.js +21 -0
  141. package/dist/tui/routines/components/LiveRunPanel.d.ts +12 -0
  142. package/dist/tui/routines/components/LiveRunPanel.js +107 -0
  143. package/dist/tui/routines/components/RoutineForm.d.ts +8 -0
  144. package/dist/tui/routines/components/RoutineForm.js +254 -0
  145. package/dist/tui/routines/components/SignalsGrid.d.ts +13 -0
  146. package/dist/tui/routines/components/SignalsGrid.js +34 -0
  147. package/dist/tui/routines/components/SignalsGrid.test.d.ts +1 -0
  148. package/dist/tui/routines/components/SignalsGrid.test.js +43 -0
  149. package/dist/tui/routines/format.d.ts +7 -0
  150. package/dist/tui/routines/format.js +51 -0
  151. package/dist/tui/routines/hooks/use-git-fleet.d.ts +33 -0
  152. package/dist/tui/routines/hooks/use-git-fleet.js +82 -0
  153. package/dist/tui/routines/hooks/use-logs-stream.d.ts +13 -0
  154. package/dist/tui/routines/hooks/use-logs-stream.js +64 -0
  155. package/dist/tui/routines/hooks/use-ops-fleet.d.ts +20 -0
  156. package/dist/tui/routines/hooks/use-ops-fleet.js +70 -0
  157. package/dist/tui/routines/hooks/use-repo-detail.d.ts +31 -0
  158. package/dist/tui/routines/hooks/use-repo-detail.js +104 -0
  159. package/dist/tui/routines/hooks/use-security.d.ts +33 -0
  160. package/dist/tui/routines/hooks/use-security.js +110 -0
  161. package/dist/tui/routines/hooks/use-signals.d.ts +9 -0
  162. package/dist/tui/routines/hooks/use-signals.js +60 -0
  163. package/dist/tui/routines/runtime.d.ts +20 -0
  164. package/dist/tui/routines/runtime.js +40 -0
  165. package/dist/tui/routines/tabs/CostTab.d.ts +7 -0
  166. package/dist/tui/routines/tabs/CostTab.js +24 -0
  167. package/dist/tui/routines/tabs/DashboardTab.d.ts +15 -0
  168. package/dist/tui/routines/tabs/DashboardTab.js +10 -0
  169. package/dist/tui/routines/tabs/GitTab.d.ts +6 -0
  170. package/dist/tui/routines/tabs/GitTab.js +39 -0
  171. package/dist/tui/routines/tabs/LogsTab.d.ts +6 -0
  172. package/dist/tui/routines/tabs/LogsTab.js +58 -0
  173. package/dist/tui/routines/tabs/OpsTab.d.ts +6 -0
  174. package/dist/tui/routines/tabs/OpsTab.js +34 -0
  175. package/dist/tui/routines/tabs/RepoDetailView.d.ts +6 -0
  176. package/dist/tui/routines/tabs/RepoDetailView.js +12 -0
  177. package/dist/tui/routines/tabs/RoutinesTab.d.ts +10 -0
  178. package/dist/tui/routines/tabs/RoutinesTab.js +58 -0
  179. package/dist/tui/routines/tabs/ScaffoldTab.d.ts +2 -0
  180. package/dist/tui/routines/tabs/ScaffoldTab.js +127 -0
  181. package/dist/tui/routines/tabs/SecurityTab.d.ts +6 -0
  182. package/dist/tui/routines/tabs/SecurityTab.js +31 -0
  183. package/dist/tui/routines/tabs/SettingsTab.d.ts +6 -0
  184. package/dist/tui/routines/tabs/SettingsTab.js +61 -0
  185. package/dist/tui/routines/tabs/TimelineTab.d.ts +7 -0
  186. package/dist/tui/routines/tabs/TimelineTab.js +26 -0
  187. package/dist/tui/state.js +1 -1
  188. package/dist/tui/tests/keyboard-integration.test.js +3 -0
  189. package/dist/tui/tests/test-app.js +1 -1
  190. package/dist/tui/types.d.ts +2 -2
  191. package/dist/tui/views/AppDetail.js +3 -4
  192. package/dist/tui/views/HealthView.js +7 -1
  193. package/dist/tui/views/LogsView.js +24 -1
  194. package/dist/tui/views/MultiLogsView.d.ts +2 -0
  195. package/dist/tui/views/MultiLogsView.js +165 -0
  196. package/dist/tui/views/SecretEdit.js +10 -3
  197. package/dist/tui/views/SecretsView.js +6 -3
  198. package/dist/ui/prompt.d.ts +52 -0
  199. package/dist/ui/prompt.js +169 -0
  200. package/package.json +33 -21
  201. package/dist/commands/motd.d.ts +0 -1
  202. package/dist/commands/motd.js +0 -10
  203. package/dist/templates/motd.d.ts +0 -1
  204. package/dist/templates/motd.js +0 -7
  205. package/dist/tui/components/AppList.d.ts +0 -12
  206. package/dist/tui/components/AppList.js +0 -32
  207. package/dist/tui/hooks/use-keyboard.d.ts +0 -1
  208. 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,17 @@ 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 { bootStartCommand } from './commands/boot-start.js';
25
+ import { rollbackCommand } from './commands/rollback.js';
26
+ import { routineRunCommand } from './commands/routine-run.js';
27
+ import { routinesCommand } from './commands/routines.js';
18
28
  import { startMcpServer } from './mcp/server.js';
19
29
  import { error } from './ui/output.js';
20
- const VERSION = '1.0.0';
30
+ const __dirname = dirname(fileURLToPath(import.meta.url));
31
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
32
+ const VERSION = pkg.version;
21
33
  const HELP = `fleet v${VERSION} - Docker production management CLI
22
34
 
23
35
  Usage: fleet <command> [options]
@@ -65,10 +77,18 @@ Commands:
65
77
  git pr list <app> List open PRs
66
78
  git release <app> Create develop->main PR
67
79
  tui, dashboard Interactive terminal dashboard
80
+ routines Fleet-wide routines TUI (signals grid + routine history)
81
+ routine-run --id <id> [--target <repo>] [--trigger scheduled]
82
+ Headless entrypoint for systemd-timer units. JSON mode: --json.
68
83
  init Auto-discover all existing apps
69
84
  watchdog Health check all services, alert on failure
70
85
  install-mcp Install fleet as Claude Code MCP server
71
86
  mcp Start as MCP server
87
+ patch-systemd Add StartLimitBurst/StartLimitIntervalSec to all service files
88
+ boot-start <app> Start app respecting boot-order dependencies
89
+ freeze <app> Freeze a crash-looping service (stop + disable)
90
+ rollback <app> Roll back app to previous image
91
+ unfreeze <app> Unfreeze and restart a frozen service
72
92
 
73
93
  Global flags:
74
94
  --json Output as JSON
@@ -85,10 +105,23 @@ export async function run(argv) {
85
105
  process.stdout.write(VERSION + '\n');
86
106
  return;
87
107
  }
88
- if (!command || args.includes('-h') || args.includes('--help')) {
108
+ if (args.includes('-h') || args.includes('--help')) {
89
109
  process.stdout.write(HELP);
90
110
  return;
91
111
  }
112
+ if (!command) {
113
+ const { launchTui } = await import('./tui/app.js');
114
+ return launchTui();
115
+ }
116
+ // Commands that require root privileges
117
+ const ROOT_COMMANDS = new Set([
118
+ 'start', 'stop', 'restart', 'deploy', 'freeze', 'unfreeze',
119
+ 'nginx', 'secrets', 'patch-systemd', 'init', 'watchdog',
120
+ ]);
121
+ if (ROOT_COMMANDS.has(command) && process.getuid && process.getuid() !== 0) {
122
+ error(`'fleet ${command}' requires root privileges. Run with sudo.`);
123
+ process.exit(1);
124
+ }
92
125
  switch (command) {
93
126
  case 'status': return statusCommand(rest);
94
127
  case 'list': return listCommand(rest);
@@ -96,6 +129,7 @@ export async function run(argv) {
96
129
  case 'stop': return stopCommand(rest);
97
130
  case 'restart': return restartCommand(rest);
98
131
  case 'logs': return logsCommand(rest);
132
+ case 'egress': return egressCommand(rest);
99
133
  case 'health': return healthCommand(rest);
100
134
  case 'deps': return depsCommand(rest);
101
135
  case 'add': return addCommand(rest);
@@ -107,12 +141,19 @@ export async function run(argv) {
107
141
  case 'init': return initCommand(rest);
108
142
  case 'watchdog': return watchdogCommand(rest);
109
143
  case 'install-mcp': return installMcpCommand(rest);
144
+ case 'patch-systemd': return patchSystemdCommand(rest);
145
+ case 'boot-start': return bootStartCommand(rest);
146
+ case 'freeze': return freezeCommand(rest);
147
+ case 'rollback': return rollbackCommand(rest);
148
+ case 'unfreeze': return unfreezeCommand(rest);
110
149
  case 'mcp': return startMcpServer();
111
150
  case 'tui':
112
151
  case 'dashboard': {
113
152
  const { launchTui } = await import('./tui/app.js');
114
153
  return launchTui();
115
154
  }
155
+ case 'routines': return routinesCommand(rest);
156
+ case 'routine-run': return routineRunCommand(rest);
116
157
  default:
117
158
  error(`Unknown command: ${command}`);
118
159
  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
+ }
@@ -1 +1 @@
1
- export declare function logsCommand(args: string[]): void;
1
+ export declare function logsCommand(args: string[]): void | Promise<void>;