sweeply 0.1.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/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/cli.js +463 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ángel Iván López Delgado (Fora)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# sweeply
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Foralitos/sweeply/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/sweeply)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
TUI para encontrar y borrar el peso muerto de tus proyectos — `node_modules`, `.next`, `dist`, `venv`, `target` y demás carpetas regenerables — con un **semáforo de seguridad git** que te dice de un vistazo qué tan tranquilo puedes estar:
|
|
8
|
+
|
|
9
|
+
- 🟢 **pusheado** — todo commiteado y en el remoto. Borrar aquí es 100% recuperable.
|
|
10
|
+
- 🟡 **cambios sin commitear / sin push / sin remoto** — revisa antes de tocar el proyecto.
|
|
11
|
+
- 🔴 **sin git** — si borras el proyecto completo, no hay vuelta atrás.
|
|
12
|
+
|
|
13
|
+
sweeply **solo borra carpetas regenerables** (las recuperas con `yarn install`, `pip install`, etc.), nunca tu código. Y siempre pide confirmación.
|
|
14
|
+
|
|
15
|
+
## Uso
|
|
16
|
+
|
|
17
|
+
Requiere **Node 22 o superior**.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx sweeply # escanea tu home
|
|
21
|
+
npx sweeply --dir ~/Sites # escanea una carpeta específica
|
|
22
|
+
npx sweeply -t .cache,tmp # detecta carpetas extra
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Teclas
|
|
26
|
+
|
|
27
|
+
| Tecla | Acción |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `↑↓` / `j k` | navegar |
|
|
30
|
+
| `espacio` | seleccionar/deseleccionar |
|
|
31
|
+
| `a` | seleccionar todos los 🟢 |
|
|
32
|
+
| `enter` | borrar seleccionados (pide confirmación) |
|
|
33
|
+
| `g` | detalle git del proyecto |
|
|
34
|
+
| `s` | ordenar por tamaño |
|
|
35
|
+
| `q` | salir |
|
|
36
|
+
|
|
37
|
+
## Qué detecta
|
|
38
|
+
|
|
39
|
+
`node_modules`, `.next`, `.nuxt`, `.turbo`, `dist`, `build`, `out` (si hay `package.json` al lado), `venv`/`.venv` (si hay proyecto Python al lado), `target` (Rust), `Pods` (iOS). La condición de "manifest al lado" evita falsos positivos: una carpeta `dist` suelta sin `package.json` no se toca.
|
|
40
|
+
|
|
41
|
+
## Desarrollo
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
yarn # instalar deps
|
|
45
|
+
yarn dev # correr desde src con tsx
|
|
46
|
+
yarn build # compilar a dist/ con tsup
|
|
47
|
+
yarn typecheck
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## ¿Por qué no npkill?
|
|
51
|
+
|
|
52
|
+
[npkill](https://npkill.js.org) está muy bien y fue la inspiración. sweeply agrega dos cosas: detección multi-ecosistema (Python, Rust, iOS, builds de Next) y el semáforo git — la respuesta a "¿será seguro borrar esto?" sin tener que abrir el proyecto.
|
|
53
|
+
|
|
54
|
+
## Contribuir
|
|
55
|
+
|
|
56
|
+
Los PRs son bienvenidos — lee [CONTRIBUTING.md](CONTRIBUTING.md) primero.
|
|
57
|
+
Para vulnerabilidades, usa el [canal privado](SECURITY.md), no un issue público.
|
|
58
|
+
|
|
59
|
+
## Licencia
|
|
60
|
+
|
|
61
|
+
MIT — ver [LICENSE](LICENSE).
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.tsx
|
|
4
|
+
import { render } from "ink";
|
|
5
|
+
import os3 from "os";
|
|
6
|
+
|
|
7
|
+
// src/ui/App.tsx
|
|
8
|
+
import { useEffect, useMemo, useState } from "react";
|
|
9
|
+
import { Box as Box3, Text as Text3, useApp, useInput, useStdout } from "ink";
|
|
10
|
+
import path3 from "path";
|
|
11
|
+
import os2 from "os";
|
|
12
|
+
|
|
13
|
+
// src/core/scanner.ts
|
|
14
|
+
import fs from "fs/promises";
|
|
15
|
+
import path from "path";
|
|
16
|
+
import os from "os";
|
|
17
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
18
|
+
"Library",
|
|
19
|
+
"Applications",
|
|
20
|
+
"Movies",
|
|
21
|
+
"Music",
|
|
22
|
+
"Pictures",
|
|
23
|
+
"Photos",
|
|
24
|
+
"Public",
|
|
25
|
+
".Trash"
|
|
26
|
+
]);
|
|
27
|
+
var MAX_DEPTH = 8;
|
|
28
|
+
async function* scan(rootDir, targets) {
|
|
29
|
+
const byName = new Map(targets.map((t) => [t.name, t]));
|
|
30
|
+
const isHome = path.resolve(rootDir) === os.homedir();
|
|
31
|
+
const queue = [
|
|
32
|
+
{ dir: path.resolve(rootDir), depth: 0 }
|
|
33
|
+
];
|
|
34
|
+
while (queue.length > 0) {
|
|
35
|
+
const { dir: dir2, depth } = queue.shift();
|
|
36
|
+
let entries;
|
|
37
|
+
try {
|
|
38
|
+
entries = await fs.readdir(dir2, { withFileTypes: true });
|
|
39
|
+
} catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const fileNames = new Set(
|
|
43
|
+
entries.filter((e) => e.isFile()).map((e) => e.name)
|
|
44
|
+
);
|
|
45
|
+
const found = [];
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
48
|
+
const def = byName.get(entry.name);
|
|
49
|
+
if (def) {
|
|
50
|
+
const ok = !def.requiresSibling || def.requiresSibling.some((f) => fileNames.has(f));
|
|
51
|
+
if (ok) {
|
|
52
|
+
found.push({
|
|
53
|
+
path: path.join(dir2, entry.name),
|
|
54
|
+
name: entry.name,
|
|
55
|
+
label: def.label
|
|
56
|
+
});
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (entry.name === ".git" || entry.name.startsWith(".")) continue;
|
|
61
|
+
if (depth === 0 && isHome && SKIP_DIRS.has(entry.name)) continue;
|
|
62
|
+
if (depth < MAX_DEPTH) {
|
|
63
|
+
queue.push({ dir: path.join(dir2, entry.name), depth: depth + 1 });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (found.length > 0) {
|
|
67
|
+
yield { path: dir2, name: path.basename(dir2), targets: found };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/core/size.ts
|
|
73
|
+
import fs2 from "fs/promises";
|
|
74
|
+
import path2 from "path";
|
|
75
|
+
async function dirSize(dir2) {
|
|
76
|
+
let total = 0;
|
|
77
|
+
const stack = [dir2];
|
|
78
|
+
while (stack.length > 0) {
|
|
79
|
+
const current = stack.pop();
|
|
80
|
+
let entries;
|
|
81
|
+
try {
|
|
82
|
+
entries = await fs2.readdir(current, { withFileTypes: true });
|
|
83
|
+
} catch {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
const full = path2.join(current, entry.name);
|
|
88
|
+
if (entry.isSymbolicLink()) continue;
|
|
89
|
+
if (entry.isDirectory()) {
|
|
90
|
+
stack.push(full);
|
|
91
|
+
} else if (entry.isFile()) {
|
|
92
|
+
try {
|
|
93
|
+
const st = await fs2.stat(full);
|
|
94
|
+
total += st.blocks > 0 ? st.blocks * 512 : st.size;
|
|
95
|
+
} catch {
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return total;
|
|
101
|
+
}
|
|
102
|
+
function formatSize(bytes) {
|
|
103
|
+
if (bytes === void 0) return "\u2026";
|
|
104
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
105
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
|
|
106
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/core/git.ts
|
|
110
|
+
import { execFile } from "child_process";
|
|
111
|
+
import { promisify } from "util";
|
|
112
|
+
import fs3 from "fs/promises";
|
|
113
|
+
var run = promisify(execFile);
|
|
114
|
+
async function git(cwd, args) {
|
|
115
|
+
const { stdout } = await run("git", args, { cwd, timeout: 1e4 });
|
|
116
|
+
return stdout.trim();
|
|
117
|
+
}
|
|
118
|
+
async function gitStatus(projectDir) {
|
|
119
|
+
let lastActivity = "";
|
|
120
|
+
try {
|
|
121
|
+
const st = await fs3.stat(projectDir);
|
|
122
|
+
lastActivity = st.mtime.toISOString().slice(0, 10);
|
|
123
|
+
} catch {
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
await git(projectDir, ["rev-parse", "--is-inside-work-tree"]);
|
|
127
|
+
} catch {
|
|
128
|
+
return {
|
|
129
|
+
semaphore: "red",
|
|
130
|
+
summary: "sin git",
|
|
131
|
+
detail: "Este directorio no es un repositorio git.\nSi borras el proyecto completo no hay forma de recuperarlo.",
|
|
132
|
+
lastActivity
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
lastActivity = await git(projectDir, ["log", "-1", "--format=%cs"]) || lastActivity;
|
|
137
|
+
} catch {
|
|
138
|
+
}
|
|
139
|
+
let branch = "";
|
|
140
|
+
try {
|
|
141
|
+
branch = await git(projectDir, ["branch", "--show-current"]);
|
|
142
|
+
} catch {
|
|
143
|
+
}
|
|
144
|
+
let dirtyLines = [];
|
|
145
|
+
try {
|
|
146
|
+
const porcelain = await git(projectDir, ["status", "--porcelain"]);
|
|
147
|
+
dirtyLines = porcelain ? porcelain.split("\n") : [];
|
|
148
|
+
} catch {
|
|
149
|
+
dirtyLines = ["(git status fall\xF3)"];
|
|
150
|
+
}
|
|
151
|
+
let ahead = null;
|
|
152
|
+
let hasUpstream = true;
|
|
153
|
+
try {
|
|
154
|
+
ahead = parseInt(await git(projectDir, ["rev-list", "@{u}..HEAD", "--count"]), 10);
|
|
155
|
+
} catch {
|
|
156
|
+
hasUpstream = false;
|
|
157
|
+
}
|
|
158
|
+
const detailParts = [`rama: ${branch || "(detached)"}`];
|
|
159
|
+
if (dirtyLines.length > 0) {
|
|
160
|
+
detailParts.push(
|
|
161
|
+
`${dirtyLines.length} archivo(s) sin commitear:`,
|
|
162
|
+
...dirtyLines.slice(0, 8).map((l) => ` ${l}`)
|
|
163
|
+
);
|
|
164
|
+
if (dirtyLines.length > 8) detailParts.push(` \u2026 y ${dirtyLines.length - 8} m\xE1s`);
|
|
165
|
+
}
|
|
166
|
+
if (!hasUpstream) detailParts.push("sin rama remota configurada (no hay respaldo en un servidor)");
|
|
167
|
+
else if (ahead && ahead > 0) detailParts.push(`${ahead} commit(s) sin pushear`);
|
|
168
|
+
if (dirtyLines.length > 0) {
|
|
169
|
+
return {
|
|
170
|
+
semaphore: "yellow",
|
|
171
|
+
summary: `${dirtyLines.length} sin commit`,
|
|
172
|
+
detail: detailParts.join("\n"),
|
|
173
|
+
lastActivity
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (!hasUpstream) {
|
|
177
|
+
return { semaphore: "yellow", summary: "sin remoto", detail: detailParts.join("\n"), lastActivity };
|
|
178
|
+
}
|
|
179
|
+
if (ahead && ahead > 0) {
|
|
180
|
+
return { semaphore: "yellow", summary: `${ahead} sin push`, detail: detailParts.join("\n"), lastActivity };
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
semaphore: "green",
|
|
184
|
+
summary: "pusheado",
|
|
185
|
+
detail: detailParts.concat("Todo commiteado y pusheado. Borrar aqu\xED es 100% recuperable.").join("\n"),
|
|
186
|
+
lastActivity
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/core/remove.ts
|
|
191
|
+
import fs4 from "fs/promises";
|
|
192
|
+
async function removeTargets(targets, onProgress) {
|
|
193
|
+
const removed = [];
|
|
194
|
+
const failed = [];
|
|
195
|
+
let done = 0;
|
|
196
|
+
for (const t of targets) {
|
|
197
|
+
onProgress(done, targets.length, t.path);
|
|
198
|
+
try {
|
|
199
|
+
await fs4.rm(t.path, { recursive: true, force: true });
|
|
200
|
+
removed.push(t.path);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
failed.push({ path: t.path, error: err instanceof Error ? err.message : String(err) });
|
|
203
|
+
}
|
|
204
|
+
done += 1;
|
|
205
|
+
onProgress(done, targets.length, t.path);
|
|
206
|
+
}
|
|
207
|
+
return { removed, failed };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// src/ui/ProjectRow.tsx
|
|
211
|
+
import { Box, Text } from "ink";
|
|
212
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
213
|
+
var SEM_COLOR = { green: "green", yellow: "yellow", red: "red" };
|
|
214
|
+
function ProjectRow({ row, isCursor }) {
|
|
215
|
+
const targets = row.project.targets.map((t) => t.name).join(" + ");
|
|
216
|
+
const sem = row.git ? SEM_COLOR[row.git.semaphore] : "gray";
|
|
217
|
+
return /* @__PURE__ */ jsxs(Box, { children: [
|
|
218
|
+
/* @__PURE__ */ jsx(Text, { color: isCursor ? "cyan" : void 0, children: isCursor ? "\u276F " : " " }),
|
|
219
|
+
/* @__PURE__ */ jsx(Text, { color: row.deleted ? "gray" : isCursor ? "cyan" : void 0, children: row.deleted ? "\u2714 " : row.selected ? "\u25A0 " : "\u25A1 " }),
|
|
220
|
+
/* @__PURE__ */ jsx(Box, { width: 24, children: /* @__PURE__ */ jsx(Text, { bold: isCursor, strikethrough: row.deleted, wrap: "truncate", children: row.project.name }) }),
|
|
221
|
+
/* @__PURE__ */ jsx(Box, { width: 9, justifyContent: "flex-end", marginRight: 2, children: /* @__PURE__ */ jsx(Text, { color: row.deleted ? "gray" : "magenta", children: row.deleted ? "liberado" : formatSize(row.size) }) }),
|
|
222
|
+
/* @__PURE__ */ jsx(Box, { width: 16, children: /* @__PURE__ */ jsxs(Text, { color: sem, children: [
|
|
223
|
+
"\u25CF ",
|
|
224
|
+
row.git ? row.git.summary : "\u2026"
|
|
225
|
+
] }) }),
|
|
226
|
+
/* @__PURE__ */ jsx(Box, { width: 12, children: /* @__PURE__ */ jsx(Text, { color: "gray", children: row.git?.lastActivity ?? "" }) }),
|
|
227
|
+
/* @__PURE__ */ jsx(Text, { color: "gray", wrap: "truncate", children: targets })
|
|
228
|
+
] });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// src/ui/Footer.tsx
|
|
232
|
+
import { Box as Box2, Text as Text2 } from "ink";
|
|
233
|
+
import { jsxs as jsxs2 } from "react/jsx-runtime";
|
|
234
|
+
function Footer({
|
|
235
|
+
selectedCount,
|
|
236
|
+
selectedSize,
|
|
237
|
+
freedSize,
|
|
238
|
+
sortBySize
|
|
239
|
+
}) {
|
|
240
|
+
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", marginTop: 1, children: [
|
|
241
|
+
freedSize > 0 && /* @__PURE__ */ jsxs2(Text2, { color: "green", children: [
|
|
242
|
+
"\u2714 liberado en esta sesi\xF3n: ",
|
|
243
|
+
formatSize(freedSize)
|
|
244
|
+
] }),
|
|
245
|
+
/* @__PURE__ */ jsxs2(Text2, { color: "gray", children: [
|
|
246
|
+
"\u2191\u2193 navegar \xB7 espacio seleccionar \xB7 a seleccionar \u{1F7E2} \xB7 enter borrar",
|
|
247
|
+
selectedCount > 0 && /* @__PURE__ */ jsxs2(Text2, { color: "white", children: [
|
|
248
|
+
" (",
|
|
249
|
+
selectedCount,
|
|
250
|
+
" = ",
|
|
251
|
+
formatSize(selectedSize),
|
|
252
|
+
")"
|
|
253
|
+
] }),
|
|
254
|
+
" \xB7 g detalle git \xB7 s orden",
|
|
255
|
+
sortBySize ? " (tama\xF1o)" : "",
|
|
256
|
+
" \xB7 q salir"
|
|
257
|
+
] })
|
|
258
|
+
] });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/ui/App.tsx
|
|
262
|
+
import { jsx as jsx2, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
263
|
+
function App({ rootDir, targets }) {
|
|
264
|
+
const { exit } = useApp();
|
|
265
|
+
const { stdout } = useStdout();
|
|
266
|
+
const [rows, setRows] = useState([]);
|
|
267
|
+
const [scanning, setScanning] = useState(true);
|
|
268
|
+
const [cursor, setCursor] = useState(0);
|
|
269
|
+
const [mode, setMode] = useState("list");
|
|
270
|
+
const [showDetail, setShowDetail] = useState(false);
|
|
271
|
+
const [sortBySize, setSortBySize] = useState(false);
|
|
272
|
+
const [freedSize, setFreedSize] = useState(0);
|
|
273
|
+
const [deleteProgress, setDeleteProgress] = useState("");
|
|
274
|
+
const patchRow = (projectPath, patch) => {
|
|
275
|
+
setRows(
|
|
276
|
+
(prev) => prev.map((r) => r.project.path === projectPath ? { ...r, ...patch } : r)
|
|
277
|
+
);
|
|
278
|
+
};
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
let cancelled = false;
|
|
281
|
+
(async () => {
|
|
282
|
+
for await (const project of scan(rootDir, targets)) {
|
|
283
|
+
if (cancelled) return;
|
|
284
|
+
setRows((prev) => [...prev, { project, selected: false, deleted: false }]);
|
|
285
|
+
void Promise.all(project.targets.map((t) => dirSize(t.path))).then((sizes) => {
|
|
286
|
+
if (!cancelled) patchRow(project.path, { size: sizes.reduce((a, b) => a + b, 0) });
|
|
287
|
+
});
|
|
288
|
+
void gitStatus(project.path).then((git2) => {
|
|
289
|
+
if (!cancelled) patchRow(project.path, { git: git2 });
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
if (!cancelled) setScanning(false);
|
|
293
|
+
})();
|
|
294
|
+
return () => {
|
|
295
|
+
cancelled = true;
|
|
296
|
+
};
|
|
297
|
+
}, [rootDir, targets]);
|
|
298
|
+
const visible = useMemo(() => {
|
|
299
|
+
const list = [...rows];
|
|
300
|
+
if (sortBySize) list.sort((a, b) => (b.size ?? -1) - (a.size ?? -1));
|
|
301
|
+
return list;
|
|
302
|
+
}, [rows, sortBySize]);
|
|
303
|
+
const current = visible[cursor];
|
|
304
|
+
const selected = rows.filter((r) => r.selected && !r.deleted);
|
|
305
|
+
const selectedSize = selected.reduce((a, r) => a + (r.size ?? 0), 0);
|
|
306
|
+
const doDelete = async () => {
|
|
307
|
+
setMode("deleting");
|
|
308
|
+
const toDelete = rows.filter((r) => r.selected && !r.deleted);
|
|
309
|
+
for (const row of toDelete) {
|
|
310
|
+
const { failed } = await removeTargets(row.project.targets, (done, total) => {
|
|
311
|
+
setDeleteProgress(`${row.project.name}: ${done}/${total}`);
|
|
312
|
+
});
|
|
313
|
+
if (failed.length === 0) {
|
|
314
|
+
setFreedSize((f) => f + (row.size ?? 0));
|
|
315
|
+
patchRow(row.project.path, { deleted: true, selected: false });
|
|
316
|
+
} else {
|
|
317
|
+
setDeleteProgress(`error en ${row.project.name}: ${failed[0].error}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
setDeleteProgress("");
|
|
321
|
+
setMode("list");
|
|
322
|
+
};
|
|
323
|
+
useInput((input, key) => {
|
|
324
|
+
if (mode === "deleting") return;
|
|
325
|
+
if (mode === "confirm") {
|
|
326
|
+
if (input === "y" || input === "s") void doDelete();
|
|
327
|
+
else if (input === "n" || key.escape) setMode("list");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (input === "q" || key.ctrl && input === "c") exit();
|
|
331
|
+
else if (key.downArrow || input === "j") setCursor((c) => Math.min(c + 1, visible.length - 1));
|
|
332
|
+
else if (key.upArrow || input === "k") setCursor((c) => Math.max(c - 1, 0));
|
|
333
|
+
else if (input === " " && current && !current.deleted) {
|
|
334
|
+
patchRow(current.project.path, { selected: !current.selected });
|
|
335
|
+
} else if (input === "a") {
|
|
336
|
+
setRows(
|
|
337
|
+
(prev) => prev.map(
|
|
338
|
+
(r) => r.git?.semaphore === "green" && !r.deleted ? { ...r, selected: true } : r
|
|
339
|
+
)
|
|
340
|
+
);
|
|
341
|
+
} else if (input === "g") setShowDetail((d) => !d);
|
|
342
|
+
else if (input === "s") setSortBySize((s) => !s);
|
|
343
|
+
else if (key.return && selected.length > 0) setMode("confirm");
|
|
344
|
+
});
|
|
345
|
+
const listHeight = Math.max(5, (stdout?.rows ?? 24) - 8);
|
|
346
|
+
const offset = Math.max(0, Math.min(cursor - Math.floor(listHeight / 2), visible.length - listHeight));
|
|
347
|
+
const windowRows = visible.slice(offset, offset + listHeight);
|
|
348
|
+
const totalFound = rows.reduce((a, r) => a + (r.size ?? 0), 0);
|
|
349
|
+
const displayDir = path3.resolve(rootDir).replace(os2.homedir(), "~");
|
|
350
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
|
|
351
|
+
/* @__PURE__ */ jsxs3(Box3, { marginBottom: 1, children: [
|
|
352
|
+
/* @__PURE__ */ jsx2(Text3, { bold: true, color: "cyan", children: "sweeply" }),
|
|
353
|
+
/* @__PURE__ */ jsxs3(Text3, { wrap: "truncate", children: [
|
|
354
|
+
/* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
|
|
355
|
+
" \xB7 ",
|
|
356
|
+
displayDir
|
|
357
|
+
] }),
|
|
358
|
+
/* @__PURE__ */ jsxs3(Text3, { children: [
|
|
359
|
+
" \xB7 ",
|
|
360
|
+
scanning ? "escaneando\u2026" : `${rows.length} proyectos`
|
|
361
|
+
] }),
|
|
362
|
+
/* @__PURE__ */ jsxs3(Text3, { color: "magenta", children: [
|
|
363
|
+
" \xB7 ",
|
|
364
|
+
formatSize(totalFound),
|
|
365
|
+
" recuperables"
|
|
366
|
+
] })
|
|
367
|
+
] })
|
|
368
|
+
] }),
|
|
369
|
+
rows.length === 0 && !scanning && /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
|
|
370
|
+
"No encontr\xE9 nada que limpiar en ",
|
|
371
|
+
rootDir,
|
|
372
|
+
"."
|
|
373
|
+
] }),
|
|
374
|
+
windowRows.map((row, i) => /* @__PURE__ */ jsx2(ProjectRow, { row, isCursor: offset + i === cursor }, row.project.path)),
|
|
375
|
+
showDetail && current?.git && /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, marginTop: 1, children: [
|
|
376
|
+
/* @__PURE__ */ jsx2(Text3, { bold: true, children: current.project.path }),
|
|
377
|
+
current.git.detail.split("\n").map((line, i) => /* @__PURE__ */ jsx2(Text3, { color: "gray", children: line }, i))
|
|
378
|
+
] }),
|
|
379
|
+
mode === "confirm" && /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", borderStyle: "double", borderColor: "red", paddingX: 1, marginTop: 1, children: [
|
|
380
|
+
/* @__PURE__ */ jsxs3(Text3, { bold: true, color: "red", children: [
|
|
381
|
+
"\xBFBorrar ",
|
|
382
|
+
selected.length,
|
|
383
|
+
" proyecto(s) \u2014 ",
|
|
384
|
+
formatSize(selectedSize),
|
|
385
|
+
"?"
|
|
386
|
+
] }),
|
|
387
|
+
selected.slice(0, 6).map((r) => /* @__PURE__ */ jsxs3(Text3, { children: [
|
|
388
|
+
" ",
|
|
389
|
+
r.project.name,
|
|
390
|
+
": ",
|
|
391
|
+
r.project.targets.map((t) => t.name).join(", ")
|
|
392
|
+
] }, r.project.path)),
|
|
393
|
+
selected.length > 6 && /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
|
|
394
|
+
" \u2026 y ",
|
|
395
|
+
selected.length - 6,
|
|
396
|
+
" m\xE1s"
|
|
397
|
+
] }),
|
|
398
|
+
/* @__PURE__ */ jsx2(Text3, { color: "gray", children: "Solo se borran las carpetas regenerables, nunca tu c\xF3digo." }),
|
|
399
|
+
/* @__PURE__ */ jsx2(Text3, { bold: true, children: "y = s\xED, borrar \xB7 n = cancelar" })
|
|
400
|
+
] }),
|
|
401
|
+
mode === "deleting" && /* @__PURE__ */ jsx2(Box3, { marginTop: 1, children: /* @__PURE__ */ jsxs3(Text3, { color: "yellow", children: [
|
|
402
|
+
"borrando\u2026 ",
|
|
403
|
+
deleteProgress
|
|
404
|
+
] }) }),
|
|
405
|
+
/* @__PURE__ */ jsx2(
|
|
406
|
+
Footer,
|
|
407
|
+
{
|
|
408
|
+
selectedCount: selected.length,
|
|
409
|
+
selectedSize,
|
|
410
|
+
freedSize,
|
|
411
|
+
sortBySize
|
|
412
|
+
}
|
|
413
|
+
)
|
|
414
|
+
] });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// src/core/targets.ts
|
|
418
|
+
var DEFAULT_TARGETS = [
|
|
419
|
+
{ name: "node_modules", requiresSibling: ["package.json"], label: "node" },
|
|
420
|
+
{ name: ".next", requiresSibling: ["package.json"], label: "next" },
|
|
421
|
+
{ name: ".nuxt", requiresSibling: ["package.json"], label: "nuxt" },
|
|
422
|
+
{ name: ".turbo", requiresSibling: ["package.json"], label: "turbo" },
|
|
423
|
+
{ name: "dist", requiresSibling: ["package.json"], label: "build" },
|
|
424
|
+
{ name: "build", requiresSibling: ["package.json"], label: "build" },
|
|
425
|
+
{ name: "out", requiresSibling: ["package.json"], label: "build" },
|
|
426
|
+
{ name: "venv", requiresSibling: ["pyproject.toml", "requirements.txt", "setup.py"], label: "python" },
|
|
427
|
+
{ name: ".venv", requiresSibling: ["pyproject.toml", "requirements.txt", "setup.py"], label: "python" },
|
|
428
|
+
{ name: "target", requiresSibling: ["Cargo.toml"], label: "rust" },
|
|
429
|
+
{ name: "Pods", requiresSibling: ["Podfile"], label: "ios" }
|
|
430
|
+
];
|
|
431
|
+
function buildTargets(extra) {
|
|
432
|
+
const custom = extra.filter((n) => n.trim().length > 0).map((n) => ({ name: n.trim(), label: "custom" }));
|
|
433
|
+
return [...DEFAULT_TARGETS, ...custom];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// src/cli.tsx
|
|
437
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
438
|
+
function parseArgs(argv) {
|
|
439
|
+
let dir2 = os3.homedir();
|
|
440
|
+
let extraTargets2 = [];
|
|
441
|
+
for (let i = 0; i < argv.length; i++) {
|
|
442
|
+
const arg = argv[i];
|
|
443
|
+
if (arg === "--dir" || arg === "-d") dir2 = argv[++i] ?? dir2;
|
|
444
|
+
else if (arg === "--targets" || arg === "-t") extraTargets2 = (argv[++i] ?? "").split(",");
|
|
445
|
+
else if (arg === "--help" || arg === "-h") {
|
|
446
|
+
console.log(`sweeply \u2014 limpia el peso muerto de tus proyectos
|
|
447
|
+
|
|
448
|
+
Uso: sweeply [opciones]
|
|
449
|
+
|
|
450
|
+
-d, --dir <ruta> d\xF3nde buscar (default: tu home)
|
|
451
|
+
-t, --targets <a,b> carpetas extra a detectar, separadas por coma
|
|
452
|
+
-h, --help esta ayuda
|
|
453
|
+
|
|
454
|
+
Dentro de la interfaz: \u2191\u2193 navegar \xB7 espacio seleccionar \xB7 a seleccionar
|
|
455
|
+
todo lo \u{1F7E2} \xB7 enter borrar (pide confirmaci\xF3n) \xB7 g detalle git \xB7 s ordenar
|
|
456
|
+
por tama\xF1o \xB7 q salir`);
|
|
457
|
+
process.exit(0);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return { dir: dir2, extraTargets: extraTargets2 };
|
|
461
|
+
}
|
|
462
|
+
var { dir, extraTargets } = parseArgs(process.argv.slice(2));
|
|
463
|
+
render(/* @__PURE__ */ jsx3(App, { rootDir: dir, targets: buildTargets(extraTargets) }));
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sweeply",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TUI para encontrar y borrar peso muerto de tus proyectos (node_modules, .next, venv...) con semáforo de seguridad git",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Fora (Ángel Iván López Delgado)",
|
|
7
|
+
"homepage": "https://github.com/Foralitos/sweeply#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Foralitos/sweeply.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Foralitos/sweeply/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"sweeply": "dist/cli.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"dev": "tsx src/cli.tsx",
|
|
32
|
+
"build": "tsup",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"prepublishOnly": "yarn typecheck && yarn build"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"cli",
|
|
38
|
+
"tui",
|
|
39
|
+
"node_modules",
|
|
40
|
+
"cleanup",
|
|
41
|
+
"disk-space",
|
|
42
|
+
"npkill",
|
|
43
|
+
"monorepo",
|
|
44
|
+
"ink"
|
|
45
|
+
],
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"ink": "^7.1.1",
|
|
48
|
+
"react": "^19.2.8"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^26.3.0",
|
|
52
|
+
"@types/react": "^19.2.18",
|
|
53
|
+
"tsup": "^8.5.1",
|
|
54
|
+
"tsx": "^4.23.12",
|
|
55
|
+
"typescript": "^7.0.2"
|
|
56
|
+
},
|
|
57
|
+
"resolutions": {
|
|
58
|
+
"esbuild": "^0.28.1"
|
|
59
|
+
}
|
|
60
|
+
}
|