@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.
- package/README.md +183 -251
- package/dist/adapters/detector/index.d.ts +8 -0
- package/dist/adapters/detector/index.js +54 -0
- package/dist/adapters/notifier/index.d.ts +2 -0
- package/dist/adapters/notifier/index.js +2 -0
- package/dist/adapters/notifier/stdout.d.ts +2 -0
- package/dist/adapters/notifier/stdout.js +8 -0
- package/dist/adapters/notifier/webhook.d.ts +9 -0
- package/dist/adapters/notifier/webhook.js +38 -0
- package/dist/adapters/runner/claude-cli.d.ts +7 -0
- package/dist/adapters/runner/claude-cli.js +231 -0
- package/dist/adapters/runner/mcp-call.d.ts +8 -0
- package/dist/adapters/runner/mcp-call.js +82 -0
- package/dist/adapters/runner/shell.d.ts +2 -0
- package/dist/adapters/runner/shell.js +103 -0
- package/dist/adapters/scheduler/systemd-timer.d.ts +17 -0
- package/dist/adapters/scheduler/systemd-timer.js +149 -0
- package/dist/adapters/signals/ci-status.d.ts +2 -0
- package/dist/adapters/signals/ci-status.js +79 -0
- package/dist/adapters/signals/container-up.d.ts +5 -0
- package/dist/adapters/signals/container-up.js +54 -0
- package/dist/adapters/signals/git-clean.d.ts +2 -0
- package/dist/adapters/signals/git-clean.js +55 -0
- package/dist/adapters/signals/index.d.ts +6 -0
- package/dist/adapters/signals/index.js +7 -0
- package/dist/adapters/types.d.ts +52 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli.js +43 -2
- package/dist/commands/add.js +0 -6
- package/dist/commands/boot-start.d.ts +1 -0
- package/dist/commands/boot-start.js +51 -0
- package/dist/commands/deploy.js +13 -0
- package/dist/commands/deps.js +5 -0
- package/dist/commands/egress.d.ts +1 -0
- package/dist/commands/egress.js +106 -0
- package/dist/commands/freeze.d.ts +4 -0
- package/dist/commands/freeze.js +64 -0
- package/dist/commands/logs.d.ts +1 -1
- package/dist/commands/logs.js +237 -8
- package/dist/commands/patch-systemd.d.ts +1 -0
- package/dist/commands/patch-systemd.js +126 -0
- package/dist/commands/rollback.d.ts +1 -0
- package/dist/commands/rollback.js +58 -0
- package/dist/commands/routine-run.d.ts +1 -0
- package/dist/commands/routine-run.js +122 -0
- package/dist/commands/routines.d.ts +1 -0
- package/dist/commands/routines.js +25 -0
- package/dist/commands/secrets.js +449 -16
- package/dist/commands/status.js +7 -3
- package/dist/commands/watchdog.d.ts +1 -1
- package/dist/commands/watchdog.js +16 -40
- package/dist/core/boot-refresh.d.ts +57 -0
- package/dist/core/boot-refresh.js +116 -0
- package/dist/core/deps/actors/pr-creator.js +11 -9
- package/dist/core/deps/collectors/docker-running.js +2 -2
- package/dist/core/deps/collectors/github-pr.js +5 -2
- package/dist/core/deps/collectors/npm.js +10 -5
- package/dist/core/deps/collectors/vulnerability.js +10 -6
- package/dist/core/deps/reporters/motd.js +1 -1
- package/dist/core/deps/reporters/telegram.js +2 -29
- package/dist/core/docker.js +45 -15
- package/dist/core/egress.d.ts +41 -0
- package/dist/core/egress.js +161 -0
- package/dist/core/exec.d.ts +7 -1
- package/dist/core/exec.js +25 -17
- package/dist/core/git.d.ts +1 -0
- package/dist/core/git.js +36 -23
- package/dist/core/github.js +27 -8
- package/dist/core/health.d.ts +3 -0
- package/dist/core/health.js +15 -3
- package/dist/core/logs-multi.d.ts +73 -0
- package/dist/core/logs-multi.js +163 -0
- package/dist/core/logs-policy.d.ts +55 -0
- package/dist/core/logs-policy.js +148 -0
- package/dist/core/nginx.js +8 -4
- package/dist/core/notify.d.ts +15 -0
- package/dist/core/notify.js +55 -0
- package/dist/core/registry.d.ts +25 -0
- package/dist/core/registry.js +57 -10
- package/dist/core/routines/cost-queries.d.ts +24 -0
- package/dist/core/routines/cost-queries.js +65 -0
- package/dist/core/routines/db.d.ts +9 -0
- package/dist/core/routines/db.js +126 -0
- package/dist/core/routines/defaults.d.ts +2 -0
- package/dist/core/routines/defaults.js +72 -0
- package/dist/core/routines/engine.d.ts +59 -0
- package/dist/core/routines/engine.js +175 -0
- package/dist/core/routines/incidents.d.ts +13 -0
- package/dist/core/routines/incidents.js +35 -0
- package/dist/core/routines/schema.d.ts +418 -0
- package/dist/core/routines/schema.js +113 -0
- package/dist/core/routines/signals-collector.d.ts +35 -0
- package/dist/core/routines/signals-collector.js +114 -0
- package/dist/core/routines/store.d.ts +316 -0
- package/dist/core/routines/store.js +99 -0
- package/dist/core/routines/test-utils.d.ts +2 -0
- package/dist/core/routines/test-utils.js +13 -0
- package/dist/core/secrets-audit.d.ts +21 -0
- package/dist/core/secrets-audit.js +60 -0
- package/dist/core/secrets-metadata.d.ts +39 -0
- package/dist/core/secrets-metadata.js +82 -0
- package/dist/core/secrets-motd.d.ts +20 -0
- package/dist/core/secrets-motd.js +72 -0
- package/dist/core/secrets-ops.d.ts +3 -1
- package/dist/core/secrets-ops.js +78 -13
- package/dist/core/secrets-providers.d.ts +50 -0
- package/dist/core/secrets-providers.js +291 -0
- package/dist/core/secrets-rotation.d.ts +52 -0
- package/dist/core/secrets-rotation.js +165 -0
- package/dist/core/secrets-snapshots.d.ts +26 -0
- package/dist/core/secrets-snapshots.js +95 -0
- package/dist/core/secrets-validate.js +2 -1
- package/dist/core/secrets.d.ts +12 -1
- package/dist/core/secrets.js +35 -24
- package/dist/core/self-update.d.ts +41 -0
- package/dist/core/self-update.js +73 -0
- package/dist/core/systemd.js +29 -12
- package/dist/core/telegram.d.ts +6 -0
- package/dist/core/telegram.js +32 -0
- package/dist/core/validate.d.ts +7 -0
- package/dist/core/validate.js +42 -0
- package/dist/index.js +0 -4
- package/dist/mcp/deps-tools.js +9 -1
- package/dist/mcp/git-tools.js +4 -4
- package/dist/mcp/server.js +193 -8
- package/dist/templates/systemd.js +3 -3
- package/dist/templates/unseal.js +5 -1
- package/dist/tui/components/Confirm.js +3 -4
- package/dist/tui/components/Header.js +37 -8
- package/dist/tui/components/KeyHint.js +14 -5
- package/dist/tui/exec-bridge.js +26 -12
- package/dist/tui/hooks/use-fleet-data.js +5 -2
- package/dist/tui/hooks/use-health.js +5 -2
- package/dist/tui/hooks/use-terminal-size.d.ts +1 -0
- package/dist/tui/hooks/use-terminal-size.js +1 -0
- package/dist/tui/router.js +133 -8
- package/dist/tui/routines/RoutinesApp.d.ts +8 -0
- package/dist/tui/routines/RoutinesApp.js +277 -0
- package/dist/tui/routines/components/AlertsPanel.d.ts +7 -0
- package/dist/tui/routines/components/AlertsPanel.js +22 -0
- package/dist/tui/routines/components/AlertsPanel.test.d.ts +1 -0
- package/dist/tui/routines/components/AlertsPanel.test.js +52 -0
- package/dist/tui/routines/components/CommandPalette.d.ts +12 -0
- package/dist/tui/routines/components/CommandPalette.js +21 -0
- package/dist/tui/routines/components/LiveRunPanel.d.ts +12 -0
- package/dist/tui/routines/components/LiveRunPanel.js +107 -0
- package/dist/tui/routines/components/RoutineForm.d.ts +8 -0
- package/dist/tui/routines/components/RoutineForm.js +254 -0
- package/dist/tui/routines/components/SignalsGrid.d.ts +13 -0
- package/dist/tui/routines/components/SignalsGrid.js +34 -0
- package/dist/tui/routines/components/SignalsGrid.test.d.ts +1 -0
- package/dist/tui/routines/components/SignalsGrid.test.js +43 -0
- package/dist/tui/routines/format.d.ts +7 -0
- package/dist/tui/routines/format.js +51 -0
- package/dist/tui/routines/hooks/use-git-fleet.d.ts +33 -0
- package/dist/tui/routines/hooks/use-git-fleet.js +82 -0
- package/dist/tui/routines/hooks/use-logs-stream.d.ts +13 -0
- package/dist/tui/routines/hooks/use-logs-stream.js +64 -0
- package/dist/tui/routines/hooks/use-ops-fleet.d.ts +20 -0
- package/dist/tui/routines/hooks/use-ops-fleet.js +70 -0
- package/dist/tui/routines/hooks/use-repo-detail.d.ts +31 -0
- package/dist/tui/routines/hooks/use-repo-detail.js +104 -0
- package/dist/tui/routines/hooks/use-security.d.ts +33 -0
- package/dist/tui/routines/hooks/use-security.js +110 -0
- package/dist/tui/routines/hooks/use-signals.d.ts +9 -0
- package/dist/tui/routines/hooks/use-signals.js +60 -0
- package/dist/tui/routines/runtime.d.ts +20 -0
- package/dist/tui/routines/runtime.js +40 -0
- package/dist/tui/routines/tabs/CostTab.d.ts +7 -0
- package/dist/tui/routines/tabs/CostTab.js +24 -0
- package/dist/tui/routines/tabs/DashboardTab.d.ts +15 -0
- package/dist/tui/routines/tabs/DashboardTab.js +10 -0
- package/dist/tui/routines/tabs/GitTab.d.ts +6 -0
- package/dist/tui/routines/tabs/GitTab.js +39 -0
- package/dist/tui/routines/tabs/LogsTab.d.ts +6 -0
- package/dist/tui/routines/tabs/LogsTab.js +58 -0
- package/dist/tui/routines/tabs/OpsTab.d.ts +6 -0
- package/dist/tui/routines/tabs/OpsTab.js +34 -0
- package/dist/tui/routines/tabs/RepoDetailView.d.ts +6 -0
- package/dist/tui/routines/tabs/RepoDetailView.js +12 -0
- package/dist/tui/routines/tabs/RoutinesTab.d.ts +10 -0
- package/dist/tui/routines/tabs/RoutinesTab.js +58 -0
- package/dist/tui/routines/tabs/ScaffoldTab.d.ts +2 -0
- package/dist/tui/routines/tabs/ScaffoldTab.js +127 -0
- package/dist/tui/routines/tabs/SecurityTab.d.ts +6 -0
- package/dist/tui/routines/tabs/SecurityTab.js +31 -0
- package/dist/tui/routines/tabs/SettingsTab.d.ts +6 -0
- package/dist/tui/routines/tabs/SettingsTab.js +61 -0
- package/dist/tui/routines/tabs/TimelineTab.d.ts +7 -0
- package/dist/tui/routines/tabs/TimelineTab.js +26 -0
- package/dist/tui/state.js +16 -1
- package/dist/tui/tests/flicker.test.d.ts +1 -0
- package/dist/tui/tests/flicker.test.js +105 -0
- package/dist/tui/tests/keyboard-integration.test.d.ts +1 -0
- package/dist/tui/tests/keyboard-integration.test.js +120 -0
- package/dist/tui/tests/test-app.d.ts +4 -0
- package/dist/tui/tests/test-app.js +79 -0
- package/dist/tui/types.d.ts +14 -1
- package/dist/tui/views/AppDetail.js +40 -26
- package/dist/tui/views/Dashboard.js +34 -9
- package/dist/tui/views/HealthView.js +42 -12
- package/dist/tui/views/LogsView.js +38 -10
- package/dist/tui/views/MultiLogsView.d.ts +2 -0
- package/dist/tui/views/MultiLogsView.js +165 -0
- package/dist/tui/views/SecretEdit.js +18 -7
- package/dist/tui/views/SecretsView.js +55 -39
- package/dist/ui/prompt.d.ts +52 -0
- package/dist/ui/prompt.js +169 -0
- package/package.json +33 -5
- package/dist/commands/motd.d.ts +0 -1
- package/dist/commands/motd.js +0 -10
- package/dist/templates/motd.d.ts +0 -1
- package/dist/templates/motd.js +0 -7
- package/dist/tui/components/AppList.d.ts +0 -12
- package/dist/tui/components/AppList.js +0 -32
- package/dist/tui/hooks/use-keyboard.d.ts +0 -1
- 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
|
+
}
|
package/dist/core/exec.d.ts
CHANGED
|
@@ -4,10 +4,16 @@ export interface ExecResult {
|
|
|
4
4
|
exitCode: number;
|
|
5
5
|
ok: boolean;
|
|
6
6
|
}
|
|
7
|
-
export declare function
|
|
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 {
|
|
2
|
-
export function
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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:
|
|
17
|
-
stderr:
|
|
18
|
-
exitCode:
|
|
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, {
|
package/dist/core/git.d.ts
CHANGED
|
@@ -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 {
|
|
3
|
+
import { execGit } from './exec.js';
|
|
4
4
|
import { GitError } from './errors.js';
|
|
5
5
|
import { detectProjectType, generateGitignore } from '../templates/gitignore.js';
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
13
|
+
return execGit(['rev-parse', '--is-inside-work-tree'], { cwd }).ok;
|
|
12
14
|
}
|
|
13
15
|
export function hasCommits(cwd) {
|
|
14
|
-
return
|
|
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 =
|
|
24
|
-
const branchResult =
|
|
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 =
|
|
31
|
+
const remoteName = execGit(['remote'], { cwd }).stdout.split('\n')[0] || '';
|
|
30
32
|
const remoteUrl = remoteName
|
|
31
|
-
?
|
|
33
|
+
? execGit(['remote', 'get-url', remoteName], { cwd }).stdout
|
|
32
34
|
: '';
|
|
33
|
-
const porcelain =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
121
|
-
const
|
|
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
|
-
|
|
127
|
-
const
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
}
|
package/dist/core/github.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
32
|
-
|
|
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 =
|
|
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 =
|
|
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 {
|
package/dist/core/health.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/core/health.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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;
|