paseo-plugin-machine-status 0.1.1
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/LICENSE +21 -0
- package/README.md +367 -0
- package/client/charts.tsx +335 -0
- package/client/cleanup.tsx +169 -0
- package/client/docker.tsx +494 -0
- package/client/menu.tsx +181 -0
- package/client/pill-card.tsx +195 -0
- package/client/pill.tsx +155 -0
- package/client/processes.tsx +321 -0
- package/client/services.tsx +251 -0
- package/client/status.tsx +514 -0
- package/client/ui.tsx +263 -0
- package/index.client.tsx +31 -0
- package/index.server.ts +41 -0
- package/package.json +45 -0
- package/paseo-plugin.json +4 -0
- package/server/cleanup.ts +222 -0
- package/server/docker.ts +260 -0
- package/server/exec.ts +62 -0
- package/server/explain-rules.ts +188 -0
- package/server/explain.ts +95 -0
- package/server/metrics.ts +312 -0
- package/server/processes.ts +165 -0
- package/server/services.ts +428 -0
- package/shared/contracts.ts +170 -0
- package/shared/docker.ts +95 -0
- package/shared/explain.ts +24 -0
- package/shared/format.ts +39 -0
- package/shared/pill.ts +63 -0
- package/shared/services-classify.ts +190 -0
- package/shared/services.ts +52 -0
- package/shared/viz.ts +159 -0
package/client/ui.tsx
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { PluginTheme } from "@getpaseo/plugin";
|
|
2
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
|
|
4
|
+
import { clampPercent } from "../shared/format";
|
|
5
|
+
import { paletteFor } from "../shared/viz";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Paseo exposes six colour tokens and a compact flag. Everything in this file is
|
|
9
|
+
* built from those alone: hardcoding a colour would break on half the themes,
|
|
10
|
+
* and there is no `surface1`, so panels are separated by hairline borders rather
|
|
11
|
+
* than by a second background.
|
|
12
|
+
*/
|
|
13
|
+
export interface Chrome {
|
|
14
|
+
theme: PluginTheme;
|
|
15
|
+
compact: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type Tone = "accent" | "danger";
|
|
19
|
+
|
|
20
|
+
export function useChromeStyles({ theme, compact }: Chrome) {
|
|
21
|
+
const palette = paletteFor(theme.colors.surface0);
|
|
22
|
+
return useMemo(
|
|
23
|
+
() =>
|
|
24
|
+
StyleSheet.create({
|
|
25
|
+
screen: { flex: 1, backgroundColor: theme.colors.surface0 },
|
|
26
|
+
content: { padding: compact ? 12 : 20, gap: compact ? 10 : 14, paddingBottom: 40 },
|
|
27
|
+
card: {
|
|
28
|
+
borderRadius: 12,
|
|
29
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
30
|
+
borderColor: palette.border,
|
|
31
|
+
padding: compact ? 12 : 16,
|
|
32
|
+
gap: compact ? 8 : 10,
|
|
33
|
+
},
|
|
34
|
+
cardTitle: {
|
|
35
|
+
color: theme.colors.foreground,
|
|
36
|
+
fontSize: compact ? 13 : 14,
|
|
37
|
+
fontWeight: "600",
|
|
38
|
+
letterSpacing: 0.4,
|
|
39
|
+
textTransform: "uppercase",
|
|
40
|
+
},
|
|
41
|
+
cardSubtitle: { color: theme.colors.foregroundMuted, fontSize: compact ? 11 : 12 },
|
|
42
|
+
heading: { color: theme.colors.foreground, fontSize: compact ? 20 : 26, fontWeight: "700" },
|
|
43
|
+
muted: { color: theme.colors.foregroundMuted, fontSize: compact ? 11 : 12 },
|
|
44
|
+
value: { color: theme.colors.foreground, fontSize: compact ? 13 : 14 },
|
|
45
|
+
valueStrong: {
|
|
46
|
+
color: theme.colors.foreground,
|
|
47
|
+
fontSize: compact ? 16 : 18,
|
|
48
|
+
fontWeight: "600",
|
|
49
|
+
},
|
|
50
|
+
danger: { color: theme.colors.statusDanger, fontSize: compact ? 11 : 12 },
|
|
51
|
+
axis: { color: theme.colors.foregroundMuted, fontSize: 10 },
|
|
52
|
+
figure: {
|
|
53
|
+
color: theme.colors.foreground,
|
|
54
|
+
fontSize: compact ? 26 : 30,
|
|
55
|
+
fontWeight: "700",
|
|
56
|
+
letterSpacing: -0.5,
|
|
57
|
+
},
|
|
58
|
+
row: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 12 },
|
|
59
|
+
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: compact ? 8 : 12 },
|
|
60
|
+
tiles: { flexDirection: "row", flexWrap: "wrap", gap: compact ? 10 : 14 },
|
|
61
|
+
chip: {
|
|
62
|
+
paddingVertical: 5,
|
|
63
|
+
paddingHorizontal: 12,
|
|
64
|
+
borderRadius: 7,
|
|
65
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
66
|
+
borderColor: palette.borderStrong,
|
|
67
|
+
},
|
|
68
|
+
track: { height: 6, borderRadius: 3, overflow: "hidden", backgroundColor: palette.neutral },
|
|
69
|
+
// A child View carries the opacity so the bar dims without fading its label.
|
|
70
|
+
trackDim: { ...StyleSheet.absoluteFillObject, backgroundColor: theme.colors.surface0, opacity: 0.75 },
|
|
71
|
+
fill: { height: 6, borderRadius: 3 },
|
|
72
|
+
button: {
|
|
73
|
+
paddingVertical: compact ? 10 : 12,
|
|
74
|
+
paddingHorizontal: 16,
|
|
75
|
+
borderRadius: 10,
|
|
76
|
+
alignItems: "center",
|
|
77
|
+
justifyContent: "center",
|
|
78
|
+
},
|
|
79
|
+
buttonText: { color: theme.colors.accentForeground, fontWeight: "600", fontSize: compact ? 13 : 14 },
|
|
80
|
+
ghostButton: {
|
|
81
|
+
paddingVertical: compact ? 10 : 12,
|
|
82
|
+
paddingHorizontal: 16,
|
|
83
|
+
borderRadius: 10,
|
|
84
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
85
|
+
borderColor: palette.borderStrong,
|
|
86
|
+
alignItems: "center",
|
|
87
|
+
justifyContent: "center",
|
|
88
|
+
},
|
|
89
|
+
ghostButtonText: { color: theme.colors.foreground, fontWeight: "600", fontSize: compact ? 13 : 14 },
|
|
90
|
+
selectableRow: {
|
|
91
|
+
borderRadius: 10,
|
|
92
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
93
|
+
borderColor: palette.border,
|
|
94
|
+
padding: compact ? 10 : 12,
|
|
95
|
+
gap: 4,
|
|
96
|
+
},
|
|
97
|
+
/** The design's two-column bands; they collapse to a stack when compact. */
|
|
98
|
+
band: {
|
|
99
|
+
flexDirection: compact ? "column" : "row",
|
|
100
|
+
gap: compact ? 10 : 14,
|
|
101
|
+
alignItems: "stretch",
|
|
102
|
+
},
|
|
103
|
+
divider: { height: StyleSheet.hairlineWidth, backgroundColor: palette.border },
|
|
104
|
+
/** One process per line: name, pid, cpu, bar, memory — no border, so the
|
|
105
|
+
* list reads as a table rather than a stack of cards. */
|
|
106
|
+
processRow: {
|
|
107
|
+
flexDirection: "row",
|
|
108
|
+
alignItems: "center",
|
|
109
|
+
gap: 10,
|
|
110
|
+
paddingVertical: 7,
|
|
111
|
+
paddingHorizontal: 10,
|
|
112
|
+
borderRadius: 8,
|
|
113
|
+
},
|
|
114
|
+
}),
|
|
115
|
+
[theme, compact, palette],
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function Card({
|
|
120
|
+
chrome,
|
|
121
|
+
title,
|
|
122
|
+
subtitle,
|
|
123
|
+
children,
|
|
124
|
+
}: {
|
|
125
|
+
chrome: Chrome;
|
|
126
|
+
title: string;
|
|
127
|
+
subtitle?: string;
|
|
128
|
+
children: React.ReactNode;
|
|
129
|
+
}) {
|
|
130
|
+
const styles = useChromeStyles(chrome);
|
|
131
|
+
return (
|
|
132
|
+
<View style={styles.card}>
|
|
133
|
+
<View style={styles.row}>
|
|
134
|
+
<Text style={styles.cardTitle}>{title}</Text>
|
|
135
|
+
{subtitle ? <Text style={styles.cardSubtitle}>{subtitle}</Text> : null}
|
|
136
|
+
</View>
|
|
137
|
+
{children}
|
|
138
|
+
</View>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function MetricRow({
|
|
143
|
+
chrome,
|
|
144
|
+
label,
|
|
145
|
+
value,
|
|
146
|
+
}: {
|
|
147
|
+
chrome: Chrome;
|
|
148
|
+
label: string;
|
|
149
|
+
value: string;
|
|
150
|
+
}) {
|
|
151
|
+
const styles = useChromeStyles(chrome);
|
|
152
|
+
return (
|
|
153
|
+
<View style={styles.row}>
|
|
154
|
+
<Text style={styles.muted}>{label}</Text>
|
|
155
|
+
<Text style={styles.value}>{value}</Text>
|
|
156
|
+
</View>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A labelled progress bar. Crosses to the danger tone once the resource is nearly spent. */
|
|
161
|
+
export function Gauge({
|
|
162
|
+
chrome,
|
|
163
|
+
label,
|
|
164
|
+
valueLabel,
|
|
165
|
+
percent,
|
|
166
|
+
dangerAt = 85,
|
|
167
|
+
}: {
|
|
168
|
+
chrome: Chrome;
|
|
169
|
+
label: string;
|
|
170
|
+
valueLabel: string;
|
|
171
|
+
percent: number | null;
|
|
172
|
+
dangerAt?: number;
|
|
173
|
+
}) {
|
|
174
|
+
const styles = useChromeStyles(chrome);
|
|
175
|
+
const width = clampPercent(percent);
|
|
176
|
+
const tone = width >= dangerAt ? chrome.theme.colors.statusDanger : chrome.theme.colors.accent;
|
|
177
|
+
return (
|
|
178
|
+
<View style={{ gap: 6 }}>
|
|
179
|
+
<View style={styles.row}>
|
|
180
|
+
<Text style={styles.muted}>{label}</Text>
|
|
181
|
+
<Text style={styles.valueStrong}>{valueLabel}</Text>
|
|
182
|
+
</View>
|
|
183
|
+
<View style={styles.track}>
|
|
184
|
+
<View style={styles.trackDim} />
|
|
185
|
+
<View style={[styles.fill, { width: `${width}%`, backgroundColor: tone }]} />
|
|
186
|
+
</View>
|
|
187
|
+
</View>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function ActionButton({
|
|
192
|
+
chrome,
|
|
193
|
+
label,
|
|
194
|
+
onPress,
|
|
195
|
+
tone = "accent",
|
|
196
|
+
variant = "solid",
|
|
197
|
+
disabled = false,
|
|
198
|
+
busy = false,
|
|
199
|
+
}: {
|
|
200
|
+
chrome: Chrome;
|
|
201
|
+
label: string;
|
|
202
|
+
onPress: () => void;
|
|
203
|
+
tone?: Tone;
|
|
204
|
+
variant?: "solid" | "ghost";
|
|
205
|
+
disabled?: boolean;
|
|
206
|
+
busy?: boolean;
|
|
207
|
+
}) {
|
|
208
|
+
const styles = useChromeStyles(chrome);
|
|
209
|
+
const background =
|
|
210
|
+
tone === "danger" ? chrome.theme.colors.statusDanger : chrome.theme.colors.accent;
|
|
211
|
+
const isGhost = variant === "ghost";
|
|
212
|
+
return (
|
|
213
|
+
<Pressable
|
|
214
|
+
accessibilityRole="button"
|
|
215
|
+
accessibilityLabel={label}
|
|
216
|
+
accessibilityState={{ disabled: disabled || busy }}
|
|
217
|
+
disabled={disabled || busy}
|
|
218
|
+
onPress={onPress}
|
|
219
|
+
style={({ pressed }) => [
|
|
220
|
+
isGhost ? styles.ghostButton : [styles.button, { backgroundColor: background }],
|
|
221
|
+
{ opacity: disabled ? 0.45 : pressed ? 0.75 : 1 },
|
|
222
|
+
]}
|
|
223
|
+
>
|
|
224
|
+
{busy ? (
|
|
225
|
+
<ActivityIndicator color={isGhost ? chrome.theme.colors.foreground : chrome.theme.colors.accentForeground} />
|
|
226
|
+
) : (
|
|
227
|
+
<Text style={isGhost ? styles.ghostButtonText : styles.buttonText}>{label}</Text>
|
|
228
|
+
)}
|
|
229
|
+
</Pressable>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Two-step confirmation for destructive actions. The armed state expires on its
|
|
235
|
+
* own so a button left armed in a background tab cannot be triggered later by a
|
|
236
|
+
* stray tap. The timer is cleared on unmount.
|
|
237
|
+
*/
|
|
238
|
+
export function useArmedConfirm(timeoutMs = 6_000) {
|
|
239
|
+
const [armed, setArmed] = useState(false);
|
|
240
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
241
|
+
|
|
242
|
+
const clear = useCallback(() => {
|
|
243
|
+
if (timer.current !== null) {
|
|
244
|
+
clearTimeout(timer.current);
|
|
245
|
+
timer.current = null;
|
|
246
|
+
}
|
|
247
|
+
}, []);
|
|
248
|
+
|
|
249
|
+
const disarm = useCallback(() => {
|
|
250
|
+
clear();
|
|
251
|
+
setArmed(false);
|
|
252
|
+
}, [clear]);
|
|
253
|
+
|
|
254
|
+
const arm = useCallback(() => {
|
|
255
|
+
clear();
|
|
256
|
+
setArmed(true);
|
|
257
|
+
timer.current = setTimeout(() => setArmed(false), timeoutMs);
|
|
258
|
+
}, [clear, timeoutMs]);
|
|
259
|
+
|
|
260
|
+
useEffect(() => clear, [clear]);
|
|
261
|
+
|
|
262
|
+
return { armed, arm, disarm };
|
|
263
|
+
}
|
package/index.client.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
|
+
import { contributePills } from "./client/pill";
|
|
3
|
+
import { MachineStatusSurface } from "./client/status";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The app half of the plugin: the Machine surface, the ways into it from the
|
|
7
|
+
* sidebar and the Command Center, and the load pill beside each agent's composer.
|
|
8
|
+
*/
|
|
9
|
+
export default function contribute(client: PluginClientContext) {
|
|
10
|
+
client.addSurface("main", MachineStatusSurface);
|
|
11
|
+
|
|
12
|
+
client.addSidebarItem({
|
|
13
|
+
id: "main",
|
|
14
|
+
title: "Machine",
|
|
15
|
+
icon: "Activity",
|
|
16
|
+
surface: "main",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
client.addCommandCenterItem({
|
|
20
|
+
id: "open-machine-status",
|
|
21
|
+
title: "Machine status",
|
|
22
|
+
icon: "Activity",
|
|
23
|
+
keywords: ["cpu", "memory", "disk", "swap", "battery", "thermal", "ports", "services"],
|
|
24
|
+
context: "global",
|
|
25
|
+
onSelect({ openSurface }) {
|
|
26
|
+
openSurface("main");
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return contributePills(client);
|
|
31
|
+
}
|
package/index.server.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import { applyCleanupHandler, planCleanupHandler } from "./server/cleanup";
|
|
3
|
+
import {
|
|
4
|
+
controlContainerHandler,
|
|
5
|
+
controlEngineHandler,
|
|
6
|
+
pruneDockerHandler,
|
|
7
|
+
readContainerLogsHandler,
|
|
8
|
+
readDockerHandler,
|
|
9
|
+
} from "./server/docker";
|
|
10
|
+
import { explainProcessHandler } from "./server/explain";
|
|
11
|
+
import { readMetricsHandler } from "./server/metrics";
|
|
12
|
+
import { killProcessHandler, listProcessesHandler } from "./server/processes";
|
|
13
|
+
import { readServicesHandler } from "./server/services";
|
|
14
|
+
import { applyCleanup, killProcess, listProcesses, planCleanup, readMetrics } from "./shared/contracts";
|
|
15
|
+
import { controlContainer, controlEngine, pruneDocker, readContainerLogs, readDocker } from "./shared/docker";
|
|
16
|
+
import { explainProcess } from "./shared/explain";
|
|
17
|
+
import { readServices } from "./shared/services";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The daemon half of the plugin. Every shell command, path and signal stays
|
|
21
|
+
* here, behind a Zod contract the app calls over RPC; nothing under `client/`
|
|
22
|
+
* can touch the machine directly.
|
|
23
|
+
*/
|
|
24
|
+
export default function contribute(server: PluginServerContext) {
|
|
25
|
+
server.handle(readMetrics, readMetricsHandler);
|
|
26
|
+
server.handle(planCleanup, planCleanupHandler);
|
|
27
|
+
server.handle(applyCleanup, applyCleanupHandler);
|
|
28
|
+
server.handle(listProcesses, listProcessesHandler);
|
|
29
|
+
server.handle(killProcess, killProcessHandler);
|
|
30
|
+
server.handle(readServices, readServicesHandler);
|
|
31
|
+
server.handle(explainProcess, explainProcessHandler);
|
|
32
|
+
server.handle(readDocker, readDockerHandler);
|
|
33
|
+
server.handle(controlContainer, controlContainerHandler);
|
|
34
|
+
server.handle(controlEngine, controlEngineHandler);
|
|
35
|
+
server.handle(readContainerLogs, readContainerLogsHandler);
|
|
36
|
+
server.handle(pruneDocker, pruneDockerHandler);
|
|
37
|
+
|
|
38
|
+
// No timers, watchers or sockets are created at registration time, so there is
|
|
39
|
+
// nothing to release. Paseo removes the handlers and stops the subprocess.
|
|
40
|
+
return () => {};
|
|
41
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "paseo-plugin-machine-status",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Machine dashboard, open ports and Docker control inside Paseo",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "dutchakdev",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/dutchakdev/paseo-plugin-machine-status.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"paseo",
|
|
13
|
+
"paseo-plugin",
|
|
14
|
+
"monitoring",
|
|
15
|
+
"macos",
|
|
16
|
+
"docker"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:watch": "vitest",
|
|
22
|
+
"preview": "tsx scripts/preview.ts"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@getpaseo/client": "0.8.0",
|
|
26
|
+
"@getpaseo/plugin": "0.8.0",
|
|
27
|
+
"@getpaseo/protocol": "0.8.0",
|
|
28
|
+
"@tanstack/react-query": "^5.90.11",
|
|
29
|
+
"@types/react": "~19.2.0",
|
|
30
|
+
"react": "19.1.0",
|
|
31
|
+
"react-native": "0.81.5",
|
|
32
|
+
"tsx": "^4.23.13",
|
|
33
|
+
"typescript": "^5.9.3",
|
|
34
|
+
"vitest": "^4.1.11",
|
|
35
|
+
"zod": "^4.4.3"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"paseo-plugin.json",
|
|
39
|
+
"index.client.tsx",
|
|
40
|
+
"index.server.ts",
|
|
41
|
+
"client/",
|
|
42
|
+
"server/",
|
|
43
|
+
"shared/"
|
|
44
|
+
]
|
|
45
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { z } from "zod";
|
|
5
|
+
import type { applyCleanup, CleanupTargetSchema, planCleanup } from "../shared/contracts";
|
|
6
|
+
import { describeError, runTolerant } from "./exec";
|
|
7
|
+
|
|
8
|
+
type CleanupTarget = z.output<typeof CleanupTargetSchema>;
|
|
9
|
+
type PlanOutput = z.output<typeof planCleanup.output>;
|
|
10
|
+
type ApplyInput = z.output<typeof applyCleanup.input>;
|
|
11
|
+
type ApplyOutput = z.output<typeof applyCleanup.output>;
|
|
12
|
+
|
|
13
|
+
interface TargetDefinition {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
hint: string;
|
|
17
|
+
/** Checked in order; the first one that exists wins. Tools move their caches between versions. */
|
|
18
|
+
candidates: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const home = (...segments: string[]): string => path.join(os.homedir(), ...segments);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Only these paths can ever be emptied. Every entry is a package-manager or
|
|
25
|
+
* build cache that its tool recreates on demand, so removing it costs a
|
|
26
|
+
* re-download rather than data. `~/Library/Caches` as a whole is deliberately
|
|
27
|
+
* absent: it also holds application state that does not regenerate.
|
|
28
|
+
*/
|
|
29
|
+
const TARGET_DEFINITIONS: readonly TargetDefinition[] = [
|
|
30
|
+
{
|
|
31
|
+
id: "npm",
|
|
32
|
+
label: "npm cache",
|
|
33
|
+
hint: "Re-downloaded on the next install",
|
|
34
|
+
candidates: [home(".npm", "_cacache")],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "pnpm",
|
|
38
|
+
label: "pnpm store",
|
|
39
|
+
hint: "Rebuilt on the next pnpm install",
|
|
40
|
+
candidates: [home("Library", "pnpm", "store"), home(".local", "share", "pnpm", "store"), home(".pnpm-store")],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "yarn",
|
|
44
|
+
label: "Yarn cache",
|
|
45
|
+
hint: "Re-downloaded on the next install",
|
|
46
|
+
candidates: [home("Library", "Caches", "Yarn")],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "bun",
|
|
50
|
+
label: "Bun install cache",
|
|
51
|
+
hint: "Re-downloaded on the next bun install",
|
|
52
|
+
candidates: [home(".bun", "install", "cache")],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "turbo",
|
|
56
|
+
label: "Turbo cache",
|
|
57
|
+
hint: "Next turbo run rebuilds instead of replaying",
|
|
58
|
+
candidates: [home(".cache", "turbo")],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: "xcode-derived-data",
|
|
62
|
+
label: "Xcode DerivedData",
|
|
63
|
+
hint: "Next Xcode build is a full rebuild",
|
|
64
|
+
candidates: [home("Library", "Developer", "Xcode", "DerivedData")],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "cargo",
|
|
68
|
+
label: "Cargo registry cache",
|
|
69
|
+
hint: "Re-downloaded on the next cargo build",
|
|
70
|
+
candidates: [home(".cargo", "registry", "cache")],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "go-build",
|
|
74
|
+
label: "Go build cache",
|
|
75
|
+
hint: "Next go build is a full rebuild",
|
|
76
|
+
candidates: [home("Library", "Caches", "go-build")],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "pip",
|
|
80
|
+
label: "pip cache",
|
|
81
|
+
hint: "Re-downloaded on the next pip install",
|
|
82
|
+
candidates: [home("Library", "Caches", "pip")],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "uv",
|
|
86
|
+
label: "uv cache",
|
|
87
|
+
hint: "Re-downloaded on the next uv sync",
|
|
88
|
+
candidates: [home(".cache", "uv")],
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
async function exists(target: string): Promise<boolean> {
|
|
93
|
+
try {
|
|
94
|
+
const stats = await fs.stat(target);
|
|
95
|
+
return stats.isDirectory();
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function resolveCandidate(definition: TargetDefinition): Promise<{ path: string; exists: boolean }> {
|
|
102
|
+
for (const candidate of definition.candidates) {
|
|
103
|
+
if (await exists(candidate)) return { path: candidate, exists: true };
|
|
104
|
+
}
|
|
105
|
+
return { path: definition.candidates[0], exists: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The last line of defence before anything is removed. Resolves symlinks first,
|
|
110
|
+
* so a link planted inside a cache directory cannot redirect the deletion, then
|
|
111
|
+
* requires the result to sit at least two levels below the real home directory.
|
|
112
|
+
*/
|
|
113
|
+
export async function assertSafePath(target: string): Promise<string> {
|
|
114
|
+
const realHome = await fs.realpath(os.homedir());
|
|
115
|
+
const resolved = await fs.realpath(target);
|
|
116
|
+
const relative = path.relative(realHome, resolved);
|
|
117
|
+
|
|
118
|
+
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
119
|
+
throw new Error(`refusing to touch ${resolved}: it is not inside the home directory`);
|
|
120
|
+
}
|
|
121
|
+
if (relative.split(path.sep).filter(Boolean).length < 2) {
|
|
122
|
+
throw new Error(`refusing to touch ${resolved}: too close to the home directory root`);
|
|
123
|
+
}
|
|
124
|
+
return resolved;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function directorySizeBytes(target: string): Promise<number> {
|
|
128
|
+
const raw = await runTolerant("/usr/bin/du", ["-s", "-k", target], 120_000);
|
|
129
|
+
const match = raw.match(/^\s*(\d+)/);
|
|
130
|
+
return match ? Number(match[1]) * 1024 : 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Removes the directory's contents but keeps the directory, which several tools expect to exist. */
|
|
134
|
+
export async function emptyDirectory(target: string): Promise<void> {
|
|
135
|
+
const entries = await fs.readdir(target);
|
|
136
|
+
const batchSize = 32;
|
|
137
|
+
for (let index = 0; index < entries.length; index += batchSize) {
|
|
138
|
+
await Promise.all(
|
|
139
|
+
entries
|
|
140
|
+
.slice(index, index + batchSize)
|
|
141
|
+
.map((entry) => fs.rm(path.join(target, entry), { recursive: true, force: true })),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function planCleanupHandler(): Promise<PlanOutput> {
|
|
147
|
+
const targets: CleanupTarget[] = await Promise.all(
|
|
148
|
+
TARGET_DEFINITIONS.map(async (definition): Promise<CleanupTarget> => {
|
|
149
|
+
const resolved = await resolveCandidate(definition);
|
|
150
|
+
const base = {
|
|
151
|
+
id: definition.id,
|
|
152
|
+
label: definition.label,
|
|
153
|
+
hint: definition.hint,
|
|
154
|
+
path: resolved.path,
|
|
155
|
+
exists: resolved.exists,
|
|
156
|
+
};
|
|
157
|
+
if (!resolved.exists) return { ...base, sizeBytes: 0, error: null };
|
|
158
|
+
try {
|
|
159
|
+
await assertSafePath(resolved.path);
|
|
160
|
+
return { ...base, sizeBytes: await directorySizeBytes(resolved.path), error: null };
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return { ...base, sizeBytes: 0, error: describeError(error) };
|
|
163
|
+
}
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
scannedAt: new Date().toISOString(),
|
|
169
|
+
targets,
|
|
170
|
+
totalBytes: targets.reduce((sum, target) => sum + target.sizeBytes, 0),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function applyCleanupHandler({ targetIds }: ApplyInput): Promise<ApplyOutput> {
|
|
175
|
+
const requested = new Set(targetIds);
|
|
176
|
+
const selected = TARGET_DEFINITIONS.filter((definition) => requested.has(definition.id));
|
|
177
|
+
|
|
178
|
+
const unknown = [...requested].filter(
|
|
179
|
+
(id) => !TARGET_DEFINITIONS.some((definition) => definition.id === id),
|
|
180
|
+
);
|
|
181
|
+
if (unknown.length > 0) {
|
|
182
|
+
throw new Error(`unknown cleanup target: ${unknown.join(", ")}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const results = await Promise.all(
|
|
186
|
+
selected.map(async (definition) => {
|
|
187
|
+
const resolved = await resolveCandidate(definition);
|
|
188
|
+
if (!resolved.exists) {
|
|
189
|
+
return { id: definition.id, label: definition.label, ok: true, freedBytes: 0, error: null };
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
// Re-validate at apply time: the plan may be minutes old.
|
|
193
|
+
const safePath = await assertSafePath(resolved.path);
|
|
194
|
+
const before = await directorySizeBytes(safePath);
|
|
195
|
+
await emptyDirectory(safePath);
|
|
196
|
+
const after = await directorySizeBytes(safePath);
|
|
197
|
+
return {
|
|
198
|
+
id: definition.id,
|
|
199
|
+
label: definition.label,
|
|
200
|
+
ok: true,
|
|
201
|
+
freedBytes: Math.max(0, before - after),
|
|
202
|
+
error: null,
|
|
203
|
+
};
|
|
204
|
+
} catch (error) {
|
|
205
|
+
console.error(`[machine-status] cleanup failed for ${definition.id}`, error);
|
|
206
|
+
return {
|
|
207
|
+
id: definition.id,
|
|
208
|
+
label: definition.label,
|
|
209
|
+
ok: false,
|
|
210
|
+
freedBytes: 0,
|
|
211
|
+
error: describeError(error),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
appliedAt: new Date().toISOString(),
|
|
219
|
+
freedBytes: results.reduce((sum, result) => sum + result.freedBytes, 0),
|
|
220
|
+
results,
|
|
221
|
+
};
|
|
222
|
+
}
|