envoic 0.0.1 → 0.0.10

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 ADDED
@@ -0,0 +1,13 @@
1
+ <img src="https://raw.githubusercontent.com/mahimailabs/envoic/main/assets/envoic.png" width="1000" alt="envoic logo" />
2
+
3
+ Discover and manage `node modules` and JavaScript artifacts.
4
+
5
+ ```bash
6
+ npx envoic scan ~/projects
7
+ ```
8
+
9
+ For full documentation, see the [main repository](https://github.com/mahimailabs/envoic).
10
+
11
+ Also available for Python: `uvx envoic scan .`
12
+
13
+ > Development note: local JS development (`npm ci`, `npm run lint`, `npm run build`) requires Node `>=20.19.0`.
@@ -0,0 +1,2 @@
1
+
2
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,841 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { createRequire } from "module";
5
+ import { Command } from "commander";
6
+
7
+ // src/cli.ts
8
+ import fs6 from "fs";
9
+ import path7 from "path";
10
+
11
+ // src/detector.ts
12
+ import fs from "fs";
13
+ import path from "path";
14
+ function detectPackageManager(nodeModulesPath) {
15
+ const parent = path.dirname(nodeModulesPath);
16
+ if (fs.existsSync(path.join(parent, "pnpm-lock.yaml"))) return "pnpm";
17
+ if (fs.existsSync(path.join(parent, "yarn.lock"))) return "yarn";
18
+ if (fs.existsSync(path.join(parent, "bun.lockb")) || fs.existsSync(path.join(parent, "bun.lock"))) return "bun";
19
+ if (fs.existsSync(path.join(parent, "package-lock.json"))) return "npm";
20
+ if (fs.existsSync(path.join(nodeModulesPath, ".modules.yaml"))) return "pnpm";
21
+ if (fs.existsSync(path.join(nodeModulesPath, ".yarn-integrity"))) return "yarn";
22
+ if (fs.existsSync(path.join(nodeModulesPath, ".package-lock.json"))) return "npm";
23
+ return "unknown";
24
+ }
25
+ function countTopLevelPackages(nodeModulesPath) {
26
+ let count = 0;
27
+ let entries;
28
+ try {
29
+ entries = fs.readdirSync(nodeModulesPath, { withFileTypes: true });
30
+ } catch {
31
+ return 0;
32
+ }
33
+ for (const entry of entries) {
34
+ if (!entry.isDirectory()) continue;
35
+ if (entry.name.startsWith(".")) continue;
36
+ if (entry.name.startsWith("@")) {
37
+ const scopedPath = path.join(nodeModulesPath, entry.name);
38
+ let scoped;
39
+ try {
40
+ scoped = fs.readdirSync(scopedPath, { withFileTypes: true });
41
+ } catch {
42
+ continue;
43
+ }
44
+ count += scoped.filter((item) => item.isDirectory()).length;
45
+ } else {
46
+ count += 1;
47
+ }
48
+ }
49
+ return count;
50
+ }
51
+ function pathSizeBytes(targetPath) {
52
+ let stat;
53
+ try {
54
+ stat = fs.lstatSync(targetPath);
55
+ } catch {
56
+ return 0;
57
+ }
58
+ if (stat.isSymbolicLink()) return stat.size;
59
+ if (stat.isFile()) return stat.size;
60
+ if (!stat.isDirectory()) return 0;
61
+ let total = 0;
62
+ const stack = [targetPath];
63
+ while (stack.length > 0) {
64
+ const current = stack.pop();
65
+ let entries;
66
+ try {
67
+ entries = fs.readdirSync(current, { withFileTypes: true });
68
+ } catch {
69
+ continue;
70
+ }
71
+ for (const entry of entries) {
72
+ const next = path.join(current, entry.name);
73
+ try {
74
+ const itemStat = fs.lstatSync(next);
75
+ if (itemStat.isSymbolicLink()) {
76
+ total += itemStat.size;
77
+ } else if (itemStat.isFile()) {
78
+ total += itemStat.size;
79
+ } else if (itemStat.isDirectory()) {
80
+ stack.push(next);
81
+ }
82
+ } catch {
83
+ continue;
84
+ }
85
+ }
86
+ }
87
+ return total;
88
+ }
89
+ function detectEnvironment(nodeModulesPath, staleDays, deep) {
90
+ const parent = path.dirname(nodeModulesPath);
91
+ const signals = [];
92
+ const hasNpmMarker = fs.existsSync(path.join(nodeModulesPath, ".package-lock.json"));
93
+ const hasYarnMarker = fs.existsSync(path.join(nodeModulesPath, ".yarn-integrity"));
94
+ const hasPnpmMarker = fs.existsSync(path.join(nodeModulesPath, ".modules.yaml"));
95
+ const hasPackageJson = fs.existsSync(path.join(parent, "package.json"));
96
+ if (hasNpmMarker) signals.push("npm-marker");
97
+ if (hasYarnMarker) signals.push("yarn-marker");
98
+ if (hasPnpmMarker) signals.push("pnpm-marker");
99
+ if (hasPackageJson) signals.push("package.json");
100
+ let stat;
101
+ try {
102
+ stat = fs.statSync(nodeModulesPath);
103
+ } catch {
104
+ return {
105
+ path: nodeModulesPath,
106
+ packageManager: detectPackageManager(nodeModulesPath),
107
+ packageCount: deep ? countTopLevelPackages(nodeModulesPath) : null,
108
+ sizeBytes: deep ? pathSizeBytes(nodeModulesPath) : null,
109
+ created: null,
110
+ modified: null,
111
+ isStale: false,
112
+ isOutdated: false,
113
+ signals: [...signals, "stat-unavailable"]
114
+ };
115
+ }
116
+ const created = stat.birthtime ?? null;
117
+ const modified = stat.mtime ?? null;
118
+ const staleThreshold = Date.now() - staleDays * 864e5;
119
+ const isStale = modified.getTime() < staleThreshold;
120
+ let isOutdated = false;
121
+ const packageJsonPath = path.join(parent, "package.json");
122
+ if (fs.existsSync(packageJsonPath)) {
123
+ const pkgStat = fs.statSync(packageJsonPath);
124
+ isOutdated = pkgStat.mtime.getTime() > modified.getTime();
125
+ }
126
+ return {
127
+ path: nodeModulesPath,
128
+ packageManager: detectPackageManager(nodeModulesPath),
129
+ packageCount: deep ? countTopLevelPackages(nodeModulesPath) : null,
130
+ sizeBytes: deep ? pathSizeBytes(nodeModulesPath) : null,
131
+ created,
132
+ modified,
133
+ isStale,
134
+ isOutdated,
135
+ signals
136
+ };
137
+ }
138
+ function installHint(packageManager) {
139
+ if (packageManager === "pnpm") return "pnpm install";
140
+ if (packageManager === "yarn") return "yarn install";
141
+ if (packageManager === "bun") return "bun install";
142
+ return "npm install";
143
+ }
144
+
145
+ // src/manager.ts
146
+ import fs2 from "fs";
147
+ import path3 from "path";
148
+ import { stdin as input, stdout as output } from "process";
149
+ import readline from "readline/promises";
150
+ import { checkbox } from "@inquirer/prompts";
151
+
152
+ // src/utils.ts
153
+ import os from "os";
154
+ import path2 from "path";
155
+ var VENV_NAMES = /* @__PURE__ */ new Set(["node_modules"]);
156
+ function formatSize(numBytes) {
157
+ if (numBytes === null) return "-";
158
+ if (numBytes < 1024) return `${numBytes}B`;
159
+ const units = ["K", "M", "G", "T"];
160
+ let value = numBytes;
161
+ for (const unit of units) {
162
+ value /= 1024;
163
+ if (value < 1024 || unit === units[units.length - 1]) {
164
+ if (value >= 100) return `${value.toFixed(0)}${unit}`;
165
+ return `${value.toFixed(1).replace(/\.0$/, "")}${unit}`;
166
+ }
167
+ }
168
+ return `${numBytes}B`;
169
+ }
170
+ function formatAge(moment, now = /* @__PURE__ */ new Date()) {
171
+ if (!moment) return "-";
172
+ const days = Math.max(0, Math.floor((now.getTime() - moment.getTime()) / 864e5));
173
+ if (days < 30) return `${days}d`;
174
+ if (days < 365) return `${Math.floor(days / 30)}mo`;
175
+ return `${Math.floor(days / 365)}y`;
176
+ }
177
+ function barChart(value, maxValue, width = 24) {
178
+ if (maxValue <= 0) return `[${"\u2591".repeat(width)}]`;
179
+ const filled = Math.max(0, Math.min(width, Math.round(value / maxValue * width)));
180
+ return `[${"\u2588".repeat(filled)}${"\u2591".repeat(width - filled)}]`;
181
+ }
182
+ function boxLine(text, width = 58) {
183
+ const clipped = text.slice(0, width);
184
+ return `\u2502${clipped.padEnd(width, " ")}\u2502`;
185
+ }
186
+ function shortenPath(inputPath, maxLen = 36) {
187
+ let text = inputPath;
188
+ const home = os.homedir();
189
+ if (text.startsWith(home)) text = `~${text.slice(home.length)}`;
190
+ if (text.length <= maxLen) return text;
191
+ const keep = maxLen - 3;
192
+ const prefix = Math.floor(keep / 2);
193
+ const suffix = keep - prefix;
194
+ return `${text.slice(0, prefix)}...${text.slice(-suffix)}`;
195
+ }
196
+ function normalizeDisplayPath(targetPath, scanRoot) {
197
+ try {
198
+ const rel = path2.relative(scanRoot, targetPath);
199
+ const normalized = rel === "" ? "." : rel;
200
+ const base = path2.basename(normalized);
201
+ if (VENV_NAMES.has(base)) {
202
+ const parent = path2.dirname(normalized);
203
+ return parent === "." ? "." : parent;
204
+ }
205
+ return normalized;
206
+ } catch {
207
+ return targetPath;
208
+ }
209
+ }
210
+ function toSerializable(value) {
211
+ return JSON.parse(
212
+ JSON.stringify(
213
+ value,
214
+ (_key, val) => val instanceof Date ? val.toISOString() : val
215
+ )
216
+ );
217
+ }
218
+
219
+ // src/manager.ts
220
+ function insideRoot(candidate, scanRoot) {
221
+ const abs = path3.resolve(candidate);
222
+ const root = path3.resolve(scanRoot);
223
+ return abs === root || abs.startsWith(`${root}${path3.sep}`);
224
+ }
225
+ function sizeForDelete(target) {
226
+ try {
227
+ const st = fs2.lstatSync(target);
228
+ if (st.isSymbolicLink() || st.isFile()) return st.size;
229
+ } catch {
230
+ return 0;
231
+ }
232
+ let total = 0;
233
+ const stack = [target];
234
+ while (stack.length > 0) {
235
+ const current = stack.pop();
236
+ let entries;
237
+ try {
238
+ entries = fs2.readdirSync(current, { withFileTypes: true });
239
+ } catch {
240
+ continue;
241
+ }
242
+ for (const entry of entries) {
243
+ const next = path3.join(current, entry.name);
244
+ try {
245
+ const st = fs2.lstatSync(next);
246
+ if (st.isSymbolicLink() || st.isFile()) total += st.size;
247
+ else if (st.isDirectory()) stack.push(next);
248
+ } catch {
249
+ continue;
250
+ }
251
+ }
252
+ }
253
+ return total;
254
+ }
255
+ function envLabel(env, scanRoot) {
256
+ const stale = env.isStale ? " STALE" : "";
257
+ const outdated = env.isOutdated ? " OUTDATED" : "";
258
+ return `${normalizeDisplayPath(env.path, scanRoot).padEnd(45, " ")} ${String(env.packageManager).padEnd(8, " ")} ${formatSize(env.sizeBytes).padStart(6, " ")} ${formatAge(env.modified).padStart(5, " ")}${stale}${outdated}`;
259
+ }
260
+ async function fallbackSelect(environments, scanRoot, staleOnly) {
261
+ console.log("Found environments. Enter numbers to delete (comma-separated):");
262
+ environments.forEach((env, i) => {
263
+ const marker = staleOnly && env.isStale ? "x" : " ";
264
+ console.log(`${String(i + 1).padStart(3, " ")} [${marker}] ${envLabel(env, scanRoot)}`);
265
+ });
266
+ const rl = readline.createInterface({ input, output });
267
+ const raw = await rl.question("Select [e.g. 1,3,5]: ");
268
+ rl.close();
269
+ const selected = /* @__PURE__ */ new Set();
270
+ for (const token of raw.split(",").map((s) => s.trim())) {
271
+ if (!/^\d+$/.test(token)) continue;
272
+ const idx = Number(token);
273
+ if (idx >= 1 && idx <= environments.length) selected.add(idx - 1);
274
+ }
275
+ return [...selected].sort((a, b) => a - b).map((idx) => environments[idx]);
276
+ }
277
+ async function interactiveSelect(environments, scanRoot, staleOnly) {
278
+ if (environments.length === 0) return [];
279
+ if (process.stdin.isTTY && process.stdout.isTTY) {
280
+ try {
281
+ const choices = environments.map((env, index) => ({
282
+ name: envLabel(env, scanRoot),
283
+ value: index,
284
+ checked: staleOnly && env.isStale
285
+ }));
286
+ const selected = await checkbox({
287
+ message: "Select environments to delete",
288
+ choices,
289
+ instructions: "Use \u2191\u2193 to move, Space to toggle, Enter to confirm"
290
+ });
291
+ return selected.map((idx) => environments[idx]);
292
+ } catch {
293
+ }
294
+ }
295
+ return fallbackSelect(environments, scanRoot, staleOnly);
296
+ }
297
+ async function confirmDeletion(selected, scanRoot, dryRun, skipConfirm) {
298
+ console.log("\n\u26A0 The following items will be PERMANENTLY DELETED:\n");
299
+ let total = 0;
300
+ selected.forEach((item, idx) => {
301
+ const size = item.sizeBytes ?? 0;
302
+ total += size;
303
+ const p = normalizeDisplayPath(item.path, scanRoot);
304
+ console.log(` ${String(idx + 1).padEnd(3, " ")} ${p.padEnd(42, " ")} ${formatSize(size).padStart(6, " ")}`);
305
+ });
306
+ console.log(`
307
+ Total: ${formatSize(total)} will be freed`);
308
+ if (dryRun) {
309
+ console.log("\nDRY RUN \u2014 no files will be deleted.");
310
+ return false;
311
+ }
312
+ if (skipConfirm) return true;
313
+ const rl = readline.createInterface({ input, output });
314
+ const typed = await rl.question('Type "delete" to confirm: ');
315
+ rl.close();
316
+ return typed.trim() === "delete";
317
+ }
318
+ function deleteSelected(selected, scanRoot, dryRun, dryRunEcho = true) {
319
+ const summary = {
320
+ selectedCount: selected.length,
321
+ deletedCount: 0,
322
+ failedCount: 0,
323
+ skippedCount: 0,
324
+ bytesFreed: 0,
325
+ wouldFreeBytes: 0,
326
+ errors: [],
327
+ dryRun
328
+ };
329
+ for (const item of selected) {
330
+ const target = item.path;
331
+ if (!insideRoot(target, scanRoot)) {
332
+ summary.skippedCount += 1;
333
+ summary.errors.push(`outside scan root: ${target}`);
334
+ continue;
335
+ }
336
+ const size = sizeForDelete(target);
337
+ summary.wouldFreeBytes += size;
338
+ if (dryRun) {
339
+ if (dryRunEcho) console.log(`[dry-run] Would delete ${normalizeDisplayPath(target, scanRoot)}`);
340
+ continue;
341
+ }
342
+ let targetStat;
343
+ try {
344
+ targetStat = fs2.lstatSync(target);
345
+ } catch {
346
+ summary.skippedCount += 1;
347
+ continue;
348
+ }
349
+ process.stdout.write(`Deleting ${normalizeDisplayPath(target, scanRoot)} ...`);
350
+ try {
351
+ if (targetStat.isSymbolicLink()) fs2.unlinkSync(target);
352
+ else fs2.rmSync(target, { recursive: true, force: false });
353
+ summary.deletedCount += 1;
354
+ summary.bytesFreed += size;
355
+ console.log(" done");
356
+ } catch (error) {
357
+ const code = error.code;
358
+ if (code === "EPERM" || code === "EACCES") {
359
+ console.log(" failed (permission denied)");
360
+ } else {
361
+ console.log(" failed");
362
+ }
363
+ summary.failedCount += 1;
364
+ summary.errors.push(String(error));
365
+ }
366
+ }
367
+ return summary;
368
+ }
369
+ function printDeletionReport(summary, initialTotal, items = []) {
370
+ const hasEnvs = items.some((i) => "packageManager" in i);
371
+ const label = hasEnvs ? "environment(s)" : items.length > 0 ? "artifact(s)" : "item(s)";
372
+ const remaining = Math.max(0, initialTotal - summary.deletedCount);
373
+ const freed = summary.dryRun ? summary.wouldFreeBytes : summary.bytesFreed;
374
+ console.log("\u2500".repeat(58));
375
+ if (summary.dryRun) console.log(" DRY RUN SUMMARY");
376
+ console.log(` Deleted: ${summary.deletedCount} ${label}`);
377
+ console.log(` Failed: ${summary.failedCount}`);
378
+ console.log(` Skipped: ${summary.skippedCount}`);
379
+ console.log(` Freed: ${formatSize(freed)}`);
380
+ console.log(` Remaining: ${remaining} ${label}`);
381
+ console.log("\u2500".repeat(58));
382
+ }
383
+
384
+ // src/report.ts
385
+ import fs4 from "fs";
386
+ import path5 from "path";
387
+
388
+ // src/artifacts.ts
389
+ import fs3 from "fs";
390
+ import path4 from "path";
391
+ var ARTIFACT_PATTERNS = [
392
+ { name: ".next", type: "dir", category: "build_cache", safety: "always_safe" },
393
+ { name: ".nuxt", type: "dir", category: "build_cache", safety: "always_safe" },
394
+ { name: ".turbo", type: "dir", category: "build_cache", safety: "always_safe" },
395
+ { name: ".parcel-cache", type: "dir", category: "build_cache", safety: "always_safe" },
396
+ { name: ".webpack", type: "dir", category: "build_cache", safety: "always_safe" },
397
+ { name: ".swc", type: "dir", category: "build_cache", safety: "always_safe" },
398
+ { name: ".output", type: "dir", category: "build_cache", safety: "always_safe" },
399
+ { name: "storybook-static", type: "dir", category: "build_cache", safety: "always_safe" },
400
+ { name: "dist", type: "dir", category: "build_output", safety: "usually_safe", requiresParentPackageJson: true },
401
+ { name: "build", type: "dir", category: "build_output", safety: "usually_safe", requiresParentPackageJson: true },
402
+ { name: ".eslintcache", type: "file", category: "tool_cache", safety: "always_safe" },
403
+ { name: ".stylelintcache", type: "file", category: "tool_cache", safety: "always_safe" },
404
+ { name: "tsconfig.tsbuildinfo", type: "file", category: "tool_cache", safety: "always_safe" },
405
+ { name: ".cache", type: "dir", category: "tool_cache", safety: "always_safe" },
406
+ { name: "coverage", type: "dir", category: "test_output", safety: "always_safe" },
407
+ { name: ".nyc_output", type: "dir", category: "test_output", safety: "always_safe" }
408
+ ];
409
+ var SAFETY_TEXT = {
410
+ always_safe: "safe to delete",
411
+ usually_safe: "usually safe",
412
+ careful: "careful"
413
+ };
414
+ function isJsProjectDir(parentPath) {
415
+ return fs3.existsSync(path4.join(parentPath, "package.json"));
416
+ }
417
+ function artifactPatternName(pattern) {
418
+ if (pattern.name) return pattern.name;
419
+ return `*${pattern.suffix}`;
420
+ }
421
+ function matchArtifact(entry, parentPath, fullPath) {
422
+ for (const pattern of ARTIFACT_PATTERNS) {
423
+ if (!pattern.name && !pattern.suffix) continue;
424
+ if (pattern.type === "dir" && !entry.isDirectory()) continue;
425
+ if (pattern.type === "file" && !entry.isFile()) continue;
426
+ if (pattern.name && entry.name !== pattern.name) continue;
427
+ if (pattern.suffix && !entry.name.endsWith(pattern.suffix)) continue;
428
+ if (pattern.requiresParentPackageJson && !isJsProjectDir(parentPath)) continue;
429
+ return {
430
+ path: fullPath,
431
+ category: pattern.category,
432
+ safety: pattern.safety,
433
+ sizeBytes: null,
434
+ patternMatched: artifactPatternName(pattern)
435
+ };
436
+ }
437
+ return null;
438
+ }
439
+ function summarizeArtifacts(artifacts) {
440
+ const grouped = /* @__PURE__ */ new Map();
441
+ for (const item of artifacts) {
442
+ const key = `${item.patternMatched}:${item.category}:${item.safety}`;
443
+ const existing = grouped.get(key);
444
+ if (!existing) {
445
+ grouped.set(key, {
446
+ pattern: item.patternMatched,
447
+ category: item.category,
448
+ safety: item.safety,
449
+ count: 1,
450
+ totalSizeBytes: item.sizeBytes ?? 0,
451
+ items: [item]
452
+ });
453
+ } else {
454
+ existing.count += 1;
455
+ existing.totalSizeBytes += item.sizeBytes ?? 0;
456
+ existing.items.push(item);
457
+ }
458
+ }
459
+ return [...grouped.values()].sort((a, b) => a.pattern.localeCompare(b.pattern));
460
+ }
461
+
462
+ // src/report.ts
463
+ var WIDTH = 58;
464
+ function boxTop(width = WIDTH) {
465
+ return `\u250C${"\u2500".repeat(width)}\u2510`;
466
+ }
467
+ function boxMid(width = WIDTH) {
468
+ return `\u251C${"\u2500".repeat(width)}\u2524`;
469
+ }
470
+ function boxBottom(width = WIDTH) {
471
+ return `\u2514${"\u2500".repeat(width)}\u2518`;
472
+ }
473
+ function row(label, value, width = WIDTH) {
474
+ const left = ` ${label.padEnd(12, " ")}`;
475
+ const rightWidth = Math.max(0, width - left.length - 2);
476
+ const clipped = value.slice(0, rightWidth);
477
+ return boxLine(`${left}${clipped.padStart(rightWidth, " ")} `, width);
478
+ }
479
+ function envHeader() {
480
+ return ` ${"#".padEnd(3, " ")} ${"Path".padEnd(30, " ")} ${"Pkg Mgr".padEnd(8, " ")} ${"Size".padStart(6, " ")} ${"Age".padStart(5, " ")}`;
481
+ }
482
+ function envRow(index, env, scanRoot) {
483
+ const label = normalizeDisplayPath(env.path, scanRoot);
484
+ const stale = env.isStale ? " STALE" : "";
485
+ const outdated = env.isOutdated ? " OUTDATED" : "";
486
+ return ` ${String(index).padEnd(3, " ")} ${shortenPath(label, 30).padEnd(30, " ")} ${env.packageManager.padEnd(8, " ")} ${formatSize(env.sizeBytes).padStart(6, " ")} ${formatAge(env.modified).padStart(5, " ")}${stale}${outdated}`;
487
+ }
488
+ function formatReport(result, deep = false) {
489
+ const staleCount = result.environments.filter((item) => item.isStale).length;
490
+ const outdatedCount = result.environments.filter((item) => item.isOutdated).length;
491
+ const artifactCount = result.artifactSummary.reduce((acc, item) => acc + item.count, 0);
492
+ const artifactSize = result.artifactSummary.reduce((acc, item) => acc + item.totalSizeBytes, 0);
493
+ const lines = [];
494
+ lines.push(boxTop());
495
+ lines.push(boxLine(" ENVOIC - JavaScript Environment Report"));
496
+ lines.push(boxLine(" TR-200 Environment Scanner"));
497
+ lines.push(boxMid());
498
+ lines.push(row("Date", result.timestamp.toISOString().replace("T", " ").slice(0, 19)));
499
+ lines.push(row("Host", result.hostname));
500
+ lines.push(row("Scan Path", shortenPath(result.scanPath, 36)));
501
+ lines.push(row("Scan Depth", String(result.scanDepth)));
502
+ lines.push(row("Duration", `${result.durationSeconds.toFixed(2)}s`));
503
+ lines.push(boxMid());
504
+ lines.push(row("NM Found", String(result.environments.length)));
505
+ lines.push(row("Total Size", formatSize(result.totalSizeBytes)));
506
+ lines.push(row("Stale", `>${result.staleDays}d: ${staleCount}`));
507
+ lines.push(row("Outdated", String(outdatedCount)));
508
+ lines.push(row("Artifacts", String(artifactCount)));
509
+ lines.push(row("Art Size", deep ? formatSize(artifactSize) : "-"));
510
+ lines.push(boxBottom());
511
+ lines.push("");
512
+ lines.push("NODE MODULES");
513
+ lines.push("\u2500".repeat(58));
514
+ lines.push(envHeader());
515
+ lines.push("\u2500".repeat(58));
516
+ if (result.environments.length === 0) {
517
+ lines.push(" (no node_modules found)");
518
+ } else {
519
+ result.environments.forEach((env, i) => lines.push(envRow(i + 1, env, result.scanPath)));
520
+ }
521
+ lines.push("\u2500".repeat(58));
522
+ lines.push("");
523
+ lines.push("ARTIFACTS");
524
+ lines.push("\u2500".repeat(58));
525
+ lines.push(` ${"Category".padEnd(22, " ")} ${"Count".padStart(6, " ")} ${"Size".padStart(8, " ")} ${"Safety".padStart(16, " ")}`);
526
+ lines.push("\u2500".repeat(58));
527
+ for (const item of result.artifactSummary) {
528
+ lines.push(` ${item.pattern.padEnd(22, " ")} ${String(item.count).padStart(6, " ")} ${formatSize(item.totalSizeBytes).padStart(8, " ")} ${SAFETY_TEXT[item.safety].padStart(16, " ")}`);
529
+ }
530
+ if (result.artifactSummary.length === 0) lines.push(" (no artifacts found)");
531
+ lines.push("\u2500".repeat(58));
532
+ if (deep) {
533
+ lines.push("");
534
+ lines.push("SIZE DISTRIBUTION");
535
+ const sized = result.environments.filter((item) => item.sizeBytes !== null);
536
+ const maxSize = sized.reduce((acc, item) => Math.max(acc, item.sizeBytes ?? 0), 0);
537
+ for (const env of sized) {
538
+ const size = env.sizeBytes ?? 0;
539
+ const label = normalizeDisplayPath(env.path, result.scanPath);
540
+ lines.push(` ${barChart(size, maxSize, 24)} ${shortenPath(label, 24).padEnd(24, " ")} ${formatSize(size).padStart(6, " ")}`);
541
+ }
542
+ }
543
+ return lines.join("\n");
544
+ }
545
+ function formatList(environments, scanRoot) {
546
+ const lines = [envHeader(), "\u2500".repeat(58)];
547
+ environments.forEach((env, i) => lines.push(envRow(i + 1, env, scanRoot)));
548
+ if (environments.length === 0) lines.push(" (no node_modules found)");
549
+ return lines.join("\n");
550
+ }
551
+ function formatInfo(env, topPackages, reinstallHint) {
552
+ const lines = [];
553
+ lines.push(boxTop());
554
+ lines.push(boxLine(" ENVOIC - Node Modules Detail"));
555
+ lines.push(boxMid());
556
+ lines.push(row("Path", env.path));
557
+ lines.push(row("Manager", env.packageManager));
558
+ lines.push(row("Packages", String(env.packageCount ?? "-")));
559
+ lines.push(row("Size", formatSize(env.sizeBytes)));
560
+ lines.push(row("Modified", env.modified ? env.modified.toISOString().replace("T", " ").slice(0, 19) : "-"));
561
+ lines.push(row("Stale", env.isStale ? "yes" : "no"));
562
+ lines.push(row("Outdated", env.isOutdated ? "yes" : "no"));
563
+ lines.push(row("Reinstall", reinstallHint));
564
+ lines.push(boxBottom());
565
+ lines.push("");
566
+ lines.push("TOP PACKAGES");
567
+ lines.push("\u2500".repeat(58));
568
+ if (topPackages.length === 0) {
569
+ lines.push(" (no packages found)");
570
+ } else {
571
+ topPackages.forEach((item, idx) => {
572
+ lines.push(` ${String(idx + 1).padStart(2, " ")}. ${item.name} (${formatSize(item.size)})`);
573
+ });
574
+ }
575
+ lines.push("\u2500".repeat(58));
576
+ return lines.join("\n");
577
+ }
578
+ function topLargestPackages(nodeModulesPath, limit = 10) {
579
+ const resolvedPath = path5.resolve(nodeModulesPath);
580
+ const result = [];
581
+ let direct;
582
+ try {
583
+ direct = fs4.readdirSync(resolvedPath, { withFileTypes: true });
584
+ } catch {
585
+ return [];
586
+ }
587
+ for (const entry of direct) {
588
+ if (!entry.isDirectory()) continue;
589
+ if (entry.name.startsWith(".")) continue;
590
+ if (entry.name.startsWith("@")) {
591
+ const scopePath = path5.join(resolvedPath, entry.name);
592
+ let scopedEntries;
593
+ try {
594
+ scopedEntries = fs4.readdirSync(scopePath, { withFileTypes: true });
595
+ } catch {
596
+ continue;
597
+ }
598
+ for (const scoped of scopedEntries) {
599
+ if (!scoped.isDirectory()) continue;
600
+ if (scoped.name.startsWith(".")) continue;
601
+ const full2 = path5.join(scopePath, scoped.name);
602
+ const size2 = measureDirSize(full2);
603
+ result.push({ name: `${entry.name}/${scoped.name}`, size: size2 });
604
+ }
605
+ continue;
606
+ }
607
+ const full = path5.join(resolvedPath, entry.name);
608
+ const size = measureDirSize(full);
609
+ result.push({ name: entry.name, size });
610
+ }
611
+ return result.sort((a, b) => b.size - a.size).slice(0, limit);
612
+ }
613
+ function measureDirSize(dirPath) {
614
+ let size = 0;
615
+ const stack = [dirPath];
616
+ while (stack.length > 0) {
617
+ const current = stack.pop();
618
+ let children;
619
+ try {
620
+ children = fs4.readdirSync(current, { withFileTypes: true });
621
+ } catch {
622
+ continue;
623
+ }
624
+ for (const child of children) {
625
+ const childPath = path5.join(current, child.name);
626
+ try {
627
+ const st = fs4.lstatSync(childPath);
628
+ if (st.isSymbolicLink() || st.isFile()) size += st.size;
629
+ else if (st.isDirectory()) stack.push(childPath);
630
+ } catch {
631
+ continue;
632
+ }
633
+ }
634
+ }
635
+ return size;
636
+ }
637
+
638
+ // src/scanner.ts
639
+ import fs5 from "fs";
640
+ import os2 from "os";
641
+ import path6 from "path";
642
+ var SKIP_NAMES = /* @__PURE__ */ new Set([".git", ".hg", ".svn"]);
643
+ var ALLOWED_HIDDEN = /* @__PURE__ */ new Set([".next", ".nuxt", ".turbo", ".swc", ".output", ".cache", ".parcel-cache", ".webpack"]);
644
+ function dirSizeBytes(targetPath) {
645
+ let total = 0;
646
+ const stack = [targetPath];
647
+ while (stack.length > 0) {
648
+ const current = stack.pop();
649
+ let entries;
650
+ try {
651
+ entries = fs5.readdirSync(current, { withFileTypes: true });
652
+ } catch {
653
+ continue;
654
+ }
655
+ for (const entry of entries) {
656
+ const next = path6.join(current, entry.name);
657
+ try {
658
+ const st = fs5.lstatSync(next);
659
+ if (st.isSymbolicLink() || st.isFile()) total += st.size;
660
+ else if (st.isDirectory()) stack.push(next);
661
+ } catch {
662
+ continue;
663
+ }
664
+ }
665
+ }
666
+ return total;
667
+ }
668
+ function scan(options) {
669
+ const scanPath = path6.resolve(options.root);
670
+ const start = performance.now();
671
+ const environments = [];
672
+ const artifacts = [];
673
+ const seenEnv = /* @__PURE__ */ new Set();
674
+ const seenArtifact = /* @__PURE__ */ new Set();
675
+ function walk(current, depth) {
676
+ if (depth > options.depth) return;
677
+ let entries;
678
+ try {
679
+ entries = fs5.readdirSync(current, { withFileTypes: true });
680
+ } catch {
681
+ return;
682
+ }
683
+ for (const entry of entries) {
684
+ const next = path6.join(current, entry.name);
685
+ if (options.includeArtifacts) {
686
+ const artifact = matchArtifact(entry, current, path6.resolve(next));
687
+ if (artifact) {
688
+ if (!seenArtifact.has(artifact.path)) {
689
+ if (options.deep) {
690
+ artifact.sizeBytes = entry.isFile() ? fs5.statSync(path6.resolve(next)).size : dirSizeBytes(artifact.path);
691
+ }
692
+ artifacts.push(artifact);
693
+ seenArtifact.add(artifact.path);
694
+ }
695
+ if (entry.isDirectory()) continue;
696
+ }
697
+ }
698
+ if (!entry.isDirectory()) continue;
699
+ if (entry.name === "node_modules") {
700
+ const abs = path6.resolve(next);
701
+ if (!seenEnv.has(abs)) {
702
+ environments.push(detectEnvironment(abs, options.staleDays, options.deep));
703
+ seenEnv.add(abs);
704
+ }
705
+ continue;
706
+ }
707
+ if (SKIP_NAMES.has(entry.name)) continue;
708
+ if (entry.name.startsWith(".") && !ALLOWED_HIDDEN.has(entry.name)) continue;
709
+ walk(next, depth + 1);
710
+ }
711
+ }
712
+ walk(scanPath, 1);
713
+ const durationSeconds = (performance.now() - start) / 1e3;
714
+ const totalSizeBytes = environments.reduce((acc, item) => acc + (item.sizeBytes ?? 0), 0);
715
+ const artifactSummary = summarizeArtifacts(artifacts);
716
+ return {
717
+ scanPath,
718
+ scanDepth: options.depth,
719
+ staleDays: options.staleDays,
720
+ durationSeconds,
721
+ environments: environments.sort((a, b) => a.path.localeCompare(b.path)),
722
+ totalSizeBytes,
723
+ hostname: os2.hostname(),
724
+ timestamp: /* @__PURE__ */ new Date(),
725
+ artifacts: artifacts.sort((a, b) => a.path.localeCompare(b.path)),
726
+ artifactSummary
727
+ };
728
+ }
729
+
730
+ // src/cli.ts
731
+ function parseIntOption(value) {
732
+ const n = Number.parseInt(value, 10);
733
+ if (!Number.isFinite(n) || n < 1) throw new Error("must be a positive integer");
734
+ return n;
735
+ }
736
+ async function confirmAndDelete(selected, scanRoot, initialTotal, dryRun, yes) {
737
+ const confirmed = await confirmDeletion(selected, scanRoot, dryRun, yes);
738
+ if (dryRun) {
739
+ const summary2 = deleteSelected(selected, scanRoot, true, false);
740
+ printDeletionReport(summary2, initialTotal, selected);
741
+ return;
742
+ }
743
+ if (!confirmed) {
744
+ console.log("Deletion cancelled.");
745
+ return;
746
+ }
747
+ const summary = deleteSelected(selected, scanRoot, false);
748
+ printDeletionReport(summary, initialTotal, selected);
749
+ }
750
+ function registerCommands(program2) {
751
+ program2.command("scan").argument("[path]", "path to scan", ".").option("-d, --depth <n>", "max directory depth", parseIntOption, 5).option("--deep", "compute size and package metadata", false).option("--json", "output JSON report", false).option("--stale-days <n>", "mark stale after N days", parseIntOption, 90).option("--no-artifacts", "disable JS artifact detection").action((targetPath, options) => {
752
+ const result = scan({
753
+ root: targetPath,
754
+ depth: options.depth,
755
+ deep: options.deep,
756
+ staleDays: options.staleDays,
757
+ includeArtifacts: options.artifacts
758
+ });
759
+ if (options.json) {
760
+ console.log(JSON.stringify(toSerializable(result), null, 2));
761
+ return;
762
+ }
763
+ console.log(formatReport(result, options.deep));
764
+ });
765
+ program2.command("list").argument("[path]", "path to scan", ".").option("-d, --depth <n>", "max directory depth", parseIntOption, 5).option("--deep", "compute size metadata", false).option("--stale-days <n>", "mark stale after N days", parseIntOption, 90).action((targetPath, options) => {
766
+ const result = scan({
767
+ root: targetPath,
768
+ depth: options.depth,
769
+ deep: options.deep,
770
+ staleDays: options.staleDays,
771
+ includeArtifacts: false
772
+ });
773
+ console.log(formatList(result.environments, result.scanPath));
774
+ });
775
+ program2.command("info").argument("<nodeModulesPath>", "path to node_modules directory").action((targetPath) => {
776
+ const abs = path7.resolve(targetPath);
777
+ if (path7.basename(abs) !== "node_modules" || !fs6.existsSync(abs)) {
778
+ console.error(`Not a recognized node_modules path: ${targetPath}`);
779
+ process.exitCode = 1;
780
+ return;
781
+ }
782
+ const scanRoot = path7.dirname(abs);
783
+ const result = scan({
784
+ root: scanRoot,
785
+ depth: 2,
786
+ deep: true,
787
+ staleDays: 90,
788
+ includeArtifacts: false
789
+ });
790
+ const env = result.environments.find((item) => path7.resolve(item.path) === abs);
791
+ if (!env) {
792
+ console.error(`Could not inspect environment: ${targetPath}`);
793
+ process.exitCode = 1;
794
+ return;
795
+ }
796
+ const topPackages = topLargestPackages(abs, 10);
797
+ console.log(formatInfo(env, topPackages, installHint(env.packageManager)));
798
+ });
799
+ program2.command("manage").argument("[path]", "path to scan", ".").option("-d, --depth <n>", "max directory depth", parseIntOption, 5).option("--stale-only", "pre-select stale environments", false).option("--stale-days <n>", "stale threshold in days", parseIntOption, 90).option("--dry-run", "preview without deleting", false).option("-y, --yes", "skip final confirmation", false).option("--deep", "compute size metadata", false).action(async (targetPath, options) => {
800
+ const result = scan({
801
+ root: targetPath,
802
+ depth: options.depth,
803
+ deep: options.deep,
804
+ staleDays: options.staleDays,
805
+ includeArtifacts: false
806
+ });
807
+ if (result.environments.length === 0) {
808
+ console.log("No environments found.");
809
+ return;
810
+ }
811
+ const selected = await interactiveSelect(result.environments, result.scanPath, options.staleOnly);
812
+ if (selected.length === 0) {
813
+ console.log("Nothing selected.");
814
+ return;
815
+ }
816
+ await confirmAndDelete(selected, result.scanPath, result.environments.length, options.dryRun, options.yes);
817
+ });
818
+ program2.command("clean").argument("[path]", "path to scan", ".").option("-d, --depth <n>", "max directory depth", parseIntOption, 5).option("--stale-days <n>", "stale threshold in days", parseIntOption, 90).option("--dry-run", "preview without deleting", false).option("-y, --yes", "skip final confirmation", false).option("--deep", "compute size metadata", true).action(async (targetPath, options) => {
819
+ const result = scan({
820
+ root: targetPath,
821
+ depth: options.depth,
822
+ deep: options.deep,
823
+ staleDays: options.staleDays,
824
+ includeArtifacts: false
825
+ });
826
+ const selected = result.environments.filter((item) => item.isStale);
827
+ if (selected.length === 0) {
828
+ console.log("No stale environments found.");
829
+ return;
830
+ }
831
+ await confirmAndDelete(selected, result.scanPath, result.environments.length, options.dryRun, options.yes);
832
+ });
833
+ }
834
+
835
+ // src/index.ts
836
+ var require2 = createRequire(import.meta.url);
837
+ var { version } = require2("../package.json");
838
+ var program = new Command();
839
+ program.name("envoic").description("Discover and manage node_modules and JS artifacts").version(version);
840
+ registerCommands(program);
841
+ program.parse();
package/package.json CHANGED
@@ -1,12 +1,51 @@
1
1
  {
2
2
  "name": "envoic",
3
- "version": "0.0.1",
4
- "description": "",
5
- "main": "index.js",
3
+ "version": "0.0.10",
4
+ "description": "Discover and manage node modules and JS artifacts on your system",
5
+ "type": "module",
6
+ "bin": {
7
+ "envoic": "./dist/index.js"
8
+ },
6
9
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
10
+ "build": "tsup",
11
+ "dev": "tsup --watch",
12
+ "test": "vitest",
13
+ "prepublishOnly": "npm run build",
14
+ "lint": "eslint src/",
15
+ "lint:fix": "eslint src/ --fix"
8
16
  },
9
- "keywords": [],
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "keywords": [
21
+ "node_modules",
22
+ "cleanup",
23
+ "disk",
24
+ "scanner",
25
+ "cli",
26
+ "devtools"
27
+ ],
10
28
  "author": "Mahimai",
11
- "license": "MIT"
12
- }
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/mahimailabs/envoic",
33
+ "directory": "packages/js"
34
+ },
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "dependencies": {
39
+ "@inquirer/prompts": "^7.10.1",
40
+ "commander": "^12.1.0"
41
+ },
42
+ "devDependencies": {
43
+ "@eslint/js": "^10.0.1",
44
+ "@types/node": "^22.19.11",
45
+ "eslint": "^10.0.1",
46
+ "tsup": "^8.5.1",
47
+ "typescript": "^5.9.3",
48
+ "typescript-eslint": "8.56.0",
49
+ "vitest": "^2.1.9"
50
+ }
51
+ }
package/index.js DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- console.log("envoic: coming soon. https://github.com/mahimailabs/envoic");