@stll/anonymize-cli 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,11 +41,60 @@ anonymize -d key.json reply.txt
41
41
  | `--threshold <n>` | Minimum confidence score 0-1 (default 0.3) |
42
42
  | `--redact-string <s>` | Replacement text in redact mode |
43
43
  | `--json` | Emit entities + redacted text as JSON |
44
+ | `--capabilities` | Emit the versioned capability manifest as JSON |
44
45
  | `--quiet` | Suppress the stderr summary |
45
46
 
46
47
  Run `anonymize --help` for the full reference, including the
47
48
  `--json` schema and exit codes.
48
49
 
50
+ ## DOCX workflows
51
+
52
+ DOCX anonymization preserves supported document structure and stores reversible
53
+ placeholder mappings only in an encrypted session archive. Create a raw 32-byte
54
+ key file outside the document and restrict its filesystem permissions:
55
+
56
+ ```bash
57
+ openssl rand 32 > matter.key
58
+ chmod 600 matter.key
59
+
60
+ anonymize docx anonymize contract.docx \
61
+ --output contract.anonymized.docx \
62
+ --session-mode create \
63
+ --session-archive matter.stlasession \
64
+ --session-key-file matter.key \
65
+ --session-id opaque_matter_1 \
66
+ --countries CZ,DE \
67
+ --languages cs,de \
68
+ --json
69
+ ```
70
+
71
+ Continue the same session across another document by changing
72
+ `--session-mode create` to `--session-mode continue`. Continue mode opens the
73
+ expected encrypted archive and atomically replaces it only after the complete
74
+ DOCX rewrite succeeds. It holds an exclusive `<archive>.lock` sidecar until the
75
+ archive and document are published, so concurrent continuations fail closed
76
+ instead of losing mappings. If a process is interrupted and leaves the lock
77
+ behind, verify that no continuation is still running before removing it.
78
+
79
+ Restore a document with the same archive, key, and expected session identity:
80
+
81
+ ```bash
82
+ anonymize docx restore contract.anonymized.docx \
83
+ --output contract.restored.docx \
84
+ --session-archive matter.stlasession \
85
+ --session-key-file matter.key \
86
+ --session-id opaque_matter_1 \
87
+ --json
88
+ ```
89
+
90
+ The default `--coverage require-full` policy fails closed on hyperlinks,
91
+ tracked revisions, and other content outside the rewrite surface. Processing
92
+ such a document requires explicit `--coverage allow-partial`. JSON output is an
93
+ aggregate audit-safe summary; it excludes extracted text, detected entity text,
94
+ session mappings, key material, and internal DOCX part paths. Document output
95
+ paths never overwrite existing files. Session keys are read only from files,
96
+ never from command arguments.
97
+
49
98
  ## Batch processing
50
99
 
51
100
  A directory argument anonymizes the text files inside it,
@@ -89,6 +138,7 @@ anonymize -d key.json --revert "[PERSON_1]" --revert "Jan Novák" reply.txt
89
138
  stderr are TTYs and no scope flags are given; piped runs
90
139
  never block.
91
140
  - `--json` offsets are UTF-16 code-unit indexes into the input.
141
+ - `--capabilities` is runtime-free and does not read document input.
92
142
 
93
143
  ## Standalone binary
94
144
 
@@ -106,4 +156,4 @@ the supported distribution in the meantime.
106
156
 
107
157
  ## License
108
158
 
109
- MIT
159
+ Apache-2.0
package/dist/cli.mjs CHANGED
@@ -4,10 +4,13 @@ import { ALL_DICTIONARY_IDS, DICTIONARY_META, loadCityDictionary, loadDictionary
4
4
  import { availableParallelism } from "node:os";
5
5
  import { parseArgs } from "node:util";
6
6
  import { realpathSync } from "node:fs";
7
- import { mkdir, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
7
+ import { link, lstat, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
8
8
  import { basename, dirname, join, relative, resolve } from "node:path";
9
9
  import { createInterface } from "node:readline/promises";
10
- import { DEFAULT_ENTITY_LABELS } from "@stll/anonymize/constants";
10
+ import { CAPABILITY_MANIFEST } from "@stll/anonymize/capabilities";
11
+ import { DEFAULT_ENTITY_LABELS, ENTITY_LABELS } from "@stll/anonymize/constants";
12
+ import { randomUUID } from "node:crypto";
13
+ import { DOCX_COVERAGE_MODES, anonymizeDocx, restoreDocxText } from "@stll/anonymize-docx";
11
14
  //#region src/args.ts
12
15
  const CLI_MODES = ["replace", "redact"];
13
16
  const DEFAULT_THRESHOLD = .3;
@@ -26,6 +29,10 @@ files inside it (add --recursive to descend into subdirectories).
26
29
  Writes to stdout, or to --output.
27
30
  All processing is local; the CLI makes no network calls.
28
31
 
32
+ DOCX workflows:
33
+ Run "anonymize docx --help" for structure-preserving DOCX anonymization
34
+ and restoration with encrypted session archives.
35
+
29
36
  Options:
30
37
  -o, --output <path> Output file, or directory for batch
31
38
  input (multiple files or a directory)
@@ -69,6 +76,7 @@ Options:
69
76
  -v, --version Show the version
70
77
  --list-labels List detectable entity labels and the
71
78
  short aliases accepted by --labels
79
+ --capabilities Emit the versioned capability manifest as JSON
72
80
 
73
81
  Batch input (directory or multiple files):
74
82
  Requires --output <directory>. The input tree is mirrored
@@ -108,9 +116,10 @@ Examples:
108
116
  anonymize -d key.json --revert "[PERSON_1]" contract.anon.txt
109
117
  cat notes.md | anonymize --countries CZ,SK --languages cs,sk
110
118
  anonymize --json --quiet input.txt | jq '.entities[].label'
119
+ anonymize docx --help
111
120
  `;
112
- const splitList = (value) => [...new Set(value.split(",").map((part) => part.trim()).filter((part) => part.length > 0))];
113
- const parseThreshold = (raw) => {
121
+ const splitList$1 = (value) => [...new Set(value.split(",").map((part) => part.trim()).filter((part) => part.length > 0))];
122
+ const parseThreshold$1 = (raw) => {
114
123
  const value = Number(raw);
115
124
  if (!Number.isFinite(value) || value < 0 || value > 1) throw new UsageError(`--threshold must be a number between 0 and 1, got "${raw}"`);
116
125
  return value;
@@ -127,7 +136,7 @@ const parseMode = (raw) => {
127
136
  };
128
137
  const COUNTRY_CODE_RE = /^[A-Za-z]{2}$/;
129
138
  const parseCountries = (raw) => {
130
- const countries = [...new Set(splitList(raw).map((code) => code.toUpperCase()))];
139
+ const countries = [...new Set(splitList$1(raw).map((code) => code.toUpperCase()))];
131
140
  const invalid = countries.find((code) => !COUNTRY_CODE_RE.test(code));
132
141
  if (invalid) throw new UsageError(`--countries expects ISO 3166-1 alpha-2 codes (e.g. "CZ,DE"), got "${invalid}"`);
133
142
  return countries;
@@ -152,16 +161,17 @@ const parseCliArgs = (argv) => {
152
161
  revert: values.revert === void 0 || values.revert.length === 0 ? void 0 : values.revert,
153
162
  recursive: values.recursive === true,
154
163
  workers: values.workers === void 0 ? defaultWorkerCount() : parseWorkers(values.workers),
155
- labels: values.labels === void 0 ? void 0 : splitList(values.labels),
156
- languages: values.languages === void 0 ? void 0 : splitList(values.languages),
164
+ labels: values.labels === void 0 ? void 0 : splitList$1(values.labels),
165
+ languages: values.languages === void 0 ? void 0 : splitList$1(values.languages),
157
166
  countries: values.countries === void 0 ? void 0 : parseCountries(values.countries),
158
- threshold: values.threshold === void 0 ? DEFAULT_THRESHOLD : parseThreshold(values.threshold),
167
+ threshold: values.threshold === void 0 ? DEFAULT_THRESHOLD : parseThreshold$1(values.threshold),
159
168
  redactString: values["redact-string"] ?? "[REDACTED]",
160
169
  json: values.json === true,
161
170
  quiet: values.quiet === true,
162
171
  help: values.help === true,
163
172
  version: values.version === true,
164
- listLabels: values["list-labels"] === true
173
+ listLabels: values["list-labels"] === true,
174
+ capabilities: values.capabilities === true
165
175
  };
166
176
  };
167
177
  const PARSE_CONFIG = {
@@ -208,7 +218,8 @@ const PARSE_CONFIG = {
208
218
  type: "boolean",
209
219
  short: "v"
210
220
  },
211
- "list-labels": { type: "boolean" }
221
+ "list-labels": { type: "boolean" },
222
+ capabilities: { type: "boolean" }
212
223
  }
213
224
  };
214
225
  //#endregion
@@ -307,7 +318,421 @@ const loadCliDictionaries = async ({ languages, countries }) => {
307
318
  };
308
319
  //#endregion
309
320
  //#region package.json
310
- var version = "2.0.0";
321
+ var version = "2.0.2";
322
+ //#endregion
323
+ //#region src/docx.ts
324
+ const DOCX_SESSION_KEY_BYTES = 32;
325
+ const DOCX_SESSION_LOCK_SUFFIX = ".lock";
326
+ const MAX_EPOCH_SECONDS = 4294967295;
327
+ const DOCX_SESSION_MODES = {
328
+ continue: "continue",
329
+ create: "create"
330
+ };
331
+ const DOCX_HELP = `Usage:
332
+ anonymize docx anonymize [options] <input.docx>
333
+ anonymize docx restore [options] <input.docx>
334
+
335
+ Anonymize or restore one DOCX file with an encrypted redaction session.
336
+ Document and session outputs are written atomically and never overwrite the
337
+ input, key file, or an existing document output.
338
+
339
+ Required options:
340
+ -o, --output <path> New DOCX output path
341
+ --session-archive <path> Encrypted session archive path
342
+ --session-key-file <path>
343
+ File containing exactly 32 raw key bytes
344
+ --session-id <id> Expected opaque session identity
345
+
346
+ Anonymize options:
347
+ --session-mode <mode> "create" or "continue" (required)
348
+ --coverage <mode> "require-full" (default) or "allow-partial"
349
+ --labels <list> Comma-separated entity labels
350
+ --languages <list> Name-corpus languages, e.g. "cs,de,en"
351
+ --countries <list> ISO 3166-1 alpha-2 country codes
352
+ --threshold <n> Minimum confidence score 0-1 (default: 0.3)
353
+
354
+ Restore options:
355
+ --coverage <mode> "require-full" (default) or "allow-partial"
356
+
357
+ Common options:
358
+ --observed-at <seconds> Deterministic Unix timestamp for lifecycle checks
359
+ --json Print the aggregate audit-safe summary as JSON
360
+ --quiet Suppress the human-readable stderr summary
361
+ -h, --help Show this help
362
+
363
+ The session key is read from a file, never from a command argument. In create
364
+ mode the archive path must not exist. Continue mode atomically replaces the
365
+ existing archive only after the DOCX rewrite succeeds. It holds an exclusive
366
+ "<archive>.lock" sidecar throughout the continuation to prevent lost updates.
367
+ Caller-supplied detection plans and interactive review are available through the
368
+ package API, not this CLI.
369
+ `;
370
+ const splitList = (value) => [...new Set(value.split(",").map((part) => part.trim()).filter((part) => part.length > 0))];
371
+ const parseThreshold = (raw) => {
372
+ const value = Number(raw);
373
+ if (!Number.isFinite(value) || value < 0 || value > 1) throw new UsageError(`--threshold must be a number between 0 and 1, got "${raw}"`);
374
+ return value;
375
+ };
376
+ const parseEpochSeconds = (raw) => {
377
+ const value = Number(raw);
378
+ if (!Number.isInteger(value) || value < 0 || value > MAX_EPOCH_SECONDS) throw new UsageError(`--observed-at must be an integer from 0 to ${MAX_EPOCH_SECONDS}, got "${raw}"`);
379
+ return value;
380
+ };
381
+ const parseCoverage = (raw) => {
382
+ const value = raw ?? DOCX_COVERAGE_MODES.requireFull;
383
+ if (value === DOCX_COVERAGE_MODES.requireFull || value === DOCX_COVERAGE_MODES.allowPartial) return value;
384
+ throw new UsageError(`--coverage must be one of: ${Object.values(DOCX_COVERAGE_MODES).join(", ")}; got "${value}"`);
385
+ };
386
+ const parseSessionMode = (raw) => {
387
+ if (raw === void 0) throw new UsageError("--session-mode is required for DOCX anonymization");
388
+ if (raw === DOCX_SESSION_MODES.create || raw === DOCX_SESSION_MODES.continue) return raw;
389
+ throw new UsageError(`--session-mode must be one of: ${Object.values(DOCX_SESSION_MODES).join(", ")}; got "${raw}"`);
390
+ };
391
+ const required = (value, flag) => {
392
+ if (value === void 0 || value.length === 0) throw new UsageError(`${flag} is required for DOCX workflows`);
393
+ return value;
394
+ };
395
+ const commonOptions = (values, positionals) => {
396
+ if (positionals.length !== 1) throw new UsageError("DOCX workflows require exactly one input file");
397
+ const inputPath = positionals.at(0);
398
+ if (inputPath === void 0) throw new UsageError("DOCX workflows require exactly one input file");
399
+ return {
400
+ inputPath,
401
+ outputPath: required(values.output, "--output"),
402
+ sessionArchivePath: required(values["session-archive"], "--session-archive"),
403
+ sessionKeyPath: required(values["session-key-file"], "--session-key-file"),
404
+ sessionId: required(values["session-id"], "--session-id"),
405
+ coverage: parseCoverage(values.coverage),
406
+ observedAtEpochSeconds: values["observed-at"] === void 0 ? void 0 : parseEpochSeconds(values["observed-at"]),
407
+ json: values.json === true,
408
+ quiet: values.quiet === true
409
+ };
410
+ };
411
+ const parseDocxCommand = (argv) => {
412
+ const action = argv.at(0);
413
+ if (action === void 0 || action === "--help" || action === "-h") return { type: "help" };
414
+ const args = argv.slice(1);
415
+ if (action === "anonymize") {
416
+ let parsed;
417
+ try {
418
+ parsed = parseArgs({
419
+ ...DOCX_ANONYMIZE_CONFIG,
420
+ args: [...args]
421
+ });
422
+ } catch (error) {
423
+ throw new UsageError(error instanceof Error ? error.message : String(error));
424
+ }
425
+ if (parsed.values.help === true) return { type: "help" };
426
+ return {
427
+ type: "anonymize",
428
+ ...commonOptions(parsed.values, parsed.positionals),
429
+ sessionMode: parseSessionMode(parsed.values["session-mode"]),
430
+ detection: {
431
+ labels: parsed.values.labels === void 0 ? void 0 : splitList(parsed.values.labels),
432
+ languages: parsed.values.languages === void 0 ? void 0 : splitList(parsed.values.languages),
433
+ countries: parsed.values.countries === void 0 ? void 0 : parseCountries(parsed.values.countries),
434
+ threshold: parsed.values.threshold === void 0 ? .3 : parseThreshold(parsed.values.threshold)
435
+ }
436
+ };
437
+ }
438
+ if (action === "restore") {
439
+ let parsed;
440
+ try {
441
+ parsed = parseArgs({
442
+ ...DOCX_RESTORE_CONFIG,
443
+ args: [...args]
444
+ });
445
+ } catch (error) {
446
+ throw new UsageError(error instanceof Error ? error.message : String(error));
447
+ }
448
+ if (parsed.values.help === true) return { type: "help" };
449
+ return {
450
+ type: "restore",
451
+ ...commonOptions(parsed.values, parsed.positionals)
452
+ };
453
+ }
454
+ throw new UsageError(`unknown DOCX action "${action}"; expected "anonymize" or "restore"`);
455
+ };
456
+ const DOCX_COMMON_PARSE_OPTIONS = {
457
+ output: {
458
+ type: "string",
459
+ short: "o"
460
+ },
461
+ "session-archive": { type: "string" },
462
+ "session-key-file": { type: "string" },
463
+ "session-id": { type: "string" },
464
+ coverage: { type: "string" },
465
+ "observed-at": { type: "string" },
466
+ json: { type: "boolean" },
467
+ quiet: { type: "boolean" },
468
+ help: {
469
+ type: "boolean",
470
+ short: "h"
471
+ }
472
+ };
473
+ const DOCX_ANONYMIZE_CONFIG = {
474
+ allowPositionals: true,
475
+ strict: true,
476
+ options: {
477
+ ...DOCX_COMMON_PARSE_OPTIONS,
478
+ "session-mode": { type: "string" },
479
+ labels: { type: "string" },
480
+ languages: { type: "string" },
481
+ countries: { type: "string" },
482
+ threshold: { type: "string" }
483
+ }
484
+ };
485
+ const DOCX_RESTORE_CONFIG = {
486
+ allowPositionals: true,
487
+ strict: true,
488
+ options: DOCX_COMMON_PARSE_OPTIONS
489
+ };
490
+ const canonicalPath$1 = (path) => {
491
+ try {
492
+ return realpathSync(path);
493
+ } catch {
494
+ return resolve(path);
495
+ }
496
+ };
497
+ const sessionArchiveLockPath = (archivePath) => `${canonicalPath$1(archivePath)}${DOCX_SESSION_LOCK_SUFFIX}`;
498
+ const assertDistinctPaths = (paths) => {
499
+ const seen = /* @__PURE__ */ new Map();
500
+ for (const entry of paths) {
501
+ const canonical = canonicalPath$1(entry.path);
502
+ const existing = seen.get(canonical);
503
+ if (existing !== void 0) throw new UsageError(`${entry.flag} collides with ${existing}`);
504
+ seen.set(canonical, `${entry.flag} "${entry.path}"`);
505
+ }
506
+ };
507
+ const isNodeError = (error, code) => error instanceof Error && "code" in error && error.code === code;
508
+ const assertPathDoesNotExist = async (path, flag) => {
509
+ try {
510
+ await lstat(path);
511
+ } catch (error) {
512
+ if (isNodeError(error, "ENOENT")) return;
513
+ throw error;
514
+ }
515
+ throw new UsageError(`${flag} refuses to overwrite existing path "${path}"`);
516
+ };
517
+ const preflightDocxCommand = async (command) => {
518
+ const paths = [
519
+ {
520
+ path: command.inputPath,
521
+ flag: "input"
522
+ },
523
+ {
524
+ path: command.outputPath,
525
+ flag: "--output"
526
+ },
527
+ {
528
+ path: command.sessionArchivePath,
529
+ flag: "--session-archive"
530
+ },
531
+ {
532
+ path: command.sessionKeyPath,
533
+ flag: "--session-key-file"
534
+ }
535
+ ];
536
+ if (command.type === "anonymize" && command.sessionMode === DOCX_SESSION_MODES.continue) paths.push({
537
+ path: sessionArchiveLockPath(command.sessionArchivePath),
538
+ flag: "session archive lock"
539
+ });
540
+ assertDistinctPaths(paths);
541
+ await assertPathDoesNotExist(command.outputPath, "--output");
542
+ if (command.type === "anonymize" && command.sessionMode === DOCX_SESSION_MODES.create) await assertPathDoesNotExist(command.sessionArchivePath, "--session-archive");
543
+ };
544
+ const captureOperationResult = async (operation) => {
545
+ try {
546
+ await operation;
547
+ return { type: "succeeded" };
548
+ } catch (error) {
549
+ return {
550
+ type: "failed",
551
+ error
552
+ };
553
+ }
554
+ };
555
+ const acquireSessionArchiveLock = async (archivePath) => {
556
+ const lockPath = sessionArchiveLockPath(archivePath);
557
+ let handle;
558
+ try {
559
+ handle = await open(lockPath, "wx", 384);
560
+ } catch (error) {
561
+ if (isNodeError(error, "EEXIST")) throw new Error(`encrypted session archive is locked by another continuation; if no process is running, remove the stale lock "${lockPath}"`);
562
+ throw error;
563
+ }
564
+ return { release: async () => {
565
+ const closeResult = await captureOperationResult(handle.close());
566
+ const unlinkResult = await captureOperationResult(unlink(lockPath));
567
+ if (closeResult.type === "failed") throw closeResult.error;
568
+ if (unlinkResult.type === "failed" && !isNodeError(unlinkResult.error, "ENOENT")) throw unlinkResult.error;
569
+ } };
570
+ };
571
+ const readSessionKey = async (path) => {
572
+ const handle = await open(path, "r");
573
+ let key;
574
+ try {
575
+ const stats = await handle.stat();
576
+ if (!stats.isFile()) throw new UsageError("--session-key-file must be a regular file");
577
+ if (process.platform !== "win32" && (stats.mode & 63) !== 0) throw new UsageError("--session-key-file must not grant permissions to group or other users (use chmod 600)");
578
+ key = await handle.readFile();
579
+ if (key.byteLength !== DOCX_SESSION_KEY_BYTES) throw new UsageError(`--session-key-file must contain exactly ${DOCX_SESSION_KEY_BYTES} raw bytes`);
580
+ await handle.close();
581
+ return key;
582
+ } catch (error) {
583
+ key?.fill(0);
584
+ try {
585
+ await handle.close();
586
+ } catch {}
587
+ throw error;
588
+ }
589
+ };
590
+ const removeStagedFile = async (path) => {
591
+ if (path === void 0) return;
592
+ try {
593
+ await unlink(path);
594
+ } catch {}
595
+ };
596
+ const stageFile = async (target, content) => {
597
+ const temporary = join(dirname(target), `.${basename(target)}.${randomUUID()}.tmp`);
598
+ const handle = await open(temporary, "wx", 384);
599
+ try {
600
+ await handle.writeFile(content);
601
+ await handle.sync();
602
+ await handle.close();
603
+ } catch (error) {
604
+ try {
605
+ await handle.close();
606
+ } catch {}
607
+ await removeStagedFile(temporary);
608
+ throw error;
609
+ }
610
+ return temporary;
611
+ };
612
+ const publishNewFile = async (temporary, target, flag) => {
613
+ try {
614
+ await link(temporary, target);
615
+ } catch (error) {
616
+ if (isNodeError(error, "EEXIST")) throw new UsageError(`${flag} refuses to overwrite existing path "${target}"`);
617
+ throw error;
618
+ }
619
+ await removeStagedFile(temporary);
620
+ };
621
+ const publishReplacement = async (temporary, target) => {
622
+ await rename(temporary, target);
623
+ };
624
+ const sessionArchive = (session, key, observedAtEpochSeconds) => observedAtEpochSeconds === void 0 ? session.toEncryptedArchive(key) : session.toEncryptedArchiveAt(key, observedAtEpochSeconds);
625
+ const outputSummary = (command, action, summary) => {
626
+ if (command.json) process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
627
+ if (command.quiet) return;
628
+ const coverage = summary.coverage.status;
629
+ if ("entityCount" in summary) {
630
+ process.stderr.write(`anonymize: DOCX ${action}: ${summary.entityCount} entities, ${summary.appliedReplacementCount} replacements, ${coverage} coverage\n`);
631
+ return;
632
+ }
633
+ process.stderr.write(`anonymize: DOCX ${action}: ${summary.restoredPlaceholderCount} placeholders, ${coverage} coverage\n`);
634
+ };
635
+ const openSession = (pipeline, command, archive, key) => pipeline.restoreEncryptedRedactionSession({
636
+ archive,
637
+ key,
638
+ expectedSessionId: command.sessionId,
639
+ ...command.observedAtEpochSeconds === void 0 ? {} : { observedAtEpochSeconds: command.observedAtEpochSeconds }
640
+ });
641
+ const runDocxAnonymize = async (command, pipeline) => {
642
+ const archivePath = command.sessionMode === DOCX_SESSION_MODES.continue ? canonicalPath$1(command.sessionArchivePath) : command.sessionArchivePath;
643
+ const archiveLock = command.sessionMode === DOCX_SESSION_MODES.continue ? await acquireSessionArchiveLock(archivePath) : void 0;
644
+ let workflowResult = { type: "succeeded" };
645
+ let lockReleaseResult = { type: "succeeded" };
646
+ let key;
647
+ let documentTemporary;
648
+ let archiveTemporary;
649
+ try {
650
+ key = await readSessionKey(command.sessionKeyPath);
651
+ const [document, existingArchive] = await Promise.all([readFile(command.inputPath), command.sessionMode === DOCX_SESSION_MODES.continue ? readFile(archivePath) : Promise.resolve(void 0)]);
652
+ const session = existingArchive === void 0 ? pipeline.createRedactionSession(command.sessionId) : openSession(pipeline, command, existingArchive, key);
653
+ const result = anonymizeDocx({
654
+ document,
655
+ session,
656
+ expectedSessionId: command.sessionId,
657
+ policy: { coverage: { mode: command.coverage } },
658
+ ...command.observedAtEpochSeconds === void 0 ? {} : { observedAtEpochSeconds: command.observedAtEpochSeconds }
659
+ });
660
+ const encryptedArchive = sessionArchive(session, key, command.observedAtEpochSeconds);
661
+ documentTemporary = await stageFile(command.outputPath, result.document);
662
+ archiveTemporary = await stageFile(archivePath, encryptedArchive);
663
+ if (command.sessionMode === DOCX_SESSION_MODES.create) await publishNewFile(archiveTemporary, command.sessionArchivePath, "--session-archive");
664
+ else await publishReplacement(archiveTemporary, archivePath);
665
+ archiveTemporary = void 0;
666
+ try {
667
+ await publishNewFile(documentTemporary, command.outputPath, "--output");
668
+ documentTemporary = void 0;
669
+ } catch (error) {
670
+ const message = error instanceof Error ? error.message : String(error);
671
+ throw new Error(`encrypted session archive was updated, but DOCX output could not be published: ${message}`);
672
+ }
673
+ outputSummary(command, "anonymized", result.summary);
674
+ } catch (error) {
675
+ workflowResult = {
676
+ type: "failed",
677
+ error
678
+ };
679
+ } finally {
680
+ key?.fill(0);
681
+ await Promise.all([removeStagedFile(documentTemporary), removeStagedFile(archiveTemporary)]);
682
+ if (archiveLock !== void 0) lockReleaseResult = await captureOperationResult(archiveLock.release());
683
+ }
684
+ if (workflowResult.type === "failed") throw workflowResult.error;
685
+ if (lockReleaseResult.type === "failed") {
686
+ const message = lockReleaseResult.error instanceof Error ? lockReleaseResult.error.message : String(lockReleaseResult.error);
687
+ throw new Error(`DOCX and session outputs were published, but the session archive lock could not be released: ${message}`);
688
+ }
689
+ };
690
+ const runDocxRestore = async (command, pipeline) => {
691
+ const key = await readSessionKey(command.sessionKeyPath);
692
+ try {
693
+ const [document, archive] = await Promise.all([readFile(command.inputPath), readFile(command.sessionArchivePath)]);
694
+ const result = restoreDocxText({
695
+ document,
696
+ session: openSession(pipeline, command, archive, key),
697
+ expectedSessionId: command.sessionId,
698
+ ...command.observedAtEpochSeconds === void 0 ? {} : { observedAtEpochSeconds: command.observedAtEpochSeconds }
699
+ });
700
+ if (command.coverage === DOCX_COVERAGE_MODES.requireFull && result.coverage.status === "partial") throw new Error("DOCX contains content outside the fully supported restoration coverage");
701
+ const temporary = await stageFile(command.outputPath, result.document);
702
+ try {
703
+ await publishNewFile(temporary, command.outputPath, "--output");
704
+ } catch (error) {
705
+ await removeStagedFile(temporary);
706
+ throw error;
707
+ }
708
+ const summary = {
709
+ sessionId: result.sessionId,
710
+ restoredBlockCount: result.restoredBlockCount,
711
+ restoredPlaceholderCount: result.restoredPlaceholderCount,
712
+ coverage: result.coverage
713
+ };
714
+ outputSummary(command, "restored", summary);
715
+ } finally {
716
+ key.fill(0);
717
+ }
718
+ };
719
+ const runDocxCommand = async ({ argv, preparePipeline }) => {
720
+ const command = parseDocxCommand(argv);
721
+ if (command.type === "help") {
722
+ process.stdout.write(DOCX_HELP);
723
+ return;
724
+ }
725
+ await preflightDocxCommand(command);
726
+ const pipeline = await preparePipeline(command.type === "anonymize" ? {
727
+ type: "anonymize",
728
+ detection: command.detection
729
+ } : { type: "restore" });
730
+ if (command.type === "anonymize") {
731
+ await runDocxAnonymize(command, pipeline);
732
+ return;
733
+ }
734
+ await runDocxRestore(command, pipeline);
735
+ };
311
736
  //#endregion
312
737
  //#region src/main.ts
313
738
  const cliVersion = () => version;
@@ -457,7 +882,7 @@ const LABEL_ALIASES = {
457
882
  "national id": "national identification number"
458
883
  };
459
884
  const LABEL_SEPARATOR_RE = /[\s_-]+/g;
460
- const ENTITY_LABEL_SET = new Set(DEFAULT_ENTITY_LABELS);
885
+ const ENTITY_LABEL_SET = new Set(ENTITY_LABELS);
461
886
  const isEntityLabel = (label) => ENTITY_LABEL_SET.has(label);
462
887
  /**
463
888
  * Resolve a user-supplied label token to a canonical label.
@@ -467,13 +892,13 @@ const isEntityLabel = (label) => ENTITY_LABEL_SET.has(label);
467
892
  */
468
893
  const canonicalizeLabel = (raw) => {
469
894
  const normalized = raw.toLowerCase().replace(LABEL_SEPARATOR_RE, " ").trim();
470
- if (DEFAULT_ENTITY_LABELS.includes(normalized)) return normalized;
895
+ if (ENTITY_LABELS.includes(normalized)) return normalized;
471
896
  return LABEL_ALIASES[normalized] ?? normalized;
472
897
  };
473
898
  const validateLabels = (labels) => {
474
899
  const resolved = [...new Set(labels.map(canonicalizeLabel))];
475
900
  const valid = [];
476
- const availableLabels = DEFAULT_ENTITY_LABELS.join(", ");
901
+ const availableLabels = ENTITY_LABELS.join(", ");
477
902
  const availableAliases = Object.keys(LABEL_ALIASES).join(", ");
478
903
  for (const label of resolved) {
479
904
  if (!isEntityLabel(label)) throw new UsageError([
@@ -693,7 +1118,8 @@ const runAnonymise = async (opts, { api, loadDictionaries }) => {
693
1118
  const [input] = await readInputs(scoped.files);
694
1119
  if (!input) throw new UsageError("no input to anonymize");
695
1120
  guardWriteTargets([], collectSingleTargets(scoped));
696
- await runAnonymiseSingle(scoped, await prepareCliRuntime(api, await buildPipelineConfig(scoped, loadDictionaries)), api, {
1121
+ const runtime = await prepareCliRuntime(api, await buildPipelineConfig(scoped, loadDictionaries));
1122
+ await runAnonymiseSingle(scoped, runtime, api, {
697
1123
  text: input.text,
698
1124
  outputPath: scoped.output,
699
1125
  source: "stdin"
@@ -705,7 +1131,8 @@ const runAnonymise = async (opts, { api, loadDictionaries }) => {
705
1131
  const [job] = jobs;
706
1132
  if (!job) throw new UsageError("no input to anonymize");
707
1133
  guardWriteTargets([job.path], collectSingleTargets(scoped));
708
- await runAnonymiseSingle(scoped, await prepareCliRuntime(api, await buildPipelineConfig(scoped, loadDictionaries)), api, {
1134
+ const runtime = await prepareCliRuntime(api, await buildPipelineConfig(scoped, loadDictionaries));
1135
+ await runAnonymiseSingle(scoped, runtime, api, {
709
1136
  text: await readFile(job.path, "utf8"),
710
1137
  outputPath: scoped.output,
711
1138
  source: job.path
@@ -720,7 +1147,8 @@ const runAnonymise = async (opts, { api, loadDictionaries }) => {
720
1147
  path: join(output, job.outputRelative),
721
1148
  flag: "--output"
722
1149
  })));
723
- await runAnonymiseBatch(scoped, await prepareCliRuntime(api, await buildPipelineConfig(scoped, loadDictionaries)), output, jobs, skipped);
1150
+ const runtime = await prepareCliRuntime(api, await buildPipelineConfig(scoped, loadDictionaries));
1151
+ await runAnonymiseBatch(scoped, runtime, output, jobs, skipped);
724
1152
  };
725
1153
  /** Write targets for a single-input run: --output and --key. */
726
1154
  const collectSingleTargets = (opts) => {
@@ -735,13 +1163,17 @@ const collectSingleTargets = (opts) => {
735
1163
  });
736
1164
  return targets;
737
1165
  };
738
- const prepareCliRuntime = async (api, config) => {
1166
+ const prepareNativeCliPipeline = async (api, config) => {
739
1167
  const pipeline = await api.createNativePipelineFromConfig({
740
1168
  binding: api.loadNativeAnonymizeBinding(),
741
1169
  config,
742
1170
  gazetteerEntries: []
743
1171
  });
744
1172
  pipeline.warmLazyRegex?.();
1173
+ return pipeline;
1174
+ };
1175
+ const prepareCliRuntime = async (api, config) => {
1176
+ const pipeline = await prepareNativeCliPipeline(api, config);
745
1177
  return { redact: async (fullText, operators) => {
746
1178
  const result = pipeline.redactText(fullText, operators);
747
1179
  return {
@@ -756,7 +1188,7 @@ const prepareCliRuntime = async (api, config) => {
756
1188
  */
757
1189
  const formatLabelList = () => {
758
1190
  const lines = ["Detectable entity labels (pass to --labels):"];
759
- for (const label of DEFAULT_ENTITY_LABELS) lines.push(` ${label}`);
1191
+ for (const label of ENTITY_LABELS) lines.push(` ${label}`);
760
1192
  lines.push("", "Short aliases:");
761
1193
  const aliases = Object.entries(LABEL_ALIASES);
762
1194
  let width = 0;
@@ -765,7 +1197,22 @@ const formatLabelList = () => {
765
1197
  return `${lines.join("\n")}\n`;
766
1198
  };
767
1199
  const dispatch = async (engine) => {
768
- const opts = parseCliArgs(process.argv.slice(2));
1200
+ const argv = process.argv.slice(2);
1201
+ if (argv.at(0) === "docx") {
1202
+ await runDocxCommand({
1203
+ argv: argv.slice(1),
1204
+ preparePipeline: async (request) => {
1205
+ const options = request.type === "anonymize" ? request.detection : {
1206
+ countries: [],
1207
+ languages: [],
1208
+ threshold: DEFAULT_THRESHOLD
1209
+ };
1210
+ return prepareNativeCliPipeline(engine.api, await buildPipelineConfig(options, engine.loadDictionaries));
1211
+ }
1212
+ });
1213
+ return;
1214
+ }
1215
+ const opts = parseCliArgs(argv);
769
1216
  if (opts.help) {
770
1217
  process.stdout.write(HELP);
771
1218
  return;
@@ -778,6 +1225,10 @@ const dispatch = async (engine) => {
778
1225
  process.stdout.write(formatLabelList());
779
1226
  return;
780
1227
  }
1228
+ if (opts.capabilities) {
1229
+ process.stdout.write(`${JSON.stringify(CAPABILITY_MANIFEST, null, 2)}\n`);
1230
+ return;
1231
+ }
781
1232
  if (opts.deanonymiseKeyPath !== void 0) {
782
1233
  await runDeanonymise(opts, engine.api);
783
1234
  return;
package/dist/cli.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.mjs","names":["pkg.version","known"],"sources":["../src/args.ts","../src/dictionary-scope.ts","../src/dictionaries.ts","../package.json","../src/main.ts","../src/cli.ts"],"sourcesContent":["import { availableParallelism } from \"node:os\";\nimport { parseArgs } from \"node:util\";\n\nexport const CLI_MODES = [\"replace\", \"redact\"] as const;\nexport type CliMode = (typeof CLI_MODES)[number];\n\nexport const DEFAULT_THRESHOLD = 0.3;\nexport const DEFAULT_REDACT_STRING = \"[REDACTED]\";\n\n/** Upper bound on the default worker count; batch I/O overlap\n * saturates well before this, and redaction itself is a\n * synchronous native call serialized on the JS thread. */\nexport const MAX_DEFAULT_WORKERS = 4;\n\n/** Default batch concurrency: min(4, cores). Workers overlap\n * file reads/writes; the shared native pipeline runs each\n * redaction to completion on the single JS thread. */\nexport const defaultWorkerCount = (): number =>\n Math.max(1, Math.min(MAX_DEFAULT_WORKERS, availableParallelism()));\n\n/** Invalid invocation; printed with usage hint, exit code 2. */\nexport class UsageError extends Error {}\n\nexport type CliOptions = {\n files: string[];\n output?: string | undefined;\n mode: CliMode;\n keyPath?: string | undefined;\n deanonymiseKeyPath?: string | undefined;\n revert?: string[] | undefined;\n recursive: boolean;\n workers: number;\n labels?: string[] | undefined;\n languages?: string[] | undefined;\n countries?: string[] | undefined;\n threshold: number;\n redactString: string;\n json: boolean;\n quiet: boolean;\n help: boolean;\n version: boolean;\n listLabels: boolean;\n};\n\nexport const HELP = `Usage: anonymize [options] [file|dir ...]\n\nDetect and anonymize PII in text. Reads the given files, or stdin\nwhen no files are given. A directory argument processes the text\nfiles inside it (add --recursive to descend into subdirectories).\nWrites to stdout, or to --output.\nAll processing is local; the CLI makes no network calls.\n\nOptions:\n -o, --output <path> Output file, or directory for batch\n input (multiple files or a directory)\n -m, --mode <mode> \"replace\" (reversible [PERSON_1]\n placeholders) or \"redact\"\n (default: replace)\n -k, --key <path> Write the redaction key as JSON\n (single input, replace mode)\n -d, --deanonymise <path> Restore redacted text using the\n redaction key at <path>\n --revert <term> With --deanonymise, restore only the\n given entity. Match a placeholder token\n (\"[PERSON_1]\") or an original value\n (\"Jan Novák\"), case-sensitive exact.\n Repeatable; others stay redacted\n -r, --recursive Descend into subdirectories when a\n directory is given as input\n --workers <n> Batch files to process concurrently\n (default: min(${MAX_DEFAULT_WORKERS}, CPU cores)). Overlaps\n file I/O; redaction is serialized on\n the JS thread\n --labels <list> Comma-separated entity labels to detect\n (default: all). Accepts canonical labels\n (\"email address\"), short aliases (email,\n phone, org, dob, ssn), and hyphen/underscore\n forms (\"credit-card-number\")\n --languages <list> Name-corpus languages, e.g. \"cs,de,en\"\n (default: all bundled)\n --countries <list> ISO 3166-1 alpha-2 codes scoping deny\n lists and city data, e.g. \"CZ,DE,GB\"\n (default: all deny lists; city data\n for a 30-country default set)\n --threshold <n> Minimum confidence score, 0-1\n (default: ${DEFAULT_THRESHOLD})\n --redact-string <s> Replacement text in redact mode\n (default: \"${DEFAULT_REDACT_STRING}\")\n --json Emit JSON (entities + redacted text) to\n stdout (single input only)\n --quiet Suppress the summary on stderr\n -h, --help Show this help\n -v, --version Show the version\n --list-labels List detectable entity labels and the\n short aliases accepted by --labels\n\nBatch input (directory or multiple files):\n Requires --output <directory>. The input tree is mirrored\n into the output directory. Directory walks process regular\n files only and skip likely-binary files (a NUL byte in the\n first 8 KiB); explicitly named files are always processed.\n The stderr summary reports how many files were processed,\n failed, and skipped; any failure sets exit code 1.\n --key and --json apply to single inputs only.\n\nInteractive prompt:\n When run on files from a terminal without --countries or\n --languages, the CLI asks once which country scope to load.\n Piped stdin/stderr or --quiet skips the prompt, so scripts\n and agents never block on input.\n\nExit codes:\n 0 success\n 1 runtime error (message on stderr)\n 2 usage error (message on stderr)\n\nJSON output (--json):\n { \"entityCount\": number,\n \"entities\": [{ \"start\": number, \"end\": number,\n \"label\": string, \"text\": string,\n \"score\": number, \"source\": string }],\n \"redactedText\": string }\n Offsets are UTF-16 code-unit indexes into the input.\n The stderr summary contains entity counts only, never\n the detected text.\n\nExamples:\n anonymize contract.txt > contract.anon.txt\n anonymize -k contract.key.json -o contract.anon.txt contract.txt\n anonymize -d contract.key.json contract.anon.txt\n anonymize -r --workers 8 -o out/ docs/\n anonymize -d key.json --revert \"[PERSON_1]\" contract.anon.txt\n cat notes.md | anonymize --countries CZ,SK --languages cs,sk\n anonymize --json --quiet input.txt | jq '.entities[].label'\n`;\n\nconst splitList = (value: string): string[] => [\n ...new Set(\n value\n .split(\",\")\n .map((part) => part.trim())\n .filter((part) => part.length > 0),\n ),\n];\n\nconst parseThreshold = (raw: string): number => {\n const value = Number(raw);\n if (!Number.isFinite(value) || value < 0 || value > 1) {\n throw new UsageError(\n `--threshold must be a number between 0 and 1, got \"${raw}\"`,\n );\n }\n return value;\n};\n\nconst parseWorkers = (raw: string): number => {\n const value = Number(raw);\n if (!Number.isInteger(value) || value < 1) {\n throw new UsageError(`--workers must be a positive integer, got \"${raw}\"`);\n }\n return value;\n};\n\nconst parseMode = (raw: string): CliMode => {\n const mode = CLI_MODES.find((candidate) => candidate === raw);\n if (!mode) {\n throw new UsageError(\n `--mode must be one of: ${CLI_MODES.join(\", \")}; got \"${raw}\"`,\n );\n }\n return mode;\n};\n\nconst COUNTRY_CODE_RE = /^[A-Za-z]{2}$/;\n\nexport const parseCountries = (raw: string): string[] => {\n const countries = [\n ...new Set(splitList(raw).map((code) => code.toUpperCase())),\n ];\n const invalid = countries.find((code) => !COUNTRY_CODE_RE.test(code));\n if (invalid) {\n throw new UsageError(\n `--countries expects ISO 3166-1 alpha-2 codes (e.g. \"CZ,DE\"), got \"${invalid}\"`,\n );\n }\n return countries;\n};\n\nexport const parseCliArgs = (argv: string[]): CliOptions => {\n let parsed: ReturnType<typeof parseArgs<typeof PARSE_CONFIG>>;\n try {\n parsed = parseArgs({ ...PARSE_CONFIG, args: argv });\n } catch (err) {\n throw new UsageError(err instanceof Error ? err.message : String(err));\n }\n const { values, positionals } = parsed;\n\n return {\n files: positionals,\n output: values.output,\n mode: values.mode === undefined ? \"replace\" : parseMode(values.mode),\n keyPath: values.key,\n deanonymiseKeyPath: values.deanonymise,\n revert:\n values.revert === undefined || values.revert.length === 0\n ? undefined\n : values.revert,\n recursive: values.recursive === true,\n workers:\n values.workers === undefined\n ? defaultWorkerCount()\n : parseWorkers(values.workers),\n labels: values.labels === undefined ? undefined : splitList(values.labels),\n languages:\n values.languages === undefined ? undefined : splitList(values.languages),\n countries:\n values.countries === undefined\n ? undefined\n : parseCountries(values.countries),\n threshold:\n values.threshold === undefined\n ? DEFAULT_THRESHOLD\n : parseThreshold(values.threshold),\n redactString: values[\"redact-string\"] ?? DEFAULT_REDACT_STRING,\n json: values.json === true,\n quiet: values.quiet === true,\n help: values.help === true,\n version: values.version === true,\n listLabels: values[\"list-labels\"] === true,\n };\n};\n\nconst PARSE_CONFIG = {\n allowPositionals: true,\n strict: true,\n options: {\n output: { type: \"string\", short: \"o\" },\n mode: { type: \"string\", short: \"m\" },\n key: { type: \"string\", short: \"k\" },\n deanonymise: { type: \"string\", short: \"d\" },\n revert: { type: \"string\", multiple: true },\n recursive: { type: \"boolean\", short: \"r\" },\n workers: { type: \"string\" },\n labels: { type: \"string\" },\n languages: { type: \"string\" },\n countries: { type: \"string\" },\n threshold: { type: \"string\" },\n \"redact-string\": { type: \"string\" },\n json: { type: \"boolean\" },\n quiet: { type: \"boolean\" },\n help: { type: \"boolean\", short: \"h\" },\n version: { type: \"boolean\", short: \"v\" },\n \"list-labels\": { type: \"boolean\" },\n },\n} as const;\n","/* Pure helpers shared by the npm and embedded dictionary\n * loaders. Must stay free of @stll/anonymize-data imports\n * so the compiled binary's bundle excludes the raw JSON\n * dictionary modules. */\nimport type { Dictionaries, DictionaryMeta } from \"@stll/anonymize\";\n\nimport { UsageError } from \"./args\";\n\nexport const NAME_DICTIONARY_PREFIXES = [\n \"names/first/\",\n \"names/surnames/\",\n] as const;\n\n/** Language code of a name dictionary id, or null. */\nexport const nameLanguageOfDictionary = (id: string): string | null => {\n const prefix = NAME_DICTIONARY_PREFIXES.find((p) => id.startsWith(p));\n return prefix ? id.slice(prefix.length) : null;\n};\n\nexport type DictionaryScope = {\n languages?: readonly string[] | undefined;\n countries?: readonly string[] | undefined;\n};\n\nconst pickKeys = <T>(\n record: Record<string, T>,\n keep: (key: string) => boolean,\n): Record<string, T> => {\n const result: Record<string, T> = {};\n for (const [key, value] of Object.entries(record)) {\n if (keep(key)) result[key] = value;\n }\n return result;\n};\n\n/** Dictionaries with every section present (possibly empty). */\nexport type ScopedDictionaries = {\n firstNames: Record<string, readonly string[]>;\n surnames: Record<string, readonly string[]>;\n denyList: Record<string, readonly string[]>;\n denyListMeta: Record<string, DictionaryMeta>;\n citiesByCountry: Record<string, readonly string[]>;\n};\n\n/**\n * Scope a fully loaded dictionary set to the requested\n * languages and countries. Mirrors the pre-load scoping\n * the npm loader does in dictionaries.ts; used by the\n * embedded loader, which always starts from the full set.\n */\nexport const filterDictionaries = (\n all: Dictionaries,\n { languages, countries }: DictionaryScope,\n): ScopedDictionaries => {\n const firstNames = all.firstNames ?? {};\n const surnames = all.surnames ?? {};\n const allDenyList = all.denyList ?? {};\n const allDenyListMeta = all.denyListMeta ?? {};\n\n if (languages !== undefined) {\n const available = Object.keys(firstNames);\n const invalid = languages.find((lang) => !available.includes(lang));\n if (invalid) {\n throw new UsageError(\n `--languages: no name dictionary for \"${invalid}\"; available: ${available.join(\", \")}`,\n );\n }\n }\n const keepLanguage = (lang: string): boolean =>\n languages === undefined || languages.includes(lang);\n const keepCountry = (country: string | null): boolean =>\n countries === undefined || country === null || countries.includes(country);\n\n const denyListMeta: Record<string, DictionaryMeta> = {};\n const denyList: Record<string, readonly string[]> = {};\n for (const [id, meta] of Object.entries(allDenyListMeta)) {\n if (!keepCountry(meta.country)) continue;\n const nameLang = nameLanguageOfDictionary(id);\n if (nameLang !== null && !keepLanguage(nameLang)) continue;\n const entries = allDenyList[id];\n if (entries === undefined) continue;\n denyListMeta[id] = meta;\n denyList[id] = entries;\n }\n\n return {\n firstNames: pickKeys(firstNames, keepLanguage),\n surnames: pickKeys(surnames, keepLanguage),\n denyList,\n denyListMeta,\n citiesByCountry: pickKeys(all.citiesByCountry ?? {}, (country) =>\n keepCountry(country),\n ),\n };\n};\n","import type { Dictionaries, DictionaryMeta } from \"@stll/anonymize\";\nimport {\n ALL_DICTIONARY_IDS,\n DICTIONARY_META,\n loadCityDictionary,\n loadDictionary,\n loadNameDictionaries,\n type NameLanguage,\n} from \"@stll/anonymize-data\";\n\nimport { UsageError } from \"./args\";\nimport type { DictionaryScope } from \"./dictionary-scope\";\nimport {\n NAME_DICTIONARY_PREFIXES,\n nameLanguageOfDictionary,\n} from \"./dictionary-scope\";\n\n/**\n * Countries with bundled city dictionaries that are\n * loaded when no --countries scope is given.\n */\nconst DEFAULT_CITY_COUNTRIES = [\n \"AT\",\n \"AU\",\n \"BE\",\n \"BG\",\n \"BR\",\n \"CA\",\n \"CH\",\n \"CZ\",\n \"DE\",\n \"DK\",\n \"ES\",\n \"FI\",\n \"FR\",\n \"GB\",\n \"GR\",\n \"HR\",\n \"HU\",\n \"IE\",\n \"IT\",\n \"LU\",\n \"NL\",\n \"NO\",\n \"NZ\",\n \"PL\",\n \"PT\",\n \"RO\",\n \"SE\",\n \"SI\",\n \"SK\",\n \"US\",\n] as const;\n\nconst availableNameLanguages = (): readonly string[] =>\n ALL_DICTIONARY_IDS.filter((id) =>\n id.startsWith(NAME_DICTIONARY_PREFIXES[0]),\n ).map((id) => id.slice(NAME_DICTIONARY_PREFIXES[0].length));\n\nconst validateLanguages = (\n languages: readonly string[],\n): readonly NameLanguage[] => {\n const available = availableNameLanguages();\n const invalid = languages.find((lang) => !available.includes(lang));\n if (invalid) {\n throw new UsageError(\n `--languages: no name dictionary for \"${invalid}\"; available: ${available.join(\", \")}`,\n );\n }\n // SAFETY: every entry was checked against the bundled\n // name dictionary ids, which define NameLanguage.\n return languages as readonly NameLanguage[];\n};\n\nexport type LoadCliDictionariesOptions = DictionaryScope;\n\n/**\n * Load the bundled @stll/anonymize-data dictionaries,\n * scoped to the requested languages and countries.\n */\nexport const loadCliDictionaries = async ({\n languages,\n countries,\n}: LoadCliDictionariesOptions): Promise<Dictionaries> => {\n const nameLanguages =\n languages === undefined ? undefined : validateLanguages(languages);\n\n const denyIds = ALL_DICTIONARY_IDS.filter((id) => {\n const meta = DICTIONARY_META[id];\n if (\n countries &&\n meta.country !== null &&\n !countries.includes(meta.country)\n ) {\n return false;\n }\n const nameLang = nameLanguageOfDictionary(id);\n if (nameLang !== null && nameLanguages !== undefined) {\n return nameLanguages.includes(\n // SAFETY: nameLang comes from a bundled dictionary\n // id, which defines NameLanguage.\n nameLang as NameLanguage,\n );\n }\n return true;\n });\n\n const cityCountries = countries ?? DEFAULT_CITY_COUNTRIES;\n\n const [names, denyEntries, cityEntries] = await Promise.all([\n loadNameDictionaries(nameLanguages),\n Promise.all(\n denyIds.map(async (id) => ({ id, entries: await loadDictionary(id) })),\n ),\n Promise.all(\n cityCountries.map(async (country) => ({\n country,\n entries: await loadCityDictionary(country),\n })),\n ),\n ]);\n\n const denyList: Record<string, readonly string[]> = {};\n const denyListMeta: Record<string, DictionaryMeta> = {};\n for (const { id, entries } of denyEntries) {\n denyList[id] = entries;\n // SAFETY: anonymize-data categories match\n // DenyListCategory at runtime.\n denyListMeta[id] = DICTIONARY_META[id] as DictionaryMeta;\n }\n\n const citiesByCountry: Record<string, readonly string[]> = {};\n for (const { country, entries } of cityEntries) {\n if (entries.length > 0) citiesByCountry[country] = entries;\n }\n\n return {\n firstNames: names.firstNames,\n surnames: names.surnames,\n denyList,\n denyListMeta,\n citiesByCountry,\n };\n};\n","","import { realpathSync } from \"node:fs\";\nimport {\n mkdir,\n open,\n readdir,\n readFile,\n stat,\n writeFile,\n} from \"node:fs/promises\";\nimport { basename, dirname, join, relative, resolve } from \"node:path\";\nimport { createInterface } from \"node:readline/promises\";\n\nimport type {\n deanonymise,\n Dictionaries,\n exportRedactionKey,\n NativeAnonymizeBinding,\n NativePipelineBuildOptions,\n OperatorType,\n PipelineConfig,\n} from \"@stll/anonymize\";\nimport { DEFAULT_ENTITY_LABELS } from \"@stll/anonymize/constants\";\n\nimport pkg from \"../package.json\" with { type: \"json\" };\n\nimport type { CliOptions } from \"./args\";\nimport { HELP, parseCliArgs, parseCountries, UsageError } from \"./args\";\nimport type { DictionaryScope } from \"./dictionary-scope\";\n\n/**\n * The pipeline functions the CLI needs, backed by the\n * @stll/anonymize native SDK: a binding loader and the\n * config-to-pipeline builder, plus the redaction-key\n * helpers used by the deanonymise path.\n */\nexport type AnonymizeApi = {\n deanonymise: typeof deanonymise;\n exportRedactionKey: typeof exportRedactionKey;\n createNativePipelineFromConfig: (\n options: NativePipelineBuildOptions,\n ) => Promise<NativeCliPipeline>;\n loadNativeAnonymizeBinding: () => NativeAnonymizeBinding;\n};\n\n/**\n * Everything an entry point injects: the pipeline engine\n * and the dictionary source (the @stll/anonymize-data\n * package for the npm bin).\n */\nexport type CliEngine = {\n api: AnonymizeApi;\n loadDictionaries: (scope: DictionaryScope) => Promise<Dictionaries>;\n};\n\n// Statically imported so the version is baked into both\n// the npm bundle and the compiled binary; a runtime\n// package.json lookup would fail inside the binary's\n// virtual filesystem.\nconst cliVersion = (): string => pkg.version;\n\n/**\n * Filesystem identity of a path: realpath when it exists\n * (so symlinks to the same file compare equal), lexical\n * resolution otherwise (the file may not exist yet).\n */\nconst canonicalPath = (path: string): string => {\n try {\n return realpathSync(path);\n } catch {\n return resolve(path);\n }\n};\n\nconst readStdin = async (): Promise<string> => {\n process.stdin.setEncoding(\"utf8\");\n let text = \"\";\n for await (const chunk of process.stdin) text += chunk;\n return text;\n};\n\ntype NamedInput = {\n /** Source path, or null when reading stdin. */\n path: string | null;\n text: string;\n};\n\nconst readInputs = async (files: string[]): Promise<NamedInput[]> => {\n if (files.length === 0) {\n if (process.stdin.isTTY) {\n throw new UsageError(\n \"no input files and stdin is a terminal (see --help)\",\n );\n }\n return [{ path: null, text: await readStdin() }];\n }\n return Promise.all(\n files.map(async (path) => ({ path, text: await readFile(path, \"utf8\") })),\n );\n};\n\n/**\n * One file to anonymize in a batch run: the source path to\n * read and the path, relative to the output directory, to\n * write. For a plain file argument the relative path is the\n * basename; for a directory argument the input tree is\n * mirrored, so it is the path relative to that directory.\n */\ntype FileJob = {\n path: string;\n outputRelative: string;\n};\n\n/** Result of expanding the positional arguments into concrete\n * files. `batch` is true when the output must be a directory:\n * more than one file, or any directory argument. */\ntype ExpandedInputs = {\n jobs: FileJob[];\n batch: boolean;\n /** Likely-binary files skipped during directory walks. */\n skipped: number;\n};\n\n// Sniff window for the binary check. A regular text file never\n// contains a NUL byte; binaries (images, archives) reliably do.\nconst TEXT_SNIFF_BYTES = 8192;\n\n/**\n * True when the file's first {@link TEXT_SNIFF_BYTES} bytes\n * contain no NUL byte. Used to skip binaries discovered by a\n * directory walk without reading the whole file.\n */\nconst looksTextual = async (path: string): Promise<boolean> => {\n const handle = await open(path, \"r\");\n try {\n const buffer = Buffer.alloc(TEXT_SNIFF_BYTES);\n const { bytesRead } = await handle.read(buffer, 0, TEXT_SNIFF_BYTES, 0);\n return buffer.subarray(0, bytesRead).indexOf(0) === -1;\n } finally {\n await handle.close();\n }\n};\n\n/**\n * Collect regular files under `root`, sorted for deterministic\n * order. Symlinks are skipped (avoids cycles and escaping the\n * tree); subdirectories are descended only when `recursive`.\n */\nconst walkDirectory = async (\n root: string,\n recursive: boolean,\n excludeDir?: string,\n): Promise<string[]> => {\n const found: string[] = [];\n const visit = async (dir: string): Promise<void> => {\n const entries = (await readdir(dir, { withFileTypes: true })).toSorted(\n (a, b) => a.name.localeCompare(b.name),\n );\n for (const entry of entries) {\n const full = join(dir, entry.name);\n if (entry.isDirectory()) {\n // Never descend into the output tree: rerunning with --output inside\n // the input directory must not ingest previously generated files.\n if (excludeDir !== undefined && resolve(full) === excludeDir) continue;\n if (recursive) await visit(full);\n } else if (entry.isFile()) {\n found.push(full);\n }\n }\n };\n await visit(root);\n return found;\n};\n\n/**\n * Expand positional arguments into concrete file jobs. A file\n * argument becomes one job (always processed); a directory is\n * walked, mirroring its tree into the output and skipping\n * likely-binary files.\n */\nconst expandInputs = async (\n files: readonly string[],\n recursive: boolean,\n outputDir?: string,\n): Promise<ExpandedInputs> => {\n const excludeDir = outputDir === undefined ? undefined : resolve(outputDir);\n const jobs: FileJob[] = [];\n let hasDirectory = false;\n let skipped = 0;\n for (const path of files) {\n // A stat failure (missing path, permission error) is not\n // fatal here: treat it as a file job so the read failure is\n // reported per file. A single such job stays single-input\n // and surfaces the error as a runtime exit; in a batch it is\n // counted as a failed file.\n let stats: Awaited<ReturnType<typeof stat>> | undefined;\n try {\n stats = await stat(path);\n } catch {\n jobs.push({ path, outputRelative: basename(path) });\n continue;\n }\n if (!stats.isDirectory()) {\n jobs.push({ path, outputRelative: basename(path) });\n continue;\n }\n hasDirectory = true;\n for (const file of await walkDirectory(path, recursive, excludeDir)) {\n // A file that disappears or turns unreadable mid-walk is queued anyway:\n // the per-file worker try/catch counts it as failed without aborting\n // the batch. Only a successful sniff that says \"binary\" skips it.\n const textual = await looksTextual(file).catch(() => true);\n if (!textual) {\n skipped += 1;\n continue;\n }\n jobs.push({ path: file, outputRelative: relative(path, file) });\n }\n }\n return { jobs, batch: hasDirectory || jobs.length > 1, skipped };\n};\n\n/**\n * Run `task` over `items` with at most `workers` in flight.\n * The shared native pipeline makes each redaction a synchronous\n * native call, so concurrency here only overlaps async file\n * I/O; the increments below are safe without locking because\n * no `await` sits between the read and the write of `next`.\n */\nconst runPool = async <T>(\n items: readonly T[],\n workers: number,\n task: (item: T) => Promise<void>,\n): Promise<void> => {\n let next = 0;\n const worker = async (): Promise<void> => {\n while (next < items.length) {\n const index = next;\n next += 1;\n // SAFETY: index < items.length checked above.\n await task(items[index] as T);\n }\n };\n const count = Math.max(1, Math.min(workers, items.length));\n await Promise.all(Array.from({ length: count }, worker));\n};\n\ntype EntityLabel = (typeof DEFAULT_ENTITY_LABELS)[number];\n\ntype CliEntity = {\n start: number;\n end: number;\n label: string;\n text: string;\n score: number;\n source: string;\n};\n\ntype CliOperatorConfig = {\n operators: Record<string, OperatorType>;\n redactString: string;\n};\n\ntype CliRedactionResult = {\n redactedText: string;\n redactionMap: Map<string, string>;\n operatorMap: Map<string, OperatorType>;\n entityCount: number;\n};\n\ntype NativeCliPipeline = {\n warmLazyRegex?: () => void;\n redactText: (\n fullText: string,\n operators?: CliOperatorConfig,\n ) => {\n resolvedEntities: CliEntity[];\n redaction: CliRedactionResult;\n };\n};\n\n// Short aliases for the canonical multi-word labels so that\n// `--labels person,email,iban` works without quoting the space\n// in \"email address\". Separator-insensitive resolution (below)\n// additionally accepts hyphen/underscore forms such as\n// \"credit-card-number\".\nconst LABEL_ALIASES: Record<string, EntityLabel> = {\n email: \"email address\",\n phone: \"phone number\",\n org: \"organization\",\n organisation: \"organization\",\n dob: \"date of birth\",\n ssn: \"social security number\",\n \"tax id\": \"tax identification number\",\n passport: \"passport number\",\n \"credit card\": \"credit card number\",\n \"national id\": \"national identification number\",\n};\n\nconst LABEL_SEPARATOR_RE = /[\\s_-]+/g;\nconst ENTITY_LABEL_SET: ReadonlySet<string> = new Set(DEFAULT_ENTITY_LABELS);\n\nconst isEntityLabel = (label: string): label is EntityLabel =>\n ENTITY_LABEL_SET.has(label);\n\n/**\n * Resolve a user-supplied label token to a canonical label.\n * Lowercases and collapses separators, then maps known short\n * aliases. Unknown tokens are returned normalized so the\n * caller can report them verbatim.\n */\nconst canonicalizeLabel = (raw: string): string => {\n const normalized = raw.toLowerCase().replace(LABEL_SEPARATOR_RE, \" \").trim();\n const known: readonly string[] = DEFAULT_ENTITY_LABELS;\n if (known.includes(normalized)) {\n return normalized;\n }\n return LABEL_ALIASES[normalized] ?? normalized;\n};\n\nconst validateLabels = (labels: readonly string[]): EntityLabel[] => {\n const resolved = [...new Set(labels.map(canonicalizeLabel))];\n const valid: EntityLabel[] = [];\n const availableLabels = DEFAULT_ENTITY_LABELS.join(\", \");\n const availableAliases = Object.keys(LABEL_ALIASES).join(\", \");\n for (const label of resolved) {\n if (!isEntityLabel(label)) {\n throw new UsageError(\n [\n \"--labels: unknown label\",\n JSON.stringify(label) + \";\",\n \"available:\",\n availableLabels,\n \"(aliases:\",\n availableAliases + \")\",\n ].join(\" \"),\n );\n }\n valid.push(label);\n }\n return valid;\n};\n\nconst buildPipelineConfig = async (\n opts: CliOptions,\n loadDictionaries: CliEngine[\"loadDictionaries\"],\n): Promise<PipelineConfig> => {\n const dictionaries = await loadDictionaries({\n languages: opts.languages,\n countries: opts.countries,\n });\n return {\n threshold: opts.threshold,\n enableTriggerPhrases: true,\n enableRegex: true,\n enableLegalForms: true,\n enableNameCorpus: true,\n ...(opts.languages === undefined\n ? {}\n : { nameCorpusLanguages: [...opts.languages] }),\n enableDenyList: true,\n ...(opts.countries === undefined\n ? {}\n : { denyListCountries: [...opts.countries] }),\n enableGazetteer: false,\n enableCountries: true,\n enableNer: false,\n enableConfidenceBoost: true,\n enableCoreference: true,\n enableZoneClassification: true,\n enableHotwordRules: true,\n labels:\n opts.labels === undefined\n ? [...DEFAULT_ENTITY_LABELS]\n : validateLabels(opts.labels),\n workspaceId: \"cli\",\n dictionaries,\n };\n};\n\nconst buildOperatorConfig = (opts: CliOptions): CliOperatorConfig => {\n const operators: Record<string, OperatorType> = {};\n if (opts.mode === \"redact\") {\n const labels =\n opts.labels === undefined\n ? DEFAULT_ENTITY_LABELS\n : validateLabels(opts.labels);\n for (const label of labels) operators[label] = \"redact\";\n }\n return { operators, redactString: opts.redactString };\n};\n\nconst writeOutput = async (\n path: string | undefined,\n content: string,\n): Promise<void> => {\n if (path === undefined) {\n process.stdout.write(content);\n return;\n }\n await writeFile(path, content, \"utf8\");\n};\n\ntype RedactionKeyFile = {\n entries: Record<string, { original: string; operator: string }>;\n};\n\nconst parseRedactionKey = (raw: string): Map<string, string> => {\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n throw new UsageError(\"redaction key is not valid JSON\");\n }\n if (typeof parsed !== \"object\" || parsed === null || !(\"entries\" in parsed)) {\n throw new UsageError(\n 'redaction key must be an object with an \"entries\" field',\n );\n }\n const { entries } = parsed as RedactionKeyFile;\n if (\n typeof entries !== \"object\" ||\n entries === null ||\n Array.isArray(entries)\n ) {\n throw new UsageError('redaction key \"entries\" must be an object');\n }\n const map = new Map<string, string>();\n for (const [placeholder, entry] of Object.entries(entries)) {\n if (typeof entry?.original !== \"string\") {\n throw new UsageError(\n `redaction key entry \"${placeholder}\" has no original text`,\n );\n }\n map.set(placeholder, entry.original);\n }\n return map;\n};\n\n/**\n * Restrict a redaction key to the entities named by --revert.\n * Each token matches a placeholder (\"[PERSON_1]\") or an original\n * value (\"Jan Novák\"), case-sensitive and exact. A token that\n * matches nothing is a usage error listing the placeholders the\n * key does define, so the caller can correct the spelling.\n */\nconst selectRevertEntries = (\n redactionMap: ReadonlyMap<string, string>,\n tokens: readonly string[],\n): Map<string, string> => {\n const selected = new Map<string, string>();\n for (const token of tokens) {\n let matched = false;\n for (const [placeholder, original] of redactionMap) {\n if (placeholder === token || original === token) {\n selected.set(placeholder, original);\n matched = true;\n }\n }\n if (!matched) {\n const MAX_LISTED_PLACEHOLDERS = 20;\n const placeholders = [...redactionMap.keys()];\n const listed = placeholders.slice(0, MAX_LISTED_PLACEHOLDERS).join(\", \");\n const rest = placeholders.length - MAX_LISTED_PLACEHOLDERS;\n const suffix = rest > 0 ? ` and ${rest} more` : \"\";\n throw new UsageError(\n `--revert ${JSON.stringify(token)} matched no placeholder or ` +\n `original; available placeholders: ${listed}${suffix}`,\n );\n }\n }\n return selected;\n};\n\n/**\n * Ask for a country scope when running interactively on\n * files with no scope flags. Skipped for piped stdin so\n * the CLI stays scriptable.\n */\nexport const shouldPromptForScope = (\n opts: CliOptions,\n tty: { stdinIsTTY: boolean; stderrIsTTY: boolean },\n): boolean =>\n opts.countries === undefined &&\n opts.languages === undefined &&\n !opts.quiet &&\n opts.files.length > 0 &&\n tty.stdinIsTTY &&\n tty.stderrIsTTY;\n\nconst promptForCountries = async (): Promise<string[] | undefined> => {\n const rl = createInterface({\n input: process.stdin,\n output: process.stderr,\n });\n try {\n const answer = await rl.question(\n \"Country scope (ISO codes like CZ,DE,GB; Enter loads all): \",\n );\n const trimmed = answer.trim();\n return trimmed === \"\" ? undefined : parseCountries(trimmed);\n } finally {\n rl.close();\n }\n};\n\nconst runDeanonymise = async (\n opts: CliOptions,\n api: AnonymizeApi,\n): Promise<void> => {\n if (opts.keyPath !== undefined) {\n throw new UsageError(\"--key cannot be combined with --deanonymise\");\n }\n const keyPath = opts.deanonymiseKeyPath;\n if (keyPath === undefined) throw new UsageError(\"missing redaction key path\");\n const fullMap = parseRedactionKey(await readFile(keyPath, \"utf8\"));\n\n // --revert restores a chosen subset; leaving the rest of the\n // key out means deanonymise skips those placeholders, so the\n // other entities stay redacted.\n const redactionMap =\n opts.revert === undefined\n ? fullMap\n : selectRevertEntries(fullMap, opts.revert);\n\n const inputs = await readInputs(opts.files);\n if (inputs.length > 1) {\n throw new UsageError(\"--deanonymise accepts a single input\");\n }\n const input = inputs[0];\n if (!input) throw new UsageError(\"no input to deanonymise\");\n if (opts.output !== undefined) {\n guardWriteTargets(input.path === null ? [] : [input.path], [\n { path: opts.output, flag: \"--output\" },\n ]);\n }\n await writeOutput(opts.output, api.deanonymise(input.text, redactionMap));\n};\n\n/**\n * Reject any write target (output or key file) whose\n * filesystem identity collides with an input file or with\n * another write target. Symlinks count as collisions.\n */\nconst guardWriteTargets = (\n inputPaths: readonly string[],\n writeTargets: readonly { path: string; flag: string }[],\n): void => {\n const inputs = new Set(inputPaths.map(canonicalPath));\n const seen = new Map<string, string>();\n for (const target of writeTargets) {\n const canonical = canonicalPath(target.path);\n if (inputs.has(canonical)) {\n throw new UsageError(\n `refusing to overwrite input file \"${target.path}\" (${target.flag})`,\n );\n }\n const clash = seen.get(canonical);\n if (clash !== undefined) {\n throw new UsageError(\n `${target.flag} \"${target.path}\" collides with ${clash}`,\n );\n }\n seen.set(canonical, `${target.flag} \"${target.path}\"`);\n }\n};\n\nconst summarize = (entities: readonly CliEntity[]): string => {\n const counts = new Map<string, number>();\n for (const entity of entities) {\n counts.set(entity.label, (counts.get(entity.label) ?? 0) + 1);\n }\n const parts = [...counts.entries()]\n .toSorted((a, b) => b[1] - a[1])\n .map(([label, count]) => `${label}: ${count}`);\n return parts.length > 0 ? parts.join(\", \") : \"none\";\n};\n\n/**\n * A single unit of anonymize work: the text to process, where\n * to write it (undefined means stdout), and a label for the\n * stderr summary. Used by the stdin and single-file flows,\n * which additionally support --json and --key.\n */\ntype SingleInput = {\n text: string;\n outputPath: string | undefined;\n source: string;\n};\n\nconst runAnonymiseSingle = async (\n opts: CliOptions,\n runtime: CliRuntime,\n api: AnonymizeApi,\n input: SingleInput,\n): Promise<void> => {\n const { entities, redaction } = await runtime.redact(\n input.text,\n buildOperatorConfig(opts),\n );\n\n if (opts.json) {\n // In redact mode the user chose irreversibility, so the\n // JSON must not carry any detected text. Whitelist the\n // non-sensitive metadata fields; this drops `text` and a\n // coref alias's `corefSourceText`. Offsets index the\n // caller's own input and are kept.\n const jsonEntities =\n opts.mode === \"redact\"\n ? entities.map(({ start, end, label, score, source }) => ({\n start,\n end,\n label,\n score,\n source,\n }))\n : entities;\n const payload = {\n entityCount: redaction.entityCount,\n entities: jsonEntities,\n redactedText: redaction.redactedText,\n };\n await writeOutput(\n input.outputPath,\n `${JSON.stringify(payload, null, 2)}\\n`,\n );\n } else {\n await writeOutput(input.outputPath, redaction.redactedText);\n }\n\n if (opts.keyPath !== undefined) {\n await writeFile(\n opts.keyPath,\n api.exportRedactionKey(redaction.redactionMap, redaction.operatorMap),\n \"utf8\",\n );\n }\n\n if (!opts.quiet) {\n process.stderr.write(\n `anonymize: ${input.source}: ${summarize(entities)}\\n`,\n );\n }\n};\n\n/** Tally of a batch run for the closing summary line. */\ntype BatchOutcome = { processed: number; failed: number };\n\nconst runAnonymiseBatch = async (\n opts: CliOptions,\n runtime: CliRuntime,\n output: string,\n jobs: readonly FileJob[],\n skipped: number,\n): Promise<void> => {\n await mkdir(output, { recursive: true });\n const operatorConfig = buildOperatorConfig(opts);\n const outcome: BatchOutcome = { processed: 0, failed: 0 };\n\n await runPool(jobs, opts.workers, async (job) => {\n const outputPath = join(output, job.outputRelative);\n try {\n const text = await readFile(job.path, \"utf8\");\n const { entities, redaction } = await runtime.redact(\n text,\n operatorConfig,\n );\n await mkdir(dirname(outputPath), { recursive: true });\n await writeFile(outputPath, redaction.redactedText, \"utf8\");\n // No await between here and the increment: safe on the\n // single JS thread despite concurrent workers.\n outcome.processed += 1;\n if (!opts.quiet) {\n process.stderr.write(\n `anonymize: ${job.path}: ${summarize(entities)}\\n`,\n );\n }\n } catch (err) {\n outcome.failed += 1;\n const message = err instanceof Error ? err.message : String(err);\n process.stderr.write(`anonymize: ${job.path}: error: ${message}\\n`);\n }\n });\n\n if (!opts.quiet) {\n const parts = [\n `${outcome.processed} processed`,\n `${outcome.failed} failed`,\n ];\n if (skipped > 0) parts.push(`${skipped} skipped`);\n process.stderr.write(`anonymize: ${parts.join(\", \")}\\n`);\n }\n // Any per-file failure is a nonzero exit, but the whole batch\n // still runs so one bad file does not hide the rest.\n if (outcome.failed > 0) process.exitCode = 1;\n};\n\nconst runAnonymise = async (\n opts: CliOptions,\n { api, loadDictionaries }: CliEngine,\n): Promise<void> => {\n if (opts.keyPath !== undefined && opts.mode !== \"replace\") {\n throw new UsageError('--key requires --mode \"replace\"');\n }\n\n const scoped = shouldPromptForScope(opts, {\n stdinIsTTY: process.stdin.isTTY === true,\n stderrIsTTY: process.stderr.isTTY === true,\n })\n ? { ...opts, countries: await promptForCountries() }\n : opts;\n\n // No positional arguments: read stdin as a single input.\n if (scoped.files.length === 0) {\n const [input] = await readInputs(scoped.files);\n if (!input) throw new UsageError(\"no input to anonymize\");\n guardWriteTargets([], collectSingleTargets(scoped));\n const runtime = await prepareCliRuntime(\n api,\n await buildPipelineConfig(scoped, loadDictionaries),\n );\n await runAnonymiseSingle(scoped, runtime, api, {\n text: input.text,\n outputPath: scoped.output,\n source: \"stdin\",\n });\n return;\n }\n\n const { jobs, batch, skipped } = await expandInputs(\n scoped.files,\n scoped.recursive,\n scoped.output,\n );\n\n if (!batch) {\n // Exactly one plain file: single-input flow with --json/--key.\n const [job] = jobs;\n if (!job) throw new UsageError(\"no input to anonymize\");\n guardWriteTargets([job.path], collectSingleTargets(scoped));\n const runtime = await prepareCliRuntime(\n api,\n await buildPipelineConfig(scoped, loadDictionaries),\n );\n await runAnonymiseSingle(scoped, runtime, api, {\n text: await readFile(job.path, \"utf8\"),\n outputPath: scoped.output,\n source: job.path,\n });\n return;\n }\n\n // Batch: a directory, or more than one file.\n const output = scoped.output;\n if (output === undefined) {\n throw new UsageError(\n \"batch input (a directory or multiple files) requires --output <directory>\",\n );\n }\n if (scoped.keyPath !== undefined) {\n throw new UsageError(\"--key works with a single input only\");\n }\n if (scoped.json) {\n throw new UsageError(\"--json works with a single input only\");\n }\n\n // Validate every write target before any work: colliding\n // output paths (same basename from different input dirs,\n // symlinks to an input) fail fast instead of silently\n // clobbering files mid-batch.\n guardWriteTargets(\n jobs.map((job) => job.path),\n jobs.map((job) => ({\n path: join(output, job.outputRelative),\n flag: \"--output\",\n })),\n );\n\n const runtime = await prepareCliRuntime(\n api,\n await buildPipelineConfig(scoped, loadDictionaries),\n );\n await runAnonymiseBatch(scoped, runtime, output, jobs, skipped);\n};\n\n/** Write targets for a single-input run: --output and --key. */\nconst collectSingleTargets = (\n opts: CliOptions,\n): { path: string; flag: string }[] => {\n const targets: { path: string; flag: string }[] = [];\n if (opts.output !== undefined) {\n targets.push({ path: opts.output, flag: \"--output\" });\n }\n if (opts.keyPath !== undefined) {\n targets.push({ path: opts.keyPath, flag: \"--key\" });\n }\n return targets;\n};\n\ntype CliRuntime = {\n redact: (\n fullText: string,\n operators: CliOperatorConfig,\n ) => Promise<{ entities: CliEntity[]; redaction: CliRedactionResult }>;\n};\n\nconst prepareCliRuntime = async (\n api: AnonymizeApi,\n config: PipelineConfig,\n): Promise<CliRuntime> => {\n const pipeline = await api.createNativePipelineFromConfig({\n binding: api.loadNativeAnonymizeBinding(),\n config,\n gazetteerEntries: [],\n });\n pipeline.warmLazyRegex?.();\n return {\n redact: async (fullText, operators) => {\n const result = pipeline.redactText(fullText, operators);\n return {\n entities: result.resolvedEntities,\n redaction: result.redaction,\n };\n },\n };\n};\n\n/**\n * Render the canonical entity labels and the short aliases\n * accepted by --labels, for the --list-labels discovery flag.\n */\nconst formatLabelList = (): string => {\n const lines: string[] = [\"Detectable entity labels (pass to --labels):\"];\n for (const label of DEFAULT_ENTITY_LABELS) {\n lines.push(` ${label}`);\n }\n lines.push(\"\", \"Short aliases:\");\n const aliases = Object.entries(LABEL_ALIASES);\n let width = 0;\n for (const [alias] of aliases) {\n width = Math.max(width, alias.length);\n }\n for (const [alias, canonical] of aliases) {\n lines.push(` ${alias.padEnd(width)} -> ${canonical}`);\n }\n return `${lines.join(\"\\n\")}\\n`;\n};\n\nconst dispatch = async (engine: CliEngine): Promise<void> => {\n const opts = parseCliArgs(process.argv.slice(2));\n if (opts.help) {\n process.stdout.write(HELP);\n return;\n }\n if (opts.version) {\n process.stdout.write(`${cliVersion()}\\n`);\n return;\n }\n if (opts.listLabels) {\n process.stdout.write(formatLabelList());\n return;\n }\n if (opts.deanonymiseKeyPath !== undefined) {\n await runDeanonymise(opts, engine.api);\n return;\n }\n if (opts.revert !== undefined) {\n throw new UsageError(\"--revert requires --deanonymise <key>\");\n }\n await runAnonymise(opts, engine);\n};\n\n/**\n * Run the CLI against the given engine and set the\n * process exit code (0 ok, 1 runtime error, 2 usage).\n */\nexport const runCli = async (engine: CliEngine): Promise<void> => {\n try {\n await dispatch(engine);\n } catch (err) {\n if (err instanceof UsageError) {\n process.stderr.write(`anonymize: ${err.message}\\n`);\n process.stderr.write(`Try \"anonymize --help\" for usage.\\n`);\n process.exitCode = 2;\n } else {\n const message = err instanceof Error ? err.message : String(err);\n process.stderr.write(`anonymize: ${message}\\n`);\n process.exitCode = 1;\n }\n }\n};\n","#!/usr/bin/env node\n/* npm-distributed entry point — backs the CLI with the\n * native engine (@stll/text-search napi bindings) and\n * the @stll/anonymize-data dictionary package. */\nimport * as anonymize from \"@stll/anonymize\";\n\nimport { loadCliDictionaries } from \"./dictionaries\";\nimport { runCli } from \"./main\";\n\nawait runCli({ api: anonymize, loadDictionaries: loadCliDictionaries });\n"],"mappings":"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"}
1
+ {"version":3,"file":"cli.mjs","names":["splitList","parseThreshold","canonicalPath","pkg.version","known"],"sources":["../src/args.ts","../src/dictionary-scope.ts","../src/dictionaries.ts","../package.json","../src/docx.ts","../src/main.ts","../src/cli.ts"],"sourcesContent":["import { availableParallelism } from \"node:os\";\nimport { parseArgs } from \"node:util\";\n\nexport const CLI_MODES = [\"replace\", \"redact\"] as const;\nexport type CliMode = (typeof CLI_MODES)[number];\n\nexport const DEFAULT_THRESHOLD = 0.3;\nexport const DEFAULT_REDACT_STRING = \"[REDACTED]\";\n\n/** Upper bound on the default worker count; batch I/O overlap\n * saturates well before this, and redaction itself is a\n * synchronous native call serialized on the JS thread. */\nexport const MAX_DEFAULT_WORKERS = 4;\n\n/** Default batch concurrency: min(4, cores). Workers overlap\n * file reads/writes; the shared native pipeline runs each\n * redaction to completion on the single JS thread. */\nexport const defaultWorkerCount = (): number =>\n Math.max(1, Math.min(MAX_DEFAULT_WORKERS, availableParallelism()));\n\n/** Invalid invocation; printed with usage hint, exit code 2. */\nexport class UsageError extends Error {}\n\nexport type CliOptions = {\n files: string[];\n output?: string | undefined;\n mode: CliMode;\n keyPath?: string | undefined;\n deanonymiseKeyPath?: string | undefined;\n revert?: string[] | undefined;\n recursive: boolean;\n workers: number;\n labels?: string[] | undefined;\n languages?: string[] | undefined;\n countries?: string[] | undefined;\n threshold: number;\n redactString: string;\n json: boolean;\n quiet: boolean;\n help: boolean;\n version: boolean;\n listLabels: boolean;\n capabilities: boolean;\n};\n\nexport const HELP = `Usage: anonymize [options] [file|dir ...]\n\nDetect and anonymize PII in text. Reads the given files, or stdin\nwhen no files are given. A directory argument processes the text\nfiles inside it (add --recursive to descend into subdirectories).\nWrites to stdout, or to --output.\nAll processing is local; the CLI makes no network calls.\n\nDOCX workflows:\n Run \"anonymize docx --help\" for structure-preserving DOCX anonymization\n and restoration with encrypted session archives.\n\nOptions:\n -o, --output <path> Output file, or directory for batch\n input (multiple files or a directory)\n -m, --mode <mode> \"replace\" (reversible [PERSON_1]\n placeholders) or \"redact\"\n (default: replace)\n -k, --key <path> Write the redaction key as JSON\n (single input, replace mode)\n -d, --deanonymise <path> Restore redacted text using the\n redaction key at <path>\n --revert <term> With --deanonymise, restore only the\n given entity. Match a placeholder token\n (\"[PERSON_1]\") or an original value\n (\"Jan Novák\"), case-sensitive exact.\n Repeatable; others stay redacted\n -r, --recursive Descend into subdirectories when a\n directory is given as input\n --workers <n> Batch files to process concurrently\n (default: min(${MAX_DEFAULT_WORKERS}, CPU cores)). Overlaps\n file I/O; redaction is serialized on\n the JS thread\n --labels <list> Comma-separated entity labels to detect\n (default: all). Accepts canonical labels\n (\"email address\"), short aliases (email,\n phone, org, dob, ssn), and hyphen/underscore\n forms (\"credit-card-number\")\n --languages <list> Name-corpus languages, e.g. \"cs,de,en\"\n (default: all bundled)\n --countries <list> ISO 3166-1 alpha-2 codes scoping deny\n lists and city data, e.g. \"CZ,DE,GB\"\n (default: all deny lists; city data\n for a 30-country default set)\n --threshold <n> Minimum confidence score, 0-1\n (default: ${DEFAULT_THRESHOLD})\n --redact-string <s> Replacement text in redact mode\n (default: \"${DEFAULT_REDACT_STRING}\")\n --json Emit JSON (entities + redacted text) to\n stdout (single input only)\n --quiet Suppress the summary on stderr\n -h, --help Show this help\n -v, --version Show the version\n --list-labels List detectable entity labels and the\n short aliases accepted by --labels\n --capabilities Emit the versioned capability manifest as JSON\n\nBatch input (directory or multiple files):\n Requires --output <directory>. The input tree is mirrored\n into the output directory. Directory walks process regular\n files only and skip likely-binary files (a NUL byte in the\n first 8 KiB); explicitly named files are always processed.\n The stderr summary reports how many files were processed,\n failed, and skipped; any failure sets exit code 1.\n --key and --json apply to single inputs only.\n\nInteractive prompt:\n When run on files from a terminal without --countries or\n --languages, the CLI asks once which country scope to load.\n Piped stdin/stderr or --quiet skips the prompt, so scripts\n and agents never block on input.\n\nExit codes:\n 0 success\n 1 runtime error (message on stderr)\n 2 usage error (message on stderr)\n\nJSON output (--json):\n { \"entityCount\": number,\n \"entities\": [{ \"start\": number, \"end\": number,\n \"label\": string, \"text\": string,\n \"score\": number, \"source\": string }],\n \"redactedText\": string }\n Offsets are UTF-16 code-unit indexes into the input.\n The stderr summary contains entity counts only, never\n the detected text.\n\nExamples:\n anonymize contract.txt > contract.anon.txt\n anonymize -k contract.key.json -o contract.anon.txt contract.txt\n anonymize -d contract.key.json contract.anon.txt\n anonymize -r --workers 8 -o out/ docs/\n anonymize -d key.json --revert \"[PERSON_1]\" contract.anon.txt\n cat notes.md | anonymize --countries CZ,SK --languages cs,sk\n anonymize --json --quiet input.txt | jq '.entities[].label'\n anonymize docx --help\n`;\n\nconst splitList = (value: string): string[] => [\n ...new Set(\n value\n .split(\",\")\n .map((part) => part.trim())\n .filter((part) => part.length > 0),\n ),\n];\n\nconst parseThreshold = (raw: string): number => {\n const value = Number(raw);\n if (!Number.isFinite(value) || value < 0 || value > 1) {\n throw new UsageError(\n `--threshold must be a number between 0 and 1, got \"${raw}\"`,\n );\n }\n return value;\n};\n\nconst parseWorkers = (raw: string): number => {\n const value = Number(raw);\n if (!Number.isInteger(value) || value < 1) {\n throw new UsageError(`--workers must be a positive integer, got \"${raw}\"`);\n }\n return value;\n};\n\nconst parseMode = (raw: string): CliMode => {\n const mode = CLI_MODES.find((candidate) => candidate === raw);\n if (!mode) {\n throw new UsageError(\n `--mode must be one of: ${CLI_MODES.join(\", \")}; got \"${raw}\"`,\n );\n }\n return mode;\n};\n\nconst COUNTRY_CODE_RE = /^[A-Za-z]{2}$/;\n\nexport const parseCountries = (raw: string): string[] => {\n const countries = [\n ...new Set(splitList(raw).map((code) => code.toUpperCase())),\n ];\n const invalid = countries.find((code) => !COUNTRY_CODE_RE.test(code));\n if (invalid) {\n throw new UsageError(\n `--countries expects ISO 3166-1 alpha-2 codes (e.g. \"CZ,DE\"), got \"${invalid}\"`,\n );\n }\n return countries;\n};\n\nexport const parseCliArgs = (argv: string[]): CliOptions => {\n let parsed: ReturnType<typeof parseArgs<typeof PARSE_CONFIG>>;\n try {\n parsed = parseArgs({ ...PARSE_CONFIG, args: argv });\n } catch (err) {\n throw new UsageError(err instanceof Error ? err.message : String(err));\n }\n const { values, positionals } = parsed;\n\n return {\n files: positionals,\n output: values.output,\n mode: values.mode === undefined ? \"replace\" : parseMode(values.mode),\n keyPath: values.key,\n deanonymiseKeyPath: values.deanonymise,\n revert:\n values.revert === undefined || values.revert.length === 0\n ? undefined\n : values.revert,\n recursive: values.recursive === true,\n workers:\n values.workers === undefined\n ? defaultWorkerCount()\n : parseWorkers(values.workers),\n labels: values.labels === undefined ? undefined : splitList(values.labels),\n languages:\n values.languages === undefined ? undefined : splitList(values.languages),\n countries:\n values.countries === undefined\n ? undefined\n : parseCountries(values.countries),\n threshold:\n values.threshold === undefined\n ? DEFAULT_THRESHOLD\n : parseThreshold(values.threshold),\n redactString: values[\"redact-string\"] ?? DEFAULT_REDACT_STRING,\n json: values.json === true,\n quiet: values.quiet === true,\n help: values.help === true,\n version: values.version === true,\n listLabels: values[\"list-labels\"] === true,\n capabilities: values.capabilities === true,\n };\n};\n\nconst PARSE_CONFIG = {\n allowPositionals: true,\n strict: true,\n options: {\n output: { type: \"string\", short: \"o\" },\n mode: { type: \"string\", short: \"m\" },\n key: { type: \"string\", short: \"k\" },\n deanonymise: { type: \"string\", short: \"d\" },\n revert: { type: \"string\", multiple: true },\n recursive: { type: \"boolean\", short: \"r\" },\n workers: { type: \"string\" },\n labels: { type: \"string\" },\n languages: { type: \"string\" },\n countries: { type: \"string\" },\n threshold: { type: \"string\" },\n \"redact-string\": { type: \"string\" },\n json: { type: \"boolean\" },\n quiet: { type: \"boolean\" },\n help: { type: \"boolean\", short: \"h\" },\n version: { type: \"boolean\", short: \"v\" },\n \"list-labels\": { type: \"boolean\" },\n capabilities: { type: \"boolean\" },\n },\n} as const;\n","/* Pure helpers shared by the npm and embedded dictionary\n * loaders. Must stay free of @stll/anonymize-data imports\n * so the compiled binary's bundle excludes the raw JSON\n * dictionary modules. */\nimport type { Dictionaries, DictionaryMeta } from \"@stll/anonymize\";\n\nimport { UsageError } from \"./args\";\n\nexport const NAME_DICTIONARY_PREFIXES = [\n \"names/first/\",\n \"names/surnames/\",\n] as const;\n\n/** Language code of a name dictionary id, or null. */\nexport const nameLanguageOfDictionary = (id: string): string | null => {\n const prefix = NAME_DICTIONARY_PREFIXES.find((p) => id.startsWith(p));\n return prefix ? id.slice(prefix.length) : null;\n};\n\nexport type DictionaryScope = {\n languages?: readonly string[] | undefined;\n countries?: readonly string[] | undefined;\n};\n\nconst pickKeys = <T>(\n record: Record<string, T>,\n keep: (key: string) => boolean,\n): Record<string, T> => {\n const result: Record<string, T> = {};\n for (const [key, value] of Object.entries(record)) {\n if (keep(key)) result[key] = value;\n }\n return result;\n};\n\n/** Dictionaries with every section present (possibly empty). */\nexport type ScopedDictionaries = {\n firstNames: Record<string, readonly string[]>;\n surnames: Record<string, readonly string[]>;\n denyList: Record<string, readonly string[]>;\n denyListMeta: Record<string, DictionaryMeta>;\n citiesByCountry: Record<string, readonly string[]>;\n};\n\n/**\n * Scope a fully loaded dictionary set to the requested\n * languages and countries. Mirrors the pre-load scoping\n * the npm loader does in dictionaries.ts; used by the\n * embedded loader, which always starts from the full set.\n */\nexport const filterDictionaries = (\n all: Dictionaries,\n { languages, countries }: DictionaryScope,\n): ScopedDictionaries => {\n const firstNames = all.firstNames ?? {};\n const surnames = all.surnames ?? {};\n const allDenyList = all.denyList ?? {};\n const allDenyListMeta = all.denyListMeta ?? {};\n\n if (languages !== undefined) {\n const available = Object.keys(firstNames);\n const invalid = languages.find((lang) => !available.includes(lang));\n if (invalid) {\n throw new UsageError(\n `--languages: no name dictionary for \"${invalid}\"; available: ${available.join(\", \")}`,\n );\n }\n }\n const keepLanguage = (lang: string): boolean =>\n languages === undefined || languages.includes(lang);\n const keepCountry = (country: string | null): boolean =>\n countries === undefined || country === null || countries.includes(country);\n\n const denyListMeta: Record<string, DictionaryMeta> = {};\n const denyList: Record<string, readonly string[]> = {};\n for (const [id, meta] of Object.entries(allDenyListMeta)) {\n if (!keepCountry(meta.country)) continue;\n const nameLang = nameLanguageOfDictionary(id);\n if (nameLang !== null && !keepLanguage(nameLang)) continue;\n const entries = allDenyList[id];\n if (entries === undefined) continue;\n denyListMeta[id] = meta;\n denyList[id] = entries;\n }\n\n return {\n firstNames: pickKeys(firstNames, keepLanguage),\n surnames: pickKeys(surnames, keepLanguage),\n denyList,\n denyListMeta,\n citiesByCountry: pickKeys(all.citiesByCountry ?? {}, (country) =>\n keepCountry(country),\n ),\n };\n};\n","import type { Dictionaries, DictionaryMeta } from \"@stll/anonymize\";\nimport {\n ALL_DICTIONARY_IDS,\n DICTIONARY_META,\n loadCityDictionary,\n loadDictionary,\n loadNameDictionaries,\n type NameLanguage,\n} from \"@stll/anonymize-data\";\n\nimport { UsageError } from \"./args\";\nimport type { DictionaryScope } from \"./dictionary-scope\";\nimport {\n NAME_DICTIONARY_PREFIXES,\n nameLanguageOfDictionary,\n} from \"./dictionary-scope\";\n\n/**\n * Countries with bundled city dictionaries that are\n * loaded when no --countries scope is given.\n */\nconst DEFAULT_CITY_COUNTRIES = [\n \"AT\",\n \"AU\",\n \"BE\",\n \"BG\",\n \"BR\",\n \"CA\",\n \"CH\",\n \"CZ\",\n \"DE\",\n \"DK\",\n \"ES\",\n \"FI\",\n \"FR\",\n \"GB\",\n \"GR\",\n \"HR\",\n \"HU\",\n \"IE\",\n \"IT\",\n \"LU\",\n \"NL\",\n \"NO\",\n \"NZ\",\n \"PL\",\n \"PT\",\n \"RO\",\n \"SE\",\n \"SI\",\n \"SK\",\n \"US\",\n] as const;\n\nconst availableNameLanguages = (): readonly string[] =>\n ALL_DICTIONARY_IDS.filter((id) =>\n id.startsWith(NAME_DICTIONARY_PREFIXES[0]),\n ).map((id) => id.slice(NAME_DICTIONARY_PREFIXES[0].length));\n\nconst validateLanguages = (\n languages: readonly string[],\n): readonly NameLanguage[] => {\n const available = availableNameLanguages();\n const invalid = languages.find((lang) => !available.includes(lang));\n if (invalid) {\n throw new UsageError(\n `--languages: no name dictionary for \"${invalid}\"; available: ${available.join(\", \")}`,\n );\n }\n // SAFETY: every entry was checked against the bundled\n // name dictionary ids, which define NameLanguage.\n return languages as readonly NameLanguage[];\n};\n\nexport type LoadCliDictionariesOptions = DictionaryScope;\n\n/**\n * Load the bundled @stll/anonymize-data dictionaries,\n * scoped to the requested languages and countries.\n */\nexport const loadCliDictionaries = async ({\n languages,\n countries,\n}: LoadCliDictionariesOptions): Promise<Dictionaries> => {\n const nameLanguages =\n languages === undefined ? undefined : validateLanguages(languages);\n\n const denyIds = ALL_DICTIONARY_IDS.filter((id) => {\n const meta = DICTIONARY_META[id];\n if (\n countries &&\n meta.country !== null &&\n !countries.includes(meta.country)\n ) {\n return false;\n }\n const nameLang = nameLanguageOfDictionary(id);\n if (nameLang !== null && nameLanguages !== undefined) {\n return nameLanguages.includes(\n // SAFETY: nameLang comes from a bundled dictionary\n // id, which defines NameLanguage.\n nameLang as NameLanguage,\n );\n }\n return true;\n });\n\n const cityCountries = countries ?? DEFAULT_CITY_COUNTRIES;\n\n const [names, denyEntries, cityEntries] = await Promise.all([\n loadNameDictionaries(nameLanguages),\n Promise.all(\n denyIds.map(async (id) => ({ id, entries: await loadDictionary(id) })),\n ),\n Promise.all(\n cityCountries.map(async (country) => ({\n country,\n entries: await loadCityDictionary(country),\n })),\n ),\n ]);\n\n const denyList: Record<string, readonly string[]> = {};\n const denyListMeta: Record<string, DictionaryMeta> = {};\n for (const { id, entries } of denyEntries) {\n denyList[id] = entries;\n // SAFETY: anonymize-data categories match\n // DenyListCategory at runtime.\n denyListMeta[id] = DICTIONARY_META[id] as DictionaryMeta;\n }\n\n const citiesByCountry: Record<string, readonly string[]> = {};\n for (const { country, entries } of cityEntries) {\n if (entries.length > 0) citiesByCountry[country] = entries;\n }\n\n return {\n firstNames: names.firstNames,\n surnames: names.surnames,\n denyList,\n denyListMeta,\n citiesByCountry,\n };\n};\n","","import { randomUUID } from \"node:crypto\";\nimport { realpathSync } from \"node:fs\";\nimport {\n type FileHandle,\n link,\n lstat,\n open,\n readFile,\n rename,\n unlink,\n} from \"node:fs/promises\";\nimport { basename, dirname, join, resolve } from \"node:path\";\nimport { parseArgs } from \"node:util\";\n\nimport type { NativeOpenSessionArchiveOptions } from \"@stll/anonymize\";\nimport {\n DOCX_COVERAGE_MODES,\n anonymizeDocx,\n restoreDocxText,\n type DocxAnonymizationSession,\n type DocxAnonymizationSummary,\n type DocxRestorationResult,\n type DocxRestorationSession,\n} from \"@stll/anonymize-docx\";\n\nimport { parseCountries, UsageError } from \"./args\";\n\nconst DOCX_SESSION_KEY_BYTES = 32;\nconst DOCX_SESSION_LOCK_SUFFIX = \".lock\";\nconst MAX_EPOCH_SECONDS = 4_294_967_295;\n\nconst DOCX_SESSION_MODES = {\n continue: \"continue\",\n create: \"create\",\n} as const;\n\ntype DocxSessionMode =\n (typeof DOCX_SESSION_MODES)[keyof typeof DOCX_SESSION_MODES];\n\ntype DocxDetectionOptions = {\n labels?: string[] | undefined;\n languages?: string[] | undefined;\n countries?: string[] | undefined;\n threshold: number;\n};\n\ntype DocxCommonOptions = {\n inputPath: string;\n outputPath: string;\n sessionArchivePath: string;\n sessionKeyPath: string;\n sessionId: string;\n coverage: (typeof DOCX_COVERAGE_MODES)[keyof typeof DOCX_COVERAGE_MODES];\n observedAtEpochSeconds?: number | undefined;\n json: boolean;\n quiet: boolean;\n};\n\ntype DocxCommand =\n | { type: \"help\" }\n | ({\n type: \"anonymize\";\n sessionMode: DocxSessionMode;\n detection: DocxDetectionOptions;\n } & DocxCommonOptions)\n | ({ type: \"restore\" } & DocxCommonOptions);\n\nexport type DocxPipelineRequest =\n | { type: \"anonymize\"; detection: DocxDetectionOptions }\n | { type: \"restore\" };\n\ntype DocxCliSession = DocxAnonymizationSession &\n DocxRestorationSession & {\n toEncryptedArchive: (key: Uint8Array) => Uint8Array;\n toEncryptedArchiveAt: (\n key: Uint8Array,\n observedAtEpochSeconds: number,\n ) => Uint8Array;\n };\n\nexport type DocxCliPipeline = {\n createRedactionSession: (sessionId: string) => DocxCliSession;\n restoreEncryptedRedactionSession: (\n options: NativeOpenSessionArchiveOptions,\n ) => DocxCliSession;\n};\n\ntype RunDocxCommandOptions = {\n argv: readonly string[];\n preparePipeline: (request: DocxPipelineRequest) => Promise<DocxCliPipeline>;\n};\n\nconst DOCX_HELP = `Usage:\n anonymize docx anonymize [options] <input.docx>\n anonymize docx restore [options] <input.docx>\n\nAnonymize or restore one DOCX file with an encrypted redaction session.\nDocument and session outputs are written atomically and never overwrite the\ninput, key file, or an existing document output.\n\nRequired options:\n -o, --output <path> New DOCX output path\n --session-archive <path> Encrypted session archive path\n --session-key-file <path>\n File containing exactly 32 raw key bytes\n --session-id <id> Expected opaque session identity\n\nAnonymize options:\n --session-mode <mode> \"create\" or \"continue\" (required)\n --coverage <mode> \"require-full\" (default) or \"allow-partial\"\n --labels <list> Comma-separated entity labels\n --languages <list> Name-corpus languages, e.g. \"cs,de,en\"\n --countries <list> ISO 3166-1 alpha-2 country codes\n --threshold <n> Minimum confidence score 0-1 (default: 0.3)\n\nRestore options:\n --coverage <mode> \"require-full\" (default) or \"allow-partial\"\n\nCommon options:\n --observed-at <seconds> Deterministic Unix timestamp for lifecycle checks\n --json Print the aggregate audit-safe summary as JSON\n --quiet Suppress the human-readable stderr summary\n -h, --help Show this help\n\nThe session key is read from a file, never from a command argument. In create\nmode the archive path must not exist. Continue mode atomically replaces the\nexisting archive only after the DOCX rewrite succeeds. It holds an exclusive\n\"<archive>.lock\" sidecar throughout the continuation to prevent lost updates.\nCaller-supplied detection plans and interactive review are available through the\npackage API, not this CLI.\n`;\n\nconst splitList = (value: string): string[] => [\n ...new Set(\n value\n .split(\",\")\n .map((part) => part.trim())\n .filter((part) => part.length > 0),\n ),\n];\n\nconst parseThreshold = (raw: string): number => {\n const value = Number(raw);\n if (!Number.isFinite(value) || value < 0 || value > 1) {\n throw new UsageError(\n `--threshold must be a number between 0 and 1, got \"${raw}\"`,\n );\n }\n return value;\n};\n\nconst parseEpochSeconds = (raw: string): number => {\n const value = Number(raw);\n if (!Number.isInteger(value) || value < 0 || value > MAX_EPOCH_SECONDS) {\n throw new UsageError(\n `--observed-at must be an integer from 0 to ${MAX_EPOCH_SECONDS}, got \"${raw}\"`,\n );\n }\n return value;\n};\n\nconst parseCoverage = (\n raw: string | undefined,\n): DocxCommonOptions[\"coverage\"] => {\n const value = raw ?? DOCX_COVERAGE_MODES.requireFull;\n if (\n value === DOCX_COVERAGE_MODES.requireFull ||\n value === DOCX_COVERAGE_MODES.allowPartial\n ) {\n return value;\n }\n throw new UsageError(\n `--coverage must be one of: ${Object.values(DOCX_COVERAGE_MODES).join(\", \")}; got \"${value}\"`,\n );\n};\n\nconst parseSessionMode = (raw: string | undefined): DocxSessionMode => {\n if (raw === undefined) {\n throw new UsageError(\"--session-mode is required for DOCX anonymization\");\n }\n if (\n raw === DOCX_SESSION_MODES.create ||\n raw === DOCX_SESSION_MODES.continue\n ) {\n return raw;\n }\n throw new UsageError(\n `--session-mode must be one of: ${Object.values(DOCX_SESSION_MODES).join(\", \")}; got \"${raw}\"`,\n );\n};\n\nconst required = (value: string | undefined, flag: string): string => {\n if (value === undefined || value.length === 0) {\n throw new UsageError(`${flag} is required for DOCX workflows`);\n }\n return value;\n};\n\ntype ParsedCommonValues = {\n output?: string | undefined;\n \"session-archive\"?: string | undefined;\n \"session-key-file\"?: string | undefined;\n \"session-id\"?: string | undefined;\n coverage?: string | undefined;\n \"observed-at\"?: string | undefined;\n json?: boolean | undefined;\n quiet?: boolean | undefined;\n};\n\nconst commonOptions = (\n values: ParsedCommonValues,\n positionals: readonly string[],\n): DocxCommonOptions => {\n if (positionals.length !== 1) {\n throw new UsageError(\"DOCX workflows require exactly one input file\");\n }\n const inputPath = positionals.at(0);\n if (inputPath === undefined) {\n throw new UsageError(\"DOCX workflows require exactly one input file\");\n }\n return {\n inputPath,\n outputPath: required(values.output, \"--output\"),\n sessionArchivePath: required(\n values[\"session-archive\"],\n \"--session-archive\",\n ),\n sessionKeyPath: required(values[\"session-key-file\"], \"--session-key-file\"),\n sessionId: required(values[\"session-id\"], \"--session-id\"),\n coverage: parseCoverage(values.coverage),\n observedAtEpochSeconds:\n values[\"observed-at\"] === undefined\n ? undefined\n : parseEpochSeconds(values[\"observed-at\"]),\n json: values.json === true,\n quiet: values.quiet === true,\n };\n};\n\nconst parseDocxCommand = (argv: readonly string[]): DocxCommand => {\n const action = argv.at(0);\n if (action === undefined || action === \"--help\" || action === \"-h\") {\n return { type: \"help\" };\n }\n const args = argv.slice(1);\n if (action === \"anonymize\") {\n let parsed: ReturnType<typeof parseArgs<typeof DOCX_ANONYMIZE_CONFIG>>;\n try {\n parsed = parseArgs({ ...DOCX_ANONYMIZE_CONFIG, args: [...args] });\n } catch (error) {\n throw new UsageError(\n error instanceof Error ? error.message : String(error),\n );\n }\n if (parsed.values.help === true) {\n return { type: \"help\" };\n }\n return {\n type: \"anonymize\",\n ...commonOptions(parsed.values, parsed.positionals),\n sessionMode: parseSessionMode(parsed.values[\"session-mode\"]),\n detection: {\n labels:\n parsed.values.labels === undefined\n ? undefined\n : splitList(parsed.values.labels),\n languages:\n parsed.values.languages === undefined\n ? undefined\n : splitList(parsed.values.languages),\n countries:\n parsed.values.countries === undefined\n ? undefined\n : parseCountries(parsed.values.countries),\n threshold:\n parsed.values.threshold === undefined\n ? 0.3\n : parseThreshold(parsed.values.threshold),\n },\n };\n }\n if (action === \"restore\") {\n let parsed: ReturnType<typeof parseArgs<typeof DOCX_RESTORE_CONFIG>>;\n try {\n parsed = parseArgs({ ...DOCX_RESTORE_CONFIG, args: [...args] });\n } catch (error) {\n throw new UsageError(\n error instanceof Error ? error.message : String(error),\n );\n }\n if (parsed.values.help === true) {\n return { type: \"help\" };\n }\n return {\n type: \"restore\",\n ...commonOptions(parsed.values, parsed.positionals),\n };\n }\n throw new UsageError(\n `unknown DOCX action \"${action}\"; expected \"anonymize\" or \"restore\"`,\n );\n};\n\nconst DOCX_COMMON_PARSE_OPTIONS = {\n output: { type: \"string\", short: \"o\" },\n \"session-archive\": { type: \"string\" },\n \"session-key-file\": { type: \"string\" },\n \"session-id\": { type: \"string\" },\n coverage: { type: \"string\" },\n \"observed-at\": { type: \"string\" },\n json: { type: \"boolean\" },\n quiet: { type: \"boolean\" },\n help: { type: \"boolean\", short: \"h\" },\n} as const;\n\nconst DOCX_ANONYMIZE_CONFIG = {\n allowPositionals: true,\n strict: true,\n options: {\n ...DOCX_COMMON_PARSE_OPTIONS,\n \"session-mode\": { type: \"string\" },\n labels: { type: \"string\" },\n languages: { type: \"string\" },\n countries: { type: \"string\" },\n threshold: { type: \"string\" },\n },\n} as const;\n\nconst DOCX_RESTORE_CONFIG = {\n allowPositionals: true,\n strict: true,\n options: DOCX_COMMON_PARSE_OPTIONS,\n} as const;\n\nconst canonicalPath = (path: string): string => {\n try {\n return realpathSync(path);\n } catch {\n return resolve(path);\n }\n};\n\nconst sessionArchiveLockPath = (archivePath: string): string =>\n `${canonicalPath(archivePath)}${DOCX_SESSION_LOCK_SUFFIX}`;\n\nconst assertDistinctPaths = (\n paths: readonly { path: string; flag: string }[],\n): void => {\n const seen = new Map<string, string>();\n for (const entry of paths) {\n const canonical = canonicalPath(entry.path);\n const existing = seen.get(canonical);\n if (existing !== undefined) {\n throw new UsageError(`${entry.flag} collides with ${existing}`);\n }\n seen.set(canonical, `${entry.flag} \"${entry.path}\"`);\n }\n};\n\nconst isNodeError = (\n error: unknown,\n code: string,\n): error is NodeJS.ErrnoException =>\n error instanceof Error && \"code\" in error && error.code === code;\n\nconst assertPathDoesNotExist = async (\n path: string,\n flag: string,\n): Promise<void> => {\n try {\n await lstat(path);\n } catch (error) {\n if (isNodeError(error, \"ENOENT\")) {\n return;\n }\n throw error;\n }\n throw new UsageError(`${flag} refuses to overwrite existing path \"${path}\"`);\n};\n\nconst preflightDocxCommand = async (\n command: Exclude<DocxCommand, { type: \"help\" }>,\n): Promise<void> => {\n const paths = [\n { path: command.inputPath, flag: \"input\" },\n { path: command.outputPath, flag: \"--output\" },\n { path: command.sessionArchivePath, flag: \"--session-archive\" },\n { path: command.sessionKeyPath, flag: \"--session-key-file\" },\n ];\n if (\n command.type === \"anonymize\" &&\n command.sessionMode === DOCX_SESSION_MODES.continue\n ) {\n paths.push({\n path: sessionArchiveLockPath(command.sessionArchivePath),\n flag: \"session archive lock\",\n });\n }\n assertDistinctPaths(paths);\n await assertPathDoesNotExist(command.outputPath, \"--output\");\n if (\n command.type === \"anonymize\" &&\n command.sessionMode === DOCX_SESSION_MODES.create\n ) {\n await assertPathDoesNotExist(\n command.sessionArchivePath,\n \"--session-archive\",\n );\n }\n};\n\ntype SessionArchiveLock = {\n release: () => Promise<void>;\n};\n\ntype OperationResult =\n | { type: \"succeeded\" }\n | { type: \"failed\"; error: unknown };\n\nconst captureOperationResult = async (\n operation: Promise<void>,\n): Promise<OperationResult> => {\n try {\n await operation;\n return { type: \"succeeded\" };\n } catch (error) {\n return { type: \"failed\", error };\n }\n};\n\nconst acquireSessionArchiveLock = async (\n archivePath: string,\n): Promise<SessionArchiveLock> => {\n const lockPath = sessionArchiveLockPath(archivePath);\n let handle: FileHandle;\n try {\n handle = await open(lockPath, \"wx\", 0o600);\n } catch (error) {\n if (isNodeError(error, \"EEXIST\")) {\n throw new Error(\n `encrypted session archive is locked by another continuation; if no process is running, remove the stale lock \"${lockPath}\"`,\n );\n }\n throw error;\n }\n return {\n release: async () => {\n const closeResult = await captureOperationResult(handle.close());\n const unlinkResult = await captureOperationResult(unlink(lockPath));\n if (closeResult.type === \"failed\") {\n throw closeResult.error;\n }\n if (\n unlinkResult.type === \"failed\" &&\n !isNodeError(unlinkResult.error, \"ENOENT\")\n ) {\n throw unlinkResult.error;\n }\n },\n };\n};\n\nconst readSessionKey = async (path: string): Promise<Uint8Array> => {\n const handle = await open(path, \"r\");\n let key: Uint8Array | undefined;\n try {\n const stats = await handle.stat();\n if (!stats.isFile()) {\n throw new UsageError(\"--session-key-file must be a regular file\");\n }\n if (process.platform !== \"win32\" && (stats.mode & 0o077) !== 0) {\n throw new UsageError(\n \"--session-key-file must not grant permissions to group or other users (use chmod 600)\",\n );\n }\n key = await handle.readFile();\n if (key.byteLength !== DOCX_SESSION_KEY_BYTES) {\n throw new UsageError(\n `--session-key-file must contain exactly ${DOCX_SESSION_KEY_BYTES} raw bytes`,\n );\n }\n await handle.close();\n return key;\n } catch (error) {\n key?.fill(0);\n try {\n await handle.close();\n } catch {\n // Preserve the validation or read error.\n }\n throw error;\n }\n};\n\nconst removeStagedFile = async (path: string | undefined): Promise<void> => {\n if (path === undefined) {\n return;\n }\n try {\n await unlink(path);\n } catch {\n // Best-effort cleanup must not hide the original operation error.\n }\n};\n\nconst stageFile = async (\n target: string,\n content: Uint8Array,\n): Promise<string> => {\n const temporary = join(\n dirname(target),\n `.${basename(target)}.${randomUUID()}.tmp`,\n );\n const handle = await open(temporary, \"wx\", 0o600);\n try {\n await handle.writeFile(content);\n await handle.sync();\n await handle.close();\n } catch (error) {\n try {\n await handle.close();\n } catch {\n // Preserve the write error while cleanup remains best effort.\n }\n await removeStagedFile(temporary);\n throw error;\n }\n return temporary;\n};\n\nconst publishNewFile = async (\n temporary: string,\n target: string,\n flag: string,\n): Promise<void> => {\n try {\n await link(temporary, target);\n } catch (error) {\n if (isNodeError(error, \"EEXIST\")) {\n throw new UsageError(\n `${flag} refuses to overwrite existing path \"${target}\"`,\n );\n }\n throw error;\n }\n await removeStagedFile(temporary);\n};\n\nconst publishReplacement = async (\n temporary: string,\n target: string,\n): Promise<void> => {\n await rename(temporary, target);\n};\n\nconst sessionArchive = (\n session: DocxCliSession,\n key: Uint8Array,\n observedAtEpochSeconds: number | undefined,\n): Uint8Array =>\n observedAtEpochSeconds === undefined\n ? session.toEncryptedArchive(key)\n : session.toEncryptedArchiveAt(key, observedAtEpochSeconds);\n\nconst outputSummary = (\n command: Pick<DocxCommonOptions, \"json\" | \"quiet\">,\n action: \"anonymized\" | \"restored\",\n summary: DocxAnonymizationSummary | Omit<DocxRestorationResult, \"document\">,\n): void => {\n if (command.json) {\n process.stdout.write(`${JSON.stringify(summary, null, 2)}\\n`);\n }\n if (command.quiet) {\n return;\n }\n const coverage = summary.coverage.status;\n if (\"entityCount\" in summary) {\n process.stderr.write(\n `anonymize: DOCX ${action}: ${summary.entityCount} entities, ${summary.appliedReplacementCount} replacements, ${coverage} coverage\\n`,\n );\n return;\n }\n process.stderr.write(\n `anonymize: DOCX ${action}: ${summary.restoredPlaceholderCount} placeholders, ${coverage} coverage\\n`,\n );\n};\n\nconst openSession = (\n pipeline: DocxCliPipeline,\n command: Pick<DocxCommonOptions, \"sessionId\" | \"observedAtEpochSeconds\">,\n archive: Uint8Array,\n key: Uint8Array,\n): DocxCliSession =>\n pipeline.restoreEncryptedRedactionSession({\n archive,\n key,\n expectedSessionId: command.sessionId,\n ...(command.observedAtEpochSeconds === undefined\n ? {}\n : { observedAtEpochSeconds: command.observedAtEpochSeconds }),\n });\n\nconst runDocxAnonymize = async (\n command: Extract<DocxCommand, { type: \"anonymize\" }>,\n pipeline: DocxCliPipeline,\n): Promise<void> => {\n const archivePath =\n command.sessionMode === DOCX_SESSION_MODES.continue\n ? canonicalPath(command.sessionArchivePath)\n : command.sessionArchivePath;\n const archiveLock =\n command.sessionMode === DOCX_SESSION_MODES.continue\n ? await acquireSessionArchiveLock(archivePath)\n : undefined;\n let workflowResult: OperationResult = { type: \"succeeded\" };\n let lockReleaseResult: OperationResult = { type: \"succeeded\" };\n let key: Uint8Array | undefined;\n let documentTemporary: string | undefined;\n let archiveTemporary: string | undefined;\n try {\n key = await readSessionKey(command.sessionKeyPath);\n const [document, existingArchive] = await Promise.all([\n readFile(command.inputPath),\n command.sessionMode === DOCX_SESSION_MODES.continue\n ? readFile(archivePath)\n : Promise.resolve(undefined),\n ]);\n const session =\n existingArchive === undefined\n ? pipeline.createRedactionSession(command.sessionId)\n : openSession(pipeline, command, existingArchive, key);\n const result = anonymizeDocx({\n document,\n session,\n expectedSessionId: command.sessionId,\n policy: { coverage: { mode: command.coverage } },\n ...(command.observedAtEpochSeconds === undefined\n ? {}\n : { observedAtEpochSeconds: command.observedAtEpochSeconds }),\n });\n const encryptedArchive = sessionArchive(\n session,\n key,\n command.observedAtEpochSeconds,\n );\n documentTemporary = await stageFile(command.outputPath, result.document);\n archiveTemporary = await stageFile(archivePath, encryptedArchive);\n if (command.sessionMode === DOCX_SESSION_MODES.create) {\n await publishNewFile(\n archiveTemporary,\n command.sessionArchivePath,\n \"--session-archive\",\n );\n } else {\n await publishReplacement(archiveTemporary, archivePath);\n }\n archiveTemporary = undefined;\n try {\n await publishNewFile(documentTemporary, command.outputPath, \"--output\");\n documentTemporary = undefined;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n throw new Error(\n `encrypted session archive was updated, but DOCX output could not be published: ${message}`,\n );\n }\n outputSummary(command, \"anonymized\", result.summary);\n } catch (error) {\n workflowResult = { type: \"failed\", error };\n } finally {\n key?.fill(0);\n await Promise.all([\n removeStagedFile(documentTemporary),\n removeStagedFile(archiveTemporary),\n ]);\n if (archiveLock !== undefined) {\n lockReleaseResult = await captureOperationResult(archiveLock.release());\n }\n }\n if (workflowResult.type === \"failed\") {\n throw workflowResult.error;\n }\n if (lockReleaseResult.type === \"failed\") {\n const message =\n lockReleaseResult.error instanceof Error\n ? lockReleaseResult.error.message\n : String(lockReleaseResult.error);\n throw new Error(\n `DOCX and session outputs were published, but the session archive lock could not be released: ${message}`,\n );\n }\n};\n\nconst runDocxRestore = async (\n command: Extract<DocxCommand, { type: \"restore\" }>,\n pipeline: DocxCliPipeline,\n): Promise<void> => {\n const key = await readSessionKey(command.sessionKeyPath);\n try {\n const [document, archive] = await Promise.all([\n readFile(command.inputPath),\n readFile(command.sessionArchivePath),\n ]);\n const session = openSession(pipeline, command, archive, key);\n const result = restoreDocxText({\n document,\n session,\n expectedSessionId: command.sessionId,\n ...(command.observedAtEpochSeconds === undefined\n ? {}\n : { observedAtEpochSeconds: command.observedAtEpochSeconds }),\n });\n if (\n command.coverage === DOCX_COVERAGE_MODES.requireFull &&\n result.coverage.status === \"partial\"\n ) {\n throw new Error(\n \"DOCX contains content outside the fully supported restoration coverage\",\n );\n }\n const temporary = await stageFile(command.outputPath, result.document);\n try {\n await publishNewFile(temporary, command.outputPath, \"--output\");\n } catch (error) {\n await removeStagedFile(temporary);\n throw error;\n }\n const summary: Omit<DocxRestorationResult, \"document\"> = {\n sessionId: result.sessionId,\n restoredBlockCount: result.restoredBlockCount,\n restoredPlaceholderCount: result.restoredPlaceholderCount,\n coverage: result.coverage,\n };\n outputSummary(command, \"restored\", summary);\n } finally {\n key.fill(0);\n }\n};\n\nexport const runDocxCommand = async ({\n argv,\n preparePipeline,\n}: RunDocxCommandOptions): Promise<void> => {\n const command = parseDocxCommand(argv);\n if (command.type === \"help\") {\n process.stdout.write(DOCX_HELP);\n return;\n }\n await preflightDocxCommand(command);\n const pipeline = await preparePipeline(\n command.type === \"anonymize\"\n ? { type: \"anonymize\", detection: command.detection }\n : { type: \"restore\" },\n );\n if (command.type === \"anonymize\") {\n await runDocxAnonymize(command, pipeline);\n return;\n }\n await runDocxRestore(command, pipeline);\n};\n","import { realpathSync } from \"node:fs\";\nimport {\n mkdir,\n open,\n readdir,\n readFile,\n stat,\n writeFile,\n} from \"node:fs/promises\";\nimport { basename, dirname, join, relative, resolve } from \"node:path\";\nimport { createInterface } from \"node:readline/promises\";\n\nimport type {\n deanonymise,\n Dictionaries,\n exportRedactionKey,\n NativeAnonymizeBinding,\n NativeOperatorConfig,\n NativePipelineBuildOptions,\n OperatorType,\n PipelineConfig,\n} from \"@stll/anonymize\";\nimport { CAPABILITY_MANIFEST } from \"@stll/anonymize/capabilities\";\nimport {\n DEFAULT_ENTITY_LABELS,\n ENTITY_LABELS,\n type EntityLabel,\n} from \"@stll/anonymize/constants\";\n\nimport pkg from \"../package.json\" with { type: \"json\" };\n\nimport type { CliOptions } from \"./args\";\nimport {\n DEFAULT_THRESHOLD,\n HELP,\n parseCliArgs,\n parseCountries,\n UsageError,\n} from \"./args\";\nimport type { DictionaryScope } from \"./dictionary-scope\";\nimport {\n type DocxCliPipeline,\n type DocxPipelineRequest,\n runDocxCommand,\n} from \"./docx\";\n\n/**\n * The pipeline functions the CLI needs, backed by the\n * @stll/anonymize native SDK: a binding loader and the\n * config-to-pipeline builder, plus the redaction-key\n * helpers used by the deanonymise path.\n */\nexport type AnonymizeApi = {\n deanonymise: typeof deanonymise;\n exportRedactionKey: typeof exportRedactionKey;\n createNativePipelineFromConfig: (\n options: NativePipelineBuildOptions,\n ) => Promise<NativeCliPipeline>;\n loadNativeAnonymizeBinding: () => NativeAnonymizeBinding;\n};\n\n/**\n * Everything an entry point injects: the pipeline engine\n * and the dictionary source (the @stll/anonymize-data\n * package for the npm bin).\n */\nexport type CliEngine = {\n api: AnonymizeApi;\n loadDictionaries: (scope: DictionaryScope) => Promise<Dictionaries>;\n};\n\n// Statically imported so the version is baked into both\n// the npm bundle and the compiled binary; a runtime\n// package.json lookup would fail inside the binary's\n// virtual filesystem.\nconst cliVersion = (): string => pkg.version;\n\n/**\n * Filesystem identity of a path: realpath when it exists\n * (so symlinks to the same file compare equal), lexical\n * resolution otherwise (the file may not exist yet).\n */\nconst canonicalPath = (path: string): string => {\n try {\n return realpathSync(path);\n } catch {\n return resolve(path);\n }\n};\n\nconst readStdin = async (): Promise<string> => {\n process.stdin.setEncoding(\"utf8\");\n let text = \"\";\n for await (const chunk of process.stdin) text += chunk;\n return text;\n};\n\ntype NamedInput = {\n /** Source path, or null when reading stdin. */\n path: string | null;\n text: string;\n};\n\nconst readInputs = async (files: string[]): Promise<NamedInput[]> => {\n if (files.length === 0) {\n if (process.stdin.isTTY) {\n throw new UsageError(\n \"no input files and stdin is a terminal (see --help)\",\n );\n }\n return [{ path: null, text: await readStdin() }];\n }\n return Promise.all(\n files.map(async (path) => ({ path, text: await readFile(path, \"utf8\") })),\n );\n};\n\n/**\n * One file to anonymize in a batch run: the source path to\n * read and the path, relative to the output directory, to\n * write. For a plain file argument the relative path is the\n * basename; for a directory argument the input tree is\n * mirrored, so it is the path relative to that directory.\n */\ntype FileJob = {\n path: string;\n outputRelative: string;\n};\n\n/** Result of expanding the positional arguments into concrete\n * files. `batch` is true when the output must be a directory:\n * more than one file, or any directory argument. */\ntype ExpandedInputs = {\n jobs: FileJob[];\n batch: boolean;\n /** Likely-binary files skipped during directory walks. */\n skipped: number;\n};\n\n// Sniff window for the binary check. A regular text file never\n// contains a NUL byte; binaries (images, archives) reliably do.\nconst TEXT_SNIFF_BYTES = 8192;\n\n/**\n * True when the file's first {@link TEXT_SNIFF_BYTES} bytes\n * contain no NUL byte. Used to skip binaries discovered by a\n * directory walk without reading the whole file.\n */\nconst looksTextual = async (path: string): Promise<boolean> => {\n const handle = await open(path, \"r\");\n try {\n const buffer = Buffer.alloc(TEXT_SNIFF_BYTES);\n const { bytesRead } = await handle.read(buffer, 0, TEXT_SNIFF_BYTES, 0);\n return buffer.subarray(0, bytesRead).indexOf(0) === -1;\n } finally {\n await handle.close();\n }\n};\n\n/**\n * Collect regular files under `root`, sorted for deterministic\n * order. Symlinks are skipped (avoids cycles and escaping the\n * tree); subdirectories are descended only when `recursive`.\n */\nconst walkDirectory = async (\n root: string,\n recursive: boolean,\n excludeDir?: string,\n): Promise<string[]> => {\n const found: string[] = [];\n const visit = async (dir: string): Promise<void> => {\n const entries = (await readdir(dir, { withFileTypes: true })).toSorted(\n (a, b) => a.name.localeCompare(b.name),\n );\n for (const entry of entries) {\n const full = join(dir, entry.name);\n if (entry.isDirectory()) {\n // Never descend into the output tree: rerunning with --output inside\n // the input directory must not ingest previously generated files.\n if (excludeDir !== undefined && resolve(full) === excludeDir) continue;\n if (recursive) await visit(full);\n } else if (entry.isFile()) {\n found.push(full);\n }\n }\n };\n await visit(root);\n return found;\n};\n\n/**\n * Expand positional arguments into concrete file jobs. A file\n * argument becomes one job (always processed); a directory is\n * walked, mirroring its tree into the output and skipping\n * likely-binary files.\n */\nconst expandInputs = async (\n files: readonly string[],\n recursive: boolean,\n outputDir?: string,\n): Promise<ExpandedInputs> => {\n const excludeDir = outputDir === undefined ? undefined : resolve(outputDir);\n const jobs: FileJob[] = [];\n let hasDirectory = false;\n let skipped = 0;\n for (const path of files) {\n // A stat failure (missing path, permission error) is not\n // fatal here: treat it as a file job so the read failure is\n // reported per file. A single such job stays single-input\n // and surfaces the error as a runtime exit; in a batch it is\n // counted as a failed file.\n let stats: Awaited<ReturnType<typeof stat>> | undefined;\n try {\n stats = await stat(path);\n } catch {\n jobs.push({ path, outputRelative: basename(path) });\n continue;\n }\n if (!stats.isDirectory()) {\n jobs.push({ path, outputRelative: basename(path) });\n continue;\n }\n hasDirectory = true;\n for (const file of await walkDirectory(path, recursive, excludeDir)) {\n // A file that disappears or turns unreadable mid-walk is queued anyway:\n // the per-file worker try/catch counts it as failed without aborting\n // the batch. Only a successful sniff that says \"binary\" skips it.\n const textual = await looksTextual(file).catch(() => true);\n if (!textual) {\n skipped += 1;\n continue;\n }\n jobs.push({ path: file, outputRelative: relative(path, file) });\n }\n }\n return { jobs, batch: hasDirectory || jobs.length > 1, skipped };\n};\n\n/**\n * Run `task` over `items` with at most `workers` in flight.\n * The shared native pipeline makes each redaction a synchronous\n * native call, so concurrency here only overlaps async file\n * I/O; the increments below are safe without locking because\n * no `await` sits between the read and the write of `next`.\n */\nconst runPool = async <T>(\n items: readonly T[],\n workers: number,\n task: (item: T) => Promise<void>,\n): Promise<void> => {\n let next = 0;\n const worker = async (): Promise<void> => {\n while (next < items.length) {\n const index = next;\n next += 1;\n // SAFETY: index < items.length checked above.\n await task(items[index] as T);\n }\n };\n const count = Math.max(1, Math.min(workers, items.length));\n await Promise.all(Array.from({ length: count }, worker));\n};\n\ntype CliEntity = {\n start: number;\n end: number;\n label: string;\n text: string;\n score: number;\n source: string;\n};\n\ntype CliRedactionResult = {\n redactedText: string;\n redactionMap: Map<string, string>;\n operatorMap: Map<string, OperatorType>;\n entityCount: number;\n};\n\ntype NativeCliPipeline = DocxCliPipeline & {\n warmLazyRegex?: () => void;\n redactText: (\n fullText: string,\n operators?: NativeOperatorConfig,\n ) => {\n resolvedEntities: CliEntity[];\n redaction: CliRedactionResult;\n };\n};\n\n// Short aliases for the canonical multi-word labels so that\n// `--labels person,email,iban` works without quoting the space\n// in \"email address\". Separator-insensitive resolution (below)\n// additionally accepts hyphen/underscore forms such as\n// \"credit-card-number\".\nconst LABEL_ALIASES: Record<string, EntityLabel> = {\n email: \"email address\",\n phone: \"phone number\",\n org: \"organization\",\n organisation: \"organization\",\n dob: \"date of birth\",\n ssn: \"social security number\",\n \"tax id\": \"tax identification number\",\n passport: \"passport number\",\n \"credit card\": \"credit card number\",\n \"national id\": \"national identification number\",\n};\n\nconst LABEL_SEPARATOR_RE = /[\\s_-]+/g;\nconst ENTITY_LABEL_SET: ReadonlySet<string> = new Set(ENTITY_LABELS);\n\nconst isEntityLabel = (label: string): label is EntityLabel =>\n ENTITY_LABEL_SET.has(label);\n\n/**\n * Resolve a user-supplied label token to a canonical label.\n * Lowercases and collapses separators, then maps known short\n * aliases. Unknown tokens are returned normalized so the\n * caller can report them verbatim.\n */\nconst canonicalizeLabel = (raw: string): string => {\n const normalized = raw.toLowerCase().replace(LABEL_SEPARATOR_RE, \" \").trim();\n const known: readonly string[] = ENTITY_LABELS;\n if (known.includes(normalized)) {\n return normalized;\n }\n return LABEL_ALIASES[normalized] ?? normalized;\n};\n\nconst validateLabels = (labels: readonly string[]): EntityLabel[] => {\n const resolved = [...new Set(labels.map(canonicalizeLabel))];\n const valid: EntityLabel[] = [];\n const availableLabels = ENTITY_LABELS.join(\", \");\n const availableAliases = Object.keys(LABEL_ALIASES).join(\", \");\n for (const label of resolved) {\n if (!isEntityLabel(label)) {\n throw new UsageError(\n [\n \"--labels: unknown label\",\n JSON.stringify(label) + \";\",\n \"available:\",\n availableLabels,\n \"(aliases:\",\n availableAliases + \")\",\n ].join(\" \"),\n );\n }\n valid.push(label);\n }\n return valid;\n};\n\ntype PipelineConfigOptions = Pick<\n CliOptions,\n \"countries\" | \"labels\" | \"languages\" | \"threshold\"\n>;\n\nconst buildPipelineConfig = async (\n opts: PipelineConfigOptions,\n loadDictionaries: CliEngine[\"loadDictionaries\"],\n): Promise<PipelineConfig> => {\n const dictionaries = await loadDictionaries({\n languages: opts.languages,\n countries: opts.countries,\n });\n return {\n threshold: opts.threshold,\n enableTriggerPhrases: true,\n enableRegex: true,\n enableLegalForms: true,\n enableNameCorpus: true,\n ...(opts.languages === undefined\n ? {}\n : { nameCorpusLanguages: [...opts.languages] }),\n enableDenyList: true,\n ...(opts.countries === undefined\n ? {}\n : { denyListCountries: [...opts.countries] }),\n enableGazetteer: false,\n enableCountries: true,\n enableNer: false,\n enableConfidenceBoost: true,\n enableCoreference: true,\n enableZoneClassification: true,\n enableHotwordRules: true,\n labels:\n opts.labels === undefined\n ? [...DEFAULT_ENTITY_LABELS]\n : validateLabels(opts.labels),\n workspaceId: \"cli\",\n dictionaries,\n };\n};\n\nconst buildOperatorConfig = (opts: CliOptions): NativeOperatorConfig => {\n const operators: NonNullable<NativeOperatorConfig[\"operators\"]> = {};\n if (opts.mode === \"redact\") {\n const labels =\n opts.labels === undefined\n ? DEFAULT_ENTITY_LABELS\n : validateLabels(opts.labels);\n for (const label of labels) operators[label] = \"redact\";\n }\n return { operators, redactString: opts.redactString };\n};\n\nconst writeOutput = async (\n path: string | undefined,\n content: string,\n): Promise<void> => {\n if (path === undefined) {\n process.stdout.write(content);\n return;\n }\n await writeFile(path, content, \"utf8\");\n};\n\ntype RedactionKeyFile = {\n entries: Record<string, { original: string; operator: string }>;\n};\n\nconst parseRedactionKey = (raw: string): Map<string, string> => {\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n throw new UsageError(\"redaction key is not valid JSON\");\n }\n if (typeof parsed !== \"object\" || parsed === null || !(\"entries\" in parsed)) {\n throw new UsageError(\n 'redaction key must be an object with an \"entries\" field',\n );\n }\n const { entries } = parsed as RedactionKeyFile;\n if (\n typeof entries !== \"object\" ||\n entries === null ||\n Array.isArray(entries)\n ) {\n throw new UsageError('redaction key \"entries\" must be an object');\n }\n const map = new Map<string, string>();\n for (const [placeholder, entry] of Object.entries(entries)) {\n if (typeof entry?.original !== \"string\") {\n throw new UsageError(\n `redaction key entry \"${placeholder}\" has no original text`,\n );\n }\n map.set(placeholder, entry.original);\n }\n return map;\n};\n\n/**\n * Restrict a redaction key to the entities named by --revert.\n * Each token matches a placeholder (\"[PERSON_1]\") or an original\n * value (\"Jan Novák\"), case-sensitive and exact. A token that\n * matches nothing is a usage error listing the placeholders the\n * key does define, so the caller can correct the spelling.\n */\nconst selectRevertEntries = (\n redactionMap: ReadonlyMap<string, string>,\n tokens: readonly string[],\n): Map<string, string> => {\n const selected = new Map<string, string>();\n for (const token of tokens) {\n let matched = false;\n for (const [placeholder, original] of redactionMap) {\n if (placeholder === token || original === token) {\n selected.set(placeholder, original);\n matched = true;\n }\n }\n if (!matched) {\n const MAX_LISTED_PLACEHOLDERS = 20;\n const placeholders = [...redactionMap.keys()];\n const listed = placeholders.slice(0, MAX_LISTED_PLACEHOLDERS).join(\", \");\n const rest = placeholders.length - MAX_LISTED_PLACEHOLDERS;\n const suffix = rest > 0 ? ` and ${rest} more` : \"\";\n throw new UsageError(\n `--revert ${JSON.stringify(token)} matched no placeholder or ` +\n `original; available placeholders: ${listed}${suffix}`,\n );\n }\n }\n return selected;\n};\n\n/**\n * Ask for a country scope when running interactively on\n * files with no scope flags. Skipped for piped stdin so\n * the CLI stays scriptable.\n */\nexport const shouldPromptForScope = (\n opts: CliOptions,\n tty: { stdinIsTTY: boolean; stderrIsTTY: boolean },\n): boolean =>\n opts.countries === undefined &&\n opts.languages === undefined &&\n !opts.quiet &&\n opts.files.length > 0 &&\n tty.stdinIsTTY &&\n tty.stderrIsTTY;\n\nconst promptForCountries = async (): Promise<string[] | undefined> => {\n const rl = createInterface({\n input: process.stdin,\n output: process.stderr,\n });\n try {\n const answer = await rl.question(\n \"Country scope (ISO codes like CZ,DE,GB; Enter loads all): \",\n );\n const trimmed = answer.trim();\n return trimmed === \"\" ? undefined : parseCountries(trimmed);\n } finally {\n rl.close();\n }\n};\n\nconst runDeanonymise = async (\n opts: CliOptions,\n api: AnonymizeApi,\n): Promise<void> => {\n if (opts.keyPath !== undefined) {\n throw new UsageError(\"--key cannot be combined with --deanonymise\");\n }\n const keyPath = opts.deanonymiseKeyPath;\n if (keyPath === undefined) throw new UsageError(\"missing redaction key path\");\n const fullMap = parseRedactionKey(await readFile(keyPath, \"utf8\"));\n\n // --revert restores a chosen subset; leaving the rest of the\n // key out means deanonymise skips those placeholders, so the\n // other entities stay redacted.\n const redactionMap =\n opts.revert === undefined\n ? fullMap\n : selectRevertEntries(fullMap, opts.revert);\n\n const inputs = await readInputs(opts.files);\n if (inputs.length > 1) {\n throw new UsageError(\"--deanonymise accepts a single input\");\n }\n const input = inputs[0];\n if (!input) throw new UsageError(\"no input to deanonymise\");\n if (opts.output !== undefined) {\n guardWriteTargets(input.path === null ? [] : [input.path], [\n { path: opts.output, flag: \"--output\" },\n ]);\n }\n await writeOutput(opts.output, api.deanonymise(input.text, redactionMap));\n};\n\n/**\n * Reject any write target (output or key file) whose\n * filesystem identity collides with an input file or with\n * another write target. Symlinks count as collisions.\n */\nconst guardWriteTargets = (\n inputPaths: readonly string[],\n writeTargets: readonly { path: string; flag: string }[],\n): void => {\n const inputs = new Set(inputPaths.map(canonicalPath));\n const seen = new Map<string, string>();\n for (const target of writeTargets) {\n const canonical = canonicalPath(target.path);\n if (inputs.has(canonical)) {\n throw new UsageError(\n `refusing to overwrite input file \"${target.path}\" (${target.flag})`,\n );\n }\n const clash = seen.get(canonical);\n if (clash !== undefined) {\n throw new UsageError(\n `${target.flag} \"${target.path}\" collides with ${clash}`,\n );\n }\n seen.set(canonical, `${target.flag} \"${target.path}\"`);\n }\n};\n\nconst summarize = (entities: readonly CliEntity[]): string => {\n const counts = new Map<string, number>();\n for (const entity of entities) {\n counts.set(entity.label, (counts.get(entity.label) ?? 0) + 1);\n }\n const parts = [...counts.entries()]\n .toSorted((a, b) => b[1] - a[1])\n .map(([label, count]) => `${label}: ${count}`);\n return parts.length > 0 ? parts.join(\", \") : \"none\";\n};\n\n/**\n * A single unit of anonymize work: the text to process, where\n * to write it (undefined means stdout), and a label for the\n * stderr summary. Used by the stdin and single-file flows,\n * which additionally support --json and --key.\n */\ntype SingleInput = {\n text: string;\n outputPath: string | undefined;\n source: string;\n};\n\nconst runAnonymiseSingle = async (\n opts: CliOptions,\n runtime: CliRuntime,\n api: AnonymizeApi,\n input: SingleInput,\n): Promise<void> => {\n const { entities, redaction } = await runtime.redact(\n input.text,\n buildOperatorConfig(opts),\n );\n\n if (opts.json) {\n // In redact mode the user chose irreversibility, so the\n // JSON must not carry any detected text. Whitelist the\n // non-sensitive metadata fields; this drops `text` and a\n // coref alias's `corefSourceText`. Offsets index the\n // caller's own input and are kept.\n const jsonEntities =\n opts.mode === \"redact\"\n ? entities.map(({ start, end, label, score, source }) => ({\n start,\n end,\n label,\n score,\n source,\n }))\n : entities;\n const payload = {\n entityCount: redaction.entityCount,\n entities: jsonEntities,\n redactedText: redaction.redactedText,\n };\n await writeOutput(\n input.outputPath,\n `${JSON.stringify(payload, null, 2)}\\n`,\n );\n } else {\n await writeOutput(input.outputPath, redaction.redactedText);\n }\n\n if (opts.keyPath !== undefined) {\n await writeFile(\n opts.keyPath,\n api.exportRedactionKey(redaction.redactionMap, redaction.operatorMap),\n \"utf8\",\n );\n }\n\n if (!opts.quiet) {\n process.stderr.write(\n `anonymize: ${input.source}: ${summarize(entities)}\\n`,\n );\n }\n};\n\n/** Tally of a batch run for the closing summary line. */\ntype BatchOutcome = { processed: number; failed: number };\n\nconst runAnonymiseBatch = async (\n opts: CliOptions,\n runtime: CliRuntime,\n output: string,\n jobs: readonly FileJob[],\n skipped: number,\n): Promise<void> => {\n await mkdir(output, { recursive: true });\n const operatorConfig = buildOperatorConfig(opts);\n const outcome: BatchOutcome = { processed: 0, failed: 0 };\n\n await runPool(jobs, opts.workers, async (job) => {\n const outputPath = join(output, job.outputRelative);\n try {\n const text = await readFile(job.path, \"utf8\");\n const { entities, redaction } = await runtime.redact(\n text,\n operatorConfig,\n );\n await mkdir(dirname(outputPath), { recursive: true });\n await writeFile(outputPath, redaction.redactedText, \"utf8\");\n // No await between here and the increment: safe on the\n // single JS thread despite concurrent workers.\n outcome.processed += 1;\n if (!opts.quiet) {\n process.stderr.write(\n `anonymize: ${job.path}: ${summarize(entities)}\\n`,\n );\n }\n } catch (err) {\n outcome.failed += 1;\n const message = err instanceof Error ? err.message : String(err);\n process.stderr.write(`anonymize: ${job.path}: error: ${message}\\n`);\n }\n });\n\n if (!opts.quiet) {\n const parts = [\n `${outcome.processed} processed`,\n `${outcome.failed} failed`,\n ];\n if (skipped > 0) parts.push(`${skipped} skipped`);\n process.stderr.write(`anonymize: ${parts.join(\", \")}\\n`);\n }\n // Any per-file failure is a nonzero exit, but the whole batch\n // still runs so one bad file does not hide the rest.\n if (outcome.failed > 0) process.exitCode = 1;\n};\n\nconst runAnonymise = async (\n opts: CliOptions,\n { api, loadDictionaries }: CliEngine,\n): Promise<void> => {\n if (opts.keyPath !== undefined && opts.mode !== \"replace\") {\n throw new UsageError('--key requires --mode \"replace\"');\n }\n\n const scoped = shouldPromptForScope(opts, {\n stdinIsTTY: process.stdin.isTTY === true,\n stderrIsTTY: process.stderr.isTTY === true,\n })\n ? { ...opts, countries: await promptForCountries() }\n : opts;\n\n // No positional arguments: read stdin as a single input.\n if (scoped.files.length === 0) {\n const [input] = await readInputs(scoped.files);\n if (!input) throw new UsageError(\"no input to anonymize\");\n guardWriteTargets([], collectSingleTargets(scoped));\n const runtime = await prepareCliRuntime(\n api,\n await buildPipelineConfig(scoped, loadDictionaries),\n );\n await runAnonymiseSingle(scoped, runtime, api, {\n text: input.text,\n outputPath: scoped.output,\n source: \"stdin\",\n });\n return;\n }\n\n const { jobs, batch, skipped } = await expandInputs(\n scoped.files,\n scoped.recursive,\n scoped.output,\n );\n\n if (!batch) {\n // Exactly one plain file: single-input flow with --json/--key.\n const [job] = jobs;\n if (!job) throw new UsageError(\"no input to anonymize\");\n guardWriteTargets([job.path], collectSingleTargets(scoped));\n const runtime = await prepareCliRuntime(\n api,\n await buildPipelineConfig(scoped, loadDictionaries),\n );\n await runAnonymiseSingle(scoped, runtime, api, {\n text: await readFile(job.path, \"utf8\"),\n outputPath: scoped.output,\n source: job.path,\n });\n return;\n }\n\n // Batch: a directory, or more than one file.\n const output = scoped.output;\n if (output === undefined) {\n throw new UsageError(\n \"batch input (a directory or multiple files) requires --output <directory>\",\n );\n }\n if (scoped.keyPath !== undefined) {\n throw new UsageError(\"--key works with a single input only\");\n }\n if (scoped.json) {\n throw new UsageError(\"--json works with a single input only\");\n }\n\n // Validate every write target before any work: colliding\n // output paths (same basename from different input dirs,\n // symlinks to an input) fail fast instead of silently\n // clobbering files mid-batch.\n guardWriteTargets(\n jobs.map((job) => job.path),\n jobs.map((job) => ({\n path: join(output, job.outputRelative),\n flag: \"--output\",\n })),\n );\n\n const runtime = await prepareCliRuntime(\n api,\n await buildPipelineConfig(scoped, loadDictionaries),\n );\n await runAnonymiseBatch(scoped, runtime, output, jobs, skipped);\n};\n\n/** Write targets for a single-input run: --output and --key. */\nconst collectSingleTargets = (\n opts: CliOptions,\n): { path: string; flag: string }[] => {\n const targets: { path: string; flag: string }[] = [];\n if (opts.output !== undefined) {\n targets.push({ path: opts.output, flag: \"--output\" });\n }\n if (opts.keyPath !== undefined) {\n targets.push({ path: opts.keyPath, flag: \"--key\" });\n }\n return targets;\n};\n\ntype CliRuntime = {\n redact: (\n fullText: string,\n operators: NativeOperatorConfig,\n ) => Promise<{ entities: CliEntity[]; redaction: CliRedactionResult }>;\n};\n\nconst prepareNativeCliPipeline = async (\n api: AnonymizeApi,\n config: PipelineConfig,\n): Promise<NativeCliPipeline> => {\n const pipeline = await api.createNativePipelineFromConfig({\n binding: api.loadNativeAnonymizeBinding(),\n config,\n gazetteerEntries: [],\n });\n pipeline.warmLazyRegex?.();\n return pipeline;\n};\n\nconst prepareCliRuntime = async (\n api: AnonymizeApi,\n config: PipelineConfig,\n): Promise<CliRuntime> => {\n const pipeline = await prepareNativeCliPipeline(api, config);\n return {\n redact: async (fullText, operators) => {\n const result = pipeline.redactText(fullText, operators);\n return {\n entities: result.resolvedEntities,\n redaction: result.redaction,\n };\n },\n };\n};\n\n/**\n * Render the canonical entity labels and the short aliases\n * accepted by --labels, for the --list-labels discovery flag.\n */\nconst formatLabelList = (): string => {\n const lines: string[] = [\"Detectable entity labels (pass to --labels):\"];\n for (const label of ENTITY_LABELS) {\n lines.push(` ${label}`);\n }\n lines.push(\"\", \"Short aliases:\");\n const aliases = Object.entries(LABEL_ALIASES);\n let width = 0;\n for (const [alias] of aliases) {\n width = Math.max(width, alias.length);\n }\n for (const [alias, canonical] of aliases) {\n lines.push(` ${alias.padEnd(width)} -> ${canonical}`);\n }\n return `${lines.join(\"\\n\")}\\n`;\n};\n\nconst dispatch = async (engine: CliEngine): Promise<void> => {\n const argv = process.argv.slice(2);\n if (argv.at(0) === \"docx\") {\n await runDocxCommand({\n argv: argv.slice(1),\n preparePipeline: async (\n request: DocxPipelineRequest,\n ): Promise<DocxCliPipeline> => {\n const options: PipelineConfigOptions =\n request.type === \"anonymize\"\n ? request.detection\n : {\n countries: [],\n languages: [],\n threshold: DEFAULT_THRESHOLD,\n };\n return prepareNativeCliPipeline(\n engine.api,\n await buildPipelineConfig(options, engine.loadDictionaries),\n );\n },\n });\n return;\n }\n const opts = parseCliArgs(argv);\n if (opts.help) {\n process.stdout.write(HELP);\n return;\n }\n if (opts.version) {\n process.stdout.write(`${cliVersion()}\\n`);\n return;\n }\n if (opts.listLabels) {\n process.stdout.write(formatLabelList());\n return;\n }\n if (opts.capabilities) {\n process.stdout.write(`${JSON.stringify(CAPABILITY_MANIFEST, null, 2)}\\n`);\n return;\n }\n if (opts.deanonymiseKeyPath !== undefined) {\n await runDeanonymise(opts, engine.api);\n return;\n }\n if (opts.revert !== undefined) {\n throw new UsageError(\"--revert requires --deanonymise <key>\");\n }\n await runAnonymise(opts, engine);\n};\n\n/**\n * Run the CLI against the given engine and set the\n * process exit code (0 ok, 1 runtime error, 2 usage).\n */\nexport const runCli = async (engine: CliEngine): Promise<void> => {\n try {\n await dispatch(engine);\n } catch (err) {\n if (err instanceof UsageError) {\n process.stderr.write(`anonymize: ${err.message}\\n`);\n process.stderr.write(`Try \"anonymize --help\" for usage.\\n`);\n process.exitCode = 2;\n } else {\n const message = err instanceof Error ? err.message : String(err);\n process.stderr.write(`anonymize: ${message}\\n`);\n process.exitCode = 1;\n }\n }\n};\n","#!/usr/bin/env node\n/* npm-distributed entry point — backs the CLI with the\n * native engine (@stll/text-search napi bindings) and\n * the @stll/anonymize-data dictionary package. */\nimport * as anonymize from \"@stll/anonymize\";\n\nimport { loadCliDictionaries } from \"./dictionaries\";\nimport { runCli } from \"./main\";\n\nawait runCli({ api: anonymize, loadDictionaries: loadCliDictionaries });\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/anonymize-cli",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "Command-line PII detection and anonymization powered by @stll/anonymize",
5
5
  "keywords": [
6
6
  "anonymization",
@@ -27,7 +27,7 @@
27
27
  "url": "git+https://github.com/stella/anonymize.git"
28
28
  },
29
29
  "homepage": "https://github.com/stella/anonymize",
30
- "license": "MIT",
30
+ "license": "Apache-2.0",
31
31
  "scripts": {
32
32
  "build": "tsdown",
33
33
  "prepublishOnly": "bun run build",
@@ -36,13 +36,15 @@
36
36
  "format": "oxfmt ."
37
37
  },
38
38
  "dependencies": {
39
- "@stll/anonymize": "^2.0.0",
40
- "@stll/anonymize-data": "^0.0.6"
39
+ "@stll/anonymize": "^2.0.2",
40
+ "@stll/anonymize-data": "^0.0.6",
41
+ "@stll/anonymize-docx": "^2.0.2"
41
42
  },
42
43
  "devDependencies": {
43
- "@types/node": "^26.0.1",
44
+ "@types/node": "^26.1.1",
44
45
  "bun-types": "^1.3.14",
45
- "tsdown": "^0.22.3",
46
+ "fflate": "^0.8.3",
47
+ "tsdown": "^0.22.4",
46
48
  "typescript": "^6.0.3"
47
49
  }
48
50
  }