remnem 0.0.0 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/bin/remnem.js +213 -0
  4. package/package.json +82 -1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leon Si
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,143 @@
1
+ # remnem
2
+
3
+ **r**e**m**ove **n**ode_**m**odules — find every nested `node_modules` in a project (root + all workspaces + any nested ones) and delete them all, as fast as possible.
4
+
5
+ Written in Rust ([napi-rs](https://napi.rs)) with a parallel directory walker and parallel deletion. Uses the **same workspace resolution as [bun](https://bun.sh/docs/install/workspaces) and [pnpm](https://pnpm.io/pnpm-workspace_yaml)** to describe the workspace layout.
6
+
7
+ ```
8
+ $ remnem
9
+ root: /Users/you/dev/my-monorepo
10
+ package.json workspace (12 packages)
11
+ found 13 node_modules totalling 2.4 GB:
12
+ 318 MB node_modules
13
+ 1.1 GB apps/web/node_modules
14
+ ...
15
+ permanently delete these 13 directories? [y/N] y
16
+
17
+ deleted: 13/13 node_modules (2.4 GB) in 412ms
18
+ ```
19
+
20
+ ## Delete vs. Trash
21
+
22
+ By default `remnem` **permanently deletes** each `node_modules` in parallel —
23
+ space is reclaimed immediately.
24
+
25
+ Pass **`-t` / `--trash`** to move them to the OS trash instead (Finder Trash on
26
+ macOS, the freedesktop trash on Linux, the Recycle Bin on Windows). On the same
27
+ volume that is a directory *rename* — O(1), effectively instant no matter how
28
+ many files the tree holds — and recoverable from the trash. The disk space is
29
+ reclaimed when you empty it. A `node_modules` on a *different* volume (rare)
30
+ can't be renamed instantly, so those fall back to a direct delete.
31
+
32
+ ## Install
33
+
34
+ ```sh
35
+ npm install -g remnem
36
+ # or: bun install -g remnem
37
+ ```
38
+
39
+ The right prebuilt native binary is pulled in automatically for your platform
40
+ via `optionalDependencies`. Supported: **macOS** (arm64, x64), **Linux** (arm64
41
+ & x64, glibc & musl), **Windows** (arm64, x64).
42
+
43
+ Then from any repo root:
44
+
45
+ ```sh
46
+ remnem
47
+ ```
48
+
49
+ ### From source
50
+
51
+ ```sh
52
+ bun install
53
+ bun run build # builds the native addon for your host → remnem.<platform>.node
54
+ bun link # makes `remnem` available on your PATH
55
+ ```
56
+
57
+ ## What it clears
58
+
59
+ **Every `node_modules` directory** under the given root — the root's own, every
60
+ workspace package's, and any stray nested ones — leaving all your source and
61
+ `package.json` files untouched. The walker never descends *into* a
62
+ `node_modules` (the whole subtree is going to be removed anyway), so it stays
63
+ fast even on trees with hundreds of thousands of files.
64
+
65
+ Workspace resolution (reading `package.json#workspaces` for bun/npm/yarn, or
66
+ `pnpm-workspace.yaml#packages` for pnpm) is used to **report** the workspace
67
+ layout; clearing always targets every nested `node_modules`, not only workspace
68
+ packages.
69
+
70
+ ## Usage
71
+
72
+ ```
73
+ remnem [path] [options]
74
+
75
+ Arguments:
76
+ path Project root to clean (default: current directory)
77
+
78
+ Options:
79
+ -t, --trash Move to the Trash instead of deleting (instant, recoverable)
80
+ -l, --list List what would be cleared; touch nothing
81
+ --no-measure Skip sizing each node_modules (faster; sizes show as 0)
82
+ --json Print the raw result as JSON
83
+ -y, --yes Skip the confirmation prompt
84
+ -h, --help Show this help
85
+ ```
86
+
87
+ By default `remnem` permanently deletes each `node_modules`, after printing what it
88
+ found and asking for confirmation (skipped with `-y`, or when stdout isn't a TTY,
89
+ e.g. in CI). Use `-t` to move them to the Trash instead (space reclaimed when you
90
+ empty it), or `-l` to list what would be cleared without touching anything.
91
+
92
+ ## Workspace resolution
93
+
94
+ `remnem` mirrors how bun and pnpm resolve workspace packages:
95
+
96
+ | Source | Field | Example |
97
+ | --- | --- | --- |
98
+ | bun / npm / yarn | `package.json` → `workspaces` | `["packages/*", "!packages/excluded"]` |
99
+ | bun / npm / yarn | `package.json` → `workspaces.packages` | `{ "packages": ["libs/*"] }` |
100
+ | pnpm | `pnpm-workspace.yaml` → `packages` | `- 'packages/*'`<br>`- '!**/test/**'` |
101
+
102
+ Glob semantics match [picomatch](https://github.com/micromatch/picomatch) (the
103
+ matcher bun/npm/yarn use):
104
+
105
+ - `*` matches exactly one path segment (`packages/*` → `packages/a`, not `packages/a/b`)
106
+ - `**` matches any number of segments, and a trailing `/**` is **optional**
107
+ (`components/**` matches `components` itself and everything beneath it)
108
+ - `!pattern` excludes previously-matched directories (`!**/test/**` drops a
109
+ directory named `test` and its contents)
110
+
111
+ A directory only counts as a workspace package when it contains its own
112
+ `package.json`.
113
+
114
+ ## API
115
+
116
+ The napi-rs core is also usable directly from JavaScript:
117
+
118
+ ```js
119
+ const { clean, resolveWorkspace } = require("remnem");
120
+
121
+ // Clear every nested node_modules under a root.
122
+ // trash: false (default) → permanent parallel delete; true → move to Trash.
123
+ const result = clean({ root: "/path/to/repo", dryRun: false, measure: true, trash: false });
124
+ // → { root, workspaceKind, workspacePackages, cleaned: [{ path, bytes, deleted, trashed, error }], totalBytes, count, failed }
125
+
126
+ // Just inspect how bun/pnpm would see the workspace (no deletion).
127
+ const ws = resolveWorkspace("/path/to/repo");
128
+ // → { workspaceKind: "pnpm" | "package.json" | "none", workspacePackages: [...] }
129
+ ```
130
+
131
+ See `index.d.ts` for the full typed surface.
132
+
133
+ ## Development
134
+
135
+ ```sh
136
+ cargo test # Rust unit tests (workspace resolution + glob semantics)
137
+ bun run build:debug # debug build
138
+ bun run build # release build (LTO)
139
+ ```
140
+
141
+ ## License
142
+
143
+ MIT
package/bin/remnem.js ADDED
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { clean } = require("../index.js");
5
+
6
+ const HELP = `remnem — delete every nested node_modules, fast
7
+
8
+ Usage:
9
+ remnem [path] [options]
10
+
11
+ Arguments:
12
+ path Project root to clean (default: current directory)
13
+
14
+ Options:
15
+ -t, --trash Move to the Trash instead of deleting (instant, recoverable)
16
+ -l, --list List what would be cleared; touch nothing
17
+ --no-measure Skip sizing each node_modules (faster; sizes show as 0)
18
+ --json Print the raw result as JSON
19
+ -y, --yes Skip the confirmation prompt
20
+ -h, --help Show this help
21
+
22
+ Finds every node_modules directory under <path> (root + all workspace packages
23
+ + any nested ones), using the same workspace resolution as bun / pnpm to report
24
+ the layout, then permanently deletes them in parallel.
25
+
26
+ With -t, moves them to the Trash instead — on the same volume that is a rename
27
+ (instant no matter how large) and recoverable in Finder; the space is reclaimed
28
+ when you empty the Trash.
29
+ `;
30
+
31
+ function parseArgs(argv) {
32
+ const opts = {
33
+ root: undefined,
34
+ list: false,
35
+ measure: true,
36
+ trash: false,
37
+ json: false,
38
+ yes: false,
39
+ help: false,
40
+ };
41
+ for (let i = 0; i < argv.length; i++) {
42
+ const arg = argv[i];
43
+ switch (arg) {
44
+ case "-h":
45
+ case "--help":
46
+ opts.help = true;
47
+ break;
48
+ case "-l":
49
+ case "--list":
50
+ opts.list = true;
51
+ break;
52
+ case "-t":
53
+ case "--trash":
54
+ opts.trash = true;
55
+ break;
56
+ case "--no-measure":
57
+ opts.measure = false;
58
+ break;
59
+ case "--measure":
60
+ opts.measure = true;
61
+ break;
62
+ case "--json":
63
+ opts.json = true;
64
+ break;
65
+ case "-y":
66
+ case "--yes":
67
+ opts.yes = true;
68
+ break;
69
+ default:
70
+ if (arg.startsWith("-")) {
71
+ process.stderr.write(`remnem: unknown option ${arg}\n\n${HELP}`);
72
+ process.exit(2);
73
+ }
74
+ if (opts.root !== undefined) {
75
+ process.stderr.write(`remnem: unexpected extra argument ${arg}\n`);
76
+ process.exit(2);
77
+ }
78
+ opts.root = arg;
79
+ }
80
+ }
81
+ return opts;
82
+ }
83
+
84
+ function formatBytes(n) {
85
+ const bytes = typeof n === "bigint" ? Number(n) : n;
86
+ if (!bytes) return "0 B";
87
+ const units = ["B", "KB", "MB", "GB", "TB"];
88
+ const exp = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
89
+ const value = bytes / 1024 ** exp;
90
+ return `${value.toFixed(value >= 10 || exp === 0 ? 0 : 1)} ${units[exp]}`;
91
+ }
92
+
93
+ function relativizePath(root, p) {
94
+ if (p === root) return ".";
95
+ if (p.startsWith(root + "/")) return p.slice(root.length + 1);
96
+ return p;
97
+ }
98
+
99
+ // Confirmation prompt (synchronous) so a bare `remnem` in a real repo can't nuke
100
+ // node_modules by a stray keystroke. Skipped with -y, with --list, or when not
101
+ // attached to a TTY (CI / piped).
102
+ function confirm(question) {
103
+ if (!process.stdin.isTTY) return true;
104
+ const fs = require("fs");
105
+ process.stdout.write(question);
106
+ const buf = Buffer.alloc(64);
107
+ let bytesRead = 0;
108
+ try {
109
+ bytesRead = fs.readSync(0, buf, 0, buf.length, null);
110
+ } catch {
111
+ return false;
112
+ }
113
+ const answer = buf.toString("utf8", 0, bytesRead).trim().toLowerCase();
114
+ return answer === "y" || answer === "yes";
115
+ }
116
+
117
+ function main() {
118
+ const opts = parseArgs(process.argv.slice(2));
119
+ if (opts.help) {
120
+ process.stdout.write(HELP);
121
+ return;
122
+ }
123
+
124
+ // Phase 1: dry-run scan so we can show the user exactly what will go, then
125
+ // (unless -y / non-TTY) confirm before the real deletion.
126
+ const scan = clean({ root: opts.root, dryRun: true, measure: opts.measure });
127
+
128
+ const kindLabel =
129
+ scan.workspaceKind === "none"
130
+ ? "no workspace config"
131
+ : `${scan.workspaceKind} workspace (${scan.workspacePackages.length} package${
132
+ scan.workspacePackages.length === 1 ? "" : "s"
133
+ })`;
134
+
135
+ if (opts.json && opts.list) {
136
+ process.stdout.write(JSON.stringify(scan, replacer, 2) + "\n");
137
+ return;
138
+ }
139
+
140
+ if (scan.count === 0) {
141
+ process.stdout.write(`remnem: no node_modules found under ${scan.root} (${kindLabel})\n`);
142
+ return;
143
+ }
144
+
145
+ process.stdout.write(`root: ${scan.root}\n`);
146
+ process.stdout.write(` ${kindLabel}\n`);
147
+ process.stdout.write(
148
+ `found ${scan.count} node_modules${
149
+ opts.measure ? ` totalling ${formatBytes(scan.totalBytes)}` : ""
150
+ }:\n`,
151
+ );
152
+ for (const dir of scan.cleaned) {
153
+ const size = opts.measure ? ` ${formatBytes(dir.bytes).padStart(8)}` : "";
154
+ process.stdout.write(`${size} ${relativizePath(scan.root, dir.path)}\n`);
155
+ }
156
+
157
+ if (opts.list) {
158
+ process.stdout.write(`\n(list only — nothing ${opts.trash ? "trashed" : "deleted"})\n`);
159
+ return;
160
+ }
161
+
162
+ const verb = opts.trash ? "move to Trash" : "permanently delete";
163
+ if (!opts.yes && !confirm(`\n${verb} these ${scan.count} directories? [y/N] `)) {
164
+ process.stdout.write("aborted.\n");
165
+ process.exit(1);
166
+ }
167
+
168
+ // Phase 2: real disposal. Re-measure is unnecessary — reuse sizes we have.
169
+ const start = process.hrtime.bigint();
170
+ const result = clean({ root: opts.root, dryRun: false, measure: false, trash: opts.trash });
171
+ const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6;
172
+
173
+ if (opts.json) {
174
+ process.stdout.write(JSON.stringify(result, replacer, 2) + "\n");
175
+ return;
176
+ }
177
+
178
+ const done = result.count - result.failed;
179
+ const trashedCount = result.cleaned.filter((d) => d.trashed).length;
180
+ // Report how the space went: "trashed" (recoverable, empty Trash to reclaim)
181
+ // vs "deleted" (gone). A trash run that fell back to hard-remove for some
182
+ // items is noted so the count still adds up.
183
+ let action;
184
+ if (!opts.trash) {
185
+ action = "deleted";
186
+ } else if (trashedCount === done) {
187
+ action = "moved to Trash";
188
+ } else {
189
+ action = `moved to Trash (${done - trashedCount} hard-deleted)`;
190
+ }
191
+ process.stdout.write(
192
+ `\n${action}: ${done}/${result.count} node_modules${
193
+ opts.measure ? ` (${formatBytes(scan.totalBytes)})` : ""
194
+ } in ${elapsedMs.toFixed(0)}ms\n`,
195
+ );
196
+ if (opts.trash && trashedCount > 0) {
197
+ process.stdout.write(`empty the Trash to reclaim the space (or re-run without -t to delete).\n`);
198
+ }
199
+ if (result.failed > 0) {
200
+ for (const dir of result.cleaned) {
201
+ if (dir.error) {
202
+ process.stderr.write(` failed: ${relativizePath(result.root, dir.path)} — ${dir.error}\n`);
203
+ }
204
+ }
205
+ process.exit(1);
206
+ }
207
+ }
208
+
209
+ function replacer(_key, value) {
210
+ return typeof value === "bigint" ? Number(value) : value;
211
+ }
212
+
213
+ main();
package/package.json CHANGED
@@ -1 +1,82 @@
1
- {"name":"remnem","version":"0.0.0"}
1
+ {
2
+ "name": "remnem",
3
+ "version": "0.1.7",
4
+ "description": "Find and delete every nested node_modules in a project as fast as possible (napi-rs / Rust)",
5
+ "type": "commonjs",
6
+ "main": "index.js",
7
+ "types": "index.d.ts",
8
+ "license": "MIT",
9
+ "author": "Leon Si",
10
+ "homepage": "https://github.com/leonsilicon/remnem#readme",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/leonsilicon/remnem.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/leonsilicon/remnem/issues"
17
+ },
18
+ "keywords": [
19
+ "node_modules",
20
+ "monorepo",
21
+ "workspace",
22
+ "clean",
23
+ "cleaner",
24
+ "delete",
25
+ "napi-rs",
26
+ "rust",
27
+ "bun",
28
+ "pnpm"
29
+ ],
30
+ "bin": {
31
+ "remnem": "bin/remnem.js"
32
+ },
33
+ "files": [
34
+ "index.js",
35
+ "index.d.ts",
36
+ "bin/remnem.js",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "napi": {
41
+ "binaryName": "remnem",
42
+ "targets": [
43
+ "x86_64-apple-darwin",
44
+ "aarch64-apple-darwin",
45
+ "x86_64-unknown-linux-gnu",
46
+ "aarch64-unknown-linux-gnu",
47
+ "x86_64-unknown-linux-musl",
48
+ "aarch64-unknown-linux-musl",
49
+ "x86_64-pc-windows-msvc",
50
+ "aarch64-pc-windows-msvc"
51
+ ],
52
+ "packageName": "@leonsilicon/remnem"
53
+ },
54
+ "engines": {
55
+ "node": ">= 18"
56
+ },
57
+ "optionalDependencies": {
58
+ "@leonsilicon/remnem-darwin-x64": "0.1.7",
59
+ "@leonsilicon/remnem-darwin-arm64": "0.1.7",
60
+ "@leonsilicon/remnem-linux-x64-gnu": "0.1.7",
61
+ "@leonsilicon/remnem-linux-arm64-gnu": "0.1.7",
62
+ "@leonsilicon/remnem-linux-x64-musl": "0.1.7",
63
+ "@leonsilicon/remnem-linux-arm64-musl": "0.1.7",
64
+ "@leonsilicon/remnem-win32-x64-msvc": "0.1.7",
65
+ "@leonsilicon/remnem-win32-arm64-msvc": "0.1.7"
66
+ },
67
+ "publishConfig": {
68
+ "access": "public",
69
+ "provenance": true
70
+ },
71
+ "scripts": {
72
+ "build": "napi build --platform --release",
73
+ "build:debug": "napi build --platform",
74
+ "artifacts": "napi artifacts",
75
+ "prepublishOnly": "napi prepublish -t npm",
76
+ "test": "cargo test",
77
+ "version": "napi version"
78
+ },
79
+ "devDependencies": {
80
+ "@napi-rs/cli": "^3.0.0"
81
+ }
82
+ }