@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,161 @@
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 { execSafe } from './exec.js';
13
+ /** Container PID for entering its network namespace. Returns 0 if not running. */
14
+ function containerPid(container) {
15
+ const r = execSafe('docker', ['inspect', '--format={{.State.Pid}}', container]);
16
+ if (!r.ok)
17
+ return 0;
18
+ return parseInt(r.stdout.trim(), 10) || 0;
19
+ }
20
+ /** Run `ss -tnH` inside a container's network namespace. Requires sudo (nsenter
21
+ * needs CAP_SYS_ADMIN). Returns empty string if the call fails. */
22
+ function nsenterSs(pid) {
23
+ const r = execSafe('nsenter', ['-t', String(pid), '-n', 'ss', '-tnH']);
24
+ if (r.ok)
25
+ return r.stdout;
26
+ // Fall back to sudo (fleet might be running unprivileged)
27
+ const s = execSafe('sudo', ['-n', 'nsenter', '-t', String(pid), '-n', 'ss', '-tnH']);
28
+ return s.ok ? s.stdout : '';
29
+ }
30
+ /** Reverse-lookup an IP → hostname. Best-effort, short timeout. */
31
+ function reverseLookup(ip) {
32
+ // Try `getent hosts` first (uses /etc/hosts + resolver)
33
+ const r = execSafe('getent', ['hosts', ip]);
34
+ if (r.ok && r.stdout.trim()) {
35
+ const parts = r.stdout.trim().split(/\s+/);
36
+ if (parts[1])
37
+ return parts[1];
38
+ }
39
+ // Fall back to `dig +short -x`
40
+ const dig = execSafe('dig', ['+short', '+time=1', '+tries=1', '-x', ip]);
41
+ if (dig.ok && dig.stdout.trim()) {
42
+ return dig.stdout.trim().replace(/\.$/, '').split('\n')[0];
43
+ }
44
+ return null;
45
+ }
46
+ const RFC1918 = [/^10\./, /^192\.168\./, /^172\.(1[6-9]|2[0-9]|3[01])\./, /^127\./, /^169\.254\./, /^::1$/, /^fe80:/];
47
+ function isPrivate(ip) {
48
+ return RFC1918.some(r => r.test(ip));
49
+ }
50
+ /**
51
+ * Read all current outbound TCP/UDP flows from the host using `ss -tnp`,
52
+ * filter to those whose SOURCE matches one of the app's container IPs.
53
+ * Connections to private addresses are kept (they may indicate intra-host
54
+ * leaks) but flagged differently.
55
+ */
56
+ export function snapshotEgress(app) {
57
+ const allFlows = [];
58
+ const allow = new Set(app.egress?.allow ?? []);
59
+ for (const ct of app.containers) {
60
+ const pid = containerPid(ct);
61
+ if (pid === 0)
62
+ continue;
63
+ const out = nsenterSs(pid);
64
+ if (!out)
65
+ continue;
66
+ for (const line of out.split('\n')) {
67
+ const cols = line.trim().split(/\s+/);
68
+ if (cols.length < 5)
69
+ continue;
70
+ const peer = cols[4];
71
+ const peerMatch = peer.match(/^(.+):(\d+)$/);
72
+ if (!peerMatch)
73
+ continue;
74
+ const remoteIp = peerMatch[1].replace(/^\[|\]$/g, '');
75
+ const remotePort = parseInt(peerMatch[2], 10);
76
+ // Skip listeners back to ourselves and intra-pod chatter
77
+ if (isPrivate(remoteIp) && (remoteIp === '127.0.0.1' || remoteIp === '::1'))
78
+ continue;
79
+ const host = reverseLookup(remoteIp) ?? remoteIp;
80
+ const remote = `${host}:${remotePort}`;
81
+ const allowed = allowMatches(allow, remote, host, remoteIp, remotePort);
82
+ allFlows.push({ app: app.name, container: ct, remote, remoteIp, remotePort, allowed });
83
+ }
84
+ }
85
+ if (allFlows.length === 0) {
86
+ return { takenAt: new Date().toISOString(), app: app.name, flows: [], uniqueRemotes: [], violations: [] };
87
+ }
88
+ const uniq = Array.from(new Set(allFlows.map(f => f.remote))).sort();
89
+ const violations = uniq.filter(r => {
90
+ const flow = allFlows.find(f => f.remote === r);
91
+ return flow ? !flow.allowed && !isPrivate(flow.remoteIp) : false;
92
+ });
93
+ return {
94
+ takenAt: new Date().toISOString(),
95
+ app: app.name,
96
+ flows: allFlows,
97
+ uniqueRemotes: uniq,
98
+ violations,
99
+ };
100
+ }
101
+ /**
102
+ * Match a destination against the allowlist. Supported entry forms:
103
+ * exact-host:port api.stripe.com:443
104
+ * bare-host (any port) api.stripe.com
105
+ * bare-ip / ip:port 1.2.3.4 / 1.2.3.4:443
106
+ * wildcard host *.stripe.com (also matches the bare apex)
107
+ * wildcard host + port *.stripe.com:443
108
+ * bare host + glob port api.stripe.com:*
109
+ *
110
+ * SECURITY NOTE: `host` is the result of a reverse-DNS lookup. An attacker
111
+ * who controls the reverse DNS for an IP they own can return whatever
112
+ * hostname they like. The IP-based allow forms (`ip`, `ip:port`) are the
113
+ * only trustworthy way to allow a destination by name; use them when the
114
+ * remote endpoint isn't under cloudflare/etc. Hostname-based entries are
115
+ * provided for ergonomics only — verify forward+reverse if you need to be
116
+ * adversarial about it.
117
+ */
118
+ function allowMatches(allow, remote, host, ip, port) {
119
+ if (allow.size === 0)
120
+ return false;
121
+ // IP-based (always trustworthy):
122
+ if (allow.has(ip))
123
+ return true;
124
+ if (allow.has(`${ip}:${port}`))
125
+ return true;
126
+ // Hostname-based (trust depends on PTR record — see note above):
127
+ if (allow.has(remote))
128
+ return true; // exact host:port
129
+ if (allow.has(host))
130
+ return true; // bare host, any port
131
+ if (allow.has(`${host}:*`))
132
+ return true; // host with glob port
133
+ for (const a of allow) {
134
+ // Wildcard host without port: '*.stripe.com'
135
+ if (a.startsWith('*.') && !a.includes(':')) {
136
+ const suffix = a.slice(1); // '.stripe.com'
137
+ const apex = a.slice(2); // 'stripe.com'
138
+ if (host === apex || host.endsWith(suffix))
139
+ return true;
140
+ }
141
+ // Wildcard host WITH port: '*.stripe.com:443'
142
+ if (a.startsWith('*.') && a.includes(':')) {
143
+ const colon = a.lastIndexOf(':');
144
+ const wildHost = a.slice(0, colon); // '*.stripe.com'
145
+ const wildPort = a.slice(colon + 1);
146
+ if (parseInt(wildPort, 10) !== port)
147
+ continue;
148
+ const suffix = wildHost.slice(1);
149
+ const apex = wildHost.slice(2);
150
+ if (host === apex || host.endsWith(suffix))
151
+ return true;
152
+ }
153
+ }
154
+ return false;
155
+ }
156
+ export function addEgressAllow(app, host) {
157
+ const cur = new Set(app.egress?.allow ?? []);
158
+ cur.add(host);
159
+ app.egress = { ...(app.egress ?? {}), allow: Array.from(cur).sort() };
160
+ return app.egress.allow;
161
+ }
@@ -4,10 +4,16 @@ export interface ExecResult {
4
4
  exitCode: number;
5
5
  ok: boolean;
6
6
  }
7
- export declare function exec(cmd: string, opts?: {
7
+ export declare function execSafe(cmd: string, args: string[], opts?: {
8
8
  timeout?: number;
9
9
  cwd?: string;
10
10
  env?: Record<string, string>;
11
+ input?: string;
12
+ }): ExecResult;
13
+ export declare function execGit(args: string[], opts: {
14
+ cwd: string;
15
+ timeout?: number;
16
+ env?: Record<string, string>;
11
17
  }): ExecResult;
12
18
  export declare function execLive(cmd: string, args: string[], opts?: {
13
19
  cwd?: string;
package/dist/core/exec.js CHANGED
@@ -1,24 +1,32 @@
1
- import { execSync, spawnSync } from 'node:child_process';
2
- export function exec(cmd, opts = {}) {
3
- try {
4
- const stdout = execSync(cmd, {
5
- timeout: opts.timeout ?? 30_000,
6
- cwd: opts.cwd,
7
- env: opts.env ? { ...process.env, ...opts.env } : undefined,
8
- encoding: 'utf-8',
9
- stdio: ['pipe', 'pipe', 'pipe'],
10
- });
11
- return { stdout: stdout.trim(), stderr: '', exitCode: 0, ok: true };
12
- }
13
- catch (err) {
14
- const e = err;
1
+ import { spawnSync } from 'node:child_process';
2
+ export function execSafe(cmd, args, opts = {}) {
3
+ const result = spawnSync(cmd, args, {
4
+ timeout: opts.timeout ?? 30_000,
5
+ cwd: opts.cwd,
6
+ env: opts.env ? { ...process.env, ...opts.env } : undefined,
7
+ encoding: 'utf-8',
8
+ stdio: 'pipe',
9
+ input: opts.input,
10
+ });
11
+ if (result.error) {
15
12
  return {
16
- stdout: (e.stdout ?? '').toString().trim(),
17
- stderr: (e.stderr ?? '').toString().trim(),
18
- exitCode: e.status ?? 1,
13
+ stdout: '',
14
+ stderr: result.error.message,
15
+ exitCode: 1,
19
16
  ok: false,
20
17
  };
21
18
  }
19
+ return {
20
+ stdout: (result.stdout ?? '').trim(),
21
+ stderr: (result.stderr ?? '').trim(),
22
+ exitCode: result.status ?? 1,
23
+ ok: result.status === 0,
24
+ };
25
+ }
26
+ export function execGit(args, opts) {
27
+ // Prepend -c safe.directory=<cwd> so git under root can operate on repos owned by other users.
28
+ // This is scoped to the one command — does not mutate global git config.
29
+ return execSafe('git', ['-c', `safe.directory=${opts.cwd}`, ...args], opts);
22
30
  }
23
31
  export function execLive(cmd, args, opts = {}) {
24
32
  const result = spawnSync(cmd, args, {
@@ -26,6 +26,7 @@ export declare function branchExists(cwd: string, branch: string): boolean;
26
26
  export declare function getProjectRoot(composePath: string): string;
27
27
  export declare function gitInit(cwd: string): void;
28
28
  export declare function gitAdd(cwd: string, paths?: string[]): void;
29
+ export declare function gitAddTracked(cwd: string): void;
29
30
  export declare function gitCommit(cwd: string, message: string): void;
30
31
  export declare function gitCheckout(cwd: string, branch: string, create?: boolean): void;
31
32
  export declare function gitPush(cwd: string, branch: string, setUpstream?: boolean): void;
package/dist/core/git.js CHANGED
@@ -1,17 +1,19 @@
1
1
  import { existsSync, writeFileSync, readFileSync } from 'node:fs';
2
2
  import { join, dirname, basename } from 'node:path';
3
- import { exec } from './exec.js';
3
+ import { execGit } from './exec.js';
4
4
  import { GitError } from './errors.js';
5
5
  import { detectProjectType, generateGitignore } from '../templates/gitignore.js';
6
- const SSH_AGENT_SOCK = '/tmp/fleet-ssh-agent.sock';
7
- if (existsSync(SSH_AGENT_SOCK)) {
6
+ import { assertBranch, assertFilePath } from './validate.js';
7
+ // Use SSH_AUTH_SOCK from environment, or check for fleet-specific socket
8
+ const SSH_AGENT_SOCK = process.env.FLEET_SSH_SOCK || '/tmp/fleet-ssh-agent.sock';
9
+ if (!process.env.SSH_AUTH_SOCK && existsSync(SSH_AGENT_SOCK)) {
8
10
  process.env.SSH_AUTH_SOCK = SSH_AGENT_SOCK;
9
11
  }
10
12
  export function isGitRepo(cwd) {
11
- return exec('git rev-parse --is-inside-work-tree', { cwd }).ok;
13
+ return execGit(['rev-parse', '--is-inside-work-tree'], { cwd }).ok;
12
14
  }
13
15
  export function hasCommits(cwd) {
14
- return exec('git rev-parse HEAD', { cwd }).ok;
16
+ return execGit(['rev-parse', 'HEAD'], { cwd }).ok;
15
17
  }
16
18
  export function getGitStatus(cwd) {
17
19
  if (!isGitRepo(cwd)) {
@@ -20,17 +22,17 @@ export function getGitStatus(cwd) {
20
22
  clean: true, staged: 0, modified: 0, untracked: 0, ahead: 0, behind: 0,
21
23
  };
22
24
  }
23
- const branch = exec('git rev-parse --abbrev-ref HEAD', { cwd }).stdout || '';
24
- const branchResult = exec('git branch --list --no-color', { cwd });
25
+ const branch = execGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd }).stdout || '';
26
+ const branchResult = execGit(['branch', '--list', '--no-color'], { cwd });
25
27
  const branches = branchResult.stdout
26
28
  .split('\n')
27
29
  .map(b => b.replace(/^\*?\s+/, '').trim())
28
30
  .filter(Boolean);
29
- const remoteName = exec('git remote', { cwd }).stdout.split('\n')[0] || '';
31
+ const remoteName = execGit(['remote'], { cwd }).stdout.split('\n')[0] || '';
30
32
  const remoteUrl = remoteName
31
- ? exec(`git remote get-url ${remoteName}`, { cwd }).stdout
33
+ ? execGit(['remote', 'get-url', remoteName], { cwd }).stdout
32
34
  : '';
33
- const porcelain = exec('git status --porcelain', { cwd }).stdout;
35
+ const porcelain = execGit(['status', '--porcelain'], { cwd }).stdout;
34
36
  const lines = porcelain ? porcelain.split('\n') : [];
35
37
  let staged = 0, modified = 0, untracked = 0;
36
38
  for (const line of lines) {
@@ -44,7 +46,7 @@ export function getGitStatus(cwd) {
44
46
  }
45
47
  let ahead = 0, behind = 0;
46
48
  if (remoteName && hasCommits(cwd)) {
47
- const abResult = exec(`git rev-list --left-right --count HEAD...${remoteName}/${branch}`, { cwd });
49
+ const abResult = execGit(['rev-list', '--left-right', '--count', `HEAD...${remoteName}/${branch}`], { cwd });
48
50
  if (abResult.ok) {
49
51
  const parts = abResult.stdout.split(/\s+/);
50
52
  ahead = parseInt(parts[0], 10) || 0;
@@ -57,7 +59,7 @@ export function getGitStatus(cwd) {
57
59
  };
58
60
  }
59
61
  export function getLog(cwd, count = 10) {
60
- const result = exec(`git log --oneline --format="%H|%s|%ci" -${count}`, { cwd });
62
+ const result = execGit(['log', '--oneline', '--format=%H|%s|%ci', `-${count}`], { cwd });
61
63
  if (!result.ok)
62
64
  return [];
63
65
  return result.stdout.split('\n').filter(Boolean).map(line => {
@@ -73,7 +75,8 @@ export function readGitignore(cwd) {
73
75
  return existsSync(p) ? readFileSync(p, 'utf-8') : '';
74
76
  }
75
77
  export function branchExists(cwd, branch) {
76
- return exec(`git show-ref --verify --quiet refs/heads/${branch}`, { cwd }).ok;
78
+ assertBranch(branch);
79
+ return execGit(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { cwd }).ok;
77
80
  }
78
81
  // walk up from composePath to find git root
79
82
  const SUBDIR_NAMES = new Set(['server', 'app', 'backend', 'frontend']);
@@ -102,44 +105,54 @@ export function getProjectRoot(composePath) {
102
105
  return dir;
103
106
  }
104
107
  export function gitInit(cwd) {
105
- const r = exec('git init -b main', { cwd });
108
+ const r = execGit(['init', '-b', 'main'], { cwd });
106
109
  if (!r.ok)
107
110
  throw new GitError(`git init failed: ${r.stderr}`);
108
111
  }
109
112
  export function gitAdd(cwd, paths = ['.']) {
110
- const r = exec(`git add ${paths.join(' ')}`, { cwd });
113
+ for (const p of paths)
114
+ if (p !== '.')
115
+ assertFilePath(p);
116
+ const r = execGit(['add', ...paths], { cwd });
111
117
  if (!r.ok)
112
118
  throw new GitError(`git add failed: ${r.stderr}`);
113
119
  }
120
+ export function gitAddTracked(cwd) {
121
+ const r = execGit(['add', '-u'], { cwd });
122
+ if (!r.ok)
123
+ throw new GitError(`git add -u failed: ${r.stderr}`);
124
+ }
114
125
  export function gitCommit(cwd, message) {
115
- const r = exec(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd });
126
+ const r = execGit(['commit', '-m', message], { cwd });
116
127
  if (!r.ok)
117
128
  throw new GitError(`git commit failed: ${r.stderr}`);
118
129
  }
119
130
  export function gitCheckout(cwd, branch, create = false) {
120
- const flag = create ? '-b' : '';
121
- const r = exec(`git checkout ${flag} ${branch}`, { cwd });
131
+ assertBranch(branch);
132
+ const args = create ? ['checkout', '-b', branch] : ['checkout', branch];
133
+ const r = execGit(args, { cwd });
122
134
  if (!r.ok)
123
135
  throw new GitError(`git checkout failed: ${r.stderr}`);
124
136
  }
125
137
  export function gitPush(cwd, branch, setUpstream = false) {
126
- const flag = setUpstream ? '-u origin' : '';
127
- const r = exec(`git push ${flag} ${branch}`, { cwd, timeout: 60_000 });
138
+ assertBranch(branch);
139
+ const args = setUpstream ? ['push', '-u', 'origin', branch] : ['push', branch];
140
+ const r = execGit(args, { cwd, timeout: 60_000 });
128
141
  if (!r.ok)
129
142
  throw new GitError(`git push failed: ${r.stderr}`);
130
143
  }
131
144
  export function gitPushAll(cwd) {
132
- const r = exec('git push --all origin', { cwd, timeout: 60_000 });
145
+ const r = execGit(['push', '--all', 'origin'], { cwd, timeout: 60_000 });
133
146
  if (!r.ok)
134
147
  throw new GitError(`git push --all failed: ${r.stderr}`);
135
148
  }
136
149
  export function gitSetRemoteUrl(cwd, url) {
137
- const r = exec(`git remote set-url origin ${url}`, { cwd });
150
+ const r = execGit(['remote', 'set-url', 'origin', url], { cwd });
138
151
  if (!r.ok)
139
152
  throw new GitError(`git remote set-url failed: ${r.stderr}`);
140
153
  }
141
154
  export function gitAddRemote(cwd, name, url) {
142
- const r = exec(`git remote add ${name} ${url}`, { cwd });
155
+ const r = execGit(['remote', 'add', name, url], { cwd });
143
156
  if (!r.ok)
144
157
  throw new GitError(`git remote add failed: ${r.stderr}`);
145
158
  }
@@ -1,11 +1,12 @@
1
1
  import { writeFileSync, unlinkSync } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
- import { exec } from './exec.js';
4
+ import { execSafe } from './exec.js';
5
5
  import { GitError } from './errors.js';
6
+ import { assertAppName } from './validate.js';
6
7
  export const GITHUB_ORG = 'heskethwebdesign';
7
8
  export function isGhAuthenticated() {
8
- return exec('gh auth status', { timeout: 10_000 }).ok;
9
+ return execSafe('gh', ['auth', 'status'], { timeout: 10_000 }).ok;
9
10
  }
10
11
  export function requireGhAuth() {
11
12
  if (!isGhAuthenticated()) {
@@ -13,13 +14,15 @@ export function requireGhAuth() {
13
14
  }
14
15
  }
15
16
  export function repoExists(name) {
16
- return exec(`gh repo view ${GITHUB_ORG}/${name} --json name`, { timeout: 15_000 }).ok;
17
+ assertAppName(name);
18
+ return execSafe('gh', ['repo', 'view', `${GITHUB_ORG}/${name}`, '--json', 'name'], { timeout: 15_000 }).ok;
17
19
  }
18
20
  export function createRepo(name) {
19
21
  requireGhAuth();
22
+ assertAppName(name);
20
23
  if (repoExists(name))
21
24
  return;
22
- const r = exec(`gh repo create ${GITHUB_ORG}/${name} --private`, { timeout: 30_000 });
25
+ const r = execSafe('gh', ['repo', 'create', `${GITHUB_ORG}/${name}`, '--private'], { timeout: 30_000 });
23
26
  if (!r.ok)
24
27
  throw new GitError(`failed to create repo: ${r.stderr}`);
25
28
  }
@@ -28,8 +31,15 @@ export function getRepoUrl(name) {
28
31
  }
29
32
  export function createPullRequest(repo, opts) {
30
33
  requireGhAuth();
31
- const bodyFlag = opts.body ? `--body "${opts.body.replace(/"/g, '\\"')}"` : '--body ""';
32
- const r = exec(`gh pr create --repo ${GITHUB_ORG}/${repo} --title "${opts.title.replace(/"/g, '\\"')}" ${bodyFlag} --head ${opts.head} --base ${opts.base} --json number,title,url,headRefName,baseRefName,state`, { timeout: 30_000 });
34
+ const r = execSafe('gh', [
35
+ 'pr', 'create',
36
+ '--repo', `${GITHUB_ORG}/${repo}`,
37
+ '--title', opts.title,
38
+ '--body', opts.body ?? '',
39
+ '--head', opts.head,
40
+ '--base', opts.base,
41
+ '--json', 'number,title,url,headRefName,baseRefName,state',
42
+ ], { timeout: 30_000 });
33
43
  if (!r.ok)
34
44
  throw new GitError(`failed to create PR: ${r.stderr}`);
35
45
  try {
@@ -51,7 +61,12 @@ export function createPullRequest(repo, opts) {
51
61
  }
52
62
  export function listPullRequests(repo, state = 'open') {
53
63
  requireGhAuth();
54
- const r = exec(`gh pr list --repo ${GITHUB_ORG}/${repo} --state ${state} --json number,title,url,headRefName,baseRefName,state`, { timeout: 15_000 });
64
+ const r = execSafe('gh', [
65
+ 'pr', 'list',
66
+ '--repo', `${GITHUB_ORG}/${repo}`,
67
+ '--state', state,
68
+ '--json', 'number,title,url,headRefName,baseRefName,state',
69
+ ], { timeout: 15_000 });
55
70
  if (!r.ok)
56
71
  return [];
57
72
  try {
@@ -80,7 +95,11 @@ export function protectBranch(repo, branch) {
80
95
  const tmpFile = join(tmpdir(), `fleet-protect-${repo}-${branch}.json`);
81
96
  writeFileSync(tmpFile, protection);
82
97
  try {
83
- const r = exec(`gh api -X PUT repos/${GITHUB_ORG}/${repo}/branches/${branch}/protection --input ${tmpFile}`, { timeout: 15_000 });
98
+ const r = execSafe('gh', [
99
+ 'api', '-X', 'PUT',
100
+ `repos/${GITHUB_ORG}/${repo}/branches/${branch}/protection`,
101
+ '--input', tmpFile,
102
+ ], { timeout: 15_000 });
84
103
  return r.ok;
85
104
  }
86
105
  finally {
@@ -12,6 +12,9 @@ export interface HealthResult {
12
12
  ok: boolean;
13
13
  status: number | null;
14
14
  error: string | null;
15
+ /** True iff the endpoint returned 404 — distinguishes "no healthcheck
16
+ * implemented for this app" from "endpoint exists but is failing". */
17
+ endpointMissing?: boolean;
15
18
  } | null;
16
19
  overall: 'healthy' | 'degraded' | 'down';
17
20
  }
@@ -1,4 +1,5 @@
1
- import { exec } from './exec.js';
1
+ import { execSafe } from './exec.js';
2
+ import { assertHealthPath } from './validate.js';
2
3
  import { getServiceStatus, getMultipleServiceStatuses, systemdAvailable } from './systemd.js';
3
4
  import { listContainers } from './docker.js';
4
5
  export function checkHealth(app, prefetched) {
@@ -36,10 +37,21 @@ export function checkHealth(app, prefetched) {
36
37
  }
37
38
  export function checkHttp(port, healthPath) {
38
39
  const path = healthPath ?? '/health';
39
- const result = exec(`curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://127.0.0.1:${port}${path}`, { timeout: 10_000 });
40
+ assertHealthPath(path);
41
+ const result = execSafe('curl', [
42
+ '-s', '-o', '/dev/null', '-w', '%{http_code}',
43
+ '--max-time', '5', `http://127.0.0.1:${port}${path}`,
44
+ ], { timeout: 10_000 });
40
45
  const status = parseInt(result.stdout, 10);
41
46
  if (!isNaN(status) && status > 0) {
42
- return { ok: status >= 200 && status < 500, status, error: null };
47
+ // Healthy = 2xx (success) or 3xx (redirect, e.g. /health /health/).
48
+ // 4xx and 5xx are NOT healthy. 404 specifically is flagged so the TUI
49
+ // can show "no healthcheck endpoint" rather than a generic failure —
50
+ // it means the path was reachable but the route doesn't exist (the app
51
+ // never implemented one). The fix is to add a /health route to the app.
52
+ const ok = status >= 200 && status < 400;
53
+ const endpointMissing = status === 404;
54
+ return { ok, status, error: null, endpointMissing };
43
55
  }
44
56
  return { ok: false, status: null, error: result.stderr || 'Connection failed' };
45
57
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Multi-source log tailer. Spawns `docker logs -f` per selected container,
3
+ * splits the streams on newlines, applies filters (level / grep / since),
4
+ * emits structured events to a callback. Caller is responsible for rendering.
5
+ *
6
+ * Design notes:
7
+ * - Each container gets its own subprocess so a stuck/dead container can't
8
+ * block the others.
9
+ * - Stdout + stderr are merged. Docker writes app output to stdout for
10
+ * json-file driver containers; some apps log errors to stderr.
11
+ * - Lines are emitted in arrival order per source. Cross-source ordering
12
+ * is best-effort (no global timestamp synchronisation — we don't reorder).
13
+ * - stop() kills the entire process group cleanly. Idempotent.
14
+ * - Used by both the CLI (`fleet logs --all -f`) and the TUI logs view.
15
+ */
16
+ import { spawn } from 'node:child_process';
17
+ import type { AppEntry } from './registry.js';
18
+ export interface LogSource {
19
+ /** Logical app name (used in the prefix). */
20
+ app: string;
21
+ /** Docker container name. */
22
+ container: string;
23
+ }
24
+ export interface LogLine {
25
+ /** Wall-clock receipt time (we don't trust the in-line timestamp — sources differ). */
26
+ ts: Date;
27
+ app: string;
28
+ container: string;
29
+ /** Inferred level from substring scan. 'unknown' if nothing matched. */
30
+ level: 'debug' | 'info' | 'warn' | 'error' | 'unknown';
31
+ text: string;
32
+ }
33
+ export interface MultiTailOpts {
34
+ /** Tail N lines from each source before going live. Default 50. */
35
+ tail?: number;
36
+ /** Restrict to lines newer than this (Docker's --since syntax: '15m', '1h'). */
37
+ since?: string;
38
+ /** Only emit lines at or above this level (everything if 'debug' or omitted). */
39
+ level?: 'debug' | 'info' | 'warn' | 'error';
40
+ /** Substring filter applied AFTER level — case sensitive. */
41
+ grep?: string;
42
+ /** When true, follow new entries forever (default). When false, just dump tail and exit. */
43
+ follow?: boolean;
44
+ }
45
+ export interface MultiTailHandle {
46
+ /** Kill all spawned subprocesses. Idempotent. Resolves when teardown is complete. */
47
+ stop: () => Promise<void>;
48
+ /** Number of currently-running tailers (drops as containers die). */
49
+ active: () => number;
50
+ }
51
+ /** Best-effort level inference from a line. Returns 'unknown' if nothing matches. */
52
+ export declare function inferLevel(text: string): LogLine['level'];
53
+ /**
54
+ * Glob-match a container name against a pattern. Supports * wildcards.
55
+ * `*-postgres` matches `glitchtip-postgres`, `shared-postgres`, etc.
56
+ */
57
+ export declare function matchesContainerGlob(name: string, glob: string): boolean;
58
+ /**
59
+ * Resolve a selection spec into a flat list of LogSource entries.
60
+ * - Empty selection → all containers across all apps
61
+ * - apps + containers can be combined; intersection wins
62
+ */
63
+ export declare function resolveSources(apps: AppEntry[], selection?: {
64
+ apps?: string[];
65
+ containers?: string[];
66
+ }): LogSource[];
67
+ /**
68
+ * Start tailing the given sources. Calls onLine for every emitted line that
69
+ * passes the filter chain. Returns a handle for graceful shutdown.
70
+ *
71
+ * For test injection, pass a custom `spawnFn` that mimics Node's spawn.
72
+ */
73
+ export declare function startMultiTail(sources: LogSource[], opts: MultiTailOpts, onLine: (line: LogLine) => void, onClose?: (source: LogSource, code: number | null) => void, spawnFn?: typeof spawn): MultiTailHandle;