@bobfrankston/tsfind 0.0.0-stage → 0.1.2
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 +95 -2
- package/index.d.ts +48 -0
- package/index.js +791 -0
- package/package.json +56 -4
package/README.md
CHANGED
|
@@ -1,3 +1,96 @@
|
|
|
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 saves
|
|
5
|
+
the scan, kept current by the drive's change journal, so repeated searches and analyses of the
|
|
6
|
+
same drive take seconds and always see it as it is now. By default it prints
|
|
7
|
+
bare paths, one per line, so the list can be edited (for example, cut down to the files to
|
|
8
|
+
delete). `-count` tallies the matches by name and by extension instead, and `-hogs` shows just the
|
|
9
|
+
largest.
|
|
10
|
+
|
|
11
|
+
A find-only companion to [tswalk](https://github.com/BobFrankston/tswalk). When the MFT cannot be
|
|
12
|
+
used (off Windows, non-NTFS or network drives, or no admin rights to be had without a UAC prompt),
|
|
13
|
+
tsfind walks the tree instead and says why.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g @bobfrankston/tsfind
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
tsfind [dir] [pattern] [options]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- **dir** - where to start. If the first argument is not a directory, it is the pattern and the
|
|
28
|
+
search starts in the current directory.
|
|
29
|
+
- **pattern** - file name: glob (`*` `?`), several separated by `;`, or `/regex/flags`.
|
|
30
|
+
Case-insensitive; matched against the name only, not the path. Default `*`.
|
|
31
|
+
|
|
32
|
+
| Flag | Description |
|
|
33
|
+
|------|-------------|
|
|
34
|
+
| `-o file` | Write the list (or the `-count` tables) to a file instead of the console |
|
|
35
|
+
| `-l` | Long: date and size before each path |
|
|
36
|
+
| `-d` | Also report directories and links whose names match (listed with no size) |
|
|
37
|
+
| `-count` | Number and total size of matches by name and by extension |
|
|
38
|
+
| `-sort key` | `-count` order: `size` (default), `count` or `name` |
|
|
39
|
+
| `-top N` | `-count` rows per table (default 50; 0 = all) |
|
|
40
|
+
| `-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% |
|
|
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 saved scan
|
|
48
|
+
|
|
49
|
+
Reading the MFT needs Windows and NTFS. The whole volume is read whatever
|
|
50
|
+
folder is searched: a 15 TB USB backup drive with 8.7M files takes about 5-8 minutes and 1.6 GB of
|
|
51
|
+
memory. Each read is saved through mftwalk in `%TEMP%\tsfind\mft`, one file per volume, keyed by
|
|
52
|
+
volume serial so it follows the drive to any letter. The drive's NTFS **change journal** keeps it
|
|
53
|
+
current (since 2026-11-07; before that tsfind kept its own snapshot, valid for `-age` minutes):
|
|
54
|
+
|
|
55
|
+
- **Unchanged** since the save: the saved scan is used as is. Seconds; no admin rights needed.
|
|
56
|
+
- **Changed**: only the MFT records the journal names are read again (admin rights).
|
|
57
|
+
- **No saved scan, or no journal**: the whole MFT is read (admin rights).
|
|
58
|
+
|
|
59
|
+
### Admin rights (2026-11-09)
|
|
60
|
+
|
|
61
|
+
When a read needs admin rights and tsfind is not elevated, it elevates by itself **only if Windows
|
|
62
|
+
would allow it without asking**: the account is an administrator and UAC is set not to prompt
|
|
63
|
+
(`ConsentPromptBehaviorAdmin` = 0). A hidden elevated copy of tsfind then brings the saved scan up
|
|
64
|
+
to date, and the search runs on it in your own, unelevated process, so pipes and `-o` work as
|
|
65
|
+
usual. Changes made in the seconds after that update are not included, and the summary says so.
|
|
66
|
+
|
|
67
|
+
If Windows would ask, tsfind never raises the popup, because it would stall a script. It says why
|
|
68
|
+
on stderr and walks the tree instead (slow on a big drive). Run it elevated for MFT speed.
|
|
69
|
+
|
|
70
|
+
The walk never enters links or junctions. Entries it cannot read are listed after the summary,
|
|
71
|
+
even with `-q`, since without them the results may be incomplete.
|
|
72
|
+
|
|
73
|
+
A drive without a change journal is read in full on every run; tsfind prints the command that
|
|
74
|
+
turns one on (`fsutil usn createjournal m=33554432 a=8388608 X:`, elevated). Measured cost of a
|
|
75
|
+
journal on a USB backup drive: creating, renaming and deleting many small files is about 10-30%
|
|
76
|
+
slower; large sequential writes are unaffected. The summary always says where the data came
|
|
77
|
+
from: the saved scan, the MFT, or a walk and why. A saved scan takes about 150-200 bytes per MFT record (S: here,
|
|
78
|
+
2.4M files: 455 MB).
|
|
79
|
+
|
|
80
|
+
A file with several hard links is found under one of its names only (mftwalk keeps one name per
|
|
81
|
+
MFT record); cargo's `target` directories are the common case.
|
|
82
|
+
|
|
83
|
+
## Examples
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
tsfind U:\ telemetrydata.db -o tele.txt # list them to tele.txt for editing
|
|
87
|
+
tsfind U:\ "*telemetry*" -l # with dates and sizes
|
|
88
|
+
tsfind U:\ -hogs ext # the 5 extensions taking the most space
|
|
89
|
+
tsfind U:\Aged -hogs 2% # every name taking at least 2% of U:\Aged
|
|
90
|
+
tsfind U:\ -count -top 30 # where the space goes, by name and extension
|
|
91
|
+
tsfind "*.bak;*.tmp" -count -sort count # in the current directory
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
ISC
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
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-11-11 — Claude Code (Opus 5.5), at Bob's direction: usage text and README brought into
|
|
8
|
+
* line: the same examples in both, and both say a walk lists unreadable entries even with -q.
|
|
9
|
+
* 2026-11-09 — Claude Code (Opus 5.5), at Bob's direction: "if you can elevate without a prompt
|
|
10
|
+
* do so, otherwise fall back", and the fallback is the walk ("bring back the walk"). When the
|
|
11
|
+
* saved MFT scan needs an update that needs admin rights, tsfind checks whether Windows would
|
|
12
|
+
* elevate without asking (an administrator, UAC set not to prompt: ConsentPromptBehaviorAdmin 0).
|
|
13
|
+
* If so, a hidden elevated copy of tsfind (-refreshscan X:) brings the saved scan up to date and
|
|
14
|
+
* the search runs on it, here, unelevated, so pipes and -o are unchanged. If Windows would ask,
|
|
15
|
+
* tsfind does NOT raise the popup (it would stall scripts): it says why and walks the tree.
|
|
16
|
+
* The walk also replaces the old "use tswalk" failures: off Windows, non-NTFS, network paths.
|
|
17
|
+
* A stale saved scan is no longer used as is; Bob chose the walk over it.
|
|
18
|
+
* 2026-11-07 — Claude Code (Opus 5.5), at Bob's direction ("the default should be to use the
|
|
19
|
+
* journal"): tsfind's own cache is gone. It now uses mftwalk's saved scans (ScanOptions.cache),
|
|
20
|
+
* which the NTFS change journal keeps current: an unchanged volume loads the saved scan without
|
|
21
|
+
* elevation, a changed one re-reads only the changed records, a full read happens only with no
|
|
22
|
+
* saved scan or no journal. So results are current, not a snapshot up to an hour old, and -age
|
|
23
|
+
* and -fresh are gone. tsfind searches the MftVolume directly; its preorder index and zstd cache
|
|
24
|
+
* file (TSFINDX1) existed only to be cached, and are removed. If an update needs admin rights
|
|
25
|
+
* that are not there, the saved scan is used as it is and the summary says it is not current.
|
|
26
|
+
* 2026-10-27 (later) — Claude Code (Opus 5.5), at Bob's direction: MFT only, with a cache.
|
|
27
|
+
* Bob: "Walking is expensive and is already handled by tswalk... the point of tsfind is to be
|
|
28
|
+
* fast. Having a compressed cache that is valid for a limited time allows me to run multiple
|
|
29
|
+
* analyses." So the walk, -nomft, -mft and -v are gone. A scan of a volume's MFT is turned into
|
|
30
|
+
* tsfind's own index (built only through mftwalk's public accessors) and saved compressed (zstd)
|
|
31
|
+
* under the temp directory, one file per volume, keyed by the volume serial number. A cache
|
|
32
|
+
* younger than -age minutes (default 60) is used instead of rescanning; -fresh rescans. Because
|
|
33
|
+
* reading the cache needs no elevation, only the scan does, later runs work from any prompt.
|
|
34
|
+
* When a rescan is not possible (not elevated) but an older cache exists, it is used and its
|
|
35
|
+
* age is reported. The start directory goes through realpath, so a path through a junction works.
|
|
36
|
+
* 2026-10-27 — Claude Code (Opus 5.5), at Bob's direction:
|
|
37
|
+
* - The directory is now the first argument: `tsfind dir [pattern]`. If the first argument is not
|
|
38
|
+
* a directory it is taken as the pattern and the search starts in the current directory.
|
|
39
|
+
* The pattern defaults to * (all files).
|
|
40
|
+
* - -hogs [name|ext] [N|N%]: the largest totals by name (default) or by extension; the top N
|
|
41
|
+
* (default 5), or with N% every one taking at least N% of the space matched.
|
|
42
|
+
* 2026-10-14 — Claude Code (Opus 5.5), at Bob's direction: created. A find-only companion to tswalk,
|
|
43
|
+
* so tswalk is not overloaded. The default output is bare paths, one per line, so the list can be
|
|
44
|
+
* edited and used for cleanup. -count tallies matches by name and by extension. First use:
|
|
45
|
+
* telemetrydata.db files on the backup drives.
|
|
46
|
+
*/
|
|
47
|
+
export {};
|
|
48
|
+
//# sourceMappingURL=index.d.ts.map
|
package/index.js
ADDED
|
@@ -0,0 +1,791 @@
|
|
|
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-11-11 — Claude Code (Opus 5.5), at Bob's direction: usage text and README brought into
|
|
8
|
+
* line: the same examples in both, and both say a walk lists unreadable entries even with -q.
|
|
9
|
+
* 2026-11-09 — Claude Code (Opus 5.5), at Bob's direction: "if you can elevate without a prompt
|
|
10
|
+
* do so, otherwise fall back", and the fallback is the walk ("bring back the walk"). When the
|
|
11
|
+
* saved MFT scan needs an update that needs admin rights, tsfind checks whether Windows would
|
|
12
|
+
* elevate without asking (an administrator, UAC set not to prompt: ConsentPromptBehaviorAdmin 0).
|
|
13
|
+
* If so, a hidden elevated copy of tsfind (-refreshscan X:) brings the saved scan up to date and
|
|
14
|
+
* the search runs on it, here, unelevated, so pipes and -o are unchanged. If Windows would ask,
|
|
15
|
+
* tsfind does NOT raise the popup (it would stall scripts): it says why and walks the tree.
|
|
16
|
+
* The walk also replaces the old "use tswalk" failures: off Windows, non-NTFS, network paths.
|
|
17
|
+
* A stale saved scan is no longer used as is; Bob chose the walk over it.
|
|
18
|
+
* 2026-11-07 — Claude Code (Opus 5.5), at Bob's direction ("the default should be to use the
|
|
19
|
+
* journal"): tsfind's own cache is gone. It now uses mftwalk's saved scans (ScanOptions.cache),
|
|
20
|
+
* which the NTFS change journal keeps current: an unchanged volume loads the saved scan without
|
|
21
|
+
* elevation, a changed one re-reads only the changed records, a full read happens only with no
|
|
22
|
+
* saved scan or no journal. So results are current, not a snapshot up to an hour old, and -age
|
|
23
|
+
* and -fresh are gone. tsfind searches the MftVolume directly; its preorder index and zstd cache
|
|
24
|
+
* file (TSFINDX1) existed only to be cached, and are removed. If an update needs admin rights
|
|
25
|
+
* that are not there, the saved scan is used as it is and the summary says it is not current.
|
|
26
|
+
* 2026-10-27 (later) — Claude Code (Opus 5.5), at Bob's direction: MFT only, with a cache.
|
|
27
|
+
* Bob: "Walking is expensive and is already handled by tswalk... the point of tsfind is to be
|
|
28
|
+
* fast. Having a compressed cache that is valid for a limited time allows me to run multiple
|
|
29
|
+
* analyses." So the walk, -nomft, -mft and -v are gone. A scan of a volume's MFT is turned into
|
|
30
|
+
* tsfind's own index (built only through mftwalk's public accessors) and saved compressed (zstd)
|
|
31
|
+
* under the temp directory, one file per volume, keyed by the volume serial number. A cache
|
|
32
|
+
* younger than -age minutes (default 60) is used instead of rescanning; -fresh rescans. Because
|
|
33
|
+
* reading the cache needs no elevation, only the scan does, later runs work from any prompt.
|
|
34
|
+
* When a rescan is not possible (not elevated) but an older cache exists, it is used and its
|
|
35
|
+
* age is reported. The start directory goes through realpath, so a path through a junction works.
|
|
36
|
+
* 2026-10-27 — Claude Code (Opus 5.5), at Bob's direction:
|
|
37
|
+
* - The directory is now the first argument: `tsfind dir [pattern]`. If the first argument is not
|
|
38
|
+
* a directory it is taken as the pattern and the search starts in the current directory.
|
|
39
|
+
* The pattern defaults to * (all files).
|
|
40
|
+
* - -hogs [name|ext] [N|N%]: the largest totals by name (default) or by extension; the top N
|
|
41
|
+
* (default 5), or with N% every one taking at least N% of the space matched.
|
|
42
|
+
* 2026-10-14 — Claude Code (Opus 5.5), at Bob's direction: created. A find-only companion to tswalk,
|
|
43
|
+
* so tswalk is not overloaded. The default output is bare paths, one per line, so the list can be
|
|
44
|
+
* edited and used for cleanup. -count tallies matches by name and by extension. First use:
|
|
45
|
+
* telemetrydata.db files on the backup drives.
|
|
46
|
+
*/
|
|
47
|
+
import * as fp from 'node:fs/promises';
|
|
48
|
+
import * as os from 'node:os';
|
|
49
|
+
import { getDriveInfo } from '@bobfrankston/dirutil';
|
|
50
|
+
import * as path from 'node:path';
|
|
51
|
+
import { styleText } from 'node:util';
|
|
52
|
+
import { finished } from 'node:stream/promises';
|
|
53
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
54
|
+
import packageJson from './package.json' with { type: 'json' };
|
|
55
|
+
const version = packageJson.version;
|
|
56
|
+
/** Default rows per -count table */
|
|
57
|
+
const DEFAULT_TOP = 50;
|
|
58
|
+
/** Default rows for -hogs */
|
|
59
|
+
const DEFAULT_HOGS = 5;
|
|
60
|
+
/** Lines handed to the output stream per write */
|
|
61
|
+
const WRITE_BATCH = 10000;
|
|
62
|
+
const CLEAR_LINE = '\x1b[2K\r';
|
|
63
|
+
/**
|
|
64
|
+
* Change journal size suggested when a drive has none (Windows' usual 32 MB, 8 MB steps); it only
|
|
65
|
+
* has to hold the changes between two runs. Same suggestion as tswalk's.
|
|
66
|
+
*/
|
|
67
|
+
const SUGGESTED_JOURNAL = 'm=33554432 a=8388608';
|
|
68
|
+
/** Directories read at once by the walk */
|
|
69
|
+
const WALK_CONCURRENCY = 16;
|
|
70
|
+
/** Unreadable directories named in the walk's summary; the rest are only counted */
|
|
71
|
+
const WALK_ERRORS_SHOWN = 5;
|
|
72
|
+
/** Well-known SIDs in `whoami /groups` */
|
|
73
|
+
const Sids = {
|
|
74
|
+
Administrators: 'S-1-5-32-544', /** BUILTIN\Administrators (deny-only in an unelevated admin token) */
|
|
75
|
+
HighIntegrity: 'S-1-16-12288', /** High Mandatory Level: the process is elevated */
|
|
76
|
+
};
|
|
77
|
+
/** HKLM key holding the UAC settings */
|
|
78
|
+
const UAC_KEY = 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System';
|
|
79
|
+
/** ConsentPromptBehaviorAdmin value meaning "elevate administrators without prompting" */
|
|
80
|
+
const UAC_ELEVATE_WITHOUT_PROMPT = 0;
|
|
81
|
+
function usage() {
|
|
82
|
+
return `tsfind v${version} - find files by name on an NTFS volume, fast, from its Master File Table
|
|
83
|
+
|
|
84
|
+
Usage: tsfind [dir] [pattern] [options]
|
|
85
|
+
dir Where to start. If the first argument is not a directory, it is the pattern
|
|
86
|
+
and the search starts in the current directory.
|
|
87
|
+
pattern File name to find: glob (* ?), several separated by ;, or /regex/flags.
|
|
88
|
+
Case-insensitive; matched against the name only, not the path. Default: *
|
|
89
|
+
|
|
90
|
+
Options:
|
|
91
|
+
-o file Write the list (or the tables) to file instead of the console
|
|
92
|
+
-l Long: date and size before each path (default: path only, one per line,
|
|
93
|
+
so the list can be edited)
|
|
94
|
+
-d Also report directories and links whose names match (listed with no size;
|
|
95
|
+
the search never goes inside links or junctions)
|
|
96
|
+
-count Instead of listing: number and total size of matches by name and by
|
|
97
|
+
extension
|
|
98
|
+
-sort key -count order: size (default), count or name
|
|
99
|
+
-top N -count rows per table (default ${DEFAULT_TOP}; 0 = all)
|
|
100
|
+
-hogs [name|ext] [N|N%]
|
|
101
|
+
Instead of listing: where the space goes. The largest totals by name
|
|
102
|
+
(default) or by extension, with each one's share of the space matched.
|
|
103
|
+
N = how many (default ${DEFAULT_HOGS}); N% = every one taking at least N%.
|
|
104
|
+
-q No progress or summary
|
|
105
|
+
-h, -help This message
|
|
106
|
+
-version Version
|
|
107
|
+
|
|
108
|
+
How it works: tsfind reads the volume's NTFS Master File Table (Windows, NTFS) rather than walking:
|
|
109
|
+
minutes instead of the hours a walk can take on a big drive. Each read is saved, one file per
|
|
110
|
+
volume, in ${cacheDir()}, and the drive's change journal keeps it current:
|
|
111
|
+
- unchanged since the save: the saved scan is used as is (seconds; no admin rights needed)
|
|
112
|
+
- changed: only the changed MFT records are read again (admin rights)
|
|
113
|
+
- no saved scan yet, or no change journal: the whole MFT is read (admin rights)
|
|
114
|
+
So every search sees the drive as it is now. A drive without a change journal is read in full
|
|
115
|
+
every time; tsfind prints the fsutil command that turns one on.
|
|
116
|
+
|
|
117
|
+
Admin rights: when a read needs them and tsfind is not elevated, it elevates by itself only if
|
|
118
|
+
Windows would allow it without asking (you are an administrator and UAC is set not to prompt).
|
|
119
|
+
Otherwise it never raises a UAC popup, which would stall a script: it says why on stderr and
|
|
120
|
+
walks the tree instead (slow on a big drive). It also walks off Windows, on non-NTFS drives and
|
|
121
|
+
on network paths. The summary always says which it did. Entries a walk cannot read are listed
|
|
122
|
+
after the summary, even with -q, since the results may then be incomplete.
|
|
123
|
+
|
|
124
|
+
Progress and the summary go to stderr, so stdout carries only the list.
|
|
125
|
+
|
|
126
|
+
Examples:
|
|
127
|
+
tsfind U:\\ telemetrydata.db -o tele.txt List them to tele.txt for editing
|
|
128
|
+
tsfind U:\\ "*telemetry*" -l With dates and sizes
|
|
129
|
+
tsfind U:\\ -hogs ext The 5 extensions taking the most space
|
|
130
|
+
tsfind U:\\Aged -hogs 2% Every name taking at least 2% of U:\\Aged
|
|
131
|
+
tsfind U:\\ -count -top 30 Where the space goes, by name and extension
|
|
132
|
+
tsfind "*.bak;*.tmp" -count -sort count In the current directory
|
|
133
|
+
`;
|
|
134
|
+
}
|
|
135
|
+
function fail(message) {
|
|
136
|
+
console.error(styleText(['red'], message));
|
|
137
|
+
console.error('tsfind -help for usage');
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
function parseArgs(args) {
|
|
141
|
+
if (args.length === 0) {
|
|
142
|
+
console.log(usage());
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
const options = {
|
|
146
|
+
positional: [], patterns: null, top: '.', outFile: '', long: false, dirs: false, count: false,
|
|
147
|
+
sort: 'size', rows: DEFAULT_TOP, hogs: null, hogsRows: DEFAULT_HOGS, hogsPercent: 0, quiet: false,
|
|
148
|
+
refreshScan: '',
|
|
149
|
+
};
|
|
150
|
+
for (let i = 0; i < args.length; i++) {
|
|
151
|
+
const arg = args[i];
|
|
152
|
+
if (!arg.startsWith('-') || arg === '-') {
|
|
153
|
+
options.positional.push(arg);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const needValue = () => {
|
|
157
|
+
if (i + 1 >= args.length)
|
|
158
|
+
fail(`${arg} needs a value`);
|
|
159
|
+
return args[++i];
|
|
160
|
+
};
|
|
161
|
+
switch (arg.replace(/^--/, '-').toLowerCase()) {
|
|
162
|
+
case '-o':
|
|
163
|
+
options.outFile = needValue();
|
|
164
|
+
break;
|
|
165
|
+
case '-l':
|
|
166
|
+
options.long = true;
|
|
167
|
+
break;
|
|
168
|
+
case '-d':
|
|
169
|
+
options.dirs = true;
|
|
170
|
+
break;
|
|
171
|
+
case '-count':
|
|
172
|
+
options.count = true;
|
|
173
|
+
break;
|
|
174
|
+
case '-sort': {
|
|
175
|
+
const key = needValue().toLowerCase();
|
|
176
|
+
if (key !== 'size' && key !== 'count' && key !== 'name')
|
|
177
|
+
fail(`-sort must be size, count or name, not ${key}`);
|
|
178
|
+
options.sort = key;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case '-top': {
|
|
182
|
+
const n = needValue();
|
|
183
|
+
if (!/^\d+$/.test(n))
|
|
184
|
+
fail(`-top needs a number, not ${n}`);
|
|
185
|
+
options.rows = parseInt(n);
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
case '-hogs': {
|
|
189
|
+
options.hogs = 'name';
|
|
190
|
+
// Both qualifiers are optional and may come in either order: -hogs ext 10, -hogs 3% name.
|
|
191
|
+
for (let taken = 0; taken < 2 && i + 1 < args.length; taken++) {
|
|
192
|
+
const next = args[i + 1].toLowerCase();
|
|
193
|
+
const amount = /^(\d+(?:\.\d+)?)(%?)$/.exec(next);
|
|
194
|
+
if (next === 'name' || next === 'ext' || next === 'extension') {
|
|
195
|
+
options.hogs = next === 'name' ? 'name' : 'ext';
|
|
196
|
+
}
|
|
197
|
+
else if (amount && amount[2]) {
|
|
198
|
+
options.hogsPercent = parseFloat(amount[1]);
|
|
199
|
+
if (options.hogsPercent <= 0 || options.hogsPercent > 100)
|
|
200
|
+
fail(`-hogs ${next}: the percentage must be above 0 and at most 100`);
|
|
201
|
+
}
|
|
202
|
+
else if (amount) {
|
|
203
|
+
if (!/^\d+$/.test(next) || parseInt(next) === 0)
|
|
204
|
+
fail(`-hogs ${next}: the count must be a whole number above 0`);
|
|
205
|
+
options.hogsRows = parseInt(next);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
i++;
|
|
211
|
+
}
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
// 2026-11-07: retired with tsfind's own cache; said explicitly rather than "Unknown option".
|
|
215
|
+
case '-age':
|
|
216
|
+
case '-fresh':
|
|
217
|
+
fail(`${arg} is gone: the drive's change journal now keeps the saved scan current, so every search is up to date.`);
|
|
218
|
+
case '-q':
|
|
219
|
+
options.quiet = true;
|
|
220
|
+
break;
|
|
221
|
+
case '-refreshscan': {
|
|
222
|
+
const drive = needValue();
|
|
223
|
+
if (!/^[A-Za-z]:$/.test(drive))
|
|
224
|
+
fail(`-refreshscan needs a drive like C:, not ${drive}`);
|
|
225
|
+
options.refreshScan = drive.toUpperCase();
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
case '-h':
|
|
229
|
+
case '-help':
|
|
230
|
+
console.log(usage());
|
|
231
|
+
process.exit(0);
|
|
232
|
+
case '-version':
|
|
233
|
+
console.log(`tsfind v${version}`);
|
|
234
|
+
process.exit(0);
|
|
235
|
+
default:
|
|
236
|
+
fail(`Unknown option: ${arg}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (options.positional.length > 2)
|
|
240
|
+
fail(`Too many arguments: ${options.positional.join(' ')} (dir, then pattern)`);
|
|
241
|
+
if (options.hogs && options.count)
|
|
242
|
+
fail('Use -count or -hogs, not both');
|
|
243
|
+
return options;
|
|
244
|
+
}
|
|
245
|
+
/** A /body/flags pattern, which is a regex rather than a glob */
|
|
246
|
+
function isRegexPattern(p) {
|
|
247
|
+
return p.startsWith('/') && p.lastIndexOf('/') > 0;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Sort out [dir] [pattern]: the first argument is the directory if it is one; otherwise it is the
|
|
251
|
+
* pattern and the search starts in the current directory. Something that looks like a path but
|
|
252
|
+
* is not a directory is reported, rather than searched for as a file name it could never match.
|
|
253
|
+
*/
|
|
254
|
+
async function resolvePositional(options) {
|
|
255
|
+
const [first, second] = options.positional;
|
|
256
|
+
let pattern = '*';
|
|
257
|
+
if (first !== undefined) {
|
|
258
|
+
let isDir = false;
|
|
259
|
+
let statError = '';
|
|
260
|
+
try {
|
|
261
|
+
isDir = (await fp.stat(first)).isDirectory();
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
// Not lost: "no such directory" is the ordinary sign that first is a pattern; if first
|
|
265
|
+
// looks like a path instead, the code is reported by the fail just below.
|
|
266
|
+
statError = error.code ?? error.message;
|
|
267
|
+
}
|
|
268
|
+
const looksLikePath = !isRegexPattern(first) && /[\\/:]/.test(first);
|
|
269
|
+
if (isDir) {
|
|
270
|
+
options.top = first;
|
|
271
|
+
if (second !== undefined)
|
|
272
|
+
pattern = second;
|
|
273
|
+
}
|
|
274
|
+
else if (looksLikePath) {
|
|
275
|
+
fail(statError ? `Cannot search ${first}: ${statError}` : `Not a directory: ${first}`);
|
|
276
|
+
}
|
|
277
|
+
else if (second !== undefined) {
|
|
278
|
+
fail(`${first} is not a directory; the directory comes first: tsfind dir [pattern]`);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
pattern = first;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
options.patterns = pattern.split(';').filter(Boolean);
|
|
285
|
+
if (options.patterns.length === 0)
|
|
286
|
+
fail('Empty pattern');
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Name patterns to RegExps: /body/flags is a regex; anything else is a glob, anchored and
|
|
290
|
+
* case-insensitive, in which only * and ? are special.
|
|
291
|
+
*/
|
|
292
|
+
function compilePatterns(patterns) {
|
|
293
|
+
return patterns.map(p => {
|
|
294
|
+
const last = p.lastIndexOf('/');
|
|
295
|
+
if (isRegexPattern(p)) {
|
|
296
|
+
try {
|
|
297
|
+
return new RegExp(p.slice(1, last), p.slice(last + 1));
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
fail(`Bad regex ${p}: ${error.message}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const body = p.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
304
|
+
return new RegExp('^' + body + '$', 'i');
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
let progressAt = 0;
|
|
308
|
+
function progress(options, text) {
|
|
309
|
+
if (options.quiet || !process.stderr.isTTY)
|
|
310
|
+
return;
|
|
311
|
+
const now = Date.now();
|
|
312
|
+
if (now - progressAt < 1000)
|
|
313
|
+
return;
|
|
314
|
+
progressAt = now;
|
|
315
|
+
process.stderr.write(CLEAR_LINE + text);
|
|
316
|
+
}
|
|
317
|
+
function clearProgress(options) {
|
|
318
|
+
if (!options.quiet && process.stderr.isTTY)
|
|
319
|
+
process.stderr.write(CLEAR_LINE);
|
|
320
|
+
}
|
|
321
|
+
// ── Volume: mftwalk's saved scan, kept current by the change journal ──
|
|
322
|
+
function cacheDir() {
|
|
323
|
+
return path.join(os.tmpdir(), 'tsfind', 'mft');
|
|
324
|
+
}
|
|
325
|
+
/** One line saying where the volume's data came from, for the summary */
|
|
326
|
+
function sourceText(volume) {
|
|
327
|
+
const origin = volume.origin;
|
|
328
|
+
switch (origin.source) {
|
|
329
|
+
case 'cache':
|
|
330
|
+
return origin.changed
|
|
331
|
+
? `saved MFT scan, NOT current: ${origin.reason}`
|
|
332
|
+
: `saved MFT scan: ${origin.reason}`;
|
|
333
|
+
case 'updated':
|
|
334
|
+
return origin.reason;
|
|
335
|
+
}
|
|
336
|
+
return `MFT read in full: ${origin.reason}`;
|
|
337
|
+
}
|
|
338
|
+
/** A walk, with the reason it is not the MFT, said now on stderr and again in the summary */
|
|
339
|
+
function walkSource(top, reason, options) {
|
|
340
|
+
if (!options.quiet)
|
|
341
|
+
console.error(styleText(['red'], `Walking ${top} instead of reading the MFT: ${reason}`));
|
|
342
|
+
return { volume: null, text: `directory walk, not the MFT: ${reason}`, stale: false };
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Can this process get admin rights without anyone being asked? True only for an administrator
|
|
346
|
+
* (deny-only Administrators in this unelevated token) on a machine whose UAC elevates
|
|
347
|
+
* administrators without prompting. `why` says what stands in the way when it is false.
|
|
348
|
+
*/
|
|
349
|
+
function silentElevation() {
|
|
350
|
+
const groups = spawnSync('whoami', ['/groups', '/fo', 'csv', '/nh'], { encoding: 'utf8' });
|
|
351
|
+
if (groups.status !== 0) {
|
|
352
|
+
return { possible: false, why: `cannot tell whether elevation would prompt (whoami: ${groups.error?.message ?? groups.stderr.trim()})` };
|
|
353
|
+
}
|
|
354
|
+
if (groups.stdout.includes(Sids.HighIntegrity)) {
|
|
355
|
+
return { possible: false, why: 'Windows refused the MFT even though tsfind is already elevated' };
|
|
356
|
+
}
|
|
357
|
+
if (!groups.stdout.includes(Sids.Administrators)) {
|
|
358
|
+
return { possible: false, why: 'it needs admin rights, and this account is not an administrator' };
|
|
359
|
+
}
|
|
360
|
+
const uac = spawnSync('reg', ['query', UAC_KEY, '/v', 'ConsentPromptBehaviorAdmin'], { encoding: 'utf8' });
|
|
361
|
+
const value = /ConsentPromptBehaviorAdmin\s+REG_DWORD\s+0x([0-9a-f]+)/i.exec(uac.stdout ?? '');
|
|
362
|
+
if (!value) {
|
|
363
|
+
return { possible: false, why: `it needs admin rights, and the UAC setting cannot be read (${uac.error?.message ?? (uac.stderr.trim() || 'no ConsentPromptBehaviorAdmin')}), so elevating might raise a prompt` };
|
|
364
|
+
}
|
|
365
|
+
const behavior = parseInt(value[1], 16);
|
|
366
|
+
if (behavior !== UAC_ELEVATE_WITHOUT_PROMPT) {
|
|
367
|
+
return { possible: false, why: `it needs admin rights, and UAC here would ask (ConsentPromptBehaviorAdmin ${behavior}); tsfind will not raise a popup that stalls a script. Run tsfind elevated for MFT speed` };
|
|
368
|
+
}
|
|
369
|
+
return { possible: true, why: '' };
|
|
370
|
+
}
|
|
371
|
+
/** Where the hidden elevated copy leaves its result, since it has no console the caller can see */
|
|
372
|
+
function refreshResultFile(driveName) {
|
|
373
|
+
return path.join(os.tmpdir(), 'tsfind', `refresh-${driveName[0]}.txt`);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Bring driveName's saved MFT scan up to date from a hidden, elevated copy of tsfind
|
|
377
|
+
* (-refreshscan). Only called when silentElevation() says Windows will not ask; the "runas" verb
|
|
378
|
+
* is Windows' own elevation request. Returns '' on success, else why it failed.
|
|
379
|
+
*/
|
|
380
|
+
async function refreshElevated(driveName, options) {
|
|
381
|
+
const resultFile = refreshResultFile(driveName);
|
|
382
|
+
await fp.mkdir(path.dirname(resultFile), { recursive: true });
|
|
383
|
+
await fp.rm(resultFile, { force: true });
|
|
384
|
+
const psQuote = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
385
|
+
const childArgs = `"${import.meta.filename}" -refreshscan ${driveName}`;
|
|
386
|
+
const command = `$p = Start-Process -FilePath ${psQuote(process.execPath)} -ArgumentList ${psQuote(childArgs)} ` +
|
|
387
|
+
`-Verb RunAs -WindowStyle Hidden -Wait -PassThru; exit $p.ExitCode`;
|
|
388
|
+
const started = Date.now();
|
|
389
|
+
const ticker = setInterval(() => progress(options, `Updating ${driveName}'s saved MFT scan, elevated: ${elapsedText(Date.now() - started)}`), 1000);
|
|
390
|
+
const { code, stderr } = await new Promise(resolve => {
|
|
391
|
+
const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
392
|
+
let errText = '';
|
|
393
|
+
child.stderr.on('data', chunk => errText += chunk);
|
|
394
|
+
child.on('error', error => resolve({ code: -1, stderr: error.message }));
|
|
395
|
+
child.on('close', exitCode => resolve({ code: exitCode, stderr: errText }));
|
|
396
|
+
});
|
|
397
|
+
clearInterval(ticker);
|
|
398
|
+
clearProgress(options);
|
|
399
|
+
let result = '';
|
|
400
|
+
try {
|
|
401
|
+
result = (await fp.readFile(resultFile, 'utf8')).trim();
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
// Not lost: no result file means the elevated copy never ran or died early; the failure
|
|
405
|
+
// below then reports PowerShell's own error and the exit code instead.
|
|
406
|
+
result = '';
|
|
407
|
+
}
|
|
408
|
+
if (code === 0 && result === 'ok')
|
|
409
|
+
return '';
|
|
410
|
+
return result || stderr.trim().split('\n')[0] || `the elevated copy of tsfind exited with code ${code}`;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* -refreshscan X: the hidden elevated copy. Brings X:'s saved scan up to date (mftwalk: updated
|
|
414
|
+
* from the journal, or read in full) and leaves 'ok' or the error in refreshResultFile.
|
|
415
|
+
*/
|
|
416
|
+
async function refreshScanAndExit(driveName) {
|
|
417
|
+
const resultFile = refreshResultFile(driveName);
|
|
418
|
+
let result = 'ok';
|
|
419
|
+
try {
|
|
420
|
+
const { scanVolume } = await import('@bobfrankston/mftwalk');
|
|
421
|
+
await scanVolume(driveName + '\\', { cache: { dir: cacheDir() } });
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
result = error.message ?? String(error);
|
|
425
|
+
}
|
|
426
|
+
await fp.mkdir(path.dirname(resultFile), { recursive: true });
|
|
427
|
+
await fp.writeFile(resultFile, result);
|
|
428
|
+
process.exit(result === 'ok' ? 0 : 1);
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Where to search `top`: the volume from mftwalk's saved scan (see usage: unchanged -> saved scan,
|
|
432
|
+
* changed -> updated from the journal, else a full read). When that needs admin rights tsfind does
|
|
433
|
+
* not have, it elevates only if Windows will not ask; otherwise, and whenever the MFT cannot be
|
|
434
|
+
* used at all, it walks the tree and says why.
|
|
435
|
+
*/
|
|
436
|
+
async function getSource(top, options) {
|
|
437
|
+
if (process.platform !== 'win32')
|
|
438
|
+
return walkSource(top, 'the MFT is read on Windows only', options);
|
|
439
|
+
if (!/^[A-Za-z]:\\/.test(top))
|
|
440
|
+
return walkSource(top, 'not on a drive letter (a network path?)', options);
|
|
441
|
+
const drive = getDriveInfo(top.slice(0, 3));
|
|
442
|
+
if (drive.fileSystem !== 'NTFS')
|
|
443
|
+
return walkSource(top, `${top.slice(0, 2)} is ${drive.fileSystem || 'not a local file system'}, not NTFS`, options);
|
|
444
|
+
const driveName = top.slice(0, 2).toUpperCase();
|
|
445
|
+
const label = drive.volumeName ? `"${drive.volumeName}"` : '(no label)';
|
|
446
|
+
if (!options.quiet)
|
|
447
|
+
console.error(styleText(['blue'], `Reading ${driveName} ${label}: saved MFT scan if current, else from the MFT`));
|
|
448
|
+
const { scanVolume, MftError } = await import('@bobfrankston/mftwalk');
|
|
449
|
+
const scanOptions = {
|
|
450
|
+
onProgress: (done, total) => progress(options, `MFT ${done.toLocaleString()} / ${total.toLocaleString()} records`),
|
|
451
|
+
cache: { dir: cacheDir() },
|
|
452
|
+
log: msg => { if (!options.quiet)
|
|
453
|
+
console.error(styleText(['red'], msg)); },
|
|
454
|
+
};
|
|
455
|
+
try {
|
|
456
|
+
const volume = await scanVolume(top, scanOptions);
|
|
457
|
+
clearProgress(options);
|
|
458
|
+
return { volume, text: sourceText(volume), stale: volume.origin.changed };
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
clearProgress(options);
|
|
462
|
+
if (!(error instanceof MftError))
|
|
463
|
+
throw error;
|
|
464
|
+
// Not swallowed: the reason goes to stderr and into the summary, and the walk takes over.
|
|
465
|
+
if (error.code !== 'EPERM')
|
|
466
|
+
return walkSource(top, `cannot read the MFT of ${driveName}: ${error.message}`, options);
|
|
467
|
+
}
|
|
468
|
+
const elevation = silentElevation();
|
|
469
|
+
if (!elevation.possible)
|
|
470
|
+
return walkSource(top, elevation.why, options);
|
|
471
|
+
if (!options.quiet)
|
|
472
|
+
console.error(styleText(['blue'], `Updating ${driveName}'s saved MFT scan elevated (UAC here elevates administrators without asking)`));
|
|
473
|
+
const refreshError = await refreshElevated(driveName, options);
|
|
474
|
+
if (refreshError)
|
|
475
|
+
return walkSource(top, `the elevated update of ${driveName}'s saved scan failed: ${refreshError}`, options);
|
|
476
|
+
const updatedAt = new Date();
|
|
477
|
+
try {
|
|
478
|
+
// trust: the saved scan was brought up to date a moment ago; changes since then (a busy
|
|
479
|
+
// C: always has some) would need admin rights again, so they are named, not read.
|
|
480
|
+
const volume = await scanVolume(top, { ...scanOptions, cache: { dir: cacheDir(), trust: true } });
|
|
481
|
+
clearProgress(options);
|
|
482
|
+
const text = volume.origin.changed
|
|
483
|
+
? `saved MFT scan, brought up to date elevated at ${updatedAt.toLocaleTimeString()} (changes after that are not included)`
|
|
484
|
+
: sourceText(volume);
|
|
485
|
+
return { volume, text, stale: false };
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
clearProgress(options);
|
|
489
|
+
if (!(error instanceof MftError))
|
|
490
|
+
throw error;
|
|
491
|
+
return walkSource(top, `cannot load ${driveName}'s saved scan after updating it: ${error.message}`, options);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
// ── Walk: the classic way, when the MFT cannot be used ────────────
|
|
495
|
+
/** Run at most `max` jobs at once; the rest wait their turn */
|
|
496
|
+
function makeLimiter(max) {
|
|
497
|
+
let running = 0;
|
|
498
|
+
const waiting = [];
|
|
499
|
+
return async (job) => {
|
|
500
|
+
if (running >= max)
|
|
501
|
+
await new Promise(resolve => waiting.push(resolve));
|
|
502
|
+
else
|
|
503
|
+
running++;
|
|
504
|
+
try {
|
|
505
|
+
return await job();
|
|
506
|
+
}
|
|
507
|
+
finally {
|
|
508
|
+
// Hand the slot straight to the next waiter, or give it back.
|
|
509
|
+
const next = waiting.shift();
|
|
510
|
+
if (next)
|
|
511
|
+
next();
|
|
512
|
+
else
|
|
513
|
+
running--;
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Hand each match beneath `top` to onFound, reading WALK_CONCURRENCY directories at a time.
|
|
519
|
+
* Links and junctions are reported like directories but never entered, as in searchVolume.
|
|
520
|
+
* Returns the directories read and those that could not be (or matches that could not be stat'd).
|
|
521
|
+
*/
|
|
522
|
+
async function walkTree(top, regexes, options, onFound) {
|
|
523
|
+
const limit = makeLimiter(WALK_CONCURRENCY);
|
|
524
|
+
const errors = [];
|
|
525
|
+
let dirs = 0;
|
|
526
|
+
const visit = async (dir) => {
|
|
527
|
+
let entries;
|
|
528
|
+
try {
|
|
529
|
+
entries = await limit(() => fp.readdir(dir, { withFileTypes: true }));
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
// Not lost: listed in the summary (count, and the first few by name).
|
|
533
|
+
errors.push(`${dir}: ${error.code ?? error.message}`);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
dirs++;
|
|
537
|
+
progress(options, `Walked ${dirs.toLocaleString()} directories`);
|
|
538
|
+
const subdirs = [];
|
|
539
|
+
const stats = [];
|
|
540
|
+
for (const entry of entries) {
|
|
541
|
+
const full = path.join(dir, entry.name);
|
|
542
|
+
const isLink = entry.isSymbolicLink();
|
|
543
|
+
const isDir = entry.isDirectory() || isLink;
|
|
544
|
+
if (isDir && !isLink)
|
|
545
|
+
subdirs.push(full);
|
|
546
|
+
if (!regexes.some(rx => rx.test(entry.name)))
|
|
547
|
+
continue;
|
|
548
|
+
if (isDir && !options.dirs)
|
|
549
|
+
continue;
|
|
550
|
+
stats.push(limit(() => fp.lstat(full)).then(st => onFound({ path: full, name: entry.name, size: isDir ? 0 : st.size, mtime: st.mtime, isDir }),
|
|
551
|
+
// Not lost: a match that vanished or cannot be read is listed with the summary.
|
|
552
|
+
(error) => { errors.push(`${full}: ${error.code ?? error.message}`); }));
|
|
553
|
+
}
|
|
554
|
+
await Promise.all([...stats, ...subdirs.map(visit)]);
|
|
555
|
+
};
|
|
556
|
+
await visit(top);
|
|
557
|
+
clearProgress(options);
|
|
558
|
+
return { dirs, errors };
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Hand each match beneath `start` to onFound; returns the number of directories searched.
|
|
562
|
+
* Depth-first through mftwalk's accessors. Links have no children in the volume, so their
|
|
563
|
+
* contents are never searched; NTFS's own files ($MFT, $Extend ...) are skipped, as readdir does.
|
|
564
|
+
*/
|
|
565
|
+
function searchVolume(volume, start, regexes, options, onFound) {
|
|
566
|
+
let dirs = 1;
|
|
567
|
+
const stack = [start];
|
|
568
|
+
while (stack.length) {
|
|
569
|
+
const kids = volume.children(stack.pop());
|
|
570
|
+
for (let k = kids.length - 1; k >= 0; k--) {
|
|
571
|
+
const id = kids[k];
|
|
572
|
+
if (volume.isMetafile(id))
|
|
573
|
+
continue;
|
|
574
|
+
const isLink = volume.isLink(id);
|
|
575
|
+
const isDir = volume.isDirectory(id) || isLink;
|
|
576
|
+
if (isDir && !isLink) {
|
|
577
|
+
dirs++;
|
|
578
|
+
stack.push(id);
|
|
579
|
+
}
|
|
580
|
+
const name = volume.name(id);
|
|
581
|
+
if (!regexes.some(rx => rx.test(name)))
|
|
582
|
+
continue;
|
|
583
|
+
if (isDir && !options.dirs)
|
|
584
|
+
continue;
|
|
585
|
+
onFound({ path: volume.path(id), name, size: isDir ? 0 : volume.size(id), mtime: volume.mtime(id), isDir });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return dirs;
|
|
589
|
+
}
|
|
590
|
+
// ── Output ─────────────────────────────────────────────────────────
|
|
591
|
+
/** Write lines to the stream in batches, waiting for it to drain */
|
|
592
|
+
async function writeLines(out, lines) {
|
|
593
|
+
for (let i = 0; i < lines.length; i += WRITE_BATCH) {
|
|
594
|
+
const chunk = lines.slice(i, i + WRITE_BATCH).join('\n') + '\n';
|
|
595
|
+
if (!out.write(chunk))
|
|
596
|
+
await new Promise(resolve => out.once('drain', () => resolve()));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function formatBytes(bytes) {
|
|
600
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
601
|
+
let value = bytes;
|
|
602
|
+
let unit = 0;
|
|
603
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
604
|
+
value /= 1024;
|
|
605
|
+
unit++;
|
|
606
|
+
}
|
|
607
|
+
return unit === 0 ? `${value} B` : `${value.toFixed(value < 10 ? 2 : 1)} ${units[unit]}`;
|
|
608
|
+
}
|
|
609
|
+
function formatDate(date) {
|
|
610
|
+
if (!date)
|
|
611
|
+
return ' '.repeat(16);
|
|
612
|
+
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
|
|
613
|
+
return local.toISOString().slice(0, 16).replace('T', ' ');
|
|
614
|
+
}
|
|
615
|
+
function elapsedText(ms) {
|
|
616
|
+
const totalSec = Math.round(ms / 1000);
|
|
617
|
+
const hours = Math.floor(totalSec / 3600);
|
|
618
|
+
const mins = Math.floor(totalSec / 60) % 60;
|
|
619
|
+
if (hours > 0)
|
|
620
|
+
return `${hours}h ${mins}m`;
|
|
621
|
+
return mins > 0 ? `${mins}m ${totalSec % 60}s` : `${(ms / 1000).toFixed(1)}s`;
|
|
622
|
+
}
|
|
623
|
+
function addTally(table, label, size) {
|
|
624
|
+
const key = label.toLowerCase();
|
|
625
|
+
const tally = table.get(key);
|
|
626
|
+
if (tally) {
|
|
627
|
+
tally.count++;
|
|
628
|
+
tally.bytes += size;
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
table.set(key, { label, count: 1, bytes: size });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
function tallyLines(title, table, options) {
|
|
635
|
+
const rows = Array.from(table.values());
|
|
636
|
+
switch (options.sort) {
|
|
637
|
+
case 'size':
|
|
638
|
+
rows.sort((a, b) => b.bytes - a.bytes || b.count - a.count);
|
|
639
|
+
break;
|
|
640
|
+
case 'count':
|
|
641
|
+
rows.sort((a, b) => b.count - a.count || b.bytes - a.bytes);
|
|
642
|
+
break;
|
|
643
|
+
case 'name':
|
|
644
|
+
rows.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
const shown = options.rows > 0 ? rows.slice(0, options.rows) : rows;
|
|
648
|
+
const heading = shown.length < rows.length
|
|
649
|
+
? `${title}: top ${shown.length} of ${rows.length.toLocaleString()}, by ${options.sort}`
|
|
650
|
+
: `${title}: ${rows.length.toLocaleString()}, by ${options.sort}`;
|
|
651
|
+
return [
|
|
652
|
+
heading,
|
|
653
|
+
`${'Count'.padStart(12)} ${'Size'.padStart(10)} ${title}`,
|
|
654
|
+
...shown.map(t => `${t.count.toLocaleString().padStart(12)} ${formatBytes(t.bytes).padStart(10)} ${t.label}`),
|
|
655
|
+
'',
|
|
656
|
+
];
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* -hogs: the largest totals, by size, with each one's share of all the space matched. Either the
|
|
660
|
+
* top hogsRows, or with hogsPercent every row whose share is at least that.
|
|
661
|
+
*/
|
|
662
|
+
function hogsLines(table, totalBytes, options) {
|
|
663
|
+
const title = options.hogs === 'name' ? 'Name' : 'Extension';
|
|
664
|
+
const rows = Array.from(table.values()).sort((a, b) => b.bytes - a.bytes || b.count - a.count);
|
|
665
|
+
const share = (t) => totalBytes > 0 ? t.bytes / totalBytes * 100 : 0;
|
|
666
|
+
const shown = options.hogsPercent > 0
|
|
667
|
+
? rows.filter(t => share(t) >= options.hogsPercent)
|
|
668
|
+
: rows.slice(0, options.hogsRows);
|
|
669
|
+
const shownBytes = shown.reduce((sum, t) => sum + t.bytes, 0);
|
|
670
|
+
const which = options.hogsPercent > 0 ? `at least ${options.hogsPercent}% each` : `top ${shown.length}`;
|
|
671
|
+
return [
|
|
672
|
+
`Largest by ${title.toLowerCase()}: ${which}, of ${rows.length.toLocaleString()}; ` +
|
|
673
|
+
`together ${formatBytes(shownBytes)} of ${formatBytes(totalBytes)} (${(totalBytes > 0 ? shownBytes / totalBytes * 100 : 0).toFixed(1)}%)`,
|
|
674
|
+
`${'Count'.padStart(12)} ${'Size'.padStart(10)} ${'Share'.padStart(6)} ${title}`,
|
|
675
|
+
...shown.map(t => `${t.count.toLocaleString().padStart(12)} ${formatBytes(t.bytes).padStart(10)} ${(share(t).toFixed(1) + '%').padStart(6)} ${t.label}`),
|
|
676
|
+
...(shown.length === 0 && rows.length > 0 ? [`(none; the largest, ${rows[0].label}, takes ${share(rows[0]).toFixed(1)}%)`] : []),
|
|
677
|
+
'',
|
|
678
|
+
];
|
|
679
|
+
}
|
|
680
|
+
async function main() {
|
|
681
|
+
const options = parseArgs(process.argv.slice(2));
|
|
682
|
+
if (options.refreshScan)
|
|
683
|
+
await refreshScanAndExit(options.refreshScan);
|
|
684
|
+
await resolvePositional(options);
|
|
685
|
+
const regexes = compilePatterns(options.patterns);
|
|
686
|
+
let top;
|
|
687
|
+
try {
|
|
688
|
+
// realpath goes through junctions, so the folder is found where it really is in the MFT.
|
|
689
|
+
top = await fp.realpath(path.resolve(options.top));
|
|
690
|
+
if (!(await fp.stat(top)).isDirectory())
|
|
691
|
+
fail(`Not a directory: ${top}`);
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
fail(`Cannot search ${options.top}: ${error.code ?? error.message}`);
|
|
695
|
+
}
|
|
696
|
+
// Open -o's file before searching, so a bad path fails now rather than after a long scan.
|
|
697
|
+
let outHandle;
|
|
698
|
+
if (options.outFile) {
|
|
699
|
+
try {
|
|
700
|
+
outHandle = await fp.open(options.outFile, 'w');
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
fail(`Cannot write ${options.outFile}: ${error.code ?? error.message}`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
// A closed pipe (e.g. | head) just ends the output.
|
|
707
|
+
process.stdout.on('error', (error) => {
|
|
708
|
+
if (error.code === 'EPIPE')
|
|
709
|
+
process.exit(0);
|
|
710
|
+
throw error;
|
|
711
|
+
});
|
|
712
|
+
const start = Date.now();
|
|
713
|
+
let source = await getSource(top, options);
|
|
714
|
+
const entry = source.volume ? source.volume.lookup(top) : -1;
|
|
715
|
+
if (source.volume && entry < 0)
|
|
716
|
+
source = walkSource(top, `${top} is not in the MFT data (${source.text})`, options);
|
|
717
|
+
const found = [];
|
|
718
|
+
const byName = new Map();
|
|
719
|
+
const byExt = new Map();
|
|
720
|
+
let matchCount = 0;
|
|
721
|
+
let matchBytes = 0;
|
|
722
|
+
const tallying = options.count || !!options.hogs;
|
|
723
|
+
const onFound = (f) => {
|
|
724
|
+
matchCount++;
|
|
725
|
+
matchBytes += f.size;
|
|
726
|
+
if (!tallying) {
|
|
727
|
+
found.push(f);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
addTally(byName, f.name, f.size);
|
|
731
|
+
addTally(byExt, f.isDir ? '(directory)' : path.extname(f.name) || '(none)', f.size);
|
|
732
|
+
};
|
|
733
|
+
let dirs;
|
|
734
|
+
let walkErrors = [];
|
|
735
|
+
if (source.volume) {
|
|
736
|
+
dirs = searchVolume(source.volume, entry, regexes, options, onFound);
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
({ dirs, errors: walkErrors } = await walkTree(top, regexes, options, onFound));
|
|
740
|
+
}
|
|
741
|
+
const elapsed = Date.now() - start;
|
|
742
|
+
let lines;
|
|
743
|
+
if (options.hogs) {
|
|
744
|
+
lines = hogsLines(options.hogs === 'name' ? byName : byExt, matchBytes, options);
|
|
745
|
+
}
|
|
746
|
+
else if (options.count) {
|
|
747
|
+
lines = [...tallyLines('Name', byName, options), ...tallyLines('Extension', byExt, options)];
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
const keyed = found.map(f => ({ key: f.path.toLowerCase(), f }));
|
|
751
|
+
keyed.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
752
|
+
lines = options.long
|
|
753
|
+
? keyed.map(({ f }) => `${formatDate(f.mtime)} ${(f.isDir ? '<DIR>' : f.size.toLocaleString()).padStart(15)} ${f.path}`)
|
|
754
|
+
: keyed.map(({ f }) => f.path);
|
|
755
|
+
}
|
|
756
|
+
const out = outHandle ? outHandle.createWriteStream({ encoding: 'utf8' }) : process.stdout;
|
|
757
|
+
await writeLines(out, lines);
|
|
758
|
+
if (outHandle) {
|
|
759
|
+
out.end();
|
|
760
|
+
await finished(out);
|
|
761
|
+
}
|
|
762
|
+
if (!options.quiet) {
|
|
763
|
+
const what = options.dirs ? 'matches' : 'files';
|
|
764
|
+
console.error(`${matchCount.toLocaleString()} ${what}, ${formatBytes(matchBytes)}; ${dirs.toLocaleString()} directories searched; ` +
|
|
765
|
+
`${elapsedText(elapsed)}${options.outFile ? `; written to ${path.resolve(options.outFile)}` : ''}`);
|
|
766
|
+
console.error(styleText([source.stale ? 'red' : 'blue'], `From the ${source.text}.`));
|
|
767
|
+
}
|
|
768
|
+
else if (source.stale) {
|
|
769
|
+
console.error(styleText(['red'], `From the ${source.text}.`));
|
|
770
|
+
}
|
|
771
|
+
// Shown even with -q: an incomplete walk is a wrong answer unless it says so.
|
|
772
|
+
if (walkErrors.length) {
|
|
773
|
+
console.error(styleText(['red'], `${walkErrors.length.toLocaleString()} could not be read, so the results may be incomplete:`));
|
|
774
|
+
for (const line of walkErrors.slice(0, WALK_ERRORS_SHOWN))
|
|
775
|
+
console.error(styleText(['red'], ` ${line}`));
|
|
776
|
+
if (walkErrors.length > WALK_ERRORS_SHOWN)
|
|
777
|
+
console.error(styleText(['red'], ` ... and ${(walkErrors.length - WALK_ERRORS_SHOWN).toLocaleString()} more`));
|
|
778
|
+
}
|
|
779
|
+
// 2026-11-07: as in tswalk, a drive without a change journal is worth a visible warning,
|
|
780
|
+
// since every run on it pays for a full MFT read.
|
|
781
|
+
const volume = source.volume;
|
|
782
|
+
if (volume && volume.origin.source === 'disk' && !volume.origin.journal && !options.quiet) {
|
|
783
|
+
const driveName = top.slice(0, 2).toUpperCase();
|
|
784
|
+
console.error(styleText(['red'], `NOTE: ${driveName} has no change journal, so the saved scan cannot be checked and EVERY run reads the whole MFT.\n` +
|
|
785
|
+
` Fix, once, elevated: fsutil usn createjournal ${SUGGESTED_JOURNAL} ${driveName}`));
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if (import.meta.main) {
|
|
789
|
+
await main();
|
|
790
|
+
}
|
|
791
|
+
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/tsfind",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
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/dirutil": "^1.6.5",
|
|
36
|
+
"@bobfrankston/mftwalk": "^0.1.4"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^25.3.0"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"allowScripts": {
|
|
45
|
+
"file:../../../../projects/NodeJS/dirutil": true,
|
|
46
|
+
"koffi": true
|
|
47
|
+
},
|
|
48
|
+
".dependencies": {
|
|
49
|
+
"@bobfrankston/dirutil": "file:../../projects/NodeJS/dirutil",
|
|
50
|
+
"@bobfrankston/mftwalk": "file:../mftwalk"
|
|
51
|
+
},
|
|
52
|
+
".transformedSnapshot": {
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@bobfrankston/dirutil": "^1.6.5",
|
|
55
|
+
"@bobfrankston/mftwalk": "^0.1.4"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|