mdenc 0.1.3 → 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/dist/cli.js CHANGED
@@ -1,35 +1,404 @@
1
1
  #!/usr/bin/env node
2
- import {
3
- constantTimeEqual,
4
- decrypt,
5
- deriveKeys,
6
- deriveMasterKey,
7
- encrypt,
8
- findGitRoot,
9
- findMarkedDirs,
10
- fromBase64,
11
- getHooksDir,
12
- getMdFilesInDir,
13
- getMdencFilesInDir,
14
- gitAdd,
15
- gitRmCached,
16
- isFileTracked,
17
- parseHeader,
18
- postCheckoutHook,
19
- postMergeHook,
20
- postRewriteHook,
21
- preCommitHook,
22
- resolvePassword,
23
- verifyHeader,
24
- zeroize
25
- } from "./chunk-DQSJGHST.js";
26
2
 
27
3
  // src/cli.ts
28
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
4
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
29
5
 
30
- // src/seal.ts
6
+ // src/crypto/encrypt.ts
7
+ import { hmac as hmac3 } from "@noble/hashes/hmac";
8
+ import { sha256 as sha2564 } from "@noble/hashes/sha256";
9
+
10
+ // src/crypto/aead.ts
11
+ import { xchacha20poly1305 } from "@noble/ciphers/chacha";
31
12
  import { hmac } from "@noble/hashes/hmac";
32
13
  import { sha256 } from "@noble/hashes/sha256";
14
+ var NONCE_LENGTH = 24;
15
+ function buildAAD(fileId) {
16
+ const fileIdHex = bytesToHex(fileId);
17
+ const aadString = `mdenc:v1
18
+ ${fileIdHex}`;
19
+ return new TextEncoder().encode(aadString);
20
+ }
21
+ function deriveNonce(nonceKey, plaintext) {
22
+ const full = hmac(sha256, nonceKey, plaintext);
23
+ return full.slice(0, NONCE_LENGTH);
24
+ }
25
+ function encryptChunk(encKey, nonceKey, plaintext, fileId) {
26
+ const nonce = deriveNonce(nonceKey, plaintext);
27
+ const aad = buildAAD(fileId);
28
+ const cipher = xchacha20poly1305(encKey, nonce, aad);
29
+ const ciphertext = cipher.encrypt(plaintext);
30
+ const result = new Uint8Array(NONCE_LENGTH + ciphertext.length);
31
+ result.set(nonce, 0);
32
+ result.set(ciphertext, NONCE_LENGTH);
33
+ return result;
34
+ }
35
+ function decryptChunk(encKey, payload, fileId) {
36
+ if (payload.length < NONCE_LENGTH + 16) {
37
+ throw new Error("Chunk payload too short");
38
+ }
39
+ const nonce = payload.slice(0, NONCE_LENGTH);
40
+ const ciphertext = payload.slice(NONCE_LENGTH);
41
+ const aad = buildAAD(fileId);
42
+ const cipher = xchacha20poly1305(encKey, nonce, aad);
43
+ try {
44
+ return cipher.decrypt(ciphertext);
45
+ } catch {
46
+ throw new Error("Chunk authentication failed");
47
+ }
48
+ }
49
+ function bytesToHex(bytes) {
50
+ let hex = "";
51
+ for (let i = 0; i < bytes.length; i++) {
52
+ hex += bytes[i].toString(16).padStart(2, "0");
53
+ }
54
+ return hex;
55
+ }
56
+
57
+ // src/crypto/chunking.ts
58
+ var DEFAULT_MAX_CHUNK_SIZE = 65536;
59
+ function chunkByParagraph(text, maxSize = DEFAULT_MAX_CHUNK_SIZE) {
60
+ const normalized = text.replace(/\r\n/g, "\n");
61
+ if (normalized.length === 0) {
62
+ return [""];
63
+ }
64
+ const chunks = [];
65
+ const boundary = /\n{2,}/g;
66
+ let lastEnd = 0;
67
+ let match;
68
+ while ((match = boundary.exec(normalized)) !== null) {
69
+ const chunkEnd = match.index + match[0].length;
70
+ chunks.push(normalized.slice(lastEnd, chunkEnd));
71
+ lastEnd = chunkEnd;
72
+ }
73
+ if (lastEnd < normalized.length) {
74
+ chunks.push(normalized.slice(lastEnd));
75
+ } else if (chunks.length === 0) {
76
+ chunks.push(normalized);
77
+ }
78
+ const result = [];
79
+ for (const chunk of chunks) {
80
+ if (byteLength(chunk) <= maxSize) {
81
+ result.push(chunk);
82
+ } else {
83
+ result.push(...splitAtByteLimit(chunk, maxSize));
84
+ }
85
+ }
86
+ return result;
87
+ }
88
+ function chunkByFixedSize(text, size) {
89
+ const normalized = text.replace(/\r\n/g, "\n");
90
+ if (normalized.length === 0) {
91
+ return [""];
92
+ }
93
+ const bytes = new TextEncoder().encode(normalized);
94
+ if (bytes.length <= size) {
95
+ return [normalized];
96
+ }
97
+ const chunks = [];
98
+ const decoder = new TextDecoder();
99
+ let offset = 0;
100
+ while (offset < bytes.length) {
101
+ const end = Math.min(offset + size, bytes.length);
102
+ let adjusted = end;
103
+ if (adjusted < bytes.length) {
104
+ while (adjusted > offset && (bytes[adjusted] & 192) === 128) {
105
+ adjusted--;
106
+ }
107
+ }
108
+ chunks.push(decoder.decode(bytes.slice(offset, adjusted)));
109
+ offset = adjusted;
110
+ }
111
+ return chunks;
112
+ }
113
+ function byteLength(s) {
114
+ return new TextEncoder().encode(s).length;
115
+ }
116
+ function splitAtByteLimit(text, maxSize) {
117
+ const bytes = new TextEncoder().encode(text);
118
+ const decoder = new TextDecoder();
119
+ const parts = [];
120
+ let offset = 0;
121
+ while (offset < bytes.length) {
122
+ let end = Math.min(offset + maxSize, bytes.length);
123
+ if (end < bytes.length) {
124
+ while (end > offset && (bytes[end] & 192) === 128) {
125
+ end--;
126
+ }
127
+ }
128
+ parts.push(decoder.decode(bytes.slice(offset, end)));
129
+ offset = end;
130
+ }
131
+ return parts;
132
+ }
133
+
134
+ // src/crypto/crypto-utils.ts
135
+ function constantTimeEqual(a, b) {
136
+ if (a.length !== b.length) return false;
137
+ let diff = 0;
138
+ for (let i = 0; i < a.length; i++) {
139
+ diff |= a[i] ^ b[i];
140
+ }
141
+ return diff === 0;
142
+ }
143
+ function zeroize(...arrays) {
144
+ for (const arr of arrays) {
145
+ arr.fill(0);
146
+ }
147
+ }
148
+
149
+ // src/crypto/header.ts
150
+ import { randomBytes } from "@noble/ciphers/webcrypto";
151
+ import { hmac as hmac2 } from "@noble/hashes/hmac";
152
+ import { sha256 as sha2562 } from "@noble/hashes/sha256";
153
+
154
+ // src/crypto/types.ts
155
+ var DEFAULT_SCRYPT_PARAMS = {
156
+ N: 16384,
157
+ r: 8,
158
+ p: 1
159
+ };
160
+ var SCRYPT_BOUNDS = {
161
+ N: { min: 1024, max: 1048576 },
162
+ // 2^10 – 2^20
163
+ r: { min: 1, max: 64 },
164
+ p: { min: 1, max: 16 }
165
+ };
166
+
167
+ // src/crypto/header.ts
168
+ function generateSalt() {
169
+ return randomBytes(16);
170
+ }
171
+ function generateFileId() {
172
+ return randomBytes(16);
173
+ }
174
+ function serializeHeader(header) {
175
+ const saltB64 = toBase64(header.salt);
176
+ const fileIdB64 = toBase64(header.fileId);
177
+ const { N, r, p } = header.scrypt;
178
+ return `mdenc:v1 salt_b64=${saltB64} file_id_b64=${fileIdB64} scrypt=N=${N},r=${r},p=${p}`;
179
+ }
180
+ function parseHeader(line) {
181
+ if (!line.startsWith("mdenc:v1 ")) {
182
+ throw new Error("Invalid header: missing mdenc:v1 prefix");
183
+ }
184
+ const saltMatch = line.match(/salt_b64=([A-Za-z0-9+/=]+)/);
185
+ if (!saltMatch?.[1]) throw new Error("Invalid header: missing salt_b64");
186
+ const salt = fromBase64(saltMatch[1]);
187
+ if (salt.length !== 16) throw new Error("Invalid header: salt must be 16 bytes");
188
+ const fileIdMatch = line.match(/file_id_b64=([A-Za-z0-9+/=]+)/);
189
+ if (!fileIdMatch?.[1]) throw new Error("Invalid header: missing file_id_b64");
190
+ const fileId = fromBase64(fileIdMatch[1]);
191
+ if (fileId.length !== 16) throw new Error("Invalid header: file_id must be 16 bytes");
192
+ const scryptMatch = line.match(/scrypt=N=(\d+),r=(\d+),p=(\d+)/);
193
+ if (!scryptMatch?.[1] || !scryptMatch[2] || !scryptMatch[3])
194
+ throw new Error("Invalid header: missing scrypt parameters");
195
+ const scryptParams = {
196
+ N: parseInt(scryptMatch[1], 10),
197
+ r: parseInt(scryptMatch[2], 10),
198
+ p: parseInt(scryptMatch[3], 10)
199
+ };
200
+ validateScryptParams(scryptParams);
201
+ return { version: "v1", salt, fileId, scrypt: scryptParams };
202
+ }
203
+ function validateScryptParams(params) {
204
+ const { N, r, p } = SCRYPT_BOUNDS;
205
+ if (params.N < N.min || params.N > N.max) {
206
+ throw new Error(`Invalid scrypt N: ${params.N} (must be ${N.min}\u2013${N.max})`);
207
+ }
208
+ if ((params.N & params.N - 1) !== 0) {
209
+ throw new Error(`Invalid scrypt N: ${params.N} (must be a power of 2)`);
210
+ }
211
+ if (params.r < r.min || params.r > r.max) {
212
+ throw new Error(`Invalid scrypt r: ${params.r} (must be ${r.min}\u2013${r.max})`);
213
+ }
214
+ if (params.p < p.min || params.p > p.max) {
215
+ throw new Error(`Invalid scrypt p: ${params.p} (must be ${p.min}\u2013${p.max})`);
216
+ }
217
+ }
218
+ function authenticateHeader(headerKey, headerLine) {
219
+ const headerBytes = new TextEncoder().encode(headerLine);
220
+ return hmac2(sha2562, headerKey, headerBytes);
221
+ }
222
+ function verifyHeader(headerKey, headerLine, hmacBytes) {
223
+ const computed = authenticateHeader(headerKey, headerLine);
224
+ return constantTimeEqual(computed, hmacBytes);
225
+ }
226
+ function toBase64(bytes) {
227
+ let binary = "";
228
+ for (let i = 0; i < bytes.length; i++) {
229
+ binary += String.fromCharCode(bytes[i]);
230
+ }
231
+ return btoa(binary);
232
+ }
233
+ function fromBase64(b64) {
234
+ const binary = atob(b64);
235
+ const bytes = new Uint8Array(binary.length);
236
+ for (let i = 0; i < binary.length; i++) {
237
+ bytes[i] = binary.charCodeAt(i);
238
+ }
239
+ return bytes;
240
+ }
241
+
242
+ // src/crypto/kdf.ts
243
+ import { hkdf } from "@noble/hashes/hkdf";
244
+ import { scrypt } from "@noble/hashes/scrypt";
245
+ import { sha256 as sha2563 } from "@noble/hashes/sha256";
246
+ var ENC_INFO = new TextEncoder().encode("mdenc-v1-enc");
247
+ var HDR_INFO = new TextEncoder().encode("mdenc-v1-hdr");
248
+ var NONCE_INFO = new TextEncoder().encode("mdenc-v1-nonce");
249
+ function normalizePassword(password) {
250
+ const normalized = password.normalize("NFKC");
251
+ return new TextEncoder().encode(normalized);
252
+ }
253
+ function deriveMasterKey(password, salt, params = DEFAULT_SCRYPT_PARAMS) {
254
+ const passwordBytes = normalizePassword(password);
255
+ try {
256
+ return scrypt(passwordBytes, salt, {
257
+ N: params.N,
258
+ r: params.r,
259
+ p: params.p,
260
+ dkLen: 32
261
+ });
262
+ } finally {
263
+ zeroize(passwordBytes);
264
+ }
265
+ }
266
+ function deriveKeys(masterKey) {
267
+ const encKey = hkdf(sha2563, masterKey, void 0, ENC_INFO, 32);
268
+ const headerKey = hkdf(sha2563, masterKey, void 0, HDR_INFO, 32);
269
+ const nonceKey = hkdf(sha2563, masterKey, void 0, NONCE_INFO, 32);
270
+ return { encKey, headerKey, nonceKey };
271
+ }
272
+
273
+ // src/crypto/encrypt.ts
274
+ async function encrypt(plaintext, password, options) {
275
+ const chunking = options?.chunking ?? "paragraph" /* Paragraph */;
276
+ const maxChunkSize = options?.maxChunkSize ?? 65536;
277
+ const scryptParams = options?.scrypt ?? DEFAULT_SCRYPT_PARAMS;
278
+ let chunks;
279
+ if (chunking === "fixed-size" /* FixedSize */) {
280
+ const fixedSize = options?.fixedChunkSize ?? 4096;
281
+ chunks = chunkByFixedSize(plaintext, fixedSize);
282
+ } else {
283
+ chunks = chunkByParagraph(plaintext, maxChunkSize);
284
+ }
285
+ let salt;
286
+ let fileId;
287
+ let masterKey;
288
+ const prev = options?.previousFile ? parsePreviousFileHeader(options.previousFile, password) : void 0;
289
+ if (prev) {
290
+ salt = prev.salt;
291
+ fileId = prev.fileId;
292
+ masterKey = prev.masterKey;
293
+ } else {
294
+ salt = generateSalt();
295
+ fileId = generateFileId();
296
+ masterKey = deriveMasterKey(password, salt, scryptParams);
297
+ }
298
+ const { encKey, headerKey, nonceKey } = deriveKeys(masterKey);
299
+ try {
300
+ const header = { version: "v1", salt, fileId, scrypt: scryptParams };
301
+ const headerLine = serializeHeader(header);
302
+ const headerHmac = authenticateHeader(headerKey, headerLine);
303
+ const headerAuthLine = `hdrauth_b64=${toBase64(headerHmac)}`;
304
+ const chunkLines = [];
305
+ for (const chunkText of chunks) {
306
+ const chunkBytes = new TextEncoder().encode(chunkText);
307
+ const payload = encryptChunk(encKey, nonceKey, chunkBytes, fileId);
308
+ chunkLines.push(toBase64(payload));
309
+ }
310
+ const sealInput = `${headerLine}
311
+ ${headerAuthLine}
312
+ ${chunkLines.join("\n")}`;
313
+ const sealData = new TextEncoder().encode(sealInput);
314
+ const sealHmac = hmac3(sha2564, headerKey, sealData);
315
+ const sealLine = `seal_b64=${toBase64(sealHmac)}`;
316
+ return [headerLine, headerAuthLine, ...chunkLines, sealLine, ""].join("\n");
317
+ } finally {
318
+ zeroize(masterKey, encKey, headerKey, nonceKey);
319
+ }
320
+ }
321
+ async function decrypt(fileContent, password) {
322
+ const lines = fileContent.split("\n");
323
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
324
+ lines.pop();
325
+ }
326
+ if (lines.length < 3) {
327
+ throw new Error("Invalid mdenc file: too few lines");
328
+ }
329
+ const headerLine = lines[0];
330
+ const header = parseHeader(headerLine);
331
+ const authLine = lines[1];
332
+ const authMatch = authLine.match(/^hdrauth_b64=([A-Za-z0-9+/=]+)$/);
333
+ if (!authMatch?.[1]) {
334
+ throw new Error("Invalid mdenc file: missing hdrauth_b64 line");
335
+ }
336
+ const headerHmac = fromBase64(authMatch[1]);
337
+ const masterKey = deriveMasterKey(password, header.salt, header.scrypt);
338
+ const { encKey, headerKey, nonceKey } = deriveKeys(masterKey);
339
+ try {
340
+ if (!verifyHeader(headerKey, headerLine, headerHmac)) {
341
+ throw new Error("Header authentication failed (wrong password or tampered header)");
342
+ }
343
+ const remaining = lines.slice(2);
344
+ const sealIndex = remaining.findIndex((l) => l.startsWith("seal_b64="));
345
+ if (sealIndex < 0) {
346
+ throw new Error("Invalid mdenc file: missing seal");
347
+ }
348
+ const chunkLines = remaining.slice(0, sealIndex);
349
+ if (chunkLines.length === 0) {
350
+ throw new Error("Invalid mdenc file: no chunk lines");
351
+ }
352
+ const sealLine = remaining[sealIndex];
353
+ const sealMatch = sealLine.match(/^seal_b64=([A-Za-z0-9+/=]+)$/);
354
+ if (!sealMatch?.[1]) throw new Error("Invalid mdenc file: malformed seal line");
355
+ const storedSealHmac = fromBase64(sealMatch[1]);
356
+ const sealInput = `${headerLine}
357
+ ${authLine}
358
+ ${chunkLines.join("\n")}`;
359
+ const sealData = new TextEncoder().encode(sealInput);
360
+ const computedSealHmac = hmac3(sha2564, headerKey, sealData);
361
+ if (!constantTimeEqual(computedSealHmac, storedSealHmac)) {
362
+ throw new Error("Seal verification failed (file tampered or chunks reordered)");
363
+ }
364
+ const plaintextParts = [];
365
+ for (const line of chunkLines) {
366
+ const payload = fromBase64(line);
367
+ const decrypted = decryptChunk(encKey, payload, header.fileId);
368
+ plaintextParts.push(new TextDecoder().decode(decrypted));
369
+ }
370
+ return plaintextParts.join("");
371
+ } finally {
372
+ zeroize(masterKey, encKey, headerKey, nonceKey);
373
+ }
374
+ }
375
+ function parsePreviousFileHeader(fileContent, password) {
376
+ try {
377
+ const lines = fileContent.split("\n");
378
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
379
+ if (lines.length < 3) return void 0;
380
+ const headerLine = lines[0];
381
+ const header = parseHeader(headerLine);
382
+ const authLine = lines[1];
383
+ const authMatch = authLine.match(/^hdrauth_b64=([A-Za-z0-9+/=]+)$/);
384
+ if (!authMatch?.[1]) return void 0;
385
+ const headerHmac = fromBase64(authMatch[1]);
386
+ const masterKey = deriveMasterKey(password, header.salt, header.scrypt);
387
+ const { encKey, headerKey, nonceKey } = deriveKeys(masterKey);
388
+ if (!verifyHeader(headerKey, headerLine, headerHmac)) {
389
+ zeroize(masterKey, encKey, headerKey, nonceKey);
390
+ return void 0;
391
+ }
392
+ zeroize(encKey, headerKey, nonceKey);
393
+ return { salt: header.salt, fileId: header.fileId, masterKey };
394
+ } catch {
395
+ return void 0;
396
+ }
397
+ }
398
+
399
+ // src/crypto/seal.ts
400
+ import { hmac as hmac4 } from "@noble/hashes/hmac";
401
+ import { sha256 as sha2565 } from "@noble/hashes/sha256";
33
402
  async function verifySeal(fileContent, password) {
34
403
  const lines = fileContent.split("\n");
35
404
  if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
@@ -38,10 +407,10 @@ async function verifySeal(fileContent, password) {
38
407
  const header = parseHeader(headerLine);
39
408
  const authLine = lines[1];
40
409
  const authMatch = authLine.match(/^hdrauth_b64=([A-Za-z0-9+/=]+)$/);
41
- if (!authMatch) throw new Error("Invalid mdenc file: missing hdrauth_b64 line");
410
+ if (!authMatch?.[1]) throw new Error("Invalid mdenc file: missing hdrauth_b64 line");
42
411
  const headerHmac = fromBase64(authMatch[1]);
43
412
  const masterKey = deriveMasterKey(password, header.salt, header.scrypt);
44
- const { headerKey, nonceKey } = deriveKeys(masterKey);
413
+ const { encKey, headerKey, nonceKey } = deriveKeys(masterKey);
45
414
  try {
46
415
  if (!verifyHeader(headerKey, headerLine, headerHmac)) {
47
416
  throw new Error("Header authentication failed");
@@ -52,194 +421,536 @@ async function verifySeal(fileContent, password) {
52
421
  throw new Error("File is not sealed: no seal_b64 line found");
53
422
  }
54
423
  const chunkLines = chunkAndSealLines.slice(0, sealIndex);
55
- const sealMatch = chunkAndSealLines[sealIndex].match(/^seal_b64=([A-Za-z0-9+/=]+)$/);
56
- if (!sealMatch) throw new Error("Invalid seal line");
424
+ const sealLine = chunkAndSealLines[sealIndex];
425
+ const sealMatch = sealLine.match(/^seal_b64=([A-Za-z0-9+/=]+)$/);
426
+ if (!sealMatch?.[1]) throw new Error("Invalid seal line");
57
427
  const storedHmac = fromBase64(sealMatch[1]);
58
- const sealInput = headerLine + "\n" + authLine + "\n" + chunkLines.join("\n");
428
+ const sealInput = `${headerLine}
429
+ ${authLine}
430
+ ${chunkLines.join("\n")}`;
59
431
  const sealData = new TextEncoder().encode(sealInput);
60
- const computed = hmac(sha256, headerKey, sealData);
432
+ const computed = hmac4(sha2565, headerKey, sealData);
61
433
  return constantTimeEqual(computed, storedHmac);
62
434
  } finally {
63
- zeroize(masterKey, headerKey, nonceKey);
435
+ zeroize(masterKey, encKey, headerKey, nonceKey);
64
436
  }
65
437
  }
66
438
 
67
- // src/git/init.ts
68
- import { readFileSync, writeFileSync, existsSync, chmodSync, unlinkSync } from "fs";
439
+ // src/git/diff-driver.ts
440
+ import { execFileSync } from "child_process";
441
+ import { mkdtempSync, rmSync, writeFileSync } from "fs";
442
+ import { tmpdir } from "os";
69
443
  import { join } from "path";
70
- var MARKER = "# mdenc-hook-marker";
71
- var HOOK_NAMES = [
72
- "pre-commit",
73
- "post-checkout",
74
- "post-merge",
75
- "post-rewrite"
76
- ];
77
- function hookBlock(hookName) {
78
- return `
79
- ${MARKER}
80
- if command -v mdenc >/dev/null 2>&1; then
81
- mdenc ${hookName}
82
- elif [ -x "./node_modules/.bin/mdenc" ]; then
83
- ./node_modules/.bin/mdenc ${hookName}
84
- else
85
- echo "mdenc: not found, skipping ${hookName} hook" >&2
86
- fi`;
87
- }
88
- function newHookScript(hookName) {
89
- return `#!/bin/sh${hookBlock(hookName)}
90
- `;
91
- }
92
- function isBinary(content) {
93
- return content.slice(0, 512).includes("\0");
94
- }
95
- function hasShellShebang(content) {
96
- const firstLine = content.split("\n")[0];
97
- return /^#!.*\b(sh|bash|zsh|dash)\b/.test(firstLine);
98
- }
99
- function looksLikeFrameworkHook(content) {
100
- const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
101
- if (lines.length <= 2) {
102
- return lines.some((l) => /^\.\s+"/.test(l.trim()) || /^exec\s+/.test(l.trim()));
103
- }
104
- return false;
105
- }
106
- function printManualInstructions(hookName) {
107
- process.stderr.write(
108
- `mdenc: ${hookName} hook exists but has an unrecognized format.
109
- Add the following to your hook manually:
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
+ }
110
493
 
111
- ${MARKER}
112
- if command -v mdenc >/dev/null 2>&1; then
113
- mdenc ${hookName}
114
- elif [ -x "./node_modules/.bin/mdenc" ]; then
115
- ./node_modules/.bin/mdenc ${hookName}
116
- fi
494
+ // src/git/password.ts
495
+ import { readFileSync } from "fs";
496
+ import { join as join2 } from "path";
497
+ var PASSWORD_FILE = ".mdenc-password";
498
+ function resolvePassword(repoRoot) {
499
+ const envPassword = process.env["MDENC_PASSWORD"];
500
+ if (envPassword) return envPassword;
501
+ try {
502
+ const content = readFileSync(join2(repoRoot, PASSWORD_FILE), "utf-8").trim();
503
+ if (content.length > 0) return content;
504
+ } catch {
505
+ }
506
+ return null;
507
+ }
117
508
 
118
- `
119
- );
509
+ // src/git/utils.ts
510
+ import { execFileSync as execFileSync2 } from "child_process";
511
+ import { readdirSync, statSync } from "fs";
512
+ import { join as join3 } from "path";
513
+ var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
514
+ var MARKER_FILE = ".mdenc.conf";
515
+ function findGitRoot() {
516
+ try {
517
+ return execFileSync2("git", ["rev-parse", "--show-toplevel"], {
518
+ encoding: "utf-8",
519
+ stdio: ["pipe", "pipe", "pipe"]
520
+ }).trim();
521
+ } catch {
522
+ throw new Error("Not a git repository");
523
+ }
120
524
  }
121
- async function initCommand() {
525
+ function gitShow(repoRoot, ref, path) {
526
+ try {
527
+ return execFileSync2("git", ["show", `${ref}:${path}`], {
528
+ cwd: repoRoot,
529
+ encoding: "utf-8",
530
+ stdio: ["pipe", "pipe", "pipe"]
531
+ });
532
+ } catch {
533
+ return void 0;
534
+ }
535
+ }
536
+ function findMarkedDirs(repoRoot) {
537
+ const results = [];
538
+ walkForMarker(repoRoot, results);
539
+ return results;
540
+ }
541
+ function walkForMarker(dir, results) {
542
+ let entries;
543
+ try {
544
+ entries = readdirSync(dir);
545
+ } catch {
546
+ return;
547
+ }
548
+ if (entries.includes(MARKER_FILE)) {
549
+ results.push(dir);
550
+ }
551
+ for (const entry of entries) {
552
+ if (SKIP_DIRS.has(entry) || entry.startsWith(".")) continue;
553
+ const full = join3(dir, entry);
554
+ try {
555
+ if (statSync(full).isDirectory()) {
556
+ walkForMarker(full, results);
557
+ }
558
+ } catch {
559
+ }
560
+ }
561
+ }
562
+ function getMdFilesInDir(dir) {
563
+ try {
564
+ return readdirSync(dir).filter((f) => f.endsWith(".md") && statSync(join3(dir, f)).isFile());
565
+ } catch {
566
+ return [];
567
+ }
568
+ }
569
+ function gitAdd(repoRoot, files) {
570
+ if (files.length === 0) return;
571
+ execFileSync2("git", ["add", "--", ...files], {
572
+ cwd: repoRoot,
573
+ stdio: ["pipe", "pipe", "pipe"]
574
+ });
575
+ }
576
+
577
+ // src/git/filter.ts
578
+ async function cleanFilter(pathname, plaintext, password, repoRoot) {
579
+ const previousFile = gitShow(repoRoot, "HEAD", pathname);
580
+ return encrypt(plaintext, password, previousFile ? { previousFile } : {});
581
+ }
582
+ async function smudgeFilter(content, password) {
583
+ if (!password || !content.startsWith("mdenc:v1")) {
584
+ return content;
585
+ }
586
+ try {
587
+ return await decrypt(content, password);
588
+ } catch {
589
+ return content;
590
+ }
591
+ }
592
+ async function simpleCleanFilter(pathname) {
593
+ const repoRoot = findGitRoot();
594
+ const password = resolvePassword(repoRoot);
595
+ if (!password) {
596
+ process.stderr.write("mdenc: no password available, cannot encrypt\n");
597
+ process.exit(1);
598
+ }
599
+ const input = await readStdin();
600
+ const encrypted = await cleanFilter(pathname, input, password, repoRoot);
601
+ process.stdout.write(encrypted);
602
+ }
603
+ async function simpleSmudgeFilter() {
122
604
  const repoRoot = findGitRoot();
123
- const hooksDir = getHooksDir();
124
- for (const hookName of HOOK_NAMES) {
125
- const hookPath = join(hooksDir, hookName);
126
- if (!existsSync(hookPath)) {
127
- writeFileSync(hookPath, newHookScript(hookName));
128
- chmodSync(hookPath, 493);
129
- console.log(`Installed ${hookName} hook`);
130
- continue;
605
+ const password = resolvePassword(repoRoot);
606
+ const input = await readStdin();
607
+ const output = await smudgeFilter(input, password);
608
+ process.stdout.write(output);
609
+ }
610
+ function readStdin() {
611
+ return new Promise((resolve2) => {
612
+ const chunks = [];
613
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
614
+ process.stdin.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
615
+ process.stdin.resume();
616
+ });
617
+ }
618
+
619
+ // src/git/filter-process.ts
620
+ var FLUSH = Buffer.from("0000", "ascii");
621
+ var MAX_PKT_DATA = 65516;
622
+ function writePktLine(data) {
623
+ const payload = Buffer.from(data, "utf-8");
624
+ const len = (payload.length + 4).toString(16).padStart(4, "0");
625
+ process.stdout.write(len, "ascii");
626
+ process.stdout.write(payload);
627
+ }
628
+ function writeFlush() {
629
+ process.stdout.write(FLUSH);
630
+ }
631
+ function writeBinaryPktLines(data) {
632
+ let offset = 0;
633
+ while (offset < data.length) {
634
+ const chunk = data.subarray(offset, offset + MAX_PKT_DATA);
635
+ const len = (chunk.length + 4).toString(16).padStart(4, "0");
636
+ process.stdout.write(len, "ascii");
637
+ process.stdout.write(chunk);
638
+ offset += chunk.length;
639
+ }
640
+ }
641
+ var PktLineReader = class {
642
+ buf = Buffer.alloc(0);
643
+ // biome-ignore lint/suspicious/noConfusingVoidType: standard Promise resolve signature
644
+ resolveWait = null;
645
+ ended = false;
646
+ constructor(stream) {
647
+ stream.on("data", (chunk) => {
648
+ this.buf = Buffer.concat([this.buf, chunk]);
649
+ if (this.resolveWait) {
650
+ const r = this.resolveWait;
651
+ this.resolveWait = null;
652
+ r();
653
+ }
654
+ });
655
+ stream.on("end", () => {
656
+ this.ended = true;
657
+ if (this.resolveWait) {
658
+ const r = this.resolveWait;
659
+ this.resolveWait = null;
660
+ r();
661
+ }
662
+ });
663
+ stream.resume();
664
+ }
665
+ async waitForData() {
666
+ if (this.buf.length > 0 || this.ended) return;
667
+ return new Promise((resolve2) => {
668
+ this.resolveWait = resolve2;
669
+ });
670
+ }
671
+ async readExact(n) {
672
+ while (this.buf.length < n) {
673
+ if (this.ended) return null;
674
+ await this.waitForData();
131
675
  }
132
- const content = readFileSync(hookPath, "utf-8");
133
- if (content.includes(MARKER)) {
134
- console.log(`${hookName} hook already installed (skipped)`);
135
- continue;
676
+ const result = this.buf.subarray(0, n);
677
+ this.buf = this.buf.subarray(n);
678
+ return result;
679
+ }
680
+ /** Read one pkt-line. Returns string for data, null for flush, undefined for EOF. */
681
+ async readPacket() {
682
+ const lenBuf = await this.readExact(4);
683
+ if (!lenBuf) return void 0;
684
+ const lenStr = lenBuf.toString("ascii");
685
+ const len = parseInt(lenStr, 16);
686
+ if (len === 0) return null;
687
+ if (len <= 4) throw new Error(`Invalid pkt-line length: ${len}`);
688
+ const payload = await this.readExact(len - 4);
689
+ if (!payload) throw new Error("Unexpected EOF in pkt-line payload");
690
+ return payload.toString("utf-8");
691
+ }
692
+ /** Read lines until flush. Returns array of strings (newlines stripped). */
693
+ async readUntilFlush() {
694
+ const lines = [];
695
+ while (true) {
696
+ const pkt = await this.readPacket();
697
+ if (pkt === null || pkt === void 0) break;
698
+ lines.push(pkt.replace(/\n$/, ""));
136
699
  }
137
- if (isBinary(content)) {
138
- process.stderr.write(
139
- `mdenc: ${hookName} hook appears to be a binary file. Skipping.
140
- `
141
- );
142
- printManualInstructions(hookName);
143
- continue;
700
+ return lines;
701
+ }
702
+ /** Read binary content until flush. Returns concatenated buffer. */
703
+ async readContentUntilFlush() {
704
+ const chunks = [];
705
+ while (true) {
706
+ const lenBuf = await this.readExact(4);
707
+ if (!lenBuf) break;
708
+ const len = parseInt(lenBuf.toString("ascii"), 16);
709
+ if (len === 0) break;
710
+ if (len <= 4) throw new Error(`Invalid pkt-line length: ${len}`);
711
+ const payload = await this.readExact(len - 4);
712
+ if (!payload) throw new Error("Unexpected EOF in pkt-line content");
713
+ chunks.push(payload);
144
714
  }
145
- if (!hasShellShebang(content)) {
146
- process.stderr.write(
147
- `mdenc: ${hookName} hook has no shell shebang. Skipping.
148
- `
149
- );
150
- printManualInstructions(hookName);
151
- continue;
715
+ return Buffer.concat(chunks);
716
+ }
717
+ };
718
+ async function filterProcessMain() {
719
+ const repoRoot = findGitRoot();
720
+ const password = resolvePassword(repoRoot);
721
+ const reader = new PktLineReader(process.stdin);
722
+ const welcome = await reader.readUntilFlush();
723
+ if (!welcome.includes("git-filter-client") || !welcome.includes("version=2")) {
724
+ process.stderr.write("mdenc: invalid filter protocol handshake\n");
725
+ process.exit(1);
726
+ }
727
+ writePktLine("git-filter-server\n");
728
+ writePktLine("version=2\n");
729
+ writeFlush();
730
+ const caps = await reader.readUntilFlush();
731
+ if (caps.includes("capability=clean")) writePktLine("capability=clean\n");
732
+ if (caps.includes("capability=smudge")) writePktLine("capability=smudge\n");
733
+ writeFlush();
734
+ while (true) {
735
+ const commandLines = await reader.readUntilFlush();
736
+ if (commandLines.length === 0) break;
737
+ let cmd = "";
738
+ let pathname = "";
739
+ for (const line of commandLines) {
740
+ if (line.startsWith("command=")) cmd = line.slice("command=".length);
741
+ if (line.startsWith("pathname=")) pathname = line.slice("pathname=".length);
152
742
  }
153
- if (looksLikeFrameworkHook(content)) {
743
+ const content = await reader.readContentUntilFlush();
744
+ const contentStr = content.toString("utf-8");
745
+ try {
746
+ let result;
747
+ if (cmd === "clean") {
748
+ if (!password) {
749
+ writePktLine("status=error\n");
750
+ writeFlush();
751
+ writeFlush();
752
+ continue;
753
+ }
754
+ result = await cleanFilter(pathname, contentStr, password, repoRoot);
755
+ } else if (cmd === "smudge") {
756
+ result = await smudgeFilter(contentStr, password);
757
+ } else {
758
+ process.stderr.write(`mdenc: unknown filter command: ${cmd}
759
+ `);
760
+ writePktLine("status=error\n");
761
+ writeFlush();
762
+ writeFlush();
763
+ continue;
764
+ }
765
+ const resultBuf = Buffer.from(result, "utf-8");
766
+ writePktLine("status=success\n");
767
+ writeFlush();
768
+ writeBinaryPktLines(resultBuf);
769
+ writeFlush();
770
+ writeFlush();
771
+ } catch (err) {
154
772
  process.stderr.write(
155
- `mdenc: ${hookName} hook appears to be managed by a framework. Skipping.
773
+ `mdenc: filter error for ${pathname}: ${err instanceof Error ? err.message : err}
156
774
  `
157
775
  );
158
- printManualInstructions(hookName);
159
- continue;
776
+ writePktLine("status=error\n");
777
+ writeFlush();
778
+ writeFlush();
160
779
  }
161
- writeFileSync(hookPath, content.trimEnd() + "\n" + hookBlock(hookName) + "\n");
162
- chmodSync(hookPath, 493);
163
- console.log(`Appended mdenc to existing ${hookName} hook`);
164
780
  }
165
- const gitignorePath = join(repoRoot, ".gitignore");
166
- const entry = ".mdenc-password";
781
+ }
782
+
783
+ // src/git/genpass.ts
784
+ import { existsSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
785
+ import { join as join4 } from "path";
786
+ import { randomBytes as randomBytes2 } from "@noble/ciphers/webcrypto";
787
+ var PASSWORD_FILE2 = ".mdenc-password";
788
+ function genpassCommand(force) {
789
+ const repoRoot = findGitRoot();
790
+ const passwordPath = join4(repoRoot, PASSWORD_FILE2);
791
+ if (existsSync(passwordPath) && !force) {
792
+ console.error(`${PASSWORD_FILE2} already exists. Use --force to overwrite.`);
793
+ process.exit(1);
794
+ }
795
+ const password = Buffer.from(randomBytes2(32)).toString("base64url");
796
+ writeFileSync2(passwordPath, `${password}
797
+ `, { mode: 384 });
798
+ console.error(`Generated password and wrote to ${PASSWORD_FILE2}`);
799
+ console.error(password);
800
+ const gitignorePath = join4(repoRoot, ".gitignore");
801
+ const entry = PASSWORD_FILE2;
167
802
  if (existsSync(gitignorePath)) {
168
- const content = readFileSync(gitignorePath, "utf-8");
803
+ const content = readFileSync2(gitignorePath, "utf-8");
804
+ const lines = content.split("\n").map((l) => l.trim());
805
+ if (!lines.includes(entry)) {
806
+ writeFileSync2(gitignorePath, `${content.trimEnd()}
807
+ ${entry}
808
+ `);
809
+ console.error("Added .mdenc-password to .gitignore");
810
+ }
811
+ } else {
812
+ writeFileSync2(gitignorePath, `${entry}
813
+ `);
814
+ console.error("Created .gitignore with .mdenc-password");
815
+ }
816
+ }
817
+
818
+ // src/git/init.ts
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";
822
+ var FILTER_CONFIGS = [
823
+ ["filter.mdenc.process", "mdenc filter-process"],
824
+ ["filter.mdenc.clean", "mdenc filter-clean %f"],
825
+ ["filter.mdenc.smudge", "mdenc filter-smudge %f"],
826
+ ["filter.mdenc.required", "true"],
827
+ ["diff.mdenc.command", "mdenc diff-driver"]
828
+ ];
829
+ function configureGitFilter(repoRoot) {
830
+ for (const [key, value] of FILTER_CONFIGS) {
831
+ execFileSync3("git", ["config", "--local", key, value], {
832
+ cwd: repoRoot,
833
+ stdio: ["pipe", "pipe", "pipe"]
834
+ });
835
+ }
836
+ }
837
+ function isFilterConfigured(repoRoot) {
838
+ try {
839
+ const val = execFileSync3("git", ["config", "--get", "filter.mdenc.process"], {
840
+ cwd: repoRoot,
841
+ encoding: "utf-8",
842
+ stdio: ["pipe", "pipe", "pipe"]
843
+ }).trim();
844
+ return val.length > 0;
845
+ } catch {
846
+ return false;
847
+ }
848
+ }
849
+ async function initCommand() {
850
+ const repoRoot = findGitRoot();
851
+ if (isFilterConfigured(repoRoot)) {
852
+ console.log("Git filter already configured (skipped)");
853
+ } else {
854
+ configureGitFilter(repoRoot);
855
+ console.log("Configured git filter (filter.mdenc + diff.mdenc)");
856
+ }
857
+ const gitignorePath = join5(repoRoot, ".gitignore");
858
+ const entry = ".mdenc-password";
859
+ if (existsSync2(gitignorePath)) {
860
+ const content = readFileSync3(gitignorePath, "utf-8");
169
861
  const lines = content.split("\n").map((l) => l.trim());
170
862
  if (!lines.includes(entry)) {
171
- writeFileSync(gitignorePath, content.trimEnd() + "\n" + entry + "\n");
863
+ writeFileSync3(gitignorePath, `${content.trimEnd()}
864
+ ${entry}
865
+ `);
172
866
  console.log("Added .mdenc-password to .gitignore");
173
867
  } else {
174
868
  console.log(".mdenc-password already in .gitignore (skipped)");
175
869
  }
176
870
  } else {
177
- writeFileSync(gitignorePath, entry + "\n");
871
+ writeFileSync3(gitignorePath, `${entry}
872
+ `);
178
873
  console.log("Created .gitignore with .mdenc-password");
179
874
  }
180
- const { decryptAll } = await import("./hooks-7DUHI6MG.js");
181
- const { decrypted } = await decryptAll();
182
- if (decrypted > 0) {
183
- console.log(`Decrypted ${decrypted} existing file(s)`);
875
+ const markedDirs = findMarkedDirs(repoRoot);
876
+ if (markedDirs.length > 0) {
877
+ const { relative: relative3 } = await import("path");
878
+ for (const dir of markedDirs) {
879
+ const relDir = relative3(repoRoot, dir) || ".";
880
+ try {
881
+ execFileSync3("git", ["checkout", "HEAD", "--", `${relDir}/*.md`], {
882
+ cwd: repoRoot,
883
+ stdio: ["pipe", "pipe", "pipe"]
884
+ });
885
+ } catch {
886
+ }
887
+ }
184
888
  }
185
889
  console.log("mdenc git integration initialized.");
186
890
  }
187
- function removeHooksCommand() {
188
- const hooksDir = getHooksDir();
189
- let removedCount = 0;
190
- for (const hookName of HOOK_NAMES) {
191
- const hookPath = join(hooksDir, hookName);
192
- if (!existsSync(hookPath)) continue;
193
- const content = readFileSync(hookPath, "utf-8");
194
- if (!content.includes(MARKER)) {
195
- console.log(`${hookName}: no mdenc block found (skipped)`);
196
- continue;
197
- }
198
- const lines = content.split("\n");
199
- const filtered = [];
200
- let inBlock = false;
201
- for (const line of lines) {
202
- if (line.trim() === MARKER) {
203
- inBlock = true;
204
- continue;
205
- }
206
- if (inBlock) {
207
- if (line.trim() === "fi") {
208
- inBlock = false;
209
- continue;
210
- }
211
- continue;
212
- }
213
- filtered.push(line);
214
- }
215
- const result = filtered.join("\n");
216
- const isEmpty = result.split("\n").every((l) => l.trim() === "" || l.startsWith("#!"));
217
- if (isEmpty) {
218
- unlinkSync(hookPath);
219
- console.log(`Removed ${hookName} hook (was mdenc-only)`);
220
- } else {
221
- writeFileSync(hookPath, result);
222
- console.log(`Removed mdenc block from ${hookName} hook`);
891
+ function removeFilterCommand() {
892
+ const repoRoot = findGitRoot();
893
+ for (const section of ["filter.mdenc", "diff.mdenc"]) {
894
+ try {
895
+ execFileSync3("git", ["config", "--local", "--remove-section", section], {
896
+ cwd: repoRoot,
897
+ stdio: ["pipe", "pipe", "pipe"]
898
+ });
899
+ } catch {
223
900
  }
224
- removedCount++;
225
- }
226
- if (removedCount === 0) {
227
- console.log("No mdenc hooks found to remove.");
228
- } else {
229
- console.log("mdenc hooks removed.");
230
901
  }
902
+ console.log("Removed git filter configuration.");
231
903
  }
232
904
 
233
905
  // src/git/mark.ts
234
- import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
235
- import { join as join2, relative, resolve } from "path";
236
- var MARKER_FILE = ".mdenc.conf";
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";
909
+ var MARKER_FILE2 = ".mdenc.conf";
237
910
  var MARKER_CONTENT = "# mdenc: .md files in this directory are automatically encrypted\n";
238
- var GITIGNORE_PATTERN = "*.md";
911
+ var GITATTR_PATTERN = "*.md filter=mdenc diff=mdenc";
912
+ function isFilterConfigured2(repoRoot) {
913
+ try {
914
+ const val = execFileSync4("git", ["config", "--get", "filter.mdenc.process"], {
915
+ cwd: repoRoot,
916
+ encoding: "utf-8",
917
+ stdio: ["pipe", "pipe", "pipe"]
918
+ }).trim();
919
+ return val.length > 0;
920
+ } catch {
921
+ return false;
922
+ }
923
+ }
924
+ function markUsage() {
925
+ console.error(`Usage: mdenc mark <directory>
926
+
927
+ Mark a directory so that all *.md files inside it are automatically
928
+ encrypted when staged (git add) and decrypted when checked out.
929
+
930
+ What it does:
931
+ 1. Creates <directory>/.mdenc.conf Marker file for mdenc to discover
932
+ 2. Creates <directory>/.gitattributes Assigns the mdenc filter to *.md files
933
+ 3. Stages both files in git
934
+
935
+ Prerequisites:
936
+ Run "mdenc init" first to configure the git filter in this clone.
937
+ Run "mdenc genpass" to generate a password (or set MDENC_PASSWORD).
938
+
939
+ Example:
940
+ mdenc init
941
+ mdenc genpass
942
+ mdenc mark docs/private
943
+ echo "# Secret" > docs/private/notes.md
944
+ git add docs/private/notes.md # encrypted automatically`);
945
+ process.exit(1);
946
+ }
239
947
  function markCommand(dirArg) {
948
+ if (dirArg === "--help" || dirArg === "-h") {
949
+ markUsage();
950
+ }
240
951
  const repoRoot = findGitRoot();
241
952
  const dir = resolve(dirArg);
242
- if (!existsSync2(dir)) {
953
+ if (!existsSync3(dir)) {
243
954
  console.error(`Error: directory "${dirArg}" does not exist`);
244
955
  process.exit(1);
245
956
  }
@@ -249,54 +960,62 @@ function markCommand(dirArg) {
249
960
  process.exit(1);
250
961
  }
251
962
  const relDir = rel || ".";
252
- const confPath = join2(dir, MARKER_FILE);
253
- if (!existsSync2(confPath)) {
254
- writeFileSync2(confPath, MARKER_CONTENT);
255
- console.log(`Created ${relDir}/${MARKER_FILE}`);
963
+ const filterReady = isFilterConfigured2(repoRoot);
964
+ const confPath = join6(dir, MARKER_FILE2);
965
+ if (!existsSync3(confPath)) {
966
+ writeFileSync4(confPath, MARKER_CONTENT);
967
+ console.log(`Created ${relDir}/${MARKER_FILE2}`);
256
968
  } else {
257
- console.log(`${relDir}/${MARKER_FILE} already exists (skipped)`);
969
+ console.log(`${relDir}/${MARKER_FILE2} already exists (skipped)`);
258
970
  }
259
- const gitignorePath = join2(dir, ".gitignore");
260
- if (existsSync2(gitignorePath)) {
261
- const content = readFileSync2(gitignorePath, "utf-8");
262
- const lines = content.split("\n").map((l) => l.trim());
263
- if (!lines.includes(GITIGNORE_PATTERN)) {
264
- writeFileSync2(gitignorePath, content.trimEnd() + "\n" + GITIGNORE_PATTERN + "\n");
265
- console.log(`Updated ${relDir}/.gitignore (added ${GITIGNORE_PATTERN})`);
971
+ const gitattrsPath = join6(dir, ".gitattributes");
972
+ if (existsSync3(gitattrsPath)) {
973
+ const content = readFileSync4(gitattrsPath, "utf-8");
974
+ if (content.includes("filter=mdenc")) {
975
+ console.log(`${relDir}/.gitattributes already has filter=mdenc (skipped)`);
266
976
  } else {
267
- console.log(`${relDir}/.gitignore already has ${GITIGNORE_PATTERN} (skipped)`);
977
+ writeFileSync4(gitattrsPath, `${content.trimEnd()}
978
+ ${GITATTR_PATTERN}
979
+ `);
980
+ console.log(`Updated ${relDir}/.gitattributes`);
268
981
  }
269
982
  } else {
270
- writeFileSync2(gitignorePath, GITIGNORE_PATTERN + "\n");
271
- console.log(`Created ${relDir}/.gitignore with ${GITIGNORE_PATTERN}`);
272
- }
273
- const mdFiles = getMdFilesInDir(dir);
274
- const trackedMd = [];
275
- for (const f of mdFiles) {
276
- const relPath = relative(repoRoot, join2(dir, f));
277
- if (isFileTracked(repoRoot, relPath)) {
278
- trackedMd.push(relPath);
279
- }
280
- }
281
- if (trackedMd.length > 0) {
282
- gitRmCached(repoRoot, trackedMd);
283
- for (const f of trackedMd) {
284
- console.log(`Untracked ${f} from git (still exists locally)`);
285
- }
983
+ writeFileSync4(gitattrsPath, `${GITATTR_PATTERN}
984
+ `);
985
+ console.log(`Created ${relDir}/.gitattributes`);
286
986
  }
287
- const toStage = [
288
- relative(repoRoot, confPath),
289
- relative(repoRoot, gitignorePath)
290
- ];
987
+ const toStage = [relative(repoRoot, confPath), relative(repoRoot, gitattrsPath)];
291
988
  gitAdd(repoRoot, toStage);
292
989
  console.log(`Marked ${relDir}/ for mdenc encryption`);
990
+ if (!filterReady) {
991
+ console.log(`
992
+ Warning: git filter not configured yet. Run "mdenc init" to enable encryption.`);
993
+ }
293
994
  }
294
995
 
295
996
  // src/git/status.ts
296
- import { existsSync as existsSync3, readFileSync as readFileSync3, statSync } from "fs";
297
- import { join as join3, relative as relative2 } from "path";
298
- var HOOK_NAMES2 = ["pre-commit", "post-checkout", "post-merge", "post-rewrite"];
299
- var MARKER2 = "# mdenc-hook-marker";
997
+ import { execFileSync as execFileSync5 } from "child_process";
998
+ import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
999
+ import { join as join7, relative as relative2 } from "path";
1000
+ function getFilterConfig(repoRoot) {
1001
+ const get = (key) => {
1002
+ try {
1003
+ return execFileSync5("git", ["config", "--get", key], {
1004
+ cwd: repoRoot,
1005
+ encoding: "utf-8",
1006
+ stdio: ["pipe", "pipe", "pipe"]
1007
+ }).trim();
1008
+ } catch {
1009
+ return null;
1010
+ }
1011
+ };
1012
+ return {
1013
+ process: get("filter.mdenc.process"),
1014
+ clean: get("filter.mdenc.clean"),
1015
+ smudge: get("filter.mdenc.smudge"),
1016
+ required: get("filter.mdenc.required") === "true"
1017
+ };
1018
+ }
300
1019
  function statusCommand() {
301
1020
  const repoRoot = findGitRoot();
302
1021
  const password = resolvePassword(repoRoot);
@@ -310,36 +1029,24 @@ function statusCommand() {
310
1029
  const relDir = relative2(repoRoot, dir) || ".";
311
1030
  console.log(` ${relDir}/`);
312
1031
  const mdFiles = getMdFilesInDir(dir);
313
- const mdencFiles = getMdencFilesInDir(dir);
314
- const mdBases = new Set(mdFiles.map((f) => f.replace(/\.md$/, "")));
315
- const mdencBases = new Set(mdencFiles.map((f) => f.replace(/\.mdenc$/, "")));
316
- for (const base of mdBases) {
317
- if (mdencBases.has(base)) {
318
- const mdPath = join3(dir, `${base}.md`);
319
- const mdencPath = join3(dir, `${base}.mdenc`);
320
- const mdMtime = statSync(mdPath).mtimeMs;
321
- const mdencMtime = statSync(mdencPath).mtimeMs;
322
- if (mdMtime > mdencMtime) {
323
- console.log(` ${base}.md [needs re-encryption]`);
324
- } else {
325
- console.log(` ${base}.md [up to date]`);
326
- }
1032
+ for (const f of mdFiles) {
1033
+ const content = readFileSync5(join7(dir, f), "utf-8");
1034
+ if (content.startsWith("mdenc:v1")) {
1035
+ console.log(` ${f} [encrypted \u2014 needs smudge]`);
327
1036
  } else {
328
- console.log(` ${base}.md [not yet encrypted]`);
1037
+ console.log(` ${f} [plaintext]`);
329
1038
  }
330
1039
  }
331
- for (const base of mdencBases) {
332
- if (!mdBases.has(base)) {
333
- console.log(` ${base}.mdenc [needs decryption]`);
334
- }
1040
+ if (mdFiles.length === 0) {
1041
+ console.log(" (no .md files)");
335
1042
  }
336
- const gitignorePath = join3(dir, ".gitignore");
337
- if (!existsSync3(gitignorePath)) {
338
- console.log(` WARNING: no .gitignore in this directory`);
1043
+ const gitattrsPath = join7(dir, ".gitattributes");
1044
+ if (!existsSync4(gitattrsPath)) {
1045
+ console.log(" WARNING: no .gitattributes in this directory");
339
1046
  } else {
340
- const content = readFileSync3(gitignorePath, "utf-8");
341
- if (!content.split("\n").some((l) => l.trim() === "*.md")) {
342
- console.log(` WARNING: .gitignore missing *.md pattern`);
1047
+ const content = readFileSync5(gitattrsPath, "utf-8");
1048
+ if (!content.includes("filter=mdenc")) {
1049
+ console.log(" WARNING: .gitattributes missing filter=mdenc pattern");
343
1050
  }
344
1051
  }
345
1052
  console.log();
@@ -351,127 +1058,37 @@ function statusCommand() {
351
1058
  } else {
352
1059
  console.log("Password: available");
353
1060
  }
354
- const hooksDir = getHooksDir();
355
- const missing = [];
356
- for (const name of HOOK_NAMES2) {
357
- const hookPath = join3(hooksDir, name);
358
- if (!existsSync3(hookPath)) {
359
- missing.push(name);
360
- } else {
361
- const content = readFileSync3(hookPath, "utf-8");
362
- if (!content.includes(MARKER2)) {
363
- missing.push(name);
364
- }
365
- }
366
- }
367
- if (missing.length > 0) {
368
- console.log(`Hooks: MISSING (${missing.join(", ")})`);
369
- console.log(' Run "mdenc init" to install hooks');
1061
+ const config = getFilterConfig(repoRoot);
1062
+ if (!config.process && !config.clean) {
1063
+ console.log("Filter: NOT CONFIGURED");
1064
+ console.log(' Run "mdenc init" to configure');
370
1065
  } else {
371
- console.log("Hooks: all installed");
1066
+ console.log("Filter: configured");
1067
+ if (!config.required) {
1068
+ console.log(" WARNING: filter.mdenc.required is not set to true");
1069
+ }
372
1070
  }
373
1071
  }
374
1072
 
375
- // src/git/watch.ts
376
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync4 } from "fs";
377
- import { join as join4, relative as relative3 } from "path";
378
- import { watch } from "chokidar";
379
- async function watchCommand() {
1073
+ // src/git/textconv.ts
1074
+ import { readFileSync as readFileSync6 } from "fs";
1075
+ async function textconvCommand(filePath) {
1076
+ const content = readFileSync6(filePath, "utf-8");
1077
+ if (!content.startsWith("mdenc:v1")) {
1078
+ process.stdout.write(content);
1079
+ return;
1080
+ }
380
1081
  const repoRoot = findGitRoot();
381
1082
  const password = resolvePassword(repoRoot);
382
1083
  if (!password) {
383
- console.error(
384
- "mdenc: no password available (set MDENC_PASSWORD or create .mdenc-password)"
385
- );
386
- process.exit(1);
1084
+ process.stdout.write(content);
1085
+ return;
387
1086
  }
388
- const markedDirs = findMarkedDirs(repoRoot);
389
- if (markedDirs.length === 0) {
390
- console.error("mdenc: no marked directories found");
391
- process.exit(1);
392
- }
393
- for (const dir of markedDirs) {
394
- const mdFiles = getMdFilesInDir(dir);
395
- for (const mdFile of mdFiles) {
396
- await encryptFile(dir, mdFile, repoRoot, password);
397
- }
398
- }
399
- const pending = /* @__PURE__ */ new Set();
400
- const watcher = watch(
401
- markedDirs.map((dir) => join4(dir, "*.md")),
402
- { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 100 } }
403
- );
404
- watcher.on("change", (filePath) => {
405
- handleFileEvent(filePath, repoRoot, password, pending);
406
- });
407
- watcher.on("add", (filePath) => {
408
- handleFileEvent(filePath, repoRoot, password, pending);
409
- });
410
- for (const dir of markedDirs) {
411
- const relDir = relative3(repoRoot, dir) || ".";
412
- console.error(`mdenc: watching ${relDir}/`);
413
- }
414
- console.error("mdenc: watching for changes (Ctrl+C to stop)");
415
- }
416
- function handleFileEvent(filePath, repoRoot, password, pending) {
417
- if (pending.has(filePath)) return;
418
- pending.add(filePath);
419
- const dir = join4(filePath, "..");
420
- const filename = filePath.slice(dir.length + 1);
421
- encryptFile(dir, filename, repoRoot, password).finally(() => {
422
- pending.delete(filePath);
423
- });
424
- }
425
- async function encryptFile(dir, mdFile, repoRoot, password) {
426
- const mdPath = join4(dir, mdFile);
427
- const mdencPath = mdPath.replace(/\.md$/, ".mdenc");
428
- const relMdPath = relative3(repoRoot, mdPath);
429
- if (!existsSync4(mdPath)) return;
430
1087
  try {
431
- const plaintext = readFileSync4(mdPath, "utf-8");
432
- let previousFile;
433
- if (existsSync4(mdencPath)) {
434
- previousFile = readFileSync4(mdencPath, "utf-8");
435
- }
436
- const encrypted = await encrypt(plaintext, password, { previousFile });
437
- if (encrypted === previousFile) return;
438
- writeFileSync3(mdencPath, encrypted);
439
- console.error(`mdenc: encrypted ${relMdPath}`);
440
- } catch (err) {
441
- console.error(
442
- `mdenc: failed to encrypt ${relMdPath}: ${err instanceof Error ? err.message : err}`
443
- );
444
- }
445
- }
446
-
447
- // src/git/genpass.ts
448
- import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
449
- import { join as join5 } from "path";
450
- import { randomBytes } from "@noble/ciphers/webcrypto";
451
- var PASSWORD_FILE = ".mdenc-password";
452
- function genpassCommand(force) {
453
- const repoRoot = findGitRoot();
454
- const passwordPath = join5(repoRoot, PASSWORD_FILE);
455
- if (existsSync5(passwordPath) && !force) {
456
- console.error(`${PASSWORD_FILE} already exists. Use --force to overwrite.`);
457
- process.exit(1);
458
- }
459
- const password = Buffer.from(randomBytes(32)).toString("base64url");
460
- writeFileSync4(passwordPath, password + "\n", { mode: 384 });
461
- console.error(`Generated password and wrote to ${PASSWORD_FILE}`);
462
- console.error(password);
463
- const gitignorePath = join5(repoRoot, ".gitignore");
464
- const entry = PASSWORD_FILE;
465
- if (existsSync5(gitignorePath)) {
466
- const content = readFileSync5(gitignorePath, "utf-8");
467
- const lines = content.split("\n").map((l) => l.trim());
468
- if (!lines.includes(entry)) {
469
- writeFileSync4(gitignorePath, content.trimEnd() + "\n" + entry + "\n");
470
- console.error("Added .mdenc-password to .gitignore");
471
- }
472
- } else {
473
- writeFileSync4(gitignorePath, entry + "\n");
474
- console.error("Created .gitignore with .mdenc-password");
1088
+ const plaintext = await decrypt(content, password);
1089
+ process.stdout.write(plaintext);
1090
+ } catch {
1091
+ process.stdout.write(content);
475
1092
  }
476
1093
  }
477
1094
 
@@ -544,25 +1161,39 @@ async function getPasswordWithConfirmation() {
544
1161
  }
545
1162
  return password;
546
1163
  }
547
- function usage() {
548
- console.error(`Usage:
1164
+ var USAGE = `Usage:
549
1165
  mdenc encrypt <file> [-o output] Encrypt a markdown file
550
1166
  mdenc decrypt <file> [-o output] Decrypt an mdenc file
551
1167
  mdenc verify <file> Verify file integrity
552
1168
 
553
1169
  Git integration:
554
- mdenc init Set up git hooks for automatic encryption
1170
+ mdenc init Set up git filter for automatic encryption
555
1171
  mdenc mark <directory> Mark a directory for encryption
556
1172
  mdenc status Show encryption status
557
- mdenc watch Watch and encrypt on file changes
558
- mdenc remove-hooks Remove mdenc git hooks
559
- mdenc genpass [--force] Generate a random password into .mdenc-password`);
560
- process.exit(1);
1173
+ mdenc remove-filter Remove git filter configuration
1174
+ mdenc genpass [--force] Generate a random password into .mdenc-password
1175
+
1176
+ Internal (called by git):
1177
+ mdenc filter-process Long-running filter process
1178
+ mdenc filter-clean <path> Single-file clean filter
1179
+ mdenc filter-smudge <path> Single-file smudge filter
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);
561
1185
  }
562
1186
  async function main() {
563
1187
  const args = process.argv.slice(2);
564
1188
  if (args.length === 0) usage();
565
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
+ }
566
1197
  try {
567
1198
  switch (command) {
568
1199
  case "encrypt": {
@@ -571,7 +1202,7 @@ async function main() {
571
1202
  const outputIdx = args.indexOf("-o");
572
1203
  const outputFile = outputIdx >= 0 ? args[outputIdx + 1] : void 0;
573
1204
  const password = await getPasswordWithConfirmation();
574
- const plaintext = readFileSync6(inputFile, "utf-8");
1205
+ const plaintext = readFileSync7(inputFile, "utf-8");
575
1206
  const encrypted = await encrypt(plaintext, password);
576
1207
  if (outputFile) {
577
1208
  writeFileSync5(outputFile, encrypted);
@@ -586,7 +1217,7 @@ async function main() {
586
1217
  const outputIdx = args.indexOf("-o");
587
1218
  const outputFile = outputIdx >= 0 ? args[outputIdx + 1] : void 0;
588
1219
  const password = await getPassword();
589
- const fileContent = readFileSync6(inputFile, "utf-8");
1220
+ const fileContent = readFileSync7(inputFile, "utf-8");
590
1221
  const decrypted = await decrypt(fileContent, password);
591
1222
  if (outputFile) {
592
1223
  writeFileSync5(outputFile, decrypted);
@@ -599,7 +1230,7 @@ async function main() {
599
1230
  if (!args[1]) usage();
600
1231
  const inputFile = args[1];
601
1232
  const password = await getPassword();
602
- const fileContent = readFileSync6(inputFile, "utf-8");
1233
+ const fileContent = readFileSync7(inputFile, "utf-8");
603
1234
  const valid = await verifySeal(fileContent, password);
604
1235
  if (valid) {
605
1236
  console.error("Seal verified: OK");
@@ -615,7 +1246,7 @@ async function main() {
615
1246
  break;
616
1247
  case "mark": {
617
1248
  if (!args[1]) {
618
- console.error("Usage: mdenc mark <directory>");
1249
+ console.error('Usage: mdenc mark <directory>\n\nRun "mdenc mark --help" for details.');
619
1250
  process.exit(1);
620
1251
  }
621
1252
  markCommand(args[1]);
@@ -624,27 +1255,31 @@ async function main() {
624
1255
  case "status":
625
1256
  statusCommand();
626
1257
  break;
627
- case "watch":
628
- await watchCommand();
629
- break;
630
- case "remove-hooks":
631
- removeHooksCommand();
1258
+ case "remove-filter":
1259
+ removeFilterCommand();
632
1260
  break;
633
1261
  case "genpass":
634
1262
  genpassCommand(args.includes("--force"));
635
1263
  break;
636
- // Git hook handlers (called by hook scripts, not directly by user)
637
- case "pre-commit":
638
- await preCommitHook();
1264
+ // Git filter commands (called by git, not directly by user)
1265
+ case "filter-process":
1266
+ await filterProcessMain();
639
1267
  break;
640
- case "post-checkout":
641
- await postCheckoutHook();
1268
+ case "filter-clean":
1269
+ await simpleCleanFilter(args[1] ?? "");
642
1270
  break;
643
- case "post-merge":
644
- await postMergeHook();
1271
+ case "filter-smudge":
1272
+ await simpleSmudgeFilter();
645
1273
  break;
646
- case "post-rewrite":
647
- await postRewriteHook();
1274
+ case "diff-driver":
1275
+ await diffDriverCommand(args.slice(1));
1276
+ break;
1277
+ case "textconv":
1278
+ if (!args[1]) {
1279
+ console.error("Usage: mdenc textconv <file>");
1280
+ process.exit(1);
1281
+ }
1282
+ await textconvCommand(args[1]);
648
1283
  break;
649
1284
  default:
650
1285
  console.error(`Unknown command: ${command}`);