@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,57 @@
1
+ import type { AppEntry } from './registry.js';
2
+ export type PreflightResult = {
3
+ ok: true;
4
+ branch: string;
5
+ } | {
6
+ ok: false;
7
+ reason: 'not-a-git-repo' | 'no-remote' | 'detached-head' | 'dirty-tree';
8
+ };
9
+ export declare function preflight(projectRoot: string): PreflightResult;
10
+ export type FetchResult = {
11
+ ok: true;
12
+ } | {
13
+ ok: false;
14
+ reason: 'fetch-failed';
15
+ detail: string;
16
+ };
17
+ export declare function fetchOrigin(projectRoot: string, branch: string): FetchResult;
18
+ export type FastForwardResult = {
19
+ ok: true;
20
+ changed: boolean;
21
+ newHead: string;
22
+ } | {
23
+ ok: false;
24
+ reason: 'non-ff' | 'rev-parse-failed';
25
+ detail: string;
26
+ };
27
+ export declare function fastForward(projectRoot: string, branch: string): FastForwardResult;
28
+ export type BuildResult = {
29
+ ok: true;
30
+ built: boolean;
31
+ } | {
32
+ ok: false;
33
+ reason: 'build-failed';
34
+ };
35
+ export declare function buildIfStale(app: AppEntry, currentHead: string): BuildResult;
36
+ export declare function recordBuiltCommit(appName: string, commit: string): void;
37
+ export declare const KILL_SWITCH = "/etc/fleet/no-auto-refresh";
38
+ export declare const DEFAULT_WALL_CLOCK_MS = 900000;
39
+ export type RefreshResult = {
40
+ kind: 'refreshed';
41
+ head: string;
42
+ built: boolean;
43
+ } | {
44
+ kind: 'no-change';
45
+ head: string;
46
+ } | {
47
+ kind: 'skipped';
48
+ reason: string;
49
+ } | {
50
+ kind: 'failed-safe';
51
+ step: string;
52
+ detail: string;
53
+ };
54
+ export interface RefreshOptions {
55
+ wallClockMs?: number;
56
+ }
57
+ export declare function refresh(app: AppEntry, opts?: RefreshOptions): Promise<RefreshResult>;
@@ -0,0 +1,116 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { isGitRepo, getGitStatus } from './git.js';
3
+ import { execGit } from './exec.js';
4
+ import { load, save } from './registry.js';
5
+ import { composeBuild } from './docker.js';
6
+ export function preflight(projectRoot) {
7
+ if (!isGitRepo(projectRoot))
8
+ return { ok: false, reason: 'not-a-git-repo' };
9
+ const s = getGitStatus(projectRoot);
10
+ if (!s.remoteName)
11
+ return { ok: false, reason: 'no-remote' };
12
+ if (!s.branch || s.branch === 'HEAD')
13
+ return { ok: false, reason: 'detached-head' };
14
+ if (!s.clean)
15
+ return { ok: false, reason: 'dirty-tree' };
16
+ return { ok: true, branch: s.branch };
17
+ }
18
+ export function fetchOrigin(projectRoot, branch) {
19
+ const r = execGit(['fetch', 'origin', branch], { cwd: projectRoot, timeout: 60_000 });
20
+ if (!r.ok)
21
+ return { ok: false, reason: 'fetch-failed', detail: r.stderr || `exit ${r.exitCode}` };
22
+ return { ok: true };
23
+ }
24
+ function revParse(projectRoot, ref) {
25
+ const r = execGit(['rev-parse', ref], { cwd: projectRoot, timeout: 10_000 });
26
+ return r.ok ? r.stdout.trim() : null;
27
+ }
28
+ export function fastForward(projectRoot, branch) {
29
+ const local = revParse(projectRoot, 'HEAD');
30
+ if (!local) {
31
+ return { ok: false, reason: 'rev-parse-failed', detail: 'rev-parse HEAD or origin/branch failed' };
32
+ }
33
+ const remote = revParse(projectRoot, `origin/${branch}`);
34
+ if (!remote) {
35
+ return { ok: false, reason: 'rev-parse-failed', detail: 'rev-parse HEAD or origin/branch failed' };
36
+ }
37
+ if (local === remote)
38
+ return { ok: true, changed: false, newHead: local };
39
+ const merge = execGit(['merge', '--ff-only', `origin/${branch}`], { cwd: projectRoot, timeout: 30_000 });
40
+ if (!merge.ok) {
41
+ execGit(['merge', '--abort'], { cwd: projectRoot, timeout: 10_000 });
42
+ return { ok: false, reason: 'non-ff', detail: merge.stderr || `exit ${merge.exitCode}` };
43
+ }
44
+ const newHead = revParse(projectRoot, 'HEAD');
45
+ return { ok: true, changed: true, newHead: newHead ?? remote };
46
+ }
47
+ export function buildIfStale(app, currentHead) {
48
+ if (app.lastBuiltCommit && app.lastBuiltCommit === currentHead) {
49
+ return { ok: true, built: false };
50
+ }
51
+ const ok = composeBuild(app.composePath, app.composeFile, app.name);
52
+ if (!ok)
53
+ return { ok: false, reason: 'build-failed' };
54
+ return { ok: true, built: true };
55
+ }
56
+ export function recordBuiltCommit(appName, commit) {
57
+ const reg = load();
58
+ const i = reg.apps.findIndex(a => a.name === appName);
59
+ if (i < 0)
60
+ return;
61
+ reg.apps[i] = { ...reg.apps[i], lastBuiltCommit: commit };
62
+ save(reg);
63
+ }
64
+ export const KILL_SWITCH = '/etc/fleet/no-auto-refresh';
65
+ function killSwitchPath() {
66
+ return process.env.FLEET_KILL_SWITCH ?? KILL_SWITCH;
67
+ }
68
+ export const DEFAULT_WALL_CLOCK_MS = 900_000;
69
+ async function doRefresh(app) {
70
+ const pre = preflight(app.composePath);
71
+ if (!pre.ok)
72
+ return { kind: 'skipped', reason: pre.reason };
73
+ const fetched = fetchOrigin(app.composePath, pre.branch);
74
+ if (!fetched.ok)
75
+ return { kind: 'failed-safe', step: 'fetch', detail: fetched.detail };
76
+ const ff = fastForward(app.composePath, pre.branch);
77
+ if (!ff.ok)
78
+ return { kind: 'failed-safe', step: 'merge', detail: ff.detail };
79
+ const build = buildIfStale(app, ff.newHead);
80
+ if (!build.ok)
81
+ return { kind: 'failed-safe', step: 'build', detail: build.reason };
82
+ if (build.built)
83
+ recordBuiltCommit(app.name, ff.newHead);
84
+ if (!ff.changed && !build.built)
85
+ return { kind: 'no-change', head: ff.newHead };
86
+ return { kind: 'refreshed', head: ff.newHead, built: build.built };
87
+ }
88
+ function isKillSwitchActive() {
89
+ try {
90
+ return existsSync(killSwitchPath());
91
+ }
92
+ catch {
93
+ return false; // permission error or similar — assume no kill switch, let refresh proceed
94
+ }
95
+ }
96
+ export async function refresh(app, opts = {}) {
97
+ if (isKillSwitchActive())
98
+ return { kind: 'skipped', reason: 'kill-switch' };
99
+ const cap = opts.wallClockMs ?? DEFAULT_WALL_CLOCK_MS;
100
+ let timer;
101
+ try {
102
+ return await Promise.race([
103
+ doRefresh(app),
104
+ new Promise((resolve) => {
105
+ timer = setTimeout(() => resolve({ kind: 'failed-safe', step: 'wall-clock', detail: `exceeded ${cap}ms` }), cap);
106
+ }),
107
+ ]);
108
+ }
109
+ catch (err) {
110
+ return { kind: 'failed-safe', step: 'exception', detail: err instanceof Error ? err.message : String(err) };
111
+ }
112
+ finally {
113
+ if (timer)
114
+ clearTimeout(timer);
115
+ }
116
+ }
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { exec } from '../../exec.js';
3
+ import { execSafe } from '../../exec.js';
4
4
  export function generateVersionBump(finding) {
5
5
  if (!finding.fixable || !finding.package || !finding.currentVersion || !finding.latestVersion) {
6
6
  return null;
@@ -74,10 +74,10 @@ export function createDepsPr(app, findings, dryRun) {
74
74
  if (dryRun) {
75
75
  return { branch, bumps };
76
76
  }
77
- const sshEnv = { SSH_AUTH_SOCK: '/tmp/fleet-ssh-agent.sock' };
78
- exec('git checkout develop', { cwd: app.composePath });
79
- exec('git pull', { cwd: app.composePath, env: sshEnv });
80
- exec(`git checkout -b ${branch}`, { cwd: app.composePath });
77
+ const sshEnv = process.env.SSH_AUTH_SOCK ? { SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK } : {};
78
+ execSafe('git', ['checkout', 'develop'], { cwd: app.composePath });
79
+ execSafe('git', ['pull'], { cwd: app.composePath, env: sshEnv });
80
+ execSafe('git', ['checkout', '-b', branch], { cwd: app.composePath });
81
81
  for (const bump of bumps) {
82
82
  const filePath = join(app.composePath, bump.file);
83
83
  if (!existsSync(filePath))
@@ -87,17 +87,19 @@ export function createDepsPr(app, findings, dryRun) {
87
87
  writeFileSync(filePath, content);
88
88
  }
89
89
  const files = [...new Set(bumps.map(b => b.file))];
90
- exec(`git add ${files.join(' ')}`, { cwd: app.composePath });
90
+ execSafe('git', ['add', ...files], { cwd: app.composePath });
91
91
  const commitMsg = bumps.length === 1
92
92
  ? `chore(deps): update ${fixable[0].package} from ${fixable[0].currentVersion} to ${fixable[0].latestVersion}`
93
93
  : `chore(deps): update ${bumps.length} dependencies`;
94
- exec(`git commit -m "${commitMsg}"`, { cwd: app.composePath });
95
- exec(`git push -u origin ${branch}`, { cwd: app.composePath, env: sshEnv });
94
+ execSafe('git', ['commit', '-m', commitMsg], { cwd: app.composePath });
95
+ execSafe('git', ['push', '-u', 'origin', branch], { cwd: app.composePath, env: sshEnv });
96
96
  if (!app.gitRepo)
97
97
  return { branch, bumps };
98
98
  const prBody = buildPrBody(fixable);
99
99
  const prTitle = `chore(deps): update dependencies (${date})`;
100
- const prResult = exec(`gh pr create --repo ${app.gitRepo} --title "${prTitle}" --body "${prBody.replace(/"/g, '\\"')}" --base develop`, { cwd: app.composePath, env: sshEnv });
100
+ const prResult = execSafe('gh', [
101
+ 'pr', 'create', '--repo', app.gitRepo, '--title', prTitle, '--body', prBody, '--base', 'develop',
102
+ ], { cwd: app.composePath, env: sshEnv });
101
103
  const prUrl = prResult.ok ? prResult.stdout.trim() : undefined;
102
104
  return { branch, bumps, prUrl };
103
105
  }
@@ -1,4 +1,4 @@
1
- import { exec } from '../../exec.js';
1
+ import { execSafe } from '../../exec.js';
2
2
  export class DockerRunningCollector {
3
3
  _overrides;
4
4
  type = 'docker-running';
@@ -11,7 +11,7 @@ export class DockerRunningCollector {
11
11
  async collect(app) {
12
12
  const findings = [];
13
13
  for (const container of app.containers) {
14
- const result = exec(`docker inspect ${container}`, { timeout: 10_000 });
14
+ const result = execSafe('docker', ['inspect', container], { timeout: 10_000 });
15
15
  if (!result.ok)
16
16
  continue;
17
17
  const info = this.parseInspectOutput(result.stdout);
@@ -1,4 +1,4 @@
1
- import { exec } from '../../exec.js';
1
+ import { execSafe } from '../../exec.js';
2
2
  export class GitHubPrCollector {
3
3
  type = 'github-pr';
4
4
  detect(_appPath, app) {
@@ -7,7 +7,10 @@ export class GitHubPrCollector {
7
7
  async collect(app) {
8
8
  if (!app.gitRepo)
9
9
  return [];
10
- const result = exec(`gh pr list --repo ${app.gitRepo} --state open --json number,title,url,labels --limit 50`, { timeout: 15_000 });
10
+ const result = execSafe('gh', [
11
+ 'pr', 'list', '--repo', app.gitRepo, '--state', 'open',
12
+ '--json', 'number,title,url,labels', '--limit', '50',
13
+ ], { timeout: 15_000 });
11
14
  if (!result.ok)
12
15
  return [];
13
16
  try {
@@ -21,16 +21,21 @@ export class NpmCollector {
21
21
  ...pkg.devDependencies,
22
22
  };
23
23
  const findings = [];
24
- const results = await Promise.allSettled(Object.entries(allDeps).map(([name, version]) => this.checkPackage(app.name, name, version)));
25
- for (const result of results) {
26
- if (result.status === 'fulfilled' && result.value) {
27
- findings.push(result.value);
24
+ const entries = Object.entries(allDeps);
25
+ const BATCH_SIZE = 10;
26
+ for (let i = 0; i < entries.length; i += BATCH_SIZE) {
27
+ const batch = entries.slice(i, i + BATCH_SIZE);
28
+ const results = await Promise.allSettled(batch.map(([name, version]) => this.checkPackage(app.name, name, version)));
29
+ for (const result of results) {
30
+ if (result.status === 'fulfilled' && result.value) {
31
+ findings.push(result.value);
32
+ }
28
33
  }
29
34
  }
30
35
  return findings;
31
36
  }
32
37
  async checkPackage(appName, name, currentRaw) {
33
- const current = currentRaw.replace(/^[\^~>=<]/, '');
38
+ const current = currentRaw.replace(/^[^\d]*/, '');
34
39
  try {
35
40
  const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`);
36
41
  if (!res.ok)
@@ -13,10 +13,14 @@ export class VulnerabilityCollector {
13
13
  if (packages.length === 0)
14
14
  return [];
15
15
  const findings = [];
16
- const results = await Promise.allSettled(packages.map(pkg => this.queryOsv(app.name, pkg)));
17
- for (const result of results) {
18
- if (result.status === 'fulfilled') {
19
- findings.push(...result.value);
16
+ const BATCH_SIZE = 10;
17
+ for (let i = 0; i < packages.length; i += BATCH_SIZE) {
18
+ const batch = packages.slice(i, i + BATCH_SIZE);
19
+ const results = await Promise.allSettled(batch.map(pkg => this.queryOsv(app.name, pkg)));
20
+ for (const result of results) {
21
+ if (result.status === 'fulfilled') {
22
+ findings.push(...result.value);
23
+ }
20
24
  }
21
25
  }
22
26
  return findings;
@@ -28,7 +32,7 @@ export class VulnerabilityCollector {
28
32
  try {
29
33
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
30
34
  for (const [name, versionRaw] of Object.entries(pkg.dependencies ?? {})) {
31
- const version = versionRaw.replace(/^[\^~>=<]/, '');
35
+ const version = versionRaw.replace(/^[^\d]*/, '');
32
36
  packages.push({ name, version, ecosystem: 'npm' });
33
37
  }
34
38
  }
@@ -41,7 +45,7 @@ export class VulnerabilityCollector {
41
45
  for (const [name, versionRaw] of Object.entries(composer.require ?? {})) {
42
46
  if (name.startsWith('php') || name.startsWith('ext-'))
43
47
  continue;
44
- const version = versionRaw.replace(/^[\^~>=<*]/, '');
48
+ const version = versionRaw.replace(/^[^\d]*/, '');
45
49
  packages.push({ name, version, ecosystem: 'Packagist' });
46
50
  }
47
51
  }
@@ -51,7 +51,7 @@ fi
51
51
  `;
52
52
  }
53
53
  function formatAge(isoDate) {
54
- const ms = Date.now() - new Date(isoDate).getTime();
54
+ const ms = Math.max(0, Date.now() - new Date(isoDate).getTime());
55
55
  const mins = Math.floor(ms / 60_000);
56
56
  if (mins < 1)
57
57
  return 'just now';
@@ -1,9 +1,9 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { loadTelegramConfig, sendTelegram } from '../../telegram.js';
4
5
  const __dirname = dirname(fileURLToPath(import.meta.url));
5
6
  const NOTIFIED_PATH = join(__dirname, '..', '..', '..', '..', 'data', 'notified-findings.json');
6
- const TELEGRAM_CONFIG_PATH = '/etc/fleet/telegram.json';
7
7
  export function formatTelegramMessage(findings, appCount) {
8
8
  if (findings.length === 0)
9
9
  return '';
@@ -56,21 +56,7 @@ export async function sendTelegramNotification(findings, appCount, previousFindi
56
56
  const message = formatTelegramMessage(filtered, appCount);
57
57
  if (!message)
58
58
  return false;
59
- try {
60
- const res = await fetch(`https://api.telegram.org/bot${config.botToken}/sendMessage`, {
61
- method: 'POST',
62
- headers: { 'Content-Type': 'application/json' },
63
- body: JSON.stringify({
64
- chat_id: config.chatId,
65
- text: message,
66
- parse_mode: 'HTML',
67
- }),
68
- });
69
- return res.ok;
70
- }
71
- catch {
72
- return false;
73
- }
59
+ return sendTelegram(config, message);
74
60
  }
75
61
  export function loadNotifiedFindings() {
76
62
  if (!existsSync(NOTIFIED_PATH))
@@ -88,19 +74,6 @@ export function saveNotifiedFindings(findings) {
88
74
  mkdirSync(dir, { recursive: true });
89
75
  writeFileSync(NOTIFIED_PATH, JSON.stringify(findings, null, 2) + '\n');
90
76
  }
91
- function loadTelegramConfig() {
92
- if (!existsSync(TELEGRAM_CONFIG_PATH))
93
- return null;
94
- try {
95
- const raw = JSON.parse(readFileSync(TELEGRAM_CONFIG_PATH, 'utf-8'));
96
- if (!raw.botToken || !raw.chatId)
97
- return null;
98
- return { botToken: raw.botToken, chatId: String(raw.chatId) };
99
- }
100
- catch {
101
- return null;
102
- }
103
- }
104
77
  function escapeHtml(text) {
105
78
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
106
79
  }
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, existsSync } from 'node:fs';
2
- import { exec } from './exec.js';
2
+ import { execSafe } from './exec.js';
3
3
  const SECRETS_BASE = '/run/fleet-secrets';
4
4
  function loadEnvFile(path) {
5
5
  if (!existsSync(path))
@@ -23,7 +23,9 @@ function loadEnvFile(path) {
23
23
  return vars;
24
24
  }
25
25
  export function listContainers() {
26
- const result = exec('docker ps --format "{{.Names}}\\t{{.Status}}\\t{{.Ports}}\\t{{.Image}}"', { timeout: 10_000 });
26
+ const result = execSafe('docker', [
27
+ 'ps', '--format', '{{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Image}}',
28
+ ], { timeout: 10_000 });
27
29
  if (!result.ok || !result.stdout)
28
30
  return [];
29
31
  return result.stdout.split('\n').map(line => {
@@ -37,36 +39,64 @@ export function listContainers() {
37
39
  });
38
40
  }
39
41
  export function getContainersByCompose(composePath, composeFile) {
40
- const fileFlag = composeFile ? `-f ${composeFile}` : '';
41
- const result = exec(`docker compose ${fileFlag} ps --format "{{.Names}}"`, { cwd: composePath, timeout: 10_000 });
42
+ const args = ['compose', ...(composeFile ? ['-f', composeFile] : []), 'ps', '--format', '{{.Names}}'];
43
+ const result = execSafe('docker', args, { cwd: composePath, timeout: 10_000 });
42
44
  if (!result.ok || !result.stdout)
43
45
  return [];
44
46
  return result.stdout.split('\n').filter(Boolean);
45
47
  }
46
48
  export function getContainerLogs(container, lines = 100) {
47
- const result = exec(`docker logs --tail ${lines} ${container} 2>&1`, { timeout: 15_000 });
48
- return result.ok ? result.stdout : result.stderr || 'No logs available';
49
+ const result = execSafe('docker', ['logs', '--tail', String(lines), container], { timeout: 15_000 });
50
+ return result.ok ? (result.stdout || result.stderr) : result.stderr || 'No logs available';
51
+ }
52
+ function resolveImageName(composePath, composeFile) {
53
+ const args = ['compose', ...(composeFile ? ['-f', composeFile] : []), 'config', '--images'];
54
+ const r = execSafe('docker', args, { cwd: composePath, timeout: 15_000 });
55
+ if (!r.ok)
56
+ return null;
57
+ const first = r.stdout.split('\n').filter(Boolean)[0];
58
+ return first ?? null;
59
+ }
60
+ function imageExists(image) {
61
+ return execSafe('docker', ['image', 'inspect', image], { timeout: 10_000 }).ok;
49
62
  }
50
63
  export function composeBuild(composePath, composeFile, appName) {
51
- const fileFlag = composeFile ? `-f ${composeFile}` : '';
64
+ const image = resolveImageName(composePath, composeFile);
65
+ if (image && imageExists(image)) {
66
+ const lastColon = image.lastIndexOf(':');
67
+ const base = lastColon > 0 ? image.slice(0, lastColon) : image;
68
+ const previous = `${base}:fleet-previous`;
69
+ execSafe('docker', ['tag', image, previous], { timeout: 10_000 });
70
+ // intentional: retag failure does not block build
71
+ }
72
+ const args = ['compose', ...(composeFile ? ['-f', composeFile] : []), 'build'];
52
73
  const env = appName ? loadEnvFile(`${SECRETS_BASE}/${appName}/.env`) : {};
53
- const result = exec(`docker compose ${fileFlag} build`, { cwd: composePath, timeout: 300_000, env: Object.keys(env).length > 0 ? env : undefined });
74
+ const result = execSafe('docker', args, {
75
+ cwd: composePath,
76
+ timeout: 300_000,
77
+ env: Object.keys(env).length > 0 ? env : undefined,
78
+ });
54
79
  return result.ok;
55
80
  }
56
81
  export function composeUp(composePath, composeFile) {
57
- const fileFlag = composeFile ? `-f ${composeFile}` : '';
58
- const result = exec(`docker compose ${fileFlag} up -d --force-recreate`, { cwd: composePath, timeout: 120_000 });
82
+ const args = ['compose', ...(composeFile ? ['-f', composeFile] : []), 'up', '-d', '--force-recreate'];
83
+ const result = execSafe('docker', args, { cwd: composePath, timeout: 120_000 });
59
84
  return result.ok;
60
85
  }
61
86
  export function composeDown(composePath, composeFile) {
62
- const fileFlag = composeFile ? `-f ${composeFile}` : '';
63
- const result = exec(`docker compose ${fileFlag} down`, { cwd: composePath, timeout: 60_000 });
87
+ const args = ['compose', ...(composeFile ? ['-f', composeFile] : []), 'down'];
88
+ const result = execSafe('docker', args, { cwd: composePath, timeout: 60_000 });
64
89
  return result.ok;
65
90
  }
66
91
  export function inspectContainer(name) {
67
- const result = exec(`docker inspect ${name}`, { timeout: 10_000 });
92
+ const result = execSafe('docker', ['inspect', name], { timeout: 10_000 });
68
93
  if (!result.ok)
69
94
  return null;
70
- const parsed = JSON.parse(result.stdout);
71
- return Array.isArray(parsed) ? parsed[0] : parsed;
95
+ try {
96
+ const parsed = JSON.parse(result.stdout);
97
+ return Array.isArray(parsed) ? parsed[0] : parsed;
98
+ }
99
+ catch {
100
+ return null;
101
+ }
72
102
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Egress observation: see where each app's containers are talking to.
3
+ *
4
+ * v1 = snapshot mode. Reads conntrack via `ss -tn` filtered by container IPs,
5
+ * resolves remote IPs to hostnames best-effort, returns the deduplicated set.
6
+ *
7
+ * v2 (Phase E) = continuous shadow daemon (eBPF-based or nftables LOG target),
8
+ * with persistent observed-set storage and a real `enforce` mode that drops
9
+ * packets to non-allowlisted destinations. Design intentionally matches v1's
10
+ * data shape so the upgrade is non-breaking.
11
+ */
12
+ import type { AppEntry } from './registry.js';
13
+ export interface EgressFlow {
14
+ /** App that owns the source container. */
15
+ app: string;
16
+ /** Container name. */
17
+ container: string;
18
+ /** Remote endpoint as host:port. Hostname resolved when possible. */
19
+ remote: string;
20
+ remoteIp: string;
21
+ remotePort: number;
22
+ /** True if remote matches the app's egress.allow list. */
23
+ allowed: boolean;
24
+ }
25
+ export interface EgressSnapshot {
26
+ takenAt: string;
27
+ app: string;
28
+ flows: EgressFlow[];
29
+ /** Distinct (host:port) destinations observed. Useful for seeding allow lists. */
30
+ uniqueRemotes: string[];
31
+ /** uniqueRemotes that aren't on the app's allow list. */
32
+ violations: string[];
33
+ }
34
+ /**
35
+ * Read all current outbound TCP/UDP flows from the host using `ss -tnp`,
36
+ * filter to those whose SOURCE matches one of the app's container IPs.
37
+ * Connections to private addresses are kept (they may indicate intra-host
38
+ * leaks) but flagged differently.
39
+ */
40
+ export declare function snapshotEgress(app: AppEntry): EgressSnapshot;
41
+ export declare function addEgressAllow(app: AppEntry, host: string): string[];