@bobfrankston/tsfind 0.1.1 → 0.1.3

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 +43 -24
  2. package/index.d.ts +23 -0
  3. package/index.js +363 -288
  4. package/package.json +10 -3
package/README.md CHANGED
@@ -1,14 +1,16 @@
1
1
  # tsfind
2
2
 
3
3
  Find files by name on an NTFS volume, fast. It reads the volume's Master File Table
4
- ([`@bobfrankston/mftwalk`](https://github.com/BobFrankston/mftwalk)) instead of walking, and caches
5
- the scan, so repeated searches and analyses of the same drive take seconds. By default it prints
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
6
7
  bare paths, one per line, so the list can be edited (for example, cut down to the files to
7
8
  delete). `-count` tallies the matches by name and by extension instead, and `-hogs` shows just the
8
9
  largest.
9
10
 
10
- A find-only companion to [tswalk](https://github.com/BobFrankston/tswalk), which walks trees
11
- (non-NTFS and network drives included).
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.
12
14
 
13
15
  ## Install
14
16
 
@@ -31,33 +33,50 @@ tsfind [dir] [pattern] [options]
31
33
  |------|-------------|
32
34
  | `-o file` | Write the list (or the `-count` tables) to a file instead of the console |
33
35
  | `-l` | Long: date and size before each path |
34
- | `-d` | Also report directories and links whose names match (listed with no size) |
36
+ | `-f` | Files only (default: files and directories whose names match) |
37
+ | `-d` | Directories and links only (listed with no size) |
35
38
  | `-count` | Number and total size of matches by name and by extension |
36
39
  | `-sort key` | `-count` order: `size` (default), `count` or `name` |
37
40
  | `-top N` | `-count` rows per table (default 50; 0 = all) |
38
41
  | `-hogs [name\|ext] [N\|N%]` | Largest totals by name (default) or extension, with each one's share; top N (default 5), or every one taking at least N% |
39
- | `-age N` | Use a cached scan up to N minutes old (default 60) |
40
- | `-fresh` | Rescan the MFT even if a cached scan is young enough |
41
42
  | `-q` | No progress or summary |
42
43
  | `-help`, `-version` | |
43
44
 
44
45
  The list is sorted by path. Progress and the summary go to stderr, so stdout carries only the
45
46
  list. Links and junctions are never entered.
46
47
 
47
- ## The MFT and the cache
48
+ ## The MFT and the saved scan
48
49
 
49
- Scanning the MFT needs Windows, NTFS and an elevated prompt (e.g. `gsudo`); tsfind does not walk.
50
- The whole volume is scanned whatever folder is searched. A 15 TB USB backup drive with 8.7M files
51
- takes about 5-8 minutes and 1.6 GB of memory.
50
+ Reading the MFT needs Windows and NTFS. The whole volume is read whatever
51
+ folder is searched: a 15 TB USB backup drive with 8.7M files takes about 5-8 minutes and 1.6 GB of
52
+ memory. Each read is saved through mftwalk in `%TEMP%\tsfind\mft`, one file per volume, keyed by
53
+ volume serial so it follows the drive to any letter. The drive's NTFS **change journal** keeps it
54
+ current (since 2026-11-07; before that tsfind kept its own snapshot, valid for `-age` minutes):
52
55
 
53
- Each scan is saved, zstd-compressed, as `%TEMP%\tsfind\<volume label>-<volume serial>.idx.zst` (e.g. `20251211y-F860296A.idx.zst`): about 11 bytes
54
- per entry (Y: here: 1.33M entries, 14 MB), so about 110 MB for 10M files. A cache younger than
55
- `-age` minutes (default 60) is used instead of rescanning, for any folder on that volume, and
56
- reading it needs no elevation. It is a snapshot: changes since the scan are not seen until it
57
- expires or `-fresh` is given. When a rescan is not possible (not elevated), an older cache is used
58
- and the summary says how old. Each drive has its own cache, named by volume label (readable) and
59
- volume serial number (unique), so it follows the drive to whatever letter it is mounted under, and
60
- two drives with the same label are kept apart. Renaming a drive's label means one rescan.
56
+ - **Unchanged** since the save: the saved scan is used as is. Seconds; no admin rights needed.
57
+ - **Changed**: only the MFT records the journal names are read again (admin rights).
58
+ - **No saved scan, or no journal**: the whole MFT is read (admin rights).
59
+
60
+ ### Admin rights (2026-11-09)
61
+
62
+ When a read needs admin rights and tsfind is not elevated, it elevates by itself **only if Windows
63
+ would allow it without asking**: the account is an administrator and UAC is set not to prompt
64
+ (`ConsentPromptBehaviorAdmin` = 0). A hidden elevated copy of tsfind then brings the saved scan up
65
+ to date, and the search runs on it in your own, unelevated process, so pipes and `-o` work as
66
+ usual. Changes made in the seconds after that update are not included, and the summary says so.
67
+
68
+ If Windows would ask, tsfind never raises the popup, because it would stall a script. It says why
69
+ on stderr and walks the tree instead (slow on a big drive). Run it elevated for MFT speed.
70
+
71
+ The walk never enters links or junctions. Entries it cannot read are listed after the summary,
72
+ even with `-q`, since without them the results may be incomplete.
73
+
74
+ A drive without a change journal is read in full on every run; tsfind prints the command that
75
+ turns one on (`fsutil usn createjournal m=33554432 a=8388608 X:`, elevated). Measured cost of a
76
+ journal on a USB backup drive: creating, renaming and deleting many small files is about 10-30%
77
+ slower; large sequential writes are unaffected. The summary always says where the data came
78
+ from: the saved scan, the MFT, or a walk and why. A saved scan takes about 150-200 bytes per MFT record (S: here,
79
+ 2.4M files: 455 MB).
61
80
 
62
81
  A file with several hard links is found under one of its names only (mftwalk keeps one name per
63
82
  MFT record); cargo's `target` directories are the common case.
@@ -65,12 +84,12 @@ MFT record); cargo's `target` directories are the common case.
65
84
  ## Examples
66
85
 
67
86
  ```bash
68
- gsudo tsfind U:\ -hogs ext # scans the MFT; the 5 extensions taking the most space
69
- tsfind U:\ -hogs 2% # from the cache: every name taking at least 2%
70
- tsfind U:\Aged -count -top 30 # from the cache: by name and extension
71
- tsfind U:\ telemetrydata.db -o tele.txt # list for editing
87
+ tsfind U:\ telemetrydata.db -o tele.txt # list them to tele.txt for editing
72
88
  tsfind U:\ "*telemetry*" -l # with dates and sizes
73
- gsudo tsfind U:\ -fresh -hogs # rescan now
89
+ tsfind U:\ -hogs ext # the 5 extensions taking the most space
90
+ tsfind U:\Aged -hogs 2% # every name taking at least 2% of U:\Aged
91
+ tsfind U:\ -count -top 30 # where the space goes, by name and extension
92
+ tsfind "*.bak;*.tmp" -count -sort count # in the current directory
74
93
  ```
75
94
 
76
95
  ## License
package/index.d.ts CHANGED
@@ -4,6 +4,29 @@
4
4
  * count them by name and extension, or show where the space goes
5
5
  *
6
6
  * CHANGE LOG (newest first)
7
+ * 2026-11-14 — Claude Code (Opus 5.5), at Bob's direction ("tsfind xlate should find the xlate
8
+ * directory as well as files with that name"): directories (and links) whose names match are now
9
+ * reported by default. -f = files only; -d now means directories only (it used to mean "also
10
+ * directories"). The summary counts files and directories separately.
11
+ * 2026-11-11 — Claude Code (Opus 5.5), at Bob's direction: usage text and README brought into
12
+ * line: the same examples in both, and both say a walk lists unreadable entries even with -q.
13
+ * 2026-11-09 — Claude Code (Opus 5.5), at Bob's direction: "if you can elevate without a prompt
14
+ * do so, otherwise fall back", and the fallback is the walk ("bring back the walk"). When the
15
+ * saved MFT scan needs an update that needs admin rights, tsfind checks whether Windows would
16
+ * elevate without asking (an administrator, UAC set not to prompt: ConsentPromptBehaviorAdmin 0).
17
+ * If so, a hidden elevated copy of tsfind (-refreshscan X:) brings the saved scan up to date and
18
+ * the search runs on it, here, unelevated, so pipes and -o are unchanged. If Windows would ask,
19
+ * tsfind does NOT raise the popup (it would stall scripts): it says why and walks the tree.
20
+ * The walk also replaces the old "use tswalk" failures: off Windows, non-NTFS, network paths.
21
+ * A stale saved scan is no longer used as is; Bob chose the walk over it.
22
+ * 2026-11-07 — Claude Code (Opus 5.5), at Bob's direction ("the default should be to use the
23
+ * journal"): tsfind's own cache is gone. It now uses mftwalk's saved scans (ScanOptions.cache),
24
+ * which the NTFS change journal keeps current: an unchanged volume loads the saved scan without
25
+ * elevation, a changed one re-reads only the changed records, a full read happens only with no
26
+ * saved scan or no journal. So results are current, not a snapshot up to an hour old, and -age
27
+ * and -fresh are gone. tsfind searches the MftVolume directly; its preorder index and zstd cache
28
+ * file (TSFINDX1) existed only to be cached, and are removed. If an update needs admin rights
29
+ * that are not there, the saved scan is used as it is and the summary says it is not current.
7
30
  * 2026-10-27 (later) — Claude Code (Opus 5.5), at Bob's direction: MFT only, with a cache.
8
31
  * Bob: "Walking is expensive and is already handled by tswalk... the point of tsfind is to be
9
32
  * fast. Having a compressed cache that is valid for a limited time allows me to run multiple
package/index.js CHANGED
@@ -4,6 +4,29 @@
4
4
  * count them by name and extension, or show where the space goes
5
5
  *
6
6
  * CHANGE LOG (newest first)
7
+ * 2026-11-14 — Claude Code (Opus 5.5), at Bob's direction ("tsfind xlate should find the xlate
8
+ * directory as well as files with that name"): directories (and links) whose names match are now
9
+ * reported by default. -f = files only; -d now means directories only (it used to mean "also
10
+ * directories"). The summary counts files and directories separately.
11
+ * 2026-11-11 — Claude Code (Opus 5.5), at Bob's direction: usage text and README brought into
12
+ * line: the same examples in both, and both say a walk lists unreadable entries even with -q.
13
+ * 2026-11-09 — Claude Code (Opus 5.5), at Bob's direction: "if you can elevate without a prompt
14
+ * do so, otherwise fall back", and the fallback is the walk ("bring back the walk"). When the
15
+ * saved MFT scan needs an update that needs admin rights, tsfind checks whether Windows would
16
+ * elevate without asking (an administrator, UAC set not to prompt: ConsentPromptBehaviorAdmin 0).
17
+ * If so, a hidden elevated copy of tsfind (-refreshscan X:) brings the saved scan up to date and
18
+ * the search runs on it, here, unelevated, so pipes and -o are unchanged. If Windows would ask,
19
+ * tsfind does NOT raise the popup (it would stall scripts): it says why and walks the tree.
20
+ * The walk also replaces the old "use tswalk" failures: off Windows, non-NTFS, network paths.
21
+ * A stale saved scan is no longer used as is; Bob chose the walk over it.
22
+ * 2026-11-07 — Claude Code (Opus 5.5), at Bob's direction ("the default should be to use the
23
+ * journal"): tsfind's own cache is gone. It now uses mftwalk's saved scans (ScanOptions.cache),
24
+ * which the NTFS change journal keeps current: an unchanged volume loads the saved scan without
25
+ * elevation, a changed one re-reads only the changed records, a full read happens only with no
26
+ * saved scan or no journal. So results are current, not a snapshot up to an hour old, and -age
27
+ * and -fresh are gone. tsfind searches the MftVolume directly; its preorder index and zstd cache
28
+ * file (TSFINDX1) existed only to be cached, and are removed. If an update needs admin rights
29
+ * that are not there, the saved scan is used as it is and the summary says it is not current.
7
30
  * 2026-10-27 (later) — Claude Code (Opus 5.5), at Bob's direction: MFT only, with a cache.
8
31
  * Bob: "Walking is expensive and is already handled by tswalk... the point of tsfind is to be
9
32
  * fast. Having a compressed cache that is valid for a limited time allows me to run multiple
@@ -26,36 +49,39 @@
26
49
  * telemetrydata.db files on the backup drives.
27
50
  */
28
51
  import * as fp from 'node:fs/promises';
29
- import { createReadStream, createWriteStream } from 'node:fs';
30
52
  import * as os from 'node:os';
31
- import { spawnSync } from 'node:child_process';
53
+ import { getDriveInfo } from '@bobfrankston/dirutil';
32
54
  import * as path from 'node:path';
33
55
  import { styleText } from 'node:util';
34
- import { Readable } from 'node:stream';
35
- import { finished, pipeline } from 'node:stream/promises';
36
- import { createZstdCompress, createZstdDecompress } from 'node:zlib';
56
+ import { finished } from 'node:stream/promises';
57
+ import { spawn, spawnSync } from 'node:child_process';
37
58
  import packageJson from './package.json' with { type: 'json' };
38
59
  const version = packageJson.version;
39
- /** A cache younger than this many minutes is used instead of rescanning (-age changes it) */
40
- const DEFAULT_CACHE_MINUTES = 60;
41
60
  /** Default rows per -count table */
42
61
  const DEFAULT_TOP = 50;
43
62
  /** Default rows for -hogs */
44
63
  const DEFAULT_HOGS = 5;
45
64
  /** Lines handed to the output stream per write */
46
65
  const WRITE_BATCH = 10000;
47
- /** Names encoded or decoded per piece of the cache's name section (keeps each string well under V8's limit) */
48
- const NAME_BATCH = 100000;
49
- /** Bytes of the name section decoded at a time when loading */
50
- const NAME_DECODE_BYTES = 64 * 1024 * 1024;
51
66
  const CLEAR_LINE = '\x1b[2K\r';
52
- /** First bytes of a cache file; the digit is the format version */
53
- const CACHE_MAGIC = 'TSFINDX1';
54
- /** Bits in VolumeIndex.flags */
55
- const IndexFlags = {
56
- Directory: 0x01, /** A directory (searched beneath) */
57
- Link: 0x02, /** A junction or symbolic link (never entered) */
67
+ /**
68
+ * Change journal size suggested when a drive has none (Windows' usual 32 MB, 8 MB steps); it only
69
+ * has to hold the changes between two runs. Same suggestion as tswalk's.
70
+ */
71
+ const SUGGESTED_JOURNAL = 'm=33554432 a=8388608';
72
+ /** Directories read at once by the walk */
73
+ const WALK_CONCURRENCY = 16;
74
+ /** Unreadable directories named in the walk's summary; the rest are only counted */
75
+ const WALK_ERRORS_SHOWN = 5;
76
+ /** Well-known SIDs in `whoami /groups` */
77
+ const Sids = {
78
+ Administrators: 'S-1-5-32-544', /** BUILTIN\Administrators (deny-only in an unelevated admin token) */
79
+ HighIntegrity: 'S-1-16-12288', /** High Mandatory Level: the process is elevated */
58
80
  };
81
+ /** HKLM key holding the UAC settings */
82
+ const UAC_KEY = 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System';
83
+ /** ConsentPromptBehaviorAdmin value meaning "elevate administrators without prompting" */
84
+ const UAC_ELEVATE_WITHOUT_PROMPT = 0;
59
85
  function usage() {
60
86
  return `tsfind v${version} - find files by name on an NTFS volume, fast, from its Master File Table
61
87
 
@@ -69,8 +95,9 @@ Options:
69
95
  -o file Write the list (or the tables) to file instead of the console
70
96
  -l Long: date and size before each path (default: path only, one per line,
71
97
  so the list can be edited)
72
- -d Also report directories and links whose names match (listed with no size;
73
- the search never goes inside links or junctions)
98
+ -f Files only (default: files and directories whose names match)
99
+ -d Directories (and links) only. Directories are listed with no size; the
100
+ search never goes inside links or junctions
74
101
  -count Instead of listing: number and total size of matches by name and by
75
102
  extension
76
103
  -sort key -count order: size (default), count or name
@@ -79,19 +106,25 @@ Options:
79
106
  Instead of listing: where the space goes. The largest totals by name
80
107
  (default) or by extension, with each one's share of the space matched.
81
108
  N = how many (default ${DEFAULT_HOGS}); N% = every one taking at least N%.
82
- -age N Use a cached scan up to N minutes old (default ${DEFAULT_CACHE_MINUTES})
83
- -fresh Rescan the MFT even if a cached scan is young enough
84
109
  -q No progress or summary
85
110
  -h, -help This message
86
111
  -version Version
87
112
 
88
- How it works: tsfind reads the volume's NTFS Master File Table (Windows, NTFS, elevated prompt),
89
- which takes minutes rather than the hours a walk can take on a big drive. The scan is cached,
90
- compressed, in ${cacheDir()}, one file per volume, so further searches of
91
- any folder on that volume take seconds and need no elevation. The cache is a snapshot:
92
- changes since the scan are not seen until it is older than -age or -fresh is given. If a
93
- rescan is not possible (not elevated) an older cache is used, and the summary says how old.
94
- For a walk of a non-NTFS or network drive, use tswalk.
113
+ How it works: tsfind reads the volume's NTFS Master File Table (Windows, NTFS) rather than walking:
114
+ minutes instead of the hours a walk can take on a big drive. Each read is saved, one file per
115
+ volume, in ${cacheDir()}, and the drive's change journal keeps it current:
116
+ - unchanged since the save: the saved scan is used as is (seconds; no admin rights needed)
117
+ - changed: only the changed MFT records are read again (admin rights)
118
+ - no saved scan yet, or no change journal: the whole MFT is read (admin rights)
119
+ So every search sees the drive as it is now. A drive without a change journal is read in full
120
+ every time; tsfind prints the fsutil command that turns one on.
121
+
122
+ Admin rights: when a read needs them and tsfind is not elevated, it elevates by itself only if
123
+ Windows would allow it without asking (you are an administrator and UAC is set not to prompt).
124
+ Otherwise it never raises a UAC popup, which would stall a script: it says why on stderr and
125
+ walks the tree instead (slow on a big drive). It also walks off Windows, on non-NTFS drives and
126
+ on network paths. The summary always says which it did. Entries a walk cannot read are listed
127
+ after the summary, even with -q, since the results may then be incomplete.
95
128
 
96
129
  Progress and the summary go to stderr, so stdout carries only the list.
97
130
 
@@ -115,9 +148,9 @@ function parseArgs(args) {
115
148
  process.exit(1);
116
149
  }
117
150
  const options = {
118
- positional: [], patterns: null, top: '.', outFile: '', long: false, dirs: false, count: false,
119
- sort: 'size', rows: DEFAULT_TOP, hogs: null, hogsRows: DEFAULT_HOGS, hogsPercent: 0,
120
- cacheMinutes: DEFAULT_CACHE_MINUTES, quiet: false,
151
+ positional: [], patterns: null, top: '.', outFile: '', long: false, kinds: 'all', count: false,
152
+ sort: 'size', rows: DEFAULT_TOP, hogs: null, hogsRows: DEFAULT_HOGS, hogsPercent: 0, quiet: false,
153
+ refreshScan: '',
121
154
  };
122
155
  for (let i = 0; i < args.length; i++) {
123
156
  const arg = args[i];
@@ -137,8 +170,11 @@ function parseArgs(args) {
137
170
  case '-l':
138
171
  options.long = true;
139
172
  break;
173
+ case '-f':
174
+ options.kinds = 'files';
175
+ break;
140
176
  case '-d':
141
- options.dirs = true;
177
+ options.kinds = 'dirs';
142
178
  break;
143
179
  case '-count':
144
180
  options.count = true;
@@ -183,19 +219,20 @@ function parseArgs(args) {
183
219
  }
184
220
  break;
185
221
  }
186
- case '-age': {
187
- const n = needValue();
188
- if (!/^\d+(\.\d+)?$/.test(n))
189
- fail(`-age needs a number of minutes, not ${n}`);
190
- options.cacheMinutes = parseFloat(n);
191
- break;
192
- }
222
+ // 2026-11-07: retired with tsfind's own cache; said explicitly rather than "Unknown option".
223
+ case '-age':
193
224
  case '-fresh':
194
- options.cacheMinutes = 0;
195
- break;
225
+ fail(`${arg} is gone: the drive's change journal now keeps the saved scan current, so every search is up to date.`);
196
226
  case '-q':
197
227
  options.quiet = true;
198
228
  break;
229
+ case '-refreshscan': {
230
+ const drive = needValue();
231
+ if (!/^[A-Za-z]:$/.test(drive))
232
+ fail(`-refreshscan needs a drive like C:, not ${drive}`);
233
+ options.refreshScan = drive.toUpperCase();
234
+ break;
235
+ }
199
236
  case '-h':
200
237
  case '-help':
201
238
  console.log(usage());
@@ -289,270 +326,284 @@ function clearProgress(options) {
289
326
  if (!options.quiet && process.stderr.isTTY)
290
327
  process.stderr.write(CLEAR_LINE);
291
328
  }
292
- // ── Index: scan, save, load ────────────────────────────────────────
329
+ // ── Volume: mftwalk's saved scan, kept current by the change journal ──
293
330
  function cacheDir() {
294
- return path.join(os.tmpdir(), 'tsfind');
331
+ return path.join(os.tmpdir(), 'tsfind', 'mft');
295
332
  }
296
- /**
297
- * Read the MFT of the volume holding `top` and build tsfind's index of it. Throws MftError when
298
- * the MFT cannot be read (not elevated, not NTFS); the caller decides what to do about that.
299
- */
300
- async function scanToIndex(top, options) {
301
- const { scanVolume } = await import('@bobfrankston/mftwalk');
302
- const volume = await scanVolume(top, {
303
- onProgress: (done, total) => progress(options, `MFT ${done.toLocaleString()} / ${total.toLocaleString()} records`),
304
- });
305
- clearProgress(options);
306
- return indexFromVolume(volume);
307
- }
308
- /** Lay the volume out in preorder from the root, through mftwalk's public accessors */
309
- function indexFromVolume(volume) {
310
- const order = [];
311
- const parentOf = [];
312
- // Depth-first; children pushed in reverse so they come out in the volume's own order.
313
- const stack = [{ id: volume.root, parent: -1 }];
314
- while (stack.length) {
315
- const { id, parent } = stack.pop();
316
- const at = order.length;
317
- order.push(id);
318
- parentOf.push(parent);
319
- const kids = volume.children(id);
320
- for (let k = kids.length - 1; k >= 0; k--) {
321
- // NTFS's own files ($MFT, $Extend, ...) are invisible to readdir; leave them out likewise.
322
- if (!volume.isMetafile(kids[k]))
323
- stack.push({ id: kids[k], parent: at });
324
- }
333
+ /** One line saying where the volume's data came from, for the summary */
334
+ function sourceText(volume) {
335
+ const origin = volume.origin;
336
+ switch (origin.source) {
337
+ case 'cache':
338
+ return origin.changed
339
+ ? `saved MFT scan, NOT current: ${origin.reason}`
340
+ : `saved MFT scan: ${origin.reason}`;
341
+ case 'updated':
342
+ return origin.reason;
325
343
  }
326
- const count = order.length;
327
- const index = {
328
- count,
329
- parent: Int32Array.from(parentOf),
330
- subtreeEnd: new Int32Array(count),
331
- flags: new Uint8Array(count),
332
- size: new Float64Array(count),
333
- mtime: new Float64Array(count),
334
- names: new Array(count),
335
- };
336
- for (let i = 0; i < count; i++) {
337
- const id = order[i];
338
- index.flags[i] = (volume.isDirectory(id) ? IndexFlags.Directory : 0) | (volume.isLink(id) ? IndexFlags.Link : 0);
339
- index.size[i] = volume.size(id);
340
- index.mtime[i] = volume.mtime(id).getTime();
341
- index.names[i] = i === 0 ? '' : volume.name(id);
342
- index.subtreeEnd[i] = i + 1;
343
- }
344
- // In preorder a subtree ends where its last descendant's subtree ends; children come after
345
- // parents, so one backward pass carries each end up to its parent.
346
- for (let i = count - 1; i > 0; i--) {
347
- const p = index.parent[i];
348
- if (index.subtreeEnd[i] > index.subtreeEnd[p])
349
- index.subtreeEnd[p] = index.subtreeEnd[i];
350
- }
351
- return index;
344
+ return `MFT read in full: ${origin.reason}`;
352
345
  }
353
- /** Bytes of a typed array, as a Buffer over the same memory */
354
- function bytesOf(array) {
355
- return Buffer.from(array.buffer, array.byteOffset, array.byteLength);
356
- }
357
- /** The cache file's pieces, in order: magic, header length, header, then the arrays and names */
358
- function* cachePieces(index) {
359
- const header = Buffer.from(JSON.stringify({ count: index.count }));
360
- const headerLength = Buffer.alloc(4);
361
- headerLength.writeUInt32LE(header.length);
362
- yield Buffer.from(CACHE_MAGIC);
363
- yield headerLength;
364
- yield header;
365
- yield bytesOf(index.parent);
366
- yield bytesOf(index.subtreeEnd);
367
- yield bytesOf(index.flags);
368
- yield bytesOf(index.size);
369
- yield bytesOf(index.mtime);
370
- // Names end with \0, which no NTFS name contains.
371
- for (let i = 0; i < index.count; i += NAME_BATCH) {
372
- yield Buffer.from(index.names.slice(i, i + NAME_BATCH).join('\0') + '\0');
373
- }
346
+ /** A walk, with the reason it is not the MFT, said now on stderr and again in the summary */
347
+ function walkSource(top, reason, options) {
348
+ if (!options.quiet)
349
+ console.error(styleText(['red'], `Walking ${top} instead of reading the MFT: ${reason}`));
350
+ return { volume: null, text: `directory walk, not the MFT: ${reason}`, stale: false };
374
351
  }
375
352
  /**
376
- * Save the index, zstd-compressed, and date the file to the start of the scan so its age is the
377
- * snapshot's age. Written to a temporary name and renamed, so a reader never sees half a file.
353
+ * Can this process get admin rights without anyone being asked? True only for an administrator
354
+ * (deny-only Administrators in this unelevated token) on a machine whose UAC elevates
355
+ * administrators without prompting. `why` says what stands in the way when it is false.
378
356
  */
379
- async function saveIndex(index, cachePath, scannedAt) {
380
- await fp.mkdir(path.dirname(cachePath), { recursive: true });
381
- const partial = cachePath + '.partial';
382
- await pipeline(Readable.from(cachePieces(index)), createZstdCompress(), createWriteStream(partial));
383
- await fp.utimes(partial, scannedAt, scannedAt);
384
- await fp.rename(partial, cachePath);
357
+ function silentElevation() {
358
+ const groups = spawnSync('whoami', ['/groups', '/fo', 'csv', '/nh'], { encoding: 'utf8' });
359
+ if (groups.status !== 0) {
360
+ return { possible: false, why: `cannot tell whether elevation would prompt (whoami: ${groups.error?.message ?? groups.stderr.trim()})` };
361
+ }
362
+ if (groups.stdout.includes(Sids.HighIntegrity)) {
363
+ return { possible: false, why: 'Windows refused the MFT even though tsfind is already elevated' };
364
+ }
365
+ if (!groups.stdout.includes(Sids.Administrators)) {
366
+ return { possible: false, why: 'it needs admin rights, and this account is not an administrator' };
367
+ }
368
+ const uac = spawnSync('reg', ['query', UAC_KEY, '/v', 'ConsentPromptBehaviorAdmin'], { encoding: 'utf8' });
369
+ const value = /ConsentPromptBehaviorAdmin\s+REG_DWORD\s+0x([0-9a-f]+)/i.exec(uac.stdout ?? '');
370
+ if (!value) {
371
+ 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` };
372
+ }
373
+ const behavior = parseInt(value[1], 16);
374
+ if (behavior !== UAC_ELEVATE_WITHOUT_PROMPT) {
375
+ 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` };
376
+ }
377
+ return { possible: true, why: '' };
385
378
  }
386
- async function loadIndex(cachePath) {
387
- const chunks = [];
388
- await pipeline(createReadStream(cachePath), createZstdDecompress(), async function* (source) {
389
- for await (const chunk of source)
390
- chunks.push(chunk);
391
- });
392
- const data = Buffer.concat(chunks);
393
- chunks.length = 0;
394
- if (data.toString('latin1', 0, CACHE_MAGIC.length) !== CACHE_MAGIC)
395
- throw new Error(`${cachePath} is not a tsfind cache of this version`);
396
- let at = CACHE_MAGIC.length;
397
- const headerLength = data.readUInt32LE(at);
398
- at += 4;
399
- const { count } = JSON.parse(data.toString('utf8', at, at + headerLength));
400
- at += headerLength;
401
- // Copied out rather than viewed in place: the arrays' offsets in the buffer are not aligned.
402
- const take = (array) => {
403
- bytesOf(array).set(data.subarray(at, at + array.byteLength));
404
- at += array.byteLength;
405
- return array;
406
- };
407
- const index = {
408
- count,
409
- parent: take(new Int32Array(count)),
410
- subtreeEnd: take(new Int32Array(count)),
411
- flags: take(new Uint8Array(count)),
412
- size: take(new Float64Array(count)),
413
- mtime: take(new Float64Array(count)),
414
- names: [],
415
- };
416
- while (at < data.length) {
417
- let end = data.indexOf(0, Math.min(at + NAME_DECODE_BYTES, data.length - 1));
418
- if (end < 0)
419
- end = data.length - 1;
420
- const piece = data.toString('utf8', at, end).split('\0');
421
- for (const name of piece)
422
- index.names.push(name);
423
- at = end + 1;
424
- }
425
- if (index.names.length !== count)
426
- throw new Error(`${cachePath} is damaged: ${index.names.length} names for ${count} entries`);
427
- return index;
379
+ /** Where the hidden elevated copy leaves its result, since it has no console the caller can see */
380
+ function refreshResultFile(driveName) {
381
+ return path.join(os.tmpdir(), 'tsfind', `refresh-${driveName[0]}.txt`);
428
382
  }
429
383
  /**
430
- * The volume label of the drive holding `top`, or '' if it has none or cannot be read.
431
- * Node has no call for it, Get-Volume takes seconds, and reading it needs no elevation through
432
- * cmd's vol, whose first line is "Volume in drive Y is <label>" (or "... has no label.").
433
- * That wording is English Windows; on any other the label reads as '' and the cache is named
434
- * "nolabel-<serial>", which still identifies the drive by its serial.
384
+ * Bring driveName's saved MFT scan up to date from a hidden, elevated copy of tsfind
385
+ * (-refreshscan). Only called when silentElevation() says Windows will not ask; the "runas" verb
386
+ * is Windows' own elevation request. Returns '' on success, else why it failed.
435
387
  */
436
- function volumeLabel(top) {
437
- const result = spawnSync('cmd', ['/d', '/c', 'vol', top.slice(0, 2)], { encoding: 'utf8' });
438
- // Only the first line: the second, "Volume Serial Number is ...", also contains " is ".
439
- const firstLine = (result.stdout ?? '').trim().split(/\r?\n/)[0];
440
- const match = /^Volume in drive \w is (.*)$/.exec(firstLine);
441
- return match ? match[1].trim() : '';
388
+ async function refreshElevated(driveName, options) {
389
+ const resultFile = refreshResultFile(driveName);
390
+ await fp.mkdir(path.dirname(resultFile), { recursive: true });
391
+ await fp.rm(resultFile, { force: true });
392
+ const psQuote = (s) => `'${s.replace(/'/g, "''")}'`;
393
+ const childArgs = `"${import.meta.filename}" -refreshscan ${driveName}`;
394
+ const command = `$p = Start-Process -FilePath ${psQuote(process.execPath)} -ArgumentList ${psQuote(childArgs)} ` +
395
+ `-Verb RunAs -WindowStyle Hidden -Wait -PassThru; exit $p.ExitCode`;
396
+ const started = Date.now();
397
+ const ticker = setInterval(() => progress(options, `Updating ${driveName}'s saved MFT scan, elevated: ${elapsedText(Date.now() - started)}`), 1000);
398
+ const { code, stderr } = await new Promise(resolve => {
399
+ const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], { stdio: ['ignore', 'ignore', 'pipe'] });
400
+ let errText = '';
401
+ child.stderr.on('data', chunk => errText += chunk);
402
+ child.on('error', error => resolve({ code: -1, stderr: error.message }));
403
+ child.on('close', exitCode => resolve({ code: exitCode, stderr: errText }));
404
+ });
405
+ clearInterval(ticker);
406
+ clearProgress(options);
407
+ let result = '';
408
+ try {
409
+ result = (await fp.readFile(resultFile, 'utf8')).trim();
410
+ }
411
+ catch (error) {
412
+ // Not lost: no result file means the elevated copy never ran or died early; the failure
413
+ // below then reports PowerShell's own error and the exit code instead.
414
+ result = '';
415
+ }
416
+ if (code === 0 && result === 'ok')
417
+ return '';
418
+ return result || stderr.trim().split('\n')[0] || `the elevated copy of tsfind exited with code ${code}`;
442
419
  }
443
420
  /**
444
- * The index for the volume holding `top`: the cache if it is young enough, else a new scan
445
- * (saved for next time), else, if the scan is not possible, an older cache with a note saying so.
421
+ * -refreshscan X: the hidden elevated copy. Brings X:'s saved scan up to date (mftwalk: updated
422
+ * from the journal, or read in full) and leaves 'ok' or the error in refreshResultFile.
446
423
  */
447
- async function getIndex(top, options) {
448
- // 2026-10-27 — Claude Code (Opus 5.5), at Bob's direction: the cache is named by volume label
449
- // and volume serial number (20251211y-F860296A.idx.zst). The label says which drive a file is
450
- // for; the serial keeps two drives with the same label apart. Either follows the drive to
451
- // whatever letter it is mounted under. (Briefly the drive letter was used instead of the label.)
452
- // On Windows fs.stat's dev is the volume serial number, and reading it needs no elevation.
453
- const serial = (await fp.stat(top)).dev.toString(16).toUpperCase().padStart(8, '0');
454
- const label = volumeLabel(top);
455
- const cachePath = path.join(cacheDir(), `${label.replace(/[^\w.-]/g, '_') || 'nolabel'}-${serial}.idx.zst`);
456
- let cacheAgeMs = -1;
424
+ async function refreshScanAndExit(driveName) {
425
+ const resultFile = refreshResultFile(driveName);
426
+ let result = 'ok';
457
427
  try {
458
- cacheAgeMs = Date.now() - (await fp.stat(cachePath)).mtimeMs;
428
+ const { scanVolume } = await import('@bobfrankston/mftwalk');
429
+ await scanVolume(driveName + '\\', { cache: { dir: cacheDir() } });
459
430
  }
460
431
  catch (error) {
461
- // Not lost: no cache file is the ordinary first-run case, and leads to a scan below.
462
- if (error.code !== 'ENOENT')
463
- throw error;
432
+ result = error.message ?? String(error);
464
433
  }
465
- // 2026-10-27 — Claude Code (Opus 5.5), at Bob's direction: say at the start which source is
466
- // used and how old the cache is, not only in the summary at the end.
467
- const announce = (text) => {
468
- if (!options.quiet)
469
- console.error(styleText(['blue'], text));
470
- };
471
- const volumeName = `${top.slice(0, 2).toUpperCase()} ${label ? `"${label}"` : '(no label)'} (serial ${serial})`;
472
- const load = async (note) => {
473
- announce(`Using the cached scan of ${volumeName}, ${elapsedText(cacheAgeMs)} old${note ? `; ${note}` : ''}: ${cachePath}`);
474
- const index = await loadIndex(cachePath);
475
- return { index, source: { scanned: false, ageMs: cacheAgeMs, cachePath, note } };
434
+ await fp.mkdir(path.dirname(resultFile), { recursive: true });
435
+ await fp.writeFile(resultFile, result);
436
+ process.exit(result === 'ok' ? 0 : 1);
437
+ }
438
+ /**
439
+ * Where to search `top`: the volume from mftwalk's saved scan (see usage: unchanged -> saved scan,
440
+ * changed -> updated from the journal, else a full read). When that needs admin rights tsfind does
441
+ * not have, it elevates only if Windows will not ask; otherwise, and whenever the MFT cannot be
442
+ * used at all, it walks the tree and says why.
443
+ */
444
+ async function getSource(top, options) {
445
+ if (process.platform !== 'win32')
446
+ return walkSource(top, 'the MFT is read on Windows only', options);
447
+ if (!/^[A-Za-z]:\\/.test(top))
448
+ return walkSource(top, 'not on a drive letter (a network path?)', options);
449
+ const drive = getDriveInfo(top.slice(0, 3));
450
+ if (drive.fileSystem !== 'NTFS')
451
+ return walkSource(top, `${top.slice(0, 2)} is ${drive.fileSystem || 'not a local file system'}, not NTFS`, options);
452
+ const driveName = top.slice(0, 2).toUpperCase();
453
+ const label = drive.volumeName ? `"${drive.volumeName}"` : '(no label)';
454
+ if (!options.quiet)
455
+ console.error(styleText(['blue'], `Reading ${driveName} ${label}: saved MFT scan if current, else from the MFT`));
456
+ const { scanVolume, MftError } = await import('@bobfrankston/mftwalk');
457
+ const scanOptions = {
458
+ onProgress: (done, total) => progress(options, `MFT ${done.toLocaleString()} / ${total.toLocaleString()} records`),
459
+ cache: { dir: cacheDir() },
460
+ log: msg => { if (!options.quiet)
461
+ console.error(styleText(['red'], msg)); },
476
462
  };
477
- if (cacheAgeMs >= 0 && cacheAgeMs < options.cacheMinutes * 60000)
478
- return load('');
479
- announce(cacheAgeMs < 0
480
- ? `Scanning the MFT of ${volumeName}; no cached scan yet`
481
- : `Scanning the MFT of ${volumeName}; the cached scan is ${elapsedText(cacheAgeMs)} old` +
482
- (options.cacheMinutes === 0 ? ' (-fresh)' : `, older than -age ${options.cacheMinutes} minutes`));
483
- const { MftError } = await import('@bobfrankston/mftwalk');
484
- const scannedAt = new Date();
485
- let index;
486
463
  try {
487
- index = await scanToIndex(top, options);
464
+ const volume = await scanVolume(top, scanOptions);
465
+ clearProgress(options);
466
+ return { volume, text: sourceText(volume), stale: volume.origin.changed };
488
467
  }
489
468
  catch (error) {
490
469
  clearProgress(options);
491
470
  if (!(error instanceof MftError))
492
471
  throw error;
493
- // Not swallowed: with an older cache the reason goes into the summary next to its age;
494
- // without one it is the failure.
495
- if (cacheAgeMs >= 0)
496
- return load(`could not rescan (${error.message})`);
497
- fail(`Cannot read the MFT: ${error.message}.\ntsfind reads the NTFS MFT (Windows, NTFS, elevated prompt); to walk a tree instead, use tswalk.`);
472
+ // Not swallowed: the reason goes to stderr and into the summary, and the walk takes over.
473
+ if (error.code !== 'EPERM')
474
+ return walkSource(top, `cannot read the MFT of ${driveName}: ${error.message}`, options);
498
475
  }
499
- let note = '';
476
+ const elevation = silentElevation();
477
+ if (!elevation.possible)
478
+ return walkSource(top, elevation.why, options);
479
+ if (!options.quiet)
480
+ console.error(styleText(['blue'], `Updating ${driveName}'s saved MFT scan elevated (UAC here elevates administrators without asking)`));
481
+ const refreshError = await refreshElevated(driveName, options);
482
+ if (refreshError)
483
+ return walkSource(top, `the elevated update of ${driveName}'s saved scan failed: ${refreshError}`, options);
484
+ const updatedAt = new Date();
500
485
  try {
501
- if (!options.quiet && process.stderr.isTTY)
502
- process.stderr.write('Saving the scan for later runs...');
503
- await saveIndex(index, cachePath, scannedAt);
486
+ // trust: the saved scan was brought up to date a moment ago; changes since then (a busy
487
+ // C: always has some) would need admin rights again, so they are named, not read.
488
+ const volume = await scanVolume(top, { ...scanOptions, cache: { dir: cacheDir(), trust: true } });
504
489
  clearProgress(options);
490
+ const text = volume.origin.changed
491
+ ? `saved MFT scan, brought up to date elevated at ${updatedAt.toLocaleTimeString()} (changes after that are not included)`
492
+ : sourceText(volume);
493
+ return { volume, text, stale: false };
505
494
  }
506
495
  catch (error) {
507
- // Not lost: the search goes ahead on the scan in memory, and the summary says the cache was not saved.
508
496
  clearProgress(options);
509
- note = `not cached: ${error.code ?? error.message}`;
497
+ if (!(error instanceof MftError))
498
+ throw error;
499
+ return walkSource(top, `cannot load ${driveName}'s saved scan after updating it: ${error.message}`, options);
510
500
  }
511
- return { index, source: { scanned: true, ageMs: Date.now() - scannedAt.getTime(), cachePath, note } };
512
501
  }
513
- /** The entry for `top` in the index, by name from the root, case-insensitively as Windows does; -1 if absent */
514
- function lookupEntry(index, top) {
515
- const rel = top.replace(/^[A-Za-z]:/, '');
516
- let at = 0;
517
- for (const part of rel.split(/[\\/]+/).filter(Boolean)) {
518
- const want = part.toLowerCase();
519
- let found = -1;
520
- // Children of `at` are the entries right after it, each followed by its own subtree.
521
- for (let child = at + 1; child < index.subtreeEnd[at]; child = index.subtreeEnd[child]) {
522
- if (index.names[child].toLowerCase() === want) {
523
- found = child;
524
- break;
525
- }
502
+ // ── Walk: the classic way, when the MFT cannot be used ────────────
503
+ /** Run at most `max` jobs at once; the rest wait their turn */
504
+ function makeLimiter(max) {
505
+ let running = 0;
506
+ const waiting = [];
507
+ return async (job) => {
508
+ if (running >= max)
509
+ await new Promise(resolve => waiting.push(resolve));
510
+ else
511
+ running++;
512
+ try {
513
+ return await job();
514
+ }
515
+ finally {
516
+ // Hand the slot straight to the next waiter, or give it back.
517
+ const next = waiting.shift();
518
+ if (next)
519
+ next();
520
+ else
521
+ running--;
526
522
  }
527
- if (found < 0)
528
- return -1;
529
- at = found;
523
+ };
524
+ }
525
+ /** "1 file", "2 files" */
526
+ function plural(n, one, many) {
527
+ return `${n.toLocaleString()} ${n === 1 ? one : many}`;
528
+ }
529
+ /** Whether a match of this kind is reported under -f / -d / neither */
530
+ function wanted(isDir, options) {
531
+ switch (options.kinds) {
532
+ case 'files': return !isDir;
533
+ case 'dirs': return isDir;
534
+ default: return true;
530
535
  }
531
- return at;
532
536
  }
533
- /** Full path of an entry, under the volume's current drive root */
534
- function entryPath(index, rootPath, entry) {
535
- const parts = [];
536
- for (let at = entry; at > 0; at = index.parent[at])
537
- parts.push(index.names[at]);
538
- return rootPath + parts.reverse().join('\\');
537
+ /**
538
+ * Hand each match beneath `top` to onFound, reading WALK_CONCURRENCY directories at a time.
539
+ * Links and junctions are reported like directories but never entered, as in searchVolume.
540
+ * Returns the directories read and those that could not be (or matches that could not be stat'd).
541
+ */
542
+ async function walkTree(top, regexes, options, onFound) {
543
+ const limit = makeLimiter(WALK_CONCURRENCY);
544
+ const errors = [];
545
+ let dirs = 0;
546
+ const visit = async (dir) => {
547
+ let entries;
548
+ try {
549
+ entries = await limit(() => fp.readdir(dir, { withFileTypes: true }));
550
+ }
551
+ catch (error) {
552
+ // Not lost: listed in the summary (count, and the first few by name).
553
+ errors.push(`${dir}: ${error.code ?? error.message}`);
554
+ return;
555
+ }
556
+ dirs++;
557
+ progress(options, `Walked ${dirs.toLocaleString()} directories`);
558
+ const subdirs = [];
559
+ const stats = [];
560
+ for (const entry of entries) {
561
+ const full = path.join(dir, entry.name);
562
+ const isLink = entry.isSymbolicLink();
563
+ const isDir = entry.isDirectory() || isLink;
564
+ if (isDir && !isLink)
565
+ subdirs.push(full);
566
+ if (!regexes.some(rx => rx.test(entry.name)))
567
+ continue;
568
+ if (!wanted(isDir, options))
569
+ continue;
570
+ stats.push(limit(() => fp.lstat(full)).then(st => onFound({ path: full, name: entry.name, size: isDir ? 0 : st.size, mtime: st.mtime, isDir }),
571
+ // Not lost: a match that vanished or cannot be read is listed with the summary.
572
+ (error) => { errors.push(`${full}: ${error.code ?? error.message}`); }));
573
+ }
574
+ await Promise.all([...stats, ...subdirs.map(visit)]);
575
+ };
576
+ await visit(top);
577
+ clearProgress(options);
578
+ return { dirs, errors };
539
579
  }
540
- /** Hand each match beneath `start` to onFound; returns the number of directories searched */
541
- function searchIndex(index, start, rootPath, regexes, options, onFound) {
580
+ /**
581
+ * Hand each match beneath `start` to onFound; returns the number of directories searched.
582
+ * Depth-first through mftwalk's accessors. Links have no children in the volume, so their
583
+ * contents are never searched; NTFS's own files ($MFT, $Extend ...) are skipped, as readdir does.
584
+ */
585
+ function searchVolume(volume, start, regexes, options, onFound) {
542
586
  let dirs = 1;
543
- const end = index.subtreeEnd[start];
544
- for (let i = start + 1; i < end; i++) {
545
- const flags = index.flags[i];
546
- const isDir = (flags & (IndexFlags.Directory | IndexFlags.Link)) !== 0;
547
- if (flags & IndexFlags.Directory && !(flags & IndexFlags.Link))
548
- dirs++;
549
- // Links have no entries beneath them in the index, so their contents are never searched.
550
- const name = index.names[i];
551
- if (!regexes.some(rx => rx.test(name)))
552
- continue;
553
- if (isDir && !options.dirs)
554
- continue;
555
- onFound({ path: entryPath(index, rootPath, i), name, size: isDir ? 0 : index.size[i], mtime: new Date(index.mtime[i]), isDir });
587
+ const stack = [start];
588
+ while (stack.length) {
589
+ const kids = volume.children(stack.pop());
590
+ for (let k = kids.length - 1; k >= 0; k--) {
591
+ const id = kids[k];
592
+ if (volume.isMetafile(id))
593
+ continue;
594
+ const isLink = volume.isLink(id);
595
+ const isDir = volume.isDirectory(id) || isLink;
596
+ if (isDir && !isLink) {
597
+ dirs++;
598
+ stack.push(id);
599
+ }
600
+ const name = volume.name(id);
601
+ if (!regexes.some(rx => rx.test(name)))
602
+ continue;
603
+ if (!wanted(isDir, options))
604
+ continue;
605
+ onFound({ path: volume.path(id), name, size: isDir ? 0 : volume.size(id), mtime: volume.mtime(id), isDir });
606
+ }
556
607
  }
557
608
  return dirs;
558
609
  }
@@ -648,8 +699,8 @@ function hogsLines(table, totalBytes, options) {
648
699
  }
649
700
  async function main() {
650
701
  const options = parseArgs(process.argv.slice(2));
651
- if (process.platform !== 'win32')
652
- fail('tsfind reads the NTFS MFT, so it runs on Windows only; to walk a tree, use tswalk.');
702
+ if (options.refreshScan)
703
+ await refreshScanAndExit(options.refreshScan);
653
704
  await resolvePositional(options);
654
705
  const regexes = compilePatterns(options.patterns);
655
706
  let top;
@@ -662,10 +713,6 @@ async function main() {
662
713
  catch (error) {
663
714
  fail(`Cannot search ${options.top}: ${error.code ?? error.message}`);
664
715
  }
665
- const drive = /^([A-Za-z]):\\/.exec(top);
666
- if (!drive)
667
- fail(`${top} is not on a drive letter; tsfind reads a local NTFS volume's MFT. For a network path, use tswalk.`);
668
- const rootPath = `${drive[1].toUpperCase()}:\\`;
669
716
  // Open -o's file before searching, so a bad path fails now rather than after a long scan.
670
717
  let outHandle;
671
718
  if (options.outFile) {
@@ -683,20 +730,22 @@ async function main() {
683
730
  throw error;
684
731
  });
685
732
  const start = Date.now();
686
- const { index, source } = await getIndex(top, options);
687
- const entry = lookupEntry(index, top);
688
- if (entry < 0) {
689
- fail(`${top} is not in the ${source.scanned ? 'scan' : `cached scan from ${elapsedText(source.ageMs)} ago`}` +
690
- `${source.scanned ? '' : '; -fresh rescans'}`);
691
- }
733
+ let source = await getSource(top, options);
734
+ const entry = source.volume ? source.volume.lookup(top) : -1;
735
+ if (source.volume && entry < 0)
736
+ source = walkSource(top, `${top} is not in the MFT data (${source.text})`, options);
692
737
  const found = [];
693
738
  const byName = new Map();
694
739
  const byExt = new Map();
695
- let matchCount = 0;
740
+ let fileCount = 0;
741
+ let dirCount = 0;
696
742
  let matchBytes = 0;
697
743
  const tallying = options.count || !!options.hogs;
698
744
  const onFound = (f) => {
699
- matchCount++;
745
+ if (f.isDir)
746
+ dirCount++;
747
+ else
748
+ fileCount++;
700
749
  matchBytes += f.size;
701
750
  if (!tallying) {
702
751
  found.push(f);
@@ -705,7 +754,14 @@ async function main() {
705
754
  addTally(byName, f.name, f.size);
706
755
  addTally(byExt, f.isDir ? '(directory)' : path.extname(f.name) || '(none)', f.size);
707
756
  };
708
- const dirs = searchIndex(index, entry, rootPath, regexes, options, onFound);
757
+ let dirs;
758
+ let walkErrors = [];
759
+ if (source.volume) {
760
+ dirs = searchVolume(source.volume, entry, regexes, options, onFound);
761
+ }
762
+ else {
763
+ ({ dirs, errors: walkErrors } = await walkTree(top, regexes, options, onFound));
764
+ }
709
765
  const elapsed = Date.now() - start;
710
766
  let lines;
711
767
  if (options.hogs) {
@@ -728,12 +784,31 @@ async function main() {
728
784
  await finished(out);
729
785
  }
730
786
  if (!options.quiet) {
731
- const what = options.dirs ? 'matches' : 'files';
732
- const from = source.scanned
733
- ? `MFT scanned${source.note ? `, ${source.note}` : ''}`
734
- : `cached scan ${elapsedText(source.ageMs)} old${source.note ? `, ${source.note}` : ''}`;
735
- console.error(`${matchCount.toLocaleString()} ${what}, ${formatBytes(matchBytes)}; ${dirs.toLocaleString()} directories searched; ` +
736
- `${elapsedText(elapsed)} (${from})${options.outFile ? `; written to ${path.resolve(options.outFile)}` : ''}`);
787
+ const counts = options.kinds === 'files' ? plural(fileCount, 'file', 'files')
788
+ : options.kinds === 'dirs' ? `${plural(dirCount, 'directory', 'directories')} matched`
789
+ : `${plural(fileCount, 'file', 'files')} and ${plural(dirCount, 'directory', 'directories')} matched`;
790
+ console.error(`${counts}, ${formatBytes(matchBytes)}; ${dirs.toLocaleString()} directories searched; ` +
791
+ `${elapsedText(elapsed)}${options.outFile ? `; written to ${path.resolve(options.outFile)}` : ''}`);
792
+ console.error(styleText([source.stale ? 'red' : 'blue'], `From the ${source.text}.`));
793
+ }
794
+ else if (source.stale) {
795
+ console.error(styleText(['red'], `From the ${source.text}.`));
796
+ }
797
+ // Shown even with -q: an incomplete walk is a wrong answer unless it says so.
798
+ if (walkErrors.length) {
799
+ console.error(styleText(['red'], `${walkErrors.length.toLocaleString()} could not be read, so the results may be incomplete:`));
800
+ for (const line of walkErrors.slice(0, WALK_ERRORS_SHOWN))
801
+ console.error(styleText(['red'], ` ${line}`));
802
+ if (walkErrors.length > WALK_ERRORS_SHOWN)
803
+ console.error(styleText(['red'], ` ... and ${(walkErrors.length - WALK_ERRORS_SHOWN).toLocaleString()} more`));
804
+ }
805
+ // 2026-11-07: as in tswalk, a drive without a change journal is worth a visible warning,
806
+ // since every run on it pays for a full MFT read.
807
+ const volume = source.volume;
808
+ if (volume && volume.origin.source === 'disk' && !volume.origin.journal && !options.quiet) {
809
+ const driveName = top.slice(0, 2).toUpperCase();
810
+ 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` +
811
+ ` Fix, once, elevated: fsutil usn createjournal ${SUGGESTED_JOURNAL} ${driveName}`));
737
812
  }
738
813
  }
739
814
  if (import.meta.main) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/tsfind",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
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
5
  "main": "index.js",
6
6
  "bin": {
@@ -32,7 +32,8 @@
32
32
  },
33
33
  "homepage": "https://github.com/BobFrankston/tsfind#readme",
34
34
  "dependencies": {
35
- "@bobfrankston/mftwalk": "^0.1.3"
35
+ "@bobfrankston/dirutil": "^1.6.5",
36
+ "@bobfrankston/mftwalk": "^0.1.4"
36
37
  },
37
38
  "devDependencies": {
38
39
  "@types/node": "^25.3.0"
@@ -40,12 +41,18 @@
40
41
  "publishConfig": {
41
42
  "access": "public"
42
43
  },
44
+ "allowScripts": {
45
+ "file:../../../../projects/NodeJS/dirutil": true,
46
+ "koffi": true
47
+ },
43
48
  ".dependencies": {
49
+ "@bobfrankston/dirutil": "file:../../projects/NodeJS/dirutil",
44
50
  "@bobfrankston/mftwalk": "file:../mftwalk"
45
51
  },
46
52
  ".transformedSnapshot": {
47
53
  "dependencies": {
48
- "@bobfrankston/mftwalk": "^0.1.3"
54
+ "@bobfrankston/dirutil": "^1.6.5",
55
+ "@bobfrankston/mftwalk": "^0.1.4"
49
56
  }
50
57
  }
51
58
  }