mdenc 0.1.5 → 2.0.0

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
@@ -2,6 +2,8 @@
2
2
 
3
3
  **Encrypt your Markdown. Keep your diffs.**
4
4
 
5
+ [**Live Demo**](https://yogh-io.github.io/mdenc/) | [npm](https://www.npmjs.com/package/mdenc) | [Specification](SPECIFICATION.md) | [Security](SECURITY.md)
6
+
5
7
  mdenc lets you store encrypted Markdown in git without losing the ability to see *what changed*. Edit one paragraph, and only that paragraph changes in the encrypted output. Your `git log` stays useful. Your pull request reviews stay sane.
6
8
 
7
9
  ## What it looks like
@@ -92,29 +94,26 @@ Password is read from `MDENC_PASSWORD` env var or prompted interactively (no ech
92
94
 
93
95
  ## Git Integration
94
96
 
95
- mdenc can automatically encrypt and decrypt files as part of your git workflow.
97
+ mdenc uses git's native **smudge/clean filter** to transparently encrypt and decrypt `.md` files. You edit plaintext locally; git stores ciphertext in the repository.
96
98
 
97
99
  ```bash
98
- # Set up git hooks (pre-commit, post-checkout, post-merge, post-rewrite)
100
+ # Set up git smudge/clean filter and textconv diff
99
101
  mdenc init
100
102
 
101
103
  # Generate a random password into .mdenc-password
102
- mdenc genpass
104
+ mdenc genpass [--force]
103
105
 
104
- # Mark a directory -- .md files inside will be encrypted on commit
106
+ # Mark a directory -- .md files inside will be filtered
105
107
  mdenc mark docs/private
106
108
 
107
- # See which files need encryption/decryption
109
+ # See which files are configured for encryption
108
110
  mdenc status
109
111
 
110
- # Watch for changes and encrypt on save
111
- mdenc watch
112
-
113
- # Remove mdenc hooks from the repository
114
- mdenc remove-hooks
112
+ # Remove git filter configuration
113
+ mdenc remove-filter
115
114
  ```
116
115
 
117
- After `mdenc init` and `mdenc mark`, the workflow is automatic: edit `.md` files normally, and the pre-commit hook encrypts them to `.mdenc` before each commit. Post-checkout and post-merge hooks decrypt them back after switching branches or pulling.
116
+ After `mdenc init` and `mdenc mark`, the workflow is transparent: the **clean filter** encrypts `.md` files when they're staged (`git add`), and the **smudge filter** decrypts them on checkout. You always see plaintext in your working directory. The custom diff driver shows plaintext diffs of encrypted content.
118
117
 
119
118
  ## Library
120
119
 
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
4
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
5
5
 
6
6
  // src/crypto/encrypt.ts
7
7
  import { hmac as hmac3 } from "@noble/hashes/hmac";
@@ -436,15 +436,70 @@ ${chunkLines.join("\n")}`;
436
436
  }
437
437
  }
438
438
 
439
+ // src/git/diff-driver.ts
440
+ import { execFileSync } from "child_process";
441
+ import { mkdtempSync, rmSync, writeFileSync } from "fs";
442
+ import { tmpdir } from "os";
443
+ import { join } from "path";
444
+ async function diffDriverCommand(args) {
445
+ const [path, oldFile, oldHex, , newFile, newHex] = args;
446
+ if (!path || !oldFile || !newFile) {
447
+ process.stderr.write("mdenc diff-driver: insufficient arguments\n");
448
+ process.exit(1);
449
+ }
450
+ const oldEnc = catBlob(oldHex);
451
+ const newEnc = catBlob(newHex);
452
+ if (oldEnc !== null || newEnc !== null) {
453
+ const tmp = mkdtempSync(join(tmpdir(), "mdenc-diff-"));
454
+ try {
455
+ const oldTmp = join(tmp, "old");
456
+ const newTmp = join(tmp, "new");
457
+ writeFileSync(oldTmp, oldEnc ?? "");
458
+ writeFileSync(newTmp, newEnc ?? "");
459
+ const encDiff = unifiedDiff(oldTmp, newTmp, `a/${path}`, `b/${path}`);
460
+ if (encDiff) process.stdout.write(encDiff);
461
+ } finally {
462
+ rmSync(tmp, { recursive: true });
463
+ }
464
+ }
465
+ const plainDiff = unifiedDiff(oldFile, newFile, `a/${path}`, `b/${path}`);
466
+ if (plainDiff) {
467
+ const annotated = plainDiff.replace(
468
+ /^(@@ .+ @@)(.*)/gm,
469
+ "$1 decrypted \u2014 not stored in repository"
470
+ );
471
+ process.stdout.write(annotated);
472
+ }
473
+ }
474
+ function catBlob(hex) {
475
+ if (!hex || hex === "." || /^0+$/.test(hex)) return null;
476
+ try {
477
+ return execFileSync("git", ["cat-file", "blob", hex], { encoding: "utf-8" });
478
+ } catch {
479
+ return null;
480
+ }
481
+ }
482
+ function unifiedDiff(oldFile, newFile, oldLabel, newLabel) {
483
+ try {
484
+ return execFileSync("diff", ["-u", "--label", oldLabel, "--label", newLabel, oldFile, newFile], {
485
+ encoding: "utf-8"
486
+ }) || null;
487
+ } catch (e) {
488
+ const err = e;
489
+ if (err.status === 1 && err.stdout) return err.stdout;
490
+ return null;
491
+ }
492
+ }
493
+
439
494
  // src/git/password.ts
440
495
  import { readFileSync } from "fs";
441
- import { join } from "path";
496
+ import { join as join2 } from "path";
442
497
  var PASSWORD_FILE = ".mdenc-password";
443
498
  function resolvePassword(repoRoot) {
444
499
  const envPassword = process.env["MDENC_PASSWORD"];
445
500
  if (envPassword) return envPassword;
446
501
  try {
447
- const content = readFileSync(join(repoRoot, PASSWORD_FILE), "utf-8").trim();
502
+ const content = readFileSync(join2(repoRoot, PASSWORD_FILE), "utf-8").trim();
448
503
  if (content.length > 0) return content;
449
504
  } catch {
450
505
  }
@@ -452,14 +507,14 @@ function resolvePassword(repoRoot) {
452
507
  }
453
508
 
454
509
  // src/git/utils.ts
455
- import { execFileSync } from "child_process";
510
+ import { execFileSync as execFileSync2 } from "child_process";
456
511
  import { readdirSync, statSync } from "fs";
457
- import { join as join2 } from "path";
512
+ import { join as join3 } from "path";
458
513
  var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
459
514
  var MARKER_FILE = ".mdenc.conf";
460
515
  function findGitRoot() {
461
516
  try {
462
- return execFileSync("git", ["rev-parse", "--show-toplevel"], {
517
+ return execFileSync2("git", ["rev-parse", "--show-toplevel"], {
463
518
  encoding: "utf-8",
464
519
  stdio: ["pipe", "pipe", "pipe"]
465
520
  }).trim();
@@ -469,7 +524,7 @@ function findGitRoot() {
469
524
  }
470
525
  function gitShow(repoRoot, ref, path) {
471
526
  try {
472
- return execFileSync("git", ["show", `${ref}:${path}`], {
527
+ return execFileSync2("git", ["show", `${ref}:${path}`], {
473
528
  cwd: repoRoot,
474
529
  encoding: "utf-8",
475
530
  stdio: ["pipe", "pipe", "pipe"]
@@ -495,7 +550,7 @@ function walkForMarker(dir, results) {
495
550
  }
496
551
  for (const entry of entries) {
497
552
  if (SKIP_DIRS.has(entry) || entry.startsWith(".")) continue;
498
- const full = join2(dir, entry);
553
+ const full = join3(dir, entry);
499
554
  try {
500
555
  if (statSync(full).isDirectory()) {
501
556
  walkForMarker(full, results);
@@ -506,14 +561,14 @@ function walkForMarker(dir, results) {
506
561
  }
507
562
  function getMdFilesInDir(dir) {
508
563
  try {
509
- return readdirSync(dir).filter((f) => f.endsWith(".md") && statSync(join2(dir, f)).isFile());
564
+ return readdirSync(dir).filter((f) => f.endsWith(".md") && statSync(join3(dir, f)).isFile());
510
565
  } catch {
511
566
  return [];
512
567
  }
513
568
  }
514
569
  function gitAdd(repoRoot, files) {
515
570
  if (files.length === 0) return;
516
- execFileSync("git", ["add", "--", ...files], {
571
+ execFileSync2("git", ["add", "--", ...files], {
517
572
  cwd: repoRoot,
518
573
  stdio: ["pipe", "pipe", "pipe"]
519
574
  });
@@ -712,6 +767,7 @@ async function filterProcessMain() {
712
767
  writeFlush();
713
768
  writeBinaryPktLines(resultBuf);
714
769
  writeFlush();
770
+ writeFlush();
715
771
  } catch (err) {
716
772
  process.stderr.write(
717
773
  `mdenc: filter error for ${pathname}: ${err instanceof Error ? err.message : err}
@@ -725,54 +781,54 @@ async function filterProcessMain() {
725
781
  }
726
782
 
727
783
  // src/git/genpass.ts
728
- import { existsSync, readFileSync as readFileSync2, writeFileSync } from "fs";
729
- import { join as join3 } from "path";
784
+ import { existsSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
785
+ import { join as join4 } from "path";
730
786
  import { randomBytes as randomBytes2 } from "@noble/ciphers/webcrypto";
731
787
  var PASSWORD_FILE2 = ".mdenc-password";
732
788
  function genpassCommand(force) {
733
789
  const repoRoot = findGitRoot();
734
- const passwordPath = join3(repoRoot, PASSWORD_FILE2);
790
+ const passwordPath = join4(repoRoot, PASSWORD_FILE2);
735
791
  if (existsSync(passwordPath) && !force) {
736
792
  console.error(`${PASSWORD_FILE2} already exists. Use --force to overwrite.`);
737
793
  process.exit(1);
738
794
  }
739
795
  const password = Buffer.from(randomBytes2(32)).toString("base64url");
740
- writeFileSync(passwordPath, `${password}
796
+ writeFileSync2(passwordPath, `${password}
741
797
  `, { mode: 384 });
742
798
  console.error(`Generated password and wrote to ${PASSWORD_FILE2}`);
743
799
  console.error(password);
744
- const gitignorePath = join3(repoRoot, ".gitignore");
800
+ const gitignorePath = join4(repoRoot, ".gitignore");
745
801
  const entry = PASSWORD_FILE2;
746
802
  if (existsSync(gitignorePath)) {
747
803
  const content = readFileSync2(gitignorePath, "utf-8");
748
804
  const lines = content.split("\n").map((l) => l.trim());
749
805
  if (!lines.includes(entry)) {
750
- writeFileSync(gitignorePath, `${content.trimEnd()}
806
+ writeFileSync2(gitignorePath, `${content.trimEnd()}
751
807
  ${entry}
752
808
  `);
753
809
  console.error("Added .mdenc-password to .gitignore");
754
810
  }
755
811
  } else {
756
- writeFileSync(gitignorePath, `${entry}
812
+ writeFileSync2(gitignorePath, `${entry}
757
813
  `);
758
814
  console.error("Created .gitignore with .mdenc-password");
759
815
  }
760
816
  }
761
817
 
762
818
  // src/git/init.ts
763
- import { execFileSync as execFileSync2 } from "child_process";
764
- import { existsSync as existsSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
765
- import { join as join4 } from "path";
819
+ import { execFileSync as execFileSync3 } from "child_process";
820
+ import { existsSync as existsSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
821
+ import { join as join5 } from "path";
766
822
  var FILTER_CONFIGS = [
767
823
  ["filter.mdenc.process", "mdenc filter-process"],
768
824
  ["filter.mdenc.clean", "mdenc filter-clean %f"],
769
825
  ["filter.mdenc.smudge", "mdenc filter-smudge %f"],
770
826
  ["filter.mdenc.required", "true"],
771
- ["diff.mdenc.textconv", "mdenc textconv"]
827
+ ["diff.mdenc.command", "mdenc diff-driver"]
772
828
  ];
773
829
  function configureGitFilter(repoRoot) {
774
830
  for (const [key, value] of FILTER_CONFIGS) {
775
- execFileSync2("git", ["config", "--local", key, value], {
831
+ execFileSync3("git", ["config", "--local", key, value], {
776
832
  cwd: repoRoot,
777
833
  stdio: ["pipe", "pipe", "pipe"]
778
834
  });
@@ -780,7 +836,7 @@ function configureGitFilter(repoRoot) {
780
836
  }
781
837
  function isFilterConfigured(repoRoot) {
782
838
  try {
783
- const val = execFileSync2("git", ["config", "--get", "filter.mdenc.process"], {
839
+ const val = execFileSync3("git", ["config", "--get", "filter.mdenc.process"], {
784
840
  cwd: repoRoot,
785
841
  encoding: "utf-8",
786
842
  stdio: ["pipe", "pipe", "pipe"]
@@ -798,13 +854,13 @@ async function initCommand() {
798
854
  configureGitFilter(repoRoot);
799
855
  console.log("Configured git filter (filter.mdenc + diff.mdenc)");
800
856
  }
801
- const gitignorePath = join4(repoRoot, ".gitignore");
857
+ const gitignorePath = join5(repoRoot, ".gitignore");
802
858
  const entry = ".mdenc-password";
803
859
  if (existsSync2(gitignorePath)) {
804
860
  const content = readFileSync3(gitignorePath, "utf-8");
805
861
  const lines = content.split("\n").map((l) => l.trim());
806
862
  if (!lines.includes(entry)) {
807
- writeFileSync2(gitignorePath, `${content.trimEnd()}
863
+ writeFileSync3(gitignorePath, `${content.trimEnd()}
808
864
  ${entry}
809
865
  `);
810
866
  console.log("Added .mdenc-password to .gitignore");
@@ -812,7 +868,7 @@ ${entry}
812
868
  console.log(".mdenc-password already in .gitignore (skipped)");
813
869
  }
814
870
  } else {
815
- writeFileSync2(gitignorePath, `${entry}
871
+ writeFileSync3(gitignorePath, `${entry}
816
872
  `);
817
873
  console.log("Created .gitignore with .mdenc-password");
818
874
  }
@@ -822,7 +878,7 @@ ${entry}
822
878
  for (const dir of markedDirs) {
823
879
  const relDir = relative3(repoRoot, dir) || ".";
824
880
  try {
825
- execFileSync2("git", ["checkout", "HEAD", "--", `${relDir}/*.md`], {
881
+ execFileSync3("git", ["checkout", "HEAD", "--", `${relDir}/*.md`], {
826
882
  cwd: repoRoot,
827
883
  stdio: ["pipe", "pipe", "pipe"]
828
884
  });
@@ -836,7 +892,7 @@ function removeFilterCommand() {
836
892
  const repoRoot = findGitRoot();
837
893
  for (const section of ["filter.mdenc", "diff.mdenc"]) {
838
894
  try {
839
- execFileSync2("git", ["config", "--local", "--remove-section", section], {
895
+ execFileSync3("git", ["config", "--local", "--remove-section", section], {
840
896
  cwd: repoRoot,
841
897
  stdio: ["pipe", "pipe", "pipe"]
842
898
  });
@@ -847,15 +903,15 @@ function removeFilterCommand() {
847
903
  }
848
904
 
849
905
  // src/git/mark.ts
850
- import { execFileSync as execFileSync3 } from "child_process";
851
- import { existsSync as existsSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
852
- import { join as join5, relative, resolve } from "path";
906
+ import { execFileSync as execFileSync4 } from "child_process";
907
+ import { existsSync as existsSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
908
+ import { join as join6, relative, resolve } from "path";
853
909
  var MARKER_FILE2 = ".mdenc.conf";
854
910
  var MARKER_CONTENT = "# mdenc: .md files in this directory are automatically encrypted\n";
855
911
  var GITATTR_PATTERN = "*.md filter=mdenc diff=mdenc";
856
912
  function isFilterConfigured2(repoRoot) {
857
913
  try {
858
- const val = execFileSync3("git", ["config", "--get", "filter.mdenc.process"], {
914
+ const val = execFileSync4("git", ["config", "--get", "filter.mdenc.process"], {
859
915
  cwd: repoRoot,
860
916
  encoding: "utf-8",
861
917
  stdio: ["pipe", "pipe", "pipe"]
@@ -905,26 +961,26 @@ function markCommand(dirArg) {
905
961
  }
906
962
  const relDir = rel || ".";
907
963
  const filterReady = isFilterConfigured2(repoRoot);
908
- const confPath = join5(dir, MARKER_FILE2);
964
+ const confPath = join6(dir, MARKER_FILE2);
909
965
  if (!existsSync3(confPath)) {
910
- writeFileSync3(confPath, MARKER_CONTENT);
966
+ writeFileSync4(confPath, MARKER_CONTENT);
911
967
  console.log(`Created ${relDir}/${MARKER_FILE2}`);
912
968
  } else {
913
969
  console.log(`${relDir}/${MARKER_FILE2} already exists (skipped)`);
914
970
  }
915
- const gitattrsPath = join5(dir, ".gitattributes");
971
+ const gitattrsPath = join6(dir, ".gitattributes");
916
972
  if (existsSync3(gitattrsPath)) {
917
973
  const content = readFileSync4(gitattrsPath, "utf-8");
918
974
  if (content.includes("filter=mdenc")) {
919
975
  console.log(`${relDir}/.gitattributes already has filter=mdenc (skipped)`);
920
976
  } else {
921
- writeFileSync3(gitattrsPath, `${content.trimEnd()}
977
+ writeFileSync4(gitattrsPath, `${content.trimEnd()}
922
978
  ${GITATTR_PATTERN}
923
979
  `);
924
980
  console.log(`Updated ${relDir}/.gitattributes`);
925
981
  }
926
982
  } else {
927
- writeFileSync3(gitattrsPath, `${GITATTR_PATTERN}
983
+ writeFileSync4(gitattrsPath, `${GITATTR_PATTERN}
928
984
  `);
929
985
  console.log(`Created ${relDir}/.gitattributes`);
930
986
  }
@@ -938,13 +994,13 @@ Warning: git filter not configured yet. Run "mdenc init" to enable encryption.`)
938
994
  }
939
995
 
940
996
  // src/git/status.ts
941
- import { execFileSync as execFileSync4 } from "child_process";
997
+ import { execFileSync as execFileSync5 } from "child_process";
942
998
  import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
943
- import { join as join6, relative as relative2 } from "path";
999
+ import { join as join7, relative as relative2 } from "path";
944
1000
  function getFilterConfig(repoRoot) {
945
1001
  const get = (key) => {
946
1002
  try {
947
- return execFileSync4("git", ["config", "--get", key], {
1003
+ return execFileSync5("git", ["config", "--get", key], {
948
1004
  cwd: repoRoot,
949
1005
  encoding: "utf-8",
950
1006
  stdio: ["pipe", "pipe", "pipe"]
@@ -957,8 +1013,7 @@ function getFilterConfig(repoRoot) {
957
1013
  process: get("filter.mdenc.process"),
958
1014
  clean: get("filter.mdenc.clean"),
959
1015
  smudge: get("filter.mdenc.smudge"),
960
- required: get("filter.mdenc.required") === "true",
961
- textconv: get("diff.mdenc.textconv")
1016
+ required: get("filter.mdenc.required") === "true"
962
1017
  };
963
1018
  }
964
1019
  function statusCommand() {
@@ -975,7 +1030,7 @@ function statusCommand() {
975
1030
  console.log(` ${relDir}/`);
976
1031
  const mdFiles = getMdFilesInDir(dir);
977
1032
  for (const f of mdFiles) {
978
- const content = readFileSync5(join6(dir, f), "utf-8");
1033
+ const content = readFileSync5(join7(dir, f), "utf-8");
979
1034
  if (content.startsWith("mdenc:v1")) {
980
1035
  console.log(` ${f} [encrypted \u2014 needs smudge]`);
981
1036
  } else {
@@ -985,7 +1040,7 @@ function statusCommand() {
985
1040
  if (mdFiles.length === 0) {
986
1041
  console.log(" (no .md files)");
987
1042
  }
988
- const gitattrsPath = join6(dir, ".gitattributes");
1043
+ const gitattrsPath = join7(dir, ".gitattributes");
989
1044
  if (!existsSync4(gitattrsPath)) {
990
1045
  console.log(" WARNING: no .gitattributes in this directory");
991
1046
  } else {
@@ -1106,8 +1161,7 @@ async function getPasswordWithConfirmation() {
1106
1161
  }
1107
1162
  return password;
1108
1163
  }
1109
- function usage() {
1110
- console.error(`Usage:
1164
+ var USAGE = `Usage:
1111
1165
  mdenc encrypt <file> [-o output] Encrypt a markdown file
1112
1166
  mdenc decrypt <file> [-o output] Decrypt an mdenc file
1113
1167
  mdenc verify <file> Verify file integrity
@@ -1123,13 +1177,23 @@ Internal (called by git):
1123
1177
  mdenc filter-process Long-running filter process
1124
1178
  mdenc filter-clean <path> Single-file clean filter
1125
1179
  mdenc filter-smudge <path> Single-file smudge filter
1126
- mdenc textconv <file> Output plaintext for git diff`);
1127
- process.exit(1);
1180
+ mdenc textconv <file> Output plaintext for git diff
1181
+ mdenc diff-driver <path> ... Custom diff driver (encrypted + plaintext)`;
1182
+ function usage(exitCode = 1) {
1183
+ console.error(USAGE);
1184
+ process.exit(exitCode);
1128
1185
  }
1129
1186
  async function main() {
1130
1187
  const args = process.argv.slice(2);
1131
1188
  if (args.length === 0) usage();
1132
1189
  const command = args[0];
1190
+ if (command === "--help" || command === "-h") usage(0);
1191
+ if (command === "--version" || command === "-v") {
1192
+ const pkgPath = new URL("../package.json", import.meta.url);
1193
+ const pkg = JSON.parse(readFileSync7(pkgPath, "utf-8"));
1194
+ console.log(pkg.version);
1195
+ return;
1196
+ }
1133
1197
  try {
1134
1198
  switch (command) {
1135
1199
  case "encrypt": {
@@ -1141,7 +1205,7 @@ async function main() {
1141
1205
  const plaintext = readFileSync7(inputFile, "utf-8");
1142
1206
  const encrypted = await encrypt(plaintext, password);
1143
1207
  if (outputFile) {
1144
- writeFileSync4(outputFile, encrypted);
1208
+ writeFileSync5(outputFile, encrypted);
1145
1209
  } else {
1146
1210
  process.stdout.write(encrypted);
1147
1211
  }
@@ -1156,7 +1220,7 @@ async function main() {
1156
1220
  const fileContent = readFileSync7(inputFile, "utf-8");
1157
1221
  const decrypted = await decrypt(fileContent, password);
1158
1222
  if (outputFile) {
1159
- writeFileSync4(outputFile, decrypted);
1223
+ writeFileSync5(outputFile, decrypted);
1160
1224
  } else {
1161
1225
  process.stdout.write(decrypted);
1162
1226
  }
@@ -1207,6 +1271,9 @@ async function main() {
1207
1271
  case "filter-smudge":
1208
1272
  await simpleSmudgeFilter();
1209
1273
  break;
1274
+ case "diff-driver":
1275
+ await diffDriverCommand(args.slice(1));
1276
+ break;
1210
1277
  case "textconv":
1211
1278
  if (!args[1]) {
1212
1279
  console.error("Usage: mdenc textconv <file>");