@bobfrankston/tsfind 0.0.0-stage → 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/README.md +77 -2
- package/index.d.ts +29 -0
- package/index.js +742 -0
- package/package.json +49 -4
package/README.md
CHANGED
|
@@ -1,3 +1,78 @@
|
|
|
1
|
-
#
|
|
1
|
+
# tsfind
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Find files by name on an NTFS volume, fast. It reads the volume's Master File Table
|
|
4
|
+
([`@bobfrankston/mftwalk`](https://github.com/BobFrankston/mftwalk)) instead of walking, and caches
|
|
5
|
+
the scan, so repeated searches and analyses of the same drive take seconds. By default it prints
|
|
6
|
+
bare paths, one per line, so the list can be edited (for example, cut down to the files to
|
|
7
|
+
delete). `-count` tallies the matches by name and by extension instead, and `-hogs` shows just the
|
|
8
|
+
largest.
|
|
9
|
+
|
|
10
|
+
A find-only companion to [tswalk](https://github.com/BobFrankston/tswalk), which walks trees
|
|
11
|
+
(non-NTFS and network drives included).
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g @bobfrankston/tsfind
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
tsfind [dir] [pattern] [options]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- **dir** - where to start. If the first argument is not a directory, it is the pattern and the
|
|
26
|
+
search starts in the current directory.
|
|
27
|
+
- **pattern** - file name: glob (`*` `?`), several separated by `;`, or `/regex/flags`.
|
|
28
|
+
Case-insensitive; matched against the name only, not the path. Default `*`.
|
|
29
|
+
|
|
30
|
+
| Flag | Description |
|
|
31
|
+
|------|-------------|
|
|
32
|
+
| `-o file` | Write the list (or the `-count` tables) to a file instead of the console |
|
|
33
|
+
| `-l` | Long: date and size before each path |
|
|
34
|
+
| `-d` | Also report directories and links whose names match (listed with no size) |
|
|
35
|
+
| `-count` | Number and total size of matches by name and by extension |
|
|
36
|
+
| `-sort key` | `-count` order: `size` (default), `count` or `name` |
|
|
37
|
+
| `-top N` | `-count` rows per table (default 50; 0 = all) |
|
|
38
|
+
| `-hogs [name\|ext] [N\|N%]` | Largest totals by name (default) or extension, with each one's share; top N (default 5), or every one taking at least N% |
|
|
39
|
+
| `-age N` | Use a cached scan up to N minutes old (default 60) |
|
|
40
|
+
| `-fresh` | Rescan the MFT even if a cached scan is young enough |
|
|
41
|
+
| `-q` | No progress or summary |
|
|
42
|
+
| `-help`, `-version` | |
|
|
43
|
+
|
|
44
|
+
The list is sorted by path. Progress and the summary go to stderr, so stdout carries only the
|
|
45
|
+
list. Links and junctions are never entered.
|
|
46
|
+
|
|
47
|
+
## The MFT and the cache
|
|
48
|
+
|
|
49
|
+
Scanning the MFT needs Windows, NTFS and an elevated prompt (e.g. `gsudo`); tsfind does not walk.
|
|
50
|
+
The whole volume is scanned whatever folder is searched. A 15 TB USB backup drive with 8.7M files
|
|
51
|
+
takes about 5-8 minutes and 1.6 GB of memory.
|
|
52
|
+
|
|
53
|
+
Each scan is saved, zstd-compressed, as `%TEMP%\tsfind\<volume label>-<volume serial>.idx.zst` (e.g. `20251211y-F860296A.idx.zst`): about 11 bytes
|
|
54
|
+
per entry (Y: here: 1.33M entries, 14 MB), so about 110 MB for 10M files. A cache younger than
|
|
55
|
+
`-age` minutes (default 60) is used instead of rescanning, for any folder on that volume, and
|
|
56
|
+
reading it needs no elevation. It is a snapshot: changes since the scan are not seen until it
|
|
57
|
+
expires or `-fresh` is given. When a rescan is not possible (not elevated), an older cache is used
|
|
58
|
+
and the summary says how old. Each drive has its own cache, named by volume label (readable) and
|
|
59
|
+
volume serial number (unique), so it follows the drive to whatever letter it is mounted under, and
|
|
60
|
+
two drives with the same label are kept apart. Renaming a drive's label means one rescan.
|
|
61
|
+
|
|
62
|
+
A file with several hard links is found under one of its names only (mftwalk keeps one name per
|
|
63
|
+
MFT record); cargo's `target` directories are the common case.
|
|
64
|
+
|
|
65
|
+
## Examples
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
gsudo tsfind U:\ -hogs ext # scans the MFT; the 5 extensions taking the most space
|
|
69
|
+
tsfind U:\ -hogs 2% # from the cache: every name taking at least 2%
|
|
70
|
+
tsfind U:\Aged -count -top 30 # from the cache: by name and extension
|
|
71
|
+
tsfind U:\ telemetrydata.db -o tele.txt # list for editing
|
|
72
|
+
tsfind U:\ "*telemetry*" -l # with dates and sizes
|
|
73
|
+
gsudo tsfind U:\ -fresh -hogs # rescan now
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
ISC
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* TSFIND - find files by name on an NTFS volume, fast, from its Master File Table; list them,
|
|
4
|
+
* count them by name and extension, or show where the space goes
|
|
5
|
+
*
|
|
6
|
+
* CHANGE LOG (newest first)
|
|
7
|
+
* 2026-10-27 (later) — Claude Code (Opus 5.5), at Bob's direction: MFT only, with a cache.
|
|
8
|
+
* Bob: "Walking is expensive and is already handled by tswalk... the point of tsfind is to be
|
|
9
|
+
* fast. Having a compressed cache that is valid for a limited time allows me to run multiple
|
|
10
|
+
* analyses." So the walk, -nomft, -mft and -v are gone. A scan of a volume's MFT is turned into
|
|
11
|
+
* tsfind's own index (built only through mftwalk's public accessors) and saved compressed (zstd)
|
|
12
|
+
* under the temp directory, one file per volume, keyed by the volume serial number. A cache
|
|
13
|
+
* younger than -age minutes (default 60) is used instead of rescanning; -fresh rescans. Because
|
|
14
|
+
* reading the cache needs no elevation, only the scan does, later runs work from any prompt.
|
|
15
|
+
* When a rescan is not possible (not elevated) but an older cache exists, it is used and its
|
|
16
|
+
* age is reported. The start directory goes through realpath, so a path through a junction works.
|
|
17
|
+
* 2026-10-27 — Claude Code (Opus 5.5), at Bob's direction:
|
|
18
|
+
* - The directory is now the first argument: `tsfind dir [pattern]`. If the first argument is not
|
|
19
|
+
* a directory it is taken as the pattern and the search starts in the current directory.
|
|
20
|
+
* The pattern defaults to * (all files).
|
|
21
|
+
* - -hogs [name|ext] [N|N%]: the largest totals by name (default) or by extension; the top N
|
|
22
|
+
* (default 5), or with N% every one taking at least N% of the space matched.
|
|
23
|
+
* 2026-10-14 — Claude Code (Opus 5.5), at Bob's direction: created. A find-only companion to tswalk,
|
|
24
|
+
* so tswalk is not overloaded. The default output is bare paths, one per line, so the list can be
|
|
25
|
+
* edited and used for cleanup. -count tallies matches by name and by extension. First use:
|
|
26
|
+
* telemetrydata.db files on the backup drives.
|
|
27
|
+
*/
|
|
28
|
+
export {};
|
|
29
|
+
//# sourceMappingURL=index.d.ts.map
|
package/index.js
ADDED
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* TSFIND - find files by name on an NTFS volume, fast, from its Master File Table; list them,
|
|
4
|
+
* count them by name and extension, or show where the space goes
|
|
5
|
+
*
|
|
6
|
+
* CHANGE LOG (newest first)
|
|
7
|
+
* 2026-10-27 (later) — Claude Code (Opus 5.5), at Bob's direction: MFT only, with a cache.
|
|
8
|
+
* Bob: "Walking is expensive and is already handled by tswalk... the point of tsfind is to be
|
|
9
|
+
* fast. Having a compressed cache that is valid for a limited time allows me to run multiple
|
|
10
|
+
* analyses." So the walk, -nomft, -mft and -v are gone. A scan of a volume's MFT is turned into
|
|
11
|
+
* tsfind's own index (built only through mftwalk's public accessors) and saved compressed (zstd)
|
|
12
|
+
* under the temp directory, one file per volume, keyed by the volume serial number. A cache
|
|
13
|
+
* younger than -age minutes (default 60) is used instead of rescanning; -fresh rescans. Because
|
|
14
|
+
* reading the cache needs no elevation, only the scan does, later runs work from any prompt.
|
|
15
|
+
* When a rescan is not possible (not elevated) but an older cache exists, it is used and its
|
|
16
|
+
* age is reported. The start directory goes through realpath, so a path through a junction works.
|
|
17
|
+
* 2026-10-27 — Claude Code (Opus 5.5), at Bob's direction:
|
|
18
|
+
* - The directory is now the first argument: `tsfind dir [pattern]`. If the first argument is not
|
|
19
|
+
* a directory it is taken as the pattern and the search starts in the current directory.
|
|
20
|
+
* The pattern defaults to * (all files).
|
|
21
|
+
* - -hogs [name|ext] [N|N%]: the largest totals by name (default) or by extension; the top N
|
|
22
|
+
* (default 5), or with N% every one taking at least N% of the space matched.
|
|
23
|
+
* 2026-10-14 — Claude Code (Opus 5.5), at Bob's direction: created. A find-only companion to tswalk,
|
|
24
|
+
* so tswalk is not overloaded. The default output is bare paths, one per line, so the list can be
|
|
25
|
+
* edited and used for cleanup. -count tallies matches by name and by extension. First use:
|
|
26
|
+
* telemetrydata.db files on the backup drives.
|
|
27
|
+
*/
|
|
28
|
+
import * as fp from 'node:fs/promises';
|
|
29
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
30
|
+
import * as os from 'node:os';
|
|
31
|
+
import { spawnSync } from 'node:child_process';
|
|
32
|
+
import * as path from 'node:path';
|
|
33
|
+
import { styleText } from 'node:util';
|
|
34
|
+
import { Readable } from 'node:stream';
|
|
35
|
+
import { finished, pipeline } from 'node:stream/promises';
|
|
36
|
+
import { createZstdCompress, createZstdDecompress } from 'node:zlib';
|
|
37
|
+
import packageJson from './package.json' with { type: 'json' };
|
|
38
|
+
const version = packageJson.version;
|
|
39
|
+
/** A cache younger than this many minutes is used instead of rescanning (-age changes it) */
|
|
40
|
+
const DEFAULT_CACHE_MINUTES = 60;
|
|
41
|
+
/** Default rows per -count table */
|
|
42
|
+
const DEFAULT_TOP = 50;
|
|
43
|
+
/** Default rows for -hogs */
|
|
44
|
+
const DEFAULT_HOGS = 5;
|
|
45
|
+
/** Lines handed to the output stream per write */
|
|
46
|
+
const WRITE_BATCH = 10000;
|
|
47
|
+
/** Names encoded or decoded per piece of the cache's name section (keeps each string well under V8's limit) */
|
|
48
|
+
const NAME_BATCH = 100000;
|
|
49
|
+
/** Bytes of the name section decoded at a time when loading */
|
|
50
|
+
const NAME_DECODE_BYTES = 64 * 1024 * 1024;
|
|
51
|
+
const CLEAR_LINE = '\x1b[2K\r';
|
|
52
|
+
/** First bytes of a cache file; the digit is the format version */
|
|
53
|
+
const CACHE_MAGIC = 'TSFINDX1';
|
|
54
|
+
/** Bits in VolumeIndex.flags */
|
|
55
|
+
const IndexFlags = {
|
|
56
|
+
Directory: 0x01, /** A directory (searched beneath) */
|
|
57
|
+
Link: 0x02, /** A junction or symbolic link (never entered) */
|
|
58
|
+
};
|
|
59
|
+
function usage() {
|
|
60
|
+
return `tsfind v${version} - find files by name on an NTFS volume, fast, from its Master File Table
|
|
61
|
+
|
|
62
|
+
Usage: tsfind [dir] [pattern] [options]
|
|
63
|
+
dir Where to start. If the first argument is not a directory, it is the pattern
|
|
64
|
+
and the search starts in the current directory.
|
|
65
|
+
pattern File name to find: glob (* ?), several separated by ;, or /regex/flags.
|
|
66
|
+
Case-insensitive; matched against the name only, not the path. Default: *
|
|
67
|
+
|
|
68
|
+
Options:
|
|
69
|
+
-o file Write the list (or the tables) to file instead of the console
|
|
70
|
+
-l Long: date and size before each path (default: path only, one per line,
|
|
71
|
+
so the list can be edited)
|
|
72
|
+
-d Also report directories and links whose names match (listed with no size;
|
|
73
|
+
the search never goes inside links or junctions)
|
|
74
|
+
-count Instead of listing: number and total size of matches by name and by
|
|
75
|
+
extension
|
|
76
|
+
-sort key -count order: size (default), count or name
|
|
77
|
+
-top N -count rows per table (default ${DEFAULT_TOP}; 0 = all)
|
|
78
|
+
-hogs [name|ext] [N|N%]
|
|
79
|
+
Instead of listing: where the space goes. The largest totals by name
|
|
80
|
+
(default) or by extension, with each one's share of the space matched.
|
|
81
|
+
N = how many (default ${DEFAULT_HOGS}); N% = every one taking at least N%.
|
|
82
|
+
-age N Use a cached scan up to N minutes old (default ${DEFAULT_CACHE_MINUTES})
|
|
83
|
+
-fresh Rescan the MFT even if a cached scan is young enough
|
|
84
|
+
-q No progress or summary
|
|
85
|
+
-h, -help This message
|
|
86
|
+
-version Version
|
|
87
|
+
|
|
88
|
+
How it works: tsfind reads the volume's NTFS Master File Table (Windows, NTFS, elevated prompt),
|
|
89
|
+
which takes minutes rather than the hours a walk can take on a big drive. The scan is cached,
|
|
90
|
+
compressed, in ${cacheDir()}, one file per volume, so further searches of
|
|
91
|
+
any folder on that volume take seconds and need no elevation. The cache is a snapshot:
|
|
92
|
+
changes since the scan are not seen until it is older than -age or -fresh is given. If a
|
|
93
|
+
rescan is not possible (not elevated) an older cache is used, and the summary says how old.
|
|
94
|
+
For a walk of a non-NTFS or network drive, use tswalk.
|
|
95
|
+
|
|
96
|
+
Progress and the summary go to stderr, so stdout carries only the list.
|
|
97
|
+
|
|
98
|
+
Examples:
|
|
99
|
+
tsfind U:\\ telemetrydata.db -o tele.txt List them to tele.txt for editing
|
|
100
|
+
tsfind U:\\ "*telemetry*" -l With dates and sizes
|
|
101
|
+
tsfind U:\\ -hogs ext The 5 extensions taking the most space
|
|
102
|
+
tsfind U:\\Aged -hogs 2% Every name taking at least 2% of U:\\Aged
|
|
103
|
+
tsfind U:\\ -count -top 30 Where the space goes, by name and extension
|
|
104
|
+
tsfind "*.bak;*.tmp" -count -sort count In the current directory
|
|
105
|
+
`;
|
|
106
|
+
}
|
|
107
|
+
function fail(message) {
|
|
108
|
+
console.error(styleText(['red'], message));
|
|
109
|
+
console.error('tsfind -help for usage');
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
function parseArgs(args) {
|
|
113
|
+
if (args.length === 0) {
|
|
114
|
+
console.log(usage());
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
const options = {
|
|
118
|
+
positional: [], patterns: null, top: '.', outFile: '', long: false, dirs: false, count: false,
|
|
119
|
+
sort: 'size', rows: DEFAULT_TOP, hogs: null, hogsRows: DEFAULT_HOGS, hogsPercent: 0,
|
|
120
|
+
cacheMinutes: DEFAULT_CACHE_MINUTES, quiet: false,
|
|
121
|
+
};
|
|
122
|
+
for (let i = 0; i < args.length; i++) {
|
|
123
|
+
const arg = args[i];
|
|
124
|
+
if (!arg.startsWith('-') || arg === '-') {
|
|
125
|
+
options.positional.push(arg);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const needValue = () => {
|
|
129
|
+
if (i + 1 >= args.length)
|
|
130
|
+
fail(`${arg} needs a value`);
|
|
131
|
+
return args[++i];
|
|
132
|
+
};
|
|
133
|
+
switch (arg.replace(/^--/, '-').toLowerCase()) {
|
|
134
|
+
case '-o':
|
|
135
|
+
options.outFile = needValue();
|
|
136
|
+
break;
|
|
137
|
+
case '-l':
|
|
138
|
+
options.long = true;
|
|
139
|
+
break;
|
|
140
|
+
case '-d':
|
|
141
|
+
options.dirs = true;
|
|
142
|
+
break;
|
|
143
|
+
case '-count':
|
|
144
|
+
options.count = true;
|
|
145
|
+
break;
|
|
146
|
+
case '-sort': {
|
|
147
|
+
const key = needValue().toLowerCase();
|
|
148
|
+
if (key !== 'size' && key !== 'count' && key !== 'name')
|
|
149
|
+
fail(`-sort must be size, count or name, not ${key}`);
|
|
150
|
+
options.sort = key;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
case '-top': {
|
|
154
|
+
const n = needValue();
|
|
155
|
+
if (!/^\d+$/.test(n))
|
|
156
|
+
fail(`-top needs a number, not ${n}`);
|
|
157
|
+
options.rows = parseInt(n);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case '-hogs': {
|
|
161
|
+
options.hogs = 'name';
|
|
162
|
+
// Both qualifiers are optional and may come in either order: -hogs ext 10, -hogs 3% name.
|
|
163
|
+
for (let taken = 0; taken < 2 && i + 1 < args.length; taken++) {
|
|
164
|
+
const next = args[i + 1].toLowerCase();
|
|
165
|
+
const amount = /^(\d+(?:\.\d+)?)(%?)$/.exec(next);
|
|
166
|
+
if (next === 'name' || next === 'ext' || next === 'extension') {
|
|
167
|
+
options.hogs = next === 'name' ? 'name' : 'ext';
|
|
168
|
+
}
|
|
169
|
+
else if (amount && amount[2]) {
|
|
170
|
+
options.hogsPercent = parseFloat(amount[1]);
|
|
171
|
+
if (options.hogsPercent <= 0 || options.hogsPercent > 100)
|
|
172
|
+
fail(`-hogs ${next}: the percentage must be above 0 and at most 100`);
|
|
173
|
+
}
|
|
174
|
+
else if (amount) {
|
|
175
|
+
if (!/^\d+$/.test(next) || parseInt(next) === 0)
|
|
176
|
+
fail(`-hogs ${next}: the count must be a whole number above 0`);
|
|
177
|
+
options.hogsRows = parseInt(next);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
i++;
|
|
183
|
+
}
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
case '-age': {
|
|
187
|
+
const n = needValue();
|
|
188
|
+
if (!/^\d+(\.\d+)?$/.test(n))
|
|
189
|
+
fail(`-age needs a number of minutes, not ${n}`);
|
|
190
|
+
options.cacheMinutes = parseFloat(n);
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case '-fresh':
|
|
194
|
+
options.cacheMinutes = 0;
|
|
195
|
+
break;
|
|
196
|
+
case '-q':
|
|
197
|
+
options.quiet = true;
|
|
198
|
+
break;
|
|
199
|
+
case '-h':
|
|
200
|
+
case '-help':
|
|
201
|
+
console.log(usage());
|
|
202
|
+
process.exit(0);
|
|
203
|
+
case '-version':
|
|
204
|
+
console.log(`tsfind v${version}`);
|
|
205
|
+
process.exit(0);
|
|
206
|
+
default:
|
|
207
|
+
fail(`Unknown option: ${arg}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (options.positional.length > 2)
|
|
211
|
+
fail(`Too many arguments: ${options.positional.join(' ')} (dir, then pattern)`);
|
|
212
|
+
if (options.hogs && options.count)
|
|
213
|
+
fail('Use -count or -hogs, not both');
|
|
214
|
+
return options;
|
|
215
|
+
}
|
|
216
|
+
/** A /body/flags pattern, which is a regex rather than a glob */
|
|
217
|
+
function isRegexPattern(p) {
|
|
218
|
+
return p.startsWith('/') && p.lastIndexOf('/') > 0;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Sort out [dir] [pattern]: the first argument is the directory if it is one; otherwise it is the
|
|
222
|
+
* pattern and the search starts in the current directory. Something that looks like a path but
|
|
223
|
+
* is not a directory is reported, rather than searched for as a file name it could never match.
|
|
224
|
+
*/
|
|
225
|
+
async function resolvePositional(options) {
|
|
226
|
+
const [first, second] = options.positional;
|
|
227
|
+
let pattern = '*';
|
|
228
|
+
if (first !== undefined) {
|
|
229
|
+
let isDir = false;
|
|
230
|
+
let statError = '';
|
|
231
|
+
try {
|
|
232
|
+
isDir = (await fp.stat(first)).isDirectory();
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
// Not lost: "no such directory" is the ordinary sign that first is a pattern; if first
|
|
236
|
+
// looks like a path instead, the code is reported by the fail just below.
|
|
237
|
+
statError = error.code ?? error.message;
|
|
238
|
+
}
|
|
239
|
+
const looksLikePath = !isRegexPattern(first) && /[\\/:]/.test(first);
|
|
240
|
+
if (isDir) {
|
|
241
|
+
options.top = first;
|
|
242
|
+
if (second !== undefined)
|
|
243
|
+
pattern = second;
|
|
244
|
+
}
|
|
245
|
+
else if (looksLikePath) {
|
|
246
|
+
fail(statError ? `Cannot search ${first}: ${statError}` : `Not a directory: ${first}`);
|
|
247
|
+
}
|
|
248
|
+
else if (second !== undefined) {
|
|
249
|
+
fail(`${first} is not a directory; the directory comes first: tsfind dir [pattern]`);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
pattern = first;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
options.patterns = pattern.split(';').filter(Boolean);
|
|
256
|
+
if (options.patterns.length === 0)
|
|
257
|
+
fail('Empty pattern');
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Name patterns to RegExps: /body/flags is a regex; anything else is a glob, anchored and
|
|
261
|
+
* case-insensitive, in which only * and ? are special.
|
|
262
|
+
*/
|
|
263
|
+
function compilePatterns(patterns) {
|
|
264
|
+
return patterns.map(p => {
|
|
265
|
+
const last = p.lastIndexOf('/');
|
|
266
|
+
if (isRegexPattern(p)) {
|
|
267
|
+
try {
|
|
268
|
+
return new RegExp(p.slice(1, last), p.slice(last + 1));
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
fail(`Bad regex ${p}: ${error.message}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const body = p.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
275
|
+
return new RegExp('^' + body + '$', 'i');
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
let progressAt = 0;
|
|
279
|
+
function progress(options, text) {
|
|
280
|
+
if (options.quiet || !process.stderr.isTTY)
|
|
281
|
+
return;
|
|
282
|
+
const now = Date.now();
|
|
283
|
+
if (now - progressAt < 1000)
|
|
284
|
+
return;
|
|
285
|
+
progressAt = now;
|
|
286
|
+
process.stderr.write(CLEAR_LINE + text);
|
|
287
|
+
}
|
|
288
|
+
function clearProgress(options) {
|
|
289
|
+
if (!options.quiet && process.stderr.isTTY)
|
|
290
|
+
process.stderr.write(CLEAR_LINE);
|
|
291
|
+
}
|
|
292
|
+
// ── Index: scan, save, load ────────────────────────────────────────
|
|
293
|
+
function cacheDir() {
|
|
294
|
+
return path.join(os.tmpdir(), 'tsfind');
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Read the MFT of the volume holding `top` and build tsfind's index of it. Throws MftError when
|
|
298
|
+
* the MFT cannot be read (not elevated, not NTFS); the caller decides what to do about that.
|
|
299
|
+
*/
|
|
300
|
+
async function scanToIndex(top, options) {
|
|
301
|
+
const { scanVolume } = await import('@bobfrankston/mftwalk');
|
|
302
|
+
const volume = await scanVolume(top, {
|
|
303
|
+
onProgress: (done, total) => progress(options, `MFT ${done.toLocaleString()} / ${total.toLocaleString()} records`),
|
|
304
|
+
});
|
|
305
|
+
clearProgress(options);
|
|
306
|
+
return indexFromVolume(volume);
|
|
307
|
+
}
|
|
308
|
+
/** Lay the volume out in preorder from the root, through mftwalk's public accessors */
|
|
309
|
+
function indexFromVolume(volume) {
|
|
310
|
+
const order = [];
|
|
311
|
+
const parentOf = [];
|
|
312
|
+
// Depth-first; children pushed in reverse so they come out in the volume's own order.
|
|
313
|
+
const stack = [{ id: volume.root, parent: -1 }];
|
|
314
|
+
while (stack.length) {
|
|
315
|
+
const { id, parent } = stack.pop();
|
|
316
|
+
const at = order.length;
|
|
317
|
+
order.push(id);
|
|
318
|
+
parentOf.push(parent);
|
|
319
|
+
const kids = volume.children(id);
|
|
320
|
+
for (let k = kids.length - 1; k >= 0; k--) {
|
|
321
|
+
// NTFS's own files ($MFT, $Extend, ...) are invisible to readdir; leave them out likewise.
|
|
322
|
+
if (!volume.isMetafile(kids[k]))
|
|
323
|
+
stack.push({ id: kids[k], parent: at });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const count = order.length;
|
|
327
|
+
const index = {
|
|
328
|
+
count,
|
|
329
|
+
parent: Int32Array.from(parentOf),
|
|
330
|
+
subtreeEnd: new Int32Array(count),
|
|
331
|
+
flags: new Uint8Array(count),
|
|
332
|
+
size: new Float64Array(count),
|
|
333
|
+
mtime: new Float64Array(count),
|
|
334
|
+
names: new Array(count),
|
|
335
|
+
};
|
|
336
|
+
for (let i = 0; i < count; i++) {
|
|
337
|
+
const id = order[i];
|
|
338
|
+
index.flags[i] = (volume.isDirectory(id) ? IndexFlags.Directory : 0) | (volume.isLink(id) ? IndexFlags.Link : 0);
|
|
339
|
+
index.size[i] = volume.size(id);
|
|
340
|
+
index.mtime[i] = volume.mtime(id).getTime();
|
|
341
|
+
index.names[i] = i === 0 ? '' : volume.name(id);
|
|
342
|
+
index.subtreeEnd[i] = i + 1;
|
|
343
|
+
}
|
|
344
|
+
// In preorder a subtree ends where its last descendant's subtree ends; children come after
|
|
345
|
+
// parents, so one backward pass carries each end up to its parent.
|
|
346
|
+
for (let i = count - 1; i > 0; i--) {
|
|
347
|
+
const p = index.parent[i];
|
|
348
|
+
if (index.subtreeEnd[i] > index.subtreeEnd[p])
|
|
349
|
+
index.subtreeEnd[p] = index.subtreeEnd[i];
|
|
350
|
+
}
|
|
351
|
+
return index;
|
|
352
|
+
}
|
|
353
|
+
/** Bytes of a typed array, as a Buffer over the same memory */
|
|
354
|
+
function bytesOf(array) {
|
|
355
|
+
return Buffer.from(array.buffer, array.byteOffset, array.byteLength);
|
|
356
|
+
}
|
|
357
|
+
/** The cache file's pieces, in order: magic, header length, header, then the arrays and names */
|
|
358
|
+
function* cachePieces(index) {
|
|
359
|
+
const header = Buffer.from(JSON.stringify({ count: index.count }));
|
|
360
|
+
const headerLength = Buffer.alloc(4);
|
|
361
|
+
headerLength.writeUInt32LE(header.length);
|
|
362
|
+
yield Buffer.from(CACHE_MAGIC);
|
|
363
|
+
yield headerLength;
|
|
364
|
+
yield header;
|
|
365
|
+
yield bytesOf(index.parent);
|
|
366
|
+
yield bytesOf(index.subtreeEnd);
|
|
367
|
+
yield bytesOf(index.flags);
|
|
368
|
+
yield bytesOf(index.size);
|
|
369
|
+
yield bytesOf(index.mtime);
|
|
370
|
+
// Names end with \0, which no NTFS name contains.
|
|
371
|
+
for (let i = 0; i < index.count; i += NAME_BATCH) {
|
|
372
|
+
yield Buffer.from(index.names.slice(i, i + NAME_BATCH).join('\0') + '\0');
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Save the index, zstd-compressed, and date the file to the start of the scan so its age is the
|
|
377
|
+
* snapshot's age. Written to a temporary name and renamed, so a reader never sees half a file.
|
|
378
|
+
*/
|
|
379
|
+
async function saveIndex(index, cachePath, scannedAt) {
|
|
380
|
+
await fp.mkdir(path.dirname(cachePath), { recursive: true });
|
|
381
|
+
const partial = cachePath + '.partial';
|
|
382
|
+
await pipeline(Readable.from(cachePieces(index)), createZstdCompress(), createWriteStream(partial));
|
|
383
|
+
await fp.utimes(partial, scannedAt, scannedAt);
|
|
384
|
+
await fp.rename(partial, cachePath);
|
|
385
|
+
}
|
|
386
|
+
async function loadIndex(cachePath) {
|
|
387
|
+
const chunks = [];
|
|
388
|
+
await pipeline(createReadStream(cachePath), createZstdDecompress(), async function* (source) {
|
|
389
|
+
for await (const chunk of source)
|
|
390
|
+
chunks.push(chunk);
|
|
391
|
+
});
|
|
392
|
+
const data = Buffer.concat(chunks);
|
|
393
|
+
chunks.length = 0;
|
|
394
|
+
if (data.toString('latin1', 0, CACHE_MAGIC.length) !== CACHE_MAGIC)
|
|
395
|
+
throw new Error(`${cachePath} is not a tsfind cache of this version`);
|
|
396
|
+
let at = CACHE_MAGIC.length;
|
|
397
|
+
const headerLength = data.readUInt32LE(at);
|
|
398
|
+
at += 4;
|
|
399
|
+
const { count } = JSON.parse(data.toString('utf8', at, at + headerLength));
|
|
400
|
+
at += headerLength;
|
|
401
|
+
// Copied out rather than viewed in place: the arrays' offsets in the buffer are not aligned.
|
|
402
|
+
const take = (array) => {
|
|
403
|
+
bytesOf(array).set(data.subarray(at, at + array.byteLength));
|
|
404
|
+
at += array.byteLength;
|
|
405
|
+
return array;
|
|
406
|
+
};
|
|
407
|
+
const index = {
|
|
408
|
+
count,
|
|
409
|
+
parent: take(new Int32Array(count)),
|
|
410
|
+
subtreeEnd: take(new Int32Array(count)),
|
|
411
|
+
flags: take(new Uint8Array(count)),
|
|
412
|
+
size: take(new Float64Array(count)),
|
|
413
|
+
mtime: take(new Float64Array(count)),
|
|
414
|
+
names: [],
|
|
415
|
+
};
|
|
416
|
+
while (at < data.length) {
|
|
417
|
+
let end = data.indexOf(0, Math.min(at + NAME_DECODE_BYTES, data.length - 1));
|
|
418
|
+
if (end < 0)
|
|
419
|
+
end = data.length - 1;
|
|
420
|
+
const piece = data.toString('utf8', at, end).split('\0');
|
|
421
|
+
for (const name of piece)
|
|
422
|
+
index.names.push(name);
|
|
423
|
+
at = end + 1;
|
|
424
|
+
}
|
|
425
|
+
if (index.names.length !== count)
|
|
426
|
+
throw new Error(`${cachePath} is damaged: ${index.names.length} names for ${count} entries`);
|
|
427
|
+
return index;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* The volume label of the drive holding `top`, or '' if it has none or cannot be read.
|
|
431
|
+
* Node has no call for it, Get-Volume takes seconds, and reading it needs no elevation through
|
|
432
|
+
* cmd's vol, whose first line is "Volume in drive Y is <label>" (or "... has no label.").
|
|
433
|
+
* That wording is English Windows; on any other the label reads as '' and the cache is named
|
|
434
|
+
* "nolabel-<serial>", which still identifies the drive by its serial.
|
|
435
|
+
*/
|
|
436
|
+
function volumeLabel(top) {
|
|
437
|
+
const result = spawnSync('cmd', ['/d', '/c', 'vol', top.slice(0, 2)], { encoding: 'utf8' });
|
|
438
|
+
// Only the first line: the second, "Volume Serial Number is ...", also contains " is ".
|
|
439
|
+
const firstLine = (result.stdout ?? '').trim().split(/\r?\n/)[0];
|
|
440
|
+
const match = /^Volume in drive \w is (.*)$/.exec(firstLine);
|
|
441
|
+
return match ? match[1].trim() : '';
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* The index for the volume holding `top`: the cache if it is young enough, else a new scan
|
|
445
|
+
* (saved for next time), else, if the scan is not possible, an older cache with a note saying so.
|
|
446
|
+
*/
|
|
447
|
+
async function getIndex(top, options) {
|
|
448
|
+
// 2026-10-27 — Claude Code (Opus 5.5), at Bob's direction: the cache is named by volume label
|
|
449
|
+
// and volume serial number (20251211y-F860296A.idx.zst). The label says which drive a file is
|
|
450
|
+
// for; the serial keeps two drives with the same label apart. Either follows the drive to
|
|
451
|
+
// whatever letter it is mounted under. (Briefly the drive letter was used instead of the label.)
|
|
452
|
+
// On Windows fs.stat's dev is the volume serial number, and reading it needs no elevation.
|
|
453
|
+
const serial = (await fp.stat(top)).dev.toString(16).toUpperCase().padStart(8, '0');
|
|
454
|
+
const label = volumeLabel(top);
|
|
455
|
+
const cachePath = path.join(cacheDir(), `${label.replace(/[^\w.-]/g, '_') || 'nolabel'}-${serial}.idx.zst`);
|
|
456
|
+
let cacheAgeMs = -1;
|
|
457
|
+
try {
|
|
458
|
+
cacheAgeMs = Date.now() - (await fp.stat(cachePath)).mtimeMs;
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
// Not lost: no cache file is the ordinary first-run case, and leads to a scan below.
|
|
462
|
+
if (error.code !== 'ENOENT')
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
465
|
+
// 2026-10-27 — Claude Code (Opus 5.5), at Bob's direction: say at the start which source is
|
|
466
|
+
// used and how old the cache is, not only in the summary at the end.
|
|
467
|
+
const announce = (text) => {
|
|
468
|
+
if (!options.quiet)
|
|
469
|
+
console.error(styleText(['blue'], text));
|
|
470
|
+
};
|
|
471
|
+
const volumeName = `${top.slice(0, 2).toUpperCase()} ${label ? `"${label}"` : '(no label)'} (serial ${serial})`;
|
|
472
|
+
const load = async (note) => {
|
|
473
|
+
announce(`Using the cached scan of ${volumeName}, ${elapsedText(cacheAgeMs)} old${note ? `; ${note}` : ''}: ${cachePath}`);
|
|
474
|
+
const index = await loadIndex(cachePath);
|
|
475
|
+
return { index, source: { scanned: false, ageMs: cacheAgeMs, cachePath, note } };
|
|
476
|
+
};
|
|
477
|
+
if (cacheAgeMs >= 0 && cacheAgeMs < options.cacheMinutes * 60000)
|
|
478
|
+
return load('');
|
|
479
|
+
announce(cacheAgeMs < 0
|
|
480
|
+
? `Scanning the MFT of ${volumeName}; no cached scan yet`
|
|
481
|
+
: `Scanning the MFT of ${volumeName}; the cached scan is ${elapsedText(cacheAgeMs)} old` +
|
|
482
|
+
(options.cacheMinutes === 0 ? ' (-fresh)' : `, older than -age ${options.cacheMinutes} minutes`));
|
|
483
|
+
const { MftError } = await import('@bobfrankston/mftwalk');
|
|
484
|
+
const scannedAt = new Date();
|
|
485
|
+
let index;
|
|
486
|
+
try {
|
|
487
|
+
index = await scanToIndex(top, options);
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
clearProgress(options);
|
|
491
|
+
if (!(error instanceof MftError))
|
|
492
|
+
throw error;
|
|
493
|
+
// Not swallowed: with an older cache the reason goes into the summary next to its age;
|
|
494
|
+
// without one it is the failure.
|
|
495
|
+
if (cacheAgeMs >= 0)
|
|
496
|
+
return load(`could not rescan (${error.message})`);
|
|
497
|
+
fail(`Cannot read the MFT: ${error.message}.\ntsfind reads the NTFS MFT (Windows, NTFS, elevated prompt); to walk a tree instead, use tswalk.`);
|
|
498
|
+
}
|
|
499
|
+
let note = '';
|
|
500
|
+
try {
|
|
501
|
+
if (!options.quiet && process.stderr.isTTY)
|
|
502
|
+
process.stderr.write('Saving the scan for later runs...');
|
|
503
|
+
await saveIndex(index, cachePath, scannedAt);
|
|
504
|
+
clearProgress(options);
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
// Not lost: the search goes ahead on the scan in memory, and the summary says the cache was not saved.
|
|
508
|
+
clearProgress(options);
|
|
509
|
+
note = `not cached: ${error.code ?? error.message}`;
|
|
510
|
+
}
|
|
511
|
+
return { index, source: { scanned: true, ageMs: Date.now() - scannedAt.getTime(), cachePath, note } };
|
|
512
|
+
}
|
|
513
|
+
/** The entry for `top` in the index, by name from the root, case-insensitively as Windows does; -1 if absent */
|
|
514
|
+
function lookupEntry(index, top) {
|
|
515
|
+
const rel = top.replace(/^[A-Za-z]:/, '');
|
|
516
|
+
let at = 0;
|
|
517
|
+
for (const part of rel.split(/[\\/]+/).filter(Boolean)) {
|
|
518
|
+
const want = part.toLowerCase();
|
|
519
|
+
let found = -1;
|
|
520
|
+
// Children of `at` are the entries right after it, each followed by its own subtree.
|
|
521
|
+
for (let child = at + 1; child < index.subtreeEnd[at]; child = index.subtreeEnd[child]) {
|
|
522
|
+
if (index.names[child].toLowerCase() === want) {
|
|
523
|
+
found = child;
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (found < 0)
|
|
528
|
+
return -1;
|
|
529
|
+
at = found;
|
|
530
|
+
}
|
|
531
|
+
return at;
|
|
532
|
+
}
|
|
533
|
+
/** Full path of an entry, under the volume's current drive root */
|
|
534
|
+
function entryPath(index, rootPath, entry) {
|
|
535
|
+
const parts = [];
|
|
536
|
+
for (let at = entry; at > 0; at = index.parent[at])
|
|
537
|
+
parts.push(index.names[at]);
|
|
538
|
+
return rootPath + parts.reverse().join('\\');
|
|
539
|
+
}
|
|
540
|
+
/** Hand each match beneath `start` to onFound; returns the number of directories searched */
|
|
541
|
+
function searchIndex(index, start, rootPath, regexes, options, onFound) {
|
|
542
|
+
let dirs = 1;
|
|
543
|
+
const end = index.subtreeEnd[start];
|
|
544
|
+
for (let i = start + 1; i < end; i++) {
|
|
545
|
+
const flags = index.flags[i];
|
|
546
|
+
const isDir = (flags & (IndexFlags.Directory | IndexFlags.Link)) !== 0;
|
|
547
|
+
if (flags & IndexFlags.Directory && !(flags & IndexFlags.Link))
|
|
548
|
+
dirs++;
|
|
549
|
+
// Links have no entries beneath them in the index, so their contents are never searched.
|
|
550
|
+
const name = index.names[i];
|
|
551
|
+
if (!regexes.some(rx => rx.test(name)))
|
|
552
|
+
continue;
|
|
553
|
+
if (isDir && !options.dirs)
|
|
554
|
+
continue;
|
|
555
|
+
onFound({ path: entryPath(index, rootPath, i), name, size: isDir ? 0 : index.size[i], mtime: new Date(index.mtime[i]), isDir });
|
|
556
|
+
}
|
|
557
|
+
return dirs;
|
|
558
|
+
}
|
|
559
|
+
// ── Output ─────────────────────────────────────────────────────────
|
|
560
|
+
/** Write lines to the stream in batches, waiting for it to drain */
|
|
561
|
+
async function writeLines(out, lines) {
|
|
562
|
+
for (let i = 0; i < lines.length; i += WRITE_BATCH) {
|
|
563
|
+
const chunk = lines.slice(i, i + WRITE_BATCH).join('\n') + '\n';
|
|
564
|
+
if (!out.write(chunk))
|
|
565
|
+
await new Promise(resolve => out.once('drain', () => resolve()));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
function formatBytes(bytes) {
|
|
569
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
570
|
+
let value = bytes;
|
|
571
|
+
let unit = 0;
|
|
572
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
573
|
+
value /= 1024;
|
|
574
|
+
unit++;
|
|
575
|
+
}
|
|
576
|
+
return unit === 0 ? `${value} B` : `${value.toFixed(value < 10 ? 2 : 1)} ${units[unit]}`;
|
|
577
|
+
}
|
|
578
|
+
function formatDate(date) {
|
|
579
|
+
if (!date)
|
|
580
|
+
return ' '.repeat(16);
|
|
581
|
+
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
|
|
582
|
+
return local.toISOString().slice(0, 16).replace('T', ' ');
|
|
583
|
+
}
|
|
584
|
+
function elapsedText(ms) {
|
|
585
|
+
const totalSec = Math.round(ms / 1000);
|
|
586
|
+
const hours = Math.floor(totalSec / 3600);
|
|
587
|
+
const mins = Math.floor(totalSec / 60) % 60;
|
|
588
|
+
if (hours > 0)
|
|
589
|
+
return `${hours}h ${mins}m`;
|
|
590
|
+
return mins > 0 ? `${mins}m ${totalSec % 60}s` : `${(ms / 1000).toFixed(1)}s`;
|
|
591
|
+
}
|
|
592
|
+
function addTally(table, label, size) {
|
|
593
|
+
const key = label.toLowerCase();
|
|
594
|
+
const tally = table.get(key);
|
|
595
|
+
if (tally) {
|
|
596
|
+
tally.count++;
|
|
597
|
+
tally.bytes += size;
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
table.set(key, { label, count: 1, bytes: size });
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
function tallyLines(title, table, options) {
|
|
604
|
+
const rows = Array.from(table.values());
|
|
605
|
+
switch (options.sort) {
|
|
606
|
+
case 'size':
|
|
607
|
+
rows.sort((a, b) => b.bytes - a.bytes || b.count - a.count);
|
|
608
|
+
break;
|
|
609
|
+
case 'count':
|
|
610
|
+
rows.sort((a, b) => b.count - a.count || b.bytes - a.bytes);
|
|
611
|
+
break;
|
|
612
|
+
case 'name':
|
|
613
|
+
rows.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
const shown = options.rows > 0 ? rows.slice(0, options.rows) : rows;
|
|
617
|
+
const heading = shown.length < rows.length
|
|
618
|
+
? `${title}: top ${shown.length} of ${rows.length.toLocaleString()}, by ${options.sort}`
|
|
619
|
+
: `${title}: ${rows.length.toLocaleString()}, by ${options.sort}`;
|
|
620
|
+
return [
|
|
621
|
+
heading,
|
|
622
|
+
`${'Count'.padStart(12)} ${'Size'.padStart(10)} ${title}`,
|
|
623
|
+
...shown.map(t => `${t.count.toLocaleString().padStart(12)} ${formatBytes(t.bytes).padStart(10)} ${t.label}`),
|
|
624
|
+
'',
|
|
625
|
+
];
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* -hogs: the largest totals, by size, with each one's share of all the space matched. Either the
|
|
629
|
+
* top hogsRows, or with hogsPercent every row whose share is at least that.
|
|
630
|
+
*/
|
|
631
|
+
function hogsLines(table, totalBytes, options) {
|
|
632
|
+
const title = options.hogs === 'name' ? 'Name' : 'Extension';
|
|
633
|
+
const rows = Array.from(table.values()).sort((a, b) => b.bytes - a.bytes || b.count - a.count);
|
|
634
|
+
const share = (t) => totalBytes > 0 ? t.bytes / totalBytes * 100 : 0;
|
|
635
|
+
const shown = options.hogsPercent > 0
|
|
636
|
+
? rows.filter(t => share(t) >= options.hogsPercent)
|
|
637
|
+
: rows.slice(0, options.hogsRows);
|
|
638
|
+
const shownBytes = shown.reduce((sum, t) => sum + t.bytes, 0);
|
|
639
|
+
const which = options.hogsPercent > 0 ? `at least ${options.hogsPercent}% each` : `top ${shown.length}`;
|
|
640
|
+
return [
|
|
641
|
+
`Largest by ${title.toLowerCase()}: ${which}, of ${rows.length.toLocaleString()}; ` +
|
|
642
|
+
`together ${formatBytes(shownBytes)} of ${formatBytes(totalBytes)} (${(totalBytes > 0 ? shownBytes / totalBytes * 100 : 0).toFixed(1)}%)`,
|
|
643
|
+
`${'Count'.padStart(12)} ${'Size'.padStart(10)} ${'Share'.padStart(6)} ${title}`,
|
|
644
|
+
...shown.map(t => `${t.count.toLocaleString().padStart(12)} ${formatBytes(t.bytes).padStart(10)} ${(share(t).toFixed(1) + '%').padStart(6)} ${t.label}`),
|
|
645
|
+
...(shown.length === 0 && rows.length > 0 ? [`(none; the largest, ${rows[0].label}, takes ${share(rows[0]).toFixed(1)}%)`] : []),
|
|
646
|
+
'',
|
|
647
|
+
];
|
|
648
|
+
}
|
|
649
|
+
async function main() {
|
|
650
|
+
const options = parseArgs(process.argv.slice(2));
|
|
651
|
+
if (process.platform !== 'win32')
|
|
652
|
+
fail('tsfind reads the NTFS MFT, so it runs on Windows only; to walk a tree, use tswalk.');
|
|
653
|
+
await resolvePositional(options);
|
|
654
|
+
const regexes = compilePatterns(options.patterns);
|
|
655
|
+
let top;
|
|
656
|
+
try {
|
|
657
|
+
// realpath goes through junctions, so the folder is found where it really is in the MFT.
|
|
658
|
+
top = await fp.realpath(path.resolve(options.top));
|
|
659
|
+
if (!(await fp.stat(top)).isDirectory())
|
|
660
|
+
fail(`Not a directory: ${top}`);
|
|
661
|
+
}
|
|
662
|
+
catch (error) {
|
|
663
|
+
fail(`Cannot search ${options.top}: ${error.code ?? error.message}`);
|
|
664
|
+
}
|
|
665
|
+
const drive = /^([A-Za-z]):\\/.exec(top);
|
|
666
|
+
if (!drive)
|
|
667
|
+
fail(`${top} is not on a drive letter; tsfind reads a local NTFS volume's MFT. For a network path, use tswalk.`);
|
|
668
|
+
const rootPath = `${drive[1].toUpperCase()}:\\`;
|
|
669
|
+
// Open -o's file before searching, so a bad path fails now rather than after a long scan.
|
|
670
|
+
let outHandle;
|
|
671
|
+
if (options.outFile) {
|
|
672
|
+
try {
|
|
673
|
+
outHandle = await fp.open(options.outFile, 'w');
|
|
674
|
+
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
fail(`Cannot write ${options.outFile}: ${error.code ?? error.message}`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
// A closed pipe (e.g. | head) just ends the output.
|
|
680
|
+
process.stdout.on('error', (error) => {
|
|
681
|
+
if (error.code === 'EPIPE')
|
|
682
|
+
process.exit(0);
|
|
683
|
+
throw error;
|
|
684
|
+
});
|
|
685
|
+
const start = Date.now();
|
|
686
|
+
const { index, source } = await getIndex(top, options);
|
|
687
|
+
const entry = lookupEntry(index, top);
|
|
688
|
+
if (entry < 0) {
|
|
689
|
+
fail(`${top} is not in the ${source.scanned ? 'scan' : `cached scan from ${elapsedText(source.ageMs)} ago`}` +
|
|
690
|
+
`${source.scanned ? '' : '; -fresh rescans'}`);
|
|
691
|
+
}
|
|
692
|
+
const found = [];
|
|
693
|
+
const byName = new Map();
|
|
694
|
+
const byExt = new Map();
|
|
695
|
+
let matchCount = 0;
|
|
696
|
+
let matchBytes = 0;
|
|
697
|
+
const tallying = options.count || !!options.hogs;
|
|
698
|
+
const onFound = (f) => {
|
|
699
|
+
matchCount++;
|
|
700
|
+
matchBytes += f.size;
|
|
701
|
+
if (!tallying) {
|
|
702
|
+
found.push(f);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
addTally(byName, f.name, f.size);
|
|
706
|
+
addTally(byExt, f.isDir ? '(directory)' : path.extname(f.name) || '(none)', f.size);
|
|
707
|
+
};
|
|
708
|
+
const dirs = searchIndex(index, entry, rootPath, regexes, options, onFound);
|
|
709
|
+
const elapsed = Date.now() - start;
|
|
710
|
+
let lines;
|
|
711
|
+
if (options.hogs) {
|
|
712
|
+
lines = hogsLines(options.hogs === 'name' ? byName : byExt, matchBytes, options);
|
|
713
|
+
}
|
|
714
|
+
else if (options.count) {
|
|
715
|
+
lines = [...tallyLines('Name', byName, options), ...tallyLines('Extension', byExt, options)];
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
const keyed = found.map(f => ({ key: f.path.toLowerCase(), f }));
|
|
719
|
+
keyed.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
720
|
+
lines = options.long
|
|
721
|
+
? keyed.map(({ f }) => `${formatDate(f.mtime)} ${(f.isDir ? '<DIR>' : f.size.toLocaleString()).padStart(15)} ${f.path}`)
|
|
722
|
+
: keyed.map(({ f }) => f.path);
|
|
723
|
+
}
|
|
724
|
+
const out = outHandle ? outHandle.createWriteStream({ encoding: 'utf8' }) : process.stdout;
|
|
725
|
+
await writeLines(out, lines);
|
|
726
|
+
if (outHandle) {
|
|
727
|
+
out.end();
|
|
728
|
+
await finished(out);
|
|
729
|
+
}
|
|
730
|
+
if (!options.quiet) {
|
|
731
|
+
const what = options.dirs ? 'matches' : 'files';
|
|
732
|
+
const from = source.scanned
|
|
733
|
+
? `MFT scanned${source.note ? `, ${source.note}` : ''}`
|
|
734
|
+
: `cached scan ${elapsedText(source.ageMs)} old${source.note ? `, ${source.note}` : ''}`;
|
|
735
|
+
console.error(`${matchCount.toLocaleString()} ${what}, ${formatBytes(matchBytes)}; ${dirs.toLocaleString()} directories searched; ` +
|
|
736
|
+
`${elapsedText(elapsed)} (${from})${options.outFile ? `; written to ${path.resolve(options.outFile)}` : ''}`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
if (import.meta.main) {
|
|
740
|
+
await main();
|
|
741
|
+
}
|
|
742
|
+
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/tsfind",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Find files by name in a directory tree; list them for editing, or count them by name and extension. Optional NTFS MFT reading for big drives.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tsfind": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=24.0.0"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"find",
|
|
18
|
+
"file-search",
|
|
19
|
+
"directory",
|
|
20
|
+
"ntfs",
|
|
21
|
+
"mft",
|
|
22
|
+
"disk-usage"
|
|
23
|
+
],
|
|
24
|
+
"author": "Bob Frankston",
|
|
25
|
+
"license": "ISC",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git@github.com:BobFrankston/tsfind.git"
|
|
29
|
+
},
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/BobFrankston/tsfind/issues"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/BobFrankston/tsfind#readme",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@bobfrankston/mftwalk": "^0.1.3"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^25.3.0"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
".dependencies": {
|
|
44
|
+
"@bobfrankston/mftwalk": "file:../mftwalk"
|
|
45
|
+
},
|
|
46
|
+
".transformedSnapshot": {
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@bobfrankston/mftwalk": "^0.1.3"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|