@bobfrankston/tsfind 0.1.1 → 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 +41 -23
  2. package/index.d.ts +19 -0
  3. package/index.js +330 -281
  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
 
@@ -36,28 +38,44 @@ tsfind [dir] [pattern] [options]
36
38
  | `-sort key` | `-count` order: `size` (default), `count` or `name` |
37
39
  | `-top N` | `-count` rows per table (default 50; 0 = all) |
38
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% |
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
41
  | `-q` | No progress or summary |
42
42
  | `-help`, `-version` | |
43
43
 
44
44
  The list is sorted by path. Progress and the summary go to stderr, so stdout carries only the
45
45
  list. Links and junctions are never entered.
46
46
 
47
- ## The MFT and the cache
47
+ ## The MFT and the saved scan
48
48
 
49
- Scanning the MFT needs Windows, NTFS and an elevated prompt (e.g. `gsudo`); tsfind does not walk.
50
- The whole volume is scanned whatever folder is searched. A 15 TB USB backup drive with 8.7M files
51
- takes about 5-8 minutes and 1.6 GB of memory.
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):
52
54
 
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.
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).
61
79
 
62
80
  A file with several hard links is found under one of its names only (mftwalk keeps one name per
63
81
  MFT record); cargo's `target` directories are the common case.
@@ -65,12 +83,12 @@ MFT record); cargo's `target` directories are the common case.
65
83
  ## Examples
66
84
 
67
85
  ```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
86
+ tsfind U:\ telemetrydata.db -o tele.txt # list them to tele.txt for editing
72
87
  tsfind U:\ "*telemetry*" -l # with dates and sizes
73
- gsudo tsfind U:\ -fresh -hogs # rescan now
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
74
92
  ```
75
93
 
76
94
  ## License
package/index.d.ts CHANGED
@@ -4,6 +4,25 @@
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-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.
7
26
  * 2026-10-27 (later) — Claude Code (Opus 5.5), at Bob's direction: MFT only, with a cache.
8
27
  * Bob: "Walking is expensive and is already handled by tswalk... the point of tsfind is to be
9
28
  * fast. Having a compressed cache that is valid for a limited time allows me to run multiple
package/index.js CHANGED
@@ -4,6 +4,25 @@
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-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.
7
26
  * 2026-10-27 (later) — Claude Code (Opus 5.5), at Bob's direction: MFT only, with a cache.
8
27
  * Bob: "Walking is expensive and is already handled by tswalk... the point of tsfind is to be
9
28
  * fast. Having a compressed cache that is valid for a limited time allows me to run multiple
@@ -26,36 +45,39 @@
26
45
  * telemetrydata.db files on the backup drives.
27
46
  */
28
47
  import * as fp from 'node:fs/promises';
29
- import { createReadStream, createWriteStream } from 'node:fs';
30
48
  import * as os from 'node:os';
31
- import { spawnSync } from 'node:child_process';
49
+ import { getDriveInfo } from '@bobfrankston/dirutil';
32
50
  import * as path from 'node:path';
33
51
  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';
52
+ import { finished } from 'node:stream/promises';
53
+ import { spawn, spawnSync } from 'node:child_process';
37
54
  import packageJson from './package.json' with { type: 'json' };
38
55
  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
56
  /** Default rows per -count table */
42
57
  const DEFAULT_TOP = 50;
43
58
  /** Default rows for -hogs */
44
59
  const DEFAULT_HOGS = 5;
45
60
  /** Lines handed to the output stream per write */
46
61
  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
62
  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) */
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 */
58
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;
59
81
  function usage() {
60
82
  return `tsfind v${version} - find files by name on an NTFS volume, fast, from its Master File Table
61
83
 
@@ -79,19 +101,25 @@ Options:
79
101
  Instead of listing: where the space goes. The largest totals by name
80
102
  (default) or by extension, with each one's share of the space matched.
81
103
  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
104
  -q No progress or summary
85
105
  -h, -help This message
86
106
  -version Version
87
107
 
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.
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.
95
123
 
96
124
  Progress and the summary go to stderr, so stdout carries only the list.
97
125
 
@@ -116,8 +144,8 @@ function parseArgs(args) {
116
144
  }
117
145
  const options = {
118
146
  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,
147
+ sort: 'size', rows: DEFAULT_TOP, hogs: null, hogsRows: DEFAULT_HOGS, hogsPercent: 0, quiet: false,
148
+ refreshScan: '',
121
149
  };
122
150
  for (let i = 0; i < args.length; i++) {
123
151
  const arg = args[i];
@@ -183,19 +211,20 @@ function parseArgs(args) {
183
211
  }
184
212
  break;
185
213
  }
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
- }
214
+ // 2026-11-07: retired with tsfind's own cache; said explicitly rather than "Unknown option".
215
+ case '-age':
193
216
  case '-fresh':
194
- options.cacheMinutes = 0;
195
- break;
217
+ fail(`${arg} is gone: the drive's change journal now keeps the saved scan current, so every search is up to date.`);
196
218
  case '-q':
197
219
  options.quiet = true;
198
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
+ }
199
228
  case '-h':
200
229
  case '-help':
201
230
  console.log(usage());
@@ -289,270 +318,272 @@ function clearProgress(options) {
289
318
  if (!options.quiet && process.stderr.isTTY)
290
319
  process.stderr.write(CLEAR_LINE);
291
320
  }
292
- // ── Index: scan, save, load ────────────────────────────────────────
321
+ // ── Volume: mftwalk's saved scan, kept current by the change journal ──
293
322
  function cacheDir() {
294
- return path.join(os.tmpdir(), 'tsfind');
295
- }
296
- /**
297
- * Read the MFT of the volume holding `top` and build tsfind's index of it. Throws MftError when
298
- * the MFT cannot be read (not elevated, not NTFS); the caller decides what to do about that.
299
- */
300
- async function scanToIndex(top, options) {
301
- const { scanVolume } = await import('@bobfrankston/mftwalk');
302
- const volume = await scanVolume(top, {
303
- onProgress: (done, total) => progress(options, `MFT ${done.toLocaleString()} / ${total.toLocaleString()} records`),
304
- });
305
- clearProgress(options);
306
- return indexFromVolume(volume);
323
+ return path.join(os.tmpdir(), 'tsfind', 'mft');
307
324
  }
308
- /** Lay the volume out in preorder from the root, through mftwalk's public accessors */
309
- function indexFromVolume(volume) {
310
- const order = [];
311
- const parentOf = [];
312
- // Depth-first; children pushed in reverse so they come out in the volume's own order.
313
- const stack = [{ id: volume.root, parent: -1 }];
314
- while (stack.length) {
315
- const { id, parent } = stack.pop();
316
- const at = order.length;
317
- order.push(id);
318
- parentOf.push(parent);
319
- const kids = volume.children(id);
320
- for (let k = kids.length - 1; k >= 0; k--) {
321
- // NTFS's own files ($MFT, $Extend, ...) are invisible to readdir; leave them out likewise.
322
- if (!volume.isMetafile(kids[k]))
323
- stack.push({ id: kids[k], parent: at });
324
- }
325
+ /** 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;
325
335
  }
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;
336
+ return `MFT read in full: ${origin.reason}`;
352
337
  }
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
- }
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 };
374
343
  }
375
344
  /**
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.
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.
378
348
  */
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);
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: '' };
385
370
  }
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;
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`);
428
374
  }
429
375
  /**
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.
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.
435
379
  */
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() : '';
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}`;
442
411
  }
443
412
  /**
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.
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.
446
415
  */
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;
416
+ async function refreshScanAndExit(driveName) {
417
+ const resultFile = refreshResultFile(driveName);
418
+ let result = 'ok';
457
419
  try {
458
- cacheAgeMs = Date.now() - (await fp.stat(cachePath)).mtimeMs;
420
+ const { scanVolume } = await import('@bobfrankston/mftwalk');
421
+ await scanVolume(driveName + '\\', { cache: { dir: cacheDir() } });
459
422
  }
460
423
  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;
424
+ result = error.message ?? String(error);
464
425
  }
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 } };
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)); },
476
454
  };
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
455
  try {
487
- index = await scanToIndex(top, options);
456
+ const volume = await scanVolume(top, scanOptions);
457
+ clearProgress(options);
458
+ return { volume, text: sourceText(volume), stale: volume.origin.changed };
488
459
  }
489
460
  catch (error) {
490
461
  clearProgress(options);
491
462
  if (!(error instanceof MftError))
492
463
  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.`);
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);
498
467
  }
499
- let note = '';
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();
500
477
  try {
501
- if (!options.quiet && process.stderr.isTTY)
502
- process.stderr.write('Saving the scan for later runs...');
503
- await saveIndex(index, cachePath, scannedAt);
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 } });
504
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 };
505
486
  }
506
487
  catch (error) {
507
- // Not lost: the search goes ahead on the scan in memory, and the summary says the cache was not saved.
508
488
  clearProgress(options);
509
- note = `not cached: ${error.code ?? error.message}`;
489
+ if (!(error instanceof MftError))
490
+ throw error;
491
+ return walkSource(top, `cannot load ${driveName}'s saved scan after updating it: ${error.message}`, options);
510
492
  }
511
- return { index, source: { scanned: true, ageMs: Date.now() - scannedAt.getTime(), cachePath, note } };
512
493
  }
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
- }
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();
526
506
  }
527
- if (found < 0)
528
- return -1;
529
- at = found;
530
- }
531
- return at;
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
+ };
532
516
  }
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('\\');
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 };
539
559
  }
540
- /** Hand each match beneath `start` to onFound; returns the number of directories searched */
541
- function searchIndex(index, start, rootPath, regexes, options, onFound) {
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) {
542
566
  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 });
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
+ }
556
587
  }
557
588
  return dirs;
558
589
  }
@@ -648,8 +679,8 @@ function hogsLines(table, totalBytes, options) {
648
679
  }
649
680
  async function main() {
650
681
  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.');
682
+ if (options.refreshScan)
683
+ await refreshScanAndExit(options.refreshScan);
653
684
  await resolvePositional(options);
654
685
  const regexes = compilePatterns(options.patterns);
655
686
  let top;
@@ -662,10 +693,6 @@ async function main() {
662
693
  catch (error) {
663
694
  fail(`Cannot search ${options.top}: ${error.code ?? error.message}`);
664
695
  }
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
696
  // Open -o's file before searching, so a bad path fails now rather than after a long scan.
670
697
  let outHandle;
671
698
  if (options.outFile) {
@@ -683,12 +710,10 @@ async function main() {
683
710
  throw error;
684
711
  });
685
712
  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
- }
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);
692
717
  const found = [];
693
718
  const byName = new Map();
694
719
  const byExt = new Map();
@@ -705,7 +730,14 @@ async function main() {
705
730
  addTally(byName, f.name, f.size);
706
731
  addTally(byExt, f.isDir ? '(directory)' : path.extname(f.name) || '(none)', f.size);
707
732
  };
708
- const dirs = searchIndex(index, entry, rootPath, regexes, options, onFound);
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
+ }
709
741
  const elapsed = Date.now() - start;
710
742
  let lines;
711
743
  if (options.hogs) {
@@ -729,11 +761,28 @@ async function main() {
729
761
  }
730
762
  if (!options.quiet) {
731
763
  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
764
  console.error(`${matchCount.toLocaleString()} ${what}, ${formatBytes(matchBytes)}; ${dirs.toLocaleString()} directories searched; ` +
736
- `${elapsedText(elapsed)} (${from})${options.outFile ? `; written to ${path.resolve(options.outFile)}` : ''}`);
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}`));
737
786
  }
738
787
  }
739
788
  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.2",
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
  }