@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.
Files changed (4) hide show
  1. package/README.md +95 -2
  2. package/index.d.ts +48 -0
  3. package/index.js +791 -0
  4. package/package.json +56 -4
package/README.md CHANGED
@@ -1,3 +1,96 @@
1
- # Temporary Holding Version
1
+ # tsfind
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }