mdenc 0.1.3 → 0.1.5

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 writeFileSync4 } 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,480 @@ 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/password.ts
440
+ import { readFileSync } from "fs";
69
441
  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:
442
+ var PASSWORD_FILE = ".mdenc-password";
443
+ function resolvePassword(repoRoot) {
444
+ const envPassword = process.env["MDENC_PASSWORD"];
445
+ if (envPassword) return envPassword;
446
+ try {
447
+ const content = readFileSync(join(repoRoot, PASSWORD_FILE), "utf-8").trim();
448
+ if (content.length > 0) return content;
449
+ } catch {
450
+ }
451
+ return null;
452
+ }
110
453
 
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
454
+ // src/git/utils.ts
455
+ import { execFileSync } from "child_process";
456
+ import { readdirSync, statSync } from "fs";
457
+ import { join as join2 } from "path";
458
+ var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
459
+ var MARKER_FILE = ".mdenc.conf";
460
+ function findGitRoot() {
461
+ try {
462
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
463
+ encoding: "utf-8",
464
+ stdio: ["pipe", "pipe", "pipe"]
465
+ }).trim();
466
+ } catch {
467
+ throw new Error("Not a git repository");
468
+ }
469
+ }
470
+ function gitShow(repoRoot, ref, path) {
471
+ try {
472
+ return execFileSync("git", ["show", `${ref}:${path}`], {
473
+ cwd: repoRoot,
474
+ encoding: "utf-8",
475
+ stdio: ["pipe", "pipe", "pipe"]
476
+ });
477
+ } catch {
478
+ return void 0;
479
+ }
480
+ }
481
+ function findMarkedDirs(repoRoot) {
482
+ const results = [];
483
+ walkForMarker(repoRoot, results);
484
+ return results;
485
+ }
486
+ function walkForMarker(dir, results) {
487
+ let entries;
488
+ try {
489
+ entries = readdirSync(dir);
490
+ } catch {
491
+ return;
492
+ }
493
+ if (entries.includes(MARKER_FILE)) {
494
+ results.push(dir);
495
+ }
496
+ for (const entry of entries) {
497
+ if (SKIP_DIRS.has(entry) || entry.startsWith(".")) continue;
498
+ const full = join2(dir, entry);
499
+ try {
500
+ if (statSync(full).isDirectory()) {
501
+ walkForMarker(full, results);
502
+ }
503
+ } catch {
504
+ }
505
+ }
506
+ }
507
+ function getMdFilesInDir(dir) {
508
+ try {
509
+ return readdirSync(dir).filter((f) => f.endsWith(".md") && statSync(join2(dir, f)).isFile());
510
+ } catch {
511
+ return [];
512
+ }
513
+ }
514
+ function gitAdd(repoRoot, files) {
515
+ if (files.length === 0) return;
516
+ execFileSync("git", ["add", "--", ...files], {
517
+ cwd: repoRoot,
518
+ stdio: ["pipe", "pipe", "pipe"]
519
+ });
520
+ }
117
521
 
118
- `
119
- );
522
+ // src/git/filter.ts
523
+ async function cleanFilter(pathname, plaintext, password, repoRoot) {
524
+ const previousFile = gitShow(repoRoot, "HEAD", pathname);
525
+ return encrypt(plaintext, password, previousFile ? { previousFile } : {});
120
526
  }
121
- async function initCommand() {
527
+ async function smudgeFilter(content, password) {
528
+ if (!password || !content.startsWith("mdenc:v1")) {
529
+ return content;
530
+ }
531
+ try {
532
+ return await decrypt(content, password);
533
+ } catch {
534
+ return content;
535
+ }
536
+ }
537
+ async function simpleCleanFilter(pathname) {
122
538
  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;
539
+ const password = resolvePassword(repoRoot);
540
+ if (!password) {
541
+ process.stderr.write("mdenc: no password available, cannot encrypt\n");
542
+ process.exit(1);
543
+ }
544
+ const input = await readStdin();
545
+ const encrypted = await cleanFilter(pathname, input, password, repoRoot);
546
+ process.stdout.write(encrypted);
547
+ }
548
+ async function simpleSmudgeFilter() {
549
+ const repoRoot = findGitRoot();
550
+ const password = resolvePassword(repoRoot);
551
+ const input = await readStdin();
552
+ const output = await smudgeFilter(input, password);
553
+ process.stdout.write(output);
554
+ }
555
+ function readStdin() {
556
+ return new Promise((resolve2) => {
557
+ const chunks = [];
558
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
559
+ process.stdin.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
560
+ process.stdin.resume();
561
+ });
562
+ }
563
+
564
+ // src/git/filter-process.ts
565
+ var FLUSH = Buffer.from("0000", "ascii");
566
+ var MAX_PKT_DATA = 65516;
567
+ function writePktLine(data) {
568
+ const payload = Buffer.from(data, "utf-8");
569
+ const len = (payload.length + 4).toString(16).padStart(4, "0");
570
+ process.stdout.write(len, "ascii");
571
+ process.stdout.write(payload);
572
+ }
573
+ function writeFlush() {
574
+ process.stdout.write(FLUSH);
575
+ }
576
+ function writeBinaryPktLines(data) {
577
+ let offset = 0;
578
+ while (offset < data.length) {
579
+ const chunk = data.subarray(offset, offset + MAX_PKT_DATA);
580
+ const len = (chunk.length + 4).toString(16).padStart(4, "0");
581
+ process.stdout.write(len, "ascii");
582
+ process.stdout.write(chunk);
583
+ offset += chunk.length;
584
+ }
585
+ }
586
+ var PktLineReader = class {
587
+ buf = Buffer.alloc(0);
588
+ // biome-ignore lint/suspicious/noConfusingVoidType: standard Promise resolve signature
589
+ resolveWait = null;
590
+ ended = false;
591
+ constructor(stream) {
592
+ stream.on("data", (chunk) => {
593
+ this.buf = Buffer.concat([this.buf, chunk]);
594
+ if (this.resolveWait) {
595
+ const r = this.resolveWait;
596
+ this.resolveWait = null;
597
+ r();
598
+ }
599
+ });
600
+ stream.on("end", () => {
601
+ this.ended = true;
602
+ if (this.resolveWait) {
603
+ const r = this.resolveWait;
604
+ this.resolveWait = null;
605
+ r();
606
+ }
607
+ });
608
+ stream.resume();
609
+ }
610
+ async waitForData() {
611
+ if (this.buf.length > 0 || this.ended) return;
612
+ return new Promise((resolve2) => {
613
+ this.resolveWait = resolve2;
614
+ });
615
+ }
616
+ async readExact(n) {
617
+ while (this.buf.length < n) {
618
+ if (this.ended) return null;
619
+ await this.waitForData();
131
620
  }
132
- const content = readFileSync(hookPath, "utf-8");
133
- if (content.includes(MARKER)) {
134
- console.log(`${hookName} hook already installed (skipped)`);
135
- continue;
621
+ const result = this.buf.subarray(0, n);
622
+ this.buf = this.buf.subarray(n);
623
+ return result;
624
+ }
625
+ /** Read one pkt-line. Returns string for data, null for flush, undefined for EOF. */
626
+ async readPacket() {
627
+ const lenBuf = await this.readExact(4);
628
+ if (!lenBuf) return void 0;
629
+ const lenStr = lenBuf.toString("ascii");
630
+ const len = parseInt(lenStr, 16);
631
+ if (len === 0) return null;
632
+ if (len <= 4) throw new Error(`Invalid pkt-line length: ${len}`);
633
+ const payload = await this.readExact(len - 4);
634
+ if (!payload) throw new Error("Unexpected EOF in pkt-line payload");
635
+ return payload.toString("utf-8");
636
+ }
637
+ /** Read lines until flush. Returns array of strings (newlines stripped). */
638
+ async readUntilFlush() {
639
+ const lines = [];
640
+ while (true) {
641
+ const pkt = await this.readPacket();
642
+ if (pkt === null || pkt === void 0) break;
643
+ lines.push(pkt.replace(/\n$/, ""));
136
644
  }
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;
645
+ return lines;
646
+ }
647
+ /** Read binary content until flush. Returns concatenated buffer. */
648
+ async readContentUntilFlush() {
649
+ const chunks = [];
650
+ while (true) {
651
+ const lenBuf = await this.readExact(4);
652
+ if (!lenBuf) break;
653
+ const len = parseInt(lenBuf.toString("ascii"), 16);
654
+ if (len === 0) break;
655
+ if (len <= 4) throw new Error(`Invalid pkt-line length: ${len}`);
656
+ const payload = await this.readExact(len - 4);
657
+ if (!payload) throw new Error("Unexpected EOF in pkt-line content");
658
+ chunks.push(payload);
144
659
  }
145
- if (!hasShellShebang(content)) {
146
- process.stderr.write(
147
- `mdenc: ${hookName} hook has no shell shebang. Skipping.
148
- `
149
- );
150
- printManualInstructions(hookName);
151
- continue;
660
+ return Buffer.concat(chunks);
661
+ }
662
+ };
663
+ async function filterProcessMain() {
664
+ const repoRoot = findGitRoot();
665
+ const password = resolvePassword(repoRoot);
666
+ const reader = new PktLineReader(process.stdin);
667
+ const welcome = await reader.readUntilFlush();
668
+ if (!welcome.includes("git-filter-client") || !welcome.includes("version=2")) {
669
+ process.stderr.write("mdenc: invalid filter protocol handshake\n");
670
+ process.exit(1);
671
+ }
672
+ writePktLine("git-filter-server\n");
673
+ writePktLine("version=2\n");
674
+ writeFlush();
675
+ const caps = await reader.readUntilFlush();
676
+ if (caps.includes("capability=clean")) writePktLine("capability=clean\n");
677
+ if (caps.includes("capability=smudge")) writePktLine("capability=smudge\n");
678
+ writeFlush();
679
+ while (true) {
680
+ const commandLines = await reader.readUntilFlush();
681
+ if (commandLines.length === 0) break;
682
+ let cmd = "";
683
+ let pathname = "";
684
+ for (const line of commandLines) {
685
+ if (line.startsWith("command=")) cmd = line.slice("command=".length);
686
+ if (line.startsWith("pathname=")) pathname = line.slice("pathname=".length);
152
687
  }
153
- if (looksLikeFrameworkHook(content)) {
688
+ const content = await reader.readContentUntilFlush();
689
+ const contentStr = content.toString("utf-8");
690
+ try {
691
+ let result;
692
+ if (cmd === "clean") {
693
+ if (!password) {
694
+ writePktLine("status=error\n");
695
+ writeFlush();
696
+ writeFlush();
697
+ continue;
698
+ }
699
+ result = await cleanFilter(pathname, contentStr, password, repoRoot);
700
+ } else if (cmd === "smudge") {
701
+ result = await smudgeFilter(contentStr, password);
702
+ } else {
703
+ process.stderr.write(`mdenc: unknown filter command: ${cmd}
704
+ `);
705
+ writePktLine("status=error\n");
706
+ writeFlush();
707
+ writeFlush();
708
+ continue;
709
+ }
710
+ const resultBuf = Buffer.from(result, "utf-8");
711
+ writePktLine("status=success\n");
712
+ writeFlush();
713
+ writeBinaryPktLines(resultBuf);
714
+ writeFlush();
715
+ } catch (err) {
154
716
  process.stderr.write(
155
- `mdenc: ${hookName} hook appears to be managed by a framework. Skipping.
717
+ `mdenc: filter error for ${pathname}: ${err instanceof Error ? err.message : err}
156
718
  `
157
719
  );
158
- printManualInstructions(hookName);
159
- continue;
720
+ writePktLine("status=error\n");
721
+ writeFlush();
722
+ writeFlush();
160
723
  }
161
- writeFileSync(hookPath, content.trimEnd() + "\n" + hookBlock(hookName) + "\n");
162
- chmodSync(hookPath, 493);
163
- console.log(`Appended mdenc to existing ${hookName} hook`);
164
724
  }
165
- const gitignorePath = join(repoRoot, ".gitignore");
166
- const entry = ".mdenc-password";
725
+ }
726
+
727
+ // src/git/genpass.ts
728
+ import { existsSync, readFileSync as readFileSync2, writeFileSync } from "fs";
729
+ import { join as join3 } from "path";
730
+ import { randomBytes as randomBytes2 } from "@noble/ciphers/webcrypto";
731
+ var PASSWORD_FILE2 = ".mdenc-password";
732
+ function genpassCommand(force) {
733
+ const repoRoot = findGitRoot();
734
+ const passwordPath = join3(repoRoot, PASSWORD_FILE2);
735
+ if (existsSync(passwordPath) && !force) {
736
+ console.error(`${PASSWORD_FILE2} already exists. Use --force to overwrite.`);
737
+ process.exit(1);
738
+ }
739
+ const password = Buffer.from(randomBytes2(32)).toString("base64url");
740
+ writeFileSync(passwordPath, `${password}
741
+ `, { mode: 384 });
742
+ console.error(`Generated password and wrote to ${PASSWORD_FILE2}`);
743
+ console.error(password);
744
+ const gitignorePath = join3(repoRoot, ".gitignore");
745
+ const entry = PASSWORD_FILE2;
167
746
  if (existsSync(gitignorePath)) {
168
- const content = readFileSync(gitignorePath, "utf-8");
747
+ const content = readFileSync2(gitignorePath, "utf-8");
169
748
  const lines = content.split("\n").map((l) => l.trim());
170
749
  if (!lines.includes(entry)) {
171
- writeFileSync(gitignorePath, content.trimEnd() + "\n" + entry + "\n");
750
+ writeFileSync(gitignorePath, `${content.trimEnd()}
751
+ ${entry}
752
+ `);
753
+ console.error("Added .mdenc-password to .gitignore");
754
+ }
755
+ } else {
756
+ writeFileSync(gitignorePath, `${entry}
757
+ `);
758
+ console.error("Created .gitignore with .mdenc-password");
759
+ }
760
+ }
761
+
762
+ // 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";
766
+ var FILTER_CONFIGS = [
767
+ ["filter.mdenc.process", "mdenc filter-process"],
768
+ ["filter.mdenc.clean", "mdenc filter-clean %f"],
769
+ ["filter.mdenc.smudge", "mdenc filter-smudge %f"],
770
+ ["filter.mdenc.required", "true"],
771
+ ["diff.mdenc.textconv", "mdenc textconv"]
772
+ ];
773
+ function configureGitFilter(repoRoot) {
774
+ for (const [key, value] of FILTER_CONFIGS) {
775
+ execFileSync2("git", ["config", "--local", key, value], {
776
+ cwd: repoRoot,
777
+ stdio: ["pipe", "pipe", "pipe"]
778
+ });
779
+ }
780
+ }
781
+ function isFilterConfigured(repoRoot) {
782
+ try {
783
+ const val = execFileSync2("git", ["config", "--get", "filter.mdenc.process"], {
784
+ cwd: repoRoot,
785
+ encoding: "utf-8",
786
+ stdio: ["pipe", "pipe", "pipe"]
787
+ }).trim();
788
+ return val.length > 0;
789
+ } catch {
790
+ return false;
791
+ }
792
+ }
793
+ async function initCommand() {
794
+ const repoRoot = findGitRoot();
795
+ if (isFilterConfigured(repoRoot)) {
796
+ console.log("Git filter already configured (skipped)");
797
+ } else {
798
+ configureGitFilter(repoRoot);
799
+ console.log("Configured git filter (filter.mdenc + diff.mdenc)");
800
+ }
801
+ const gitignorePath = join4(repoRoot, ".gitignore");
802
+ const entry = ".mdenc-password";
803
+ if (existsSync2(gitignorePath)) {
804
+ const content = readFileSync3(gitignorePath, "utf-8");
805
+ const lines = content.split("\n").map((l) => l.trim());
806
+ if (!lines.includes(entry)) {
807
+ writeFileSync2(gitignorePath, `${content.trimEnd()}
808
+ ${entry}
809
+ `);
172
810
  console.log("Added .mdenc-password to .gitignore");
173
811
  } else {
174
812
  console.log(".mdenc-password already in .gitignore (skipped)");
175
813
  }
176
814
  } else {
177
- writeFileSync(gitignorePath, entry + "\n");
815
+ writeFileSync2(gitignorePath, `${entry}
816
+ `);
178
817
  console.log("Created .gitignore with .mdenc-password");
179
818
  }
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)`);
819
+ const markedDirs = findMarkedDirs(repoRoot);
820
+ if (markedDirs.length > 0) {
821
+ const { relative: relative3 } = await import("path");
822
+ for (const dir of markedDirs) {
823
+ const relDir = relative3(repoRoot, dir) || ".";
824
+ try {
825
+ execFileSync2("git", ["checkout", "HEAD", "--", `${relDir}/*.md`], {
826
+ cwd: repoRoot,
827
+ stdio: ["pipe", "pipe", "pipe"]
828
+ });
829
+ } catch {
830
+ }
831
+ }
184
832
  }
185
833
  console.log("mdenc git integration initialized.");
186
834
  }
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`);
835
+ function removeFilterCommand() {
836
+ const repoRoot = findGitRoot();
837
+ for (const section of ["filter.mdenc", "diff.mdenc"]) {
838
+ try {
839
+ execFileSync2("git", ["config", "--local", "--remove-section", section], {
840
+ cwd: repoRoot,
841
+ stdio: ["pipe", "pipe", "pipe"]
842
+ });
843
+ } catch {
223
844
  }
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
845
  }
846
+ console.log("Removed git filter configuration.");
231
847
  }
232
848
 
233
849
  // 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";
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";
853
+ var MARKER_FILE2 = ".mdenc.conf";
237
854
  var MARKER_CONTENT = "# mdenc: .md files in this directory are automatically encrypted\n";
238
- var GITIGNORE_PATTERN = "*.md";
855
+ var GITATTR_PATTERN = "*.md filter=mdenc diff=mdenc";
856
+ function isFilterConfigured2(repoRoot) {
857
+ try {
858
+ const val = execFileSync3("git", ["config", "--get", "filter.mdenc.process"], {
859
+ cwd: repoRoot,
860
+ encoding: "utf-8",
861
+ stdio: ["pipe", "pipe", "pipe"]
862
+ }).trim();
863
+ return val.length > 0;
864
+ } catch {
865
+ return false;
866
+ }
867
+ }
868
+ function markUsage() {
869
+ console.error(`Usage: mdenc mark <directory>
870
+
871
+ Mark a directory so that all *.md files inside it are automatically
872
+ encrypted when staged (git add) and decrypted when checked out.
873
+
874
+ What it does:
875
+ 1. Creates <directory>/.mdenc.conf Marker file for mdenc to discover
876
+ 2. Creates <directory>/.gitattributes Assigns the mdenc filter to *.md files
877
+ 3. Stages both files in git
878
+
879
+ Prerequisites:
880
+ Run "mdenc init" first to configure the git filter in this clone.
881
+ Run "mdenc genpass" to generate a password (or set MDENC_PASSWORD).
882
+
883
+ Example:
884
+ mdenc init
885
+ mdenc genpass
886
+ mdenc mark docs/private
887
+ echo "# Secret" > docs/private/notes.md
888
+ git add docs/private/notes.md # encrypted automatically`);
889
+ process.exit(1);
890
+ }
239
891
  function markCommand(dirArg) {
892
+ if (dirArg === "--help" || dirArg === "-h") {
893
+ markUsage();
894
+ }
240
895
  const repoRoot = findGitRoot();
241
896
  const dir = resolve(dirArg);
242
- if (!existsSync2(dir)) {
897
+ if (!existsSync3(dir)) {
243
898
  console.error(`Error: directory "${dirArg}" does not exist`);
244
899
  process.exit(1);
245
900
  }
@@ -249,54 +904,63 @@ function markCommand(dirArg) {
249
904
  process.exit(1);
250
905
  }
251
906
  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}`);
907
+ const filterReady = isFilterConfigured2(repoRoot);
908
+ const confPath = join5(dir, MARKER_FILE2);
909
+ if (!existsSync3(confPath)) {
910
+ writeFileSync3(confPath, MARKER_CONTENT);
911
+ console.log(`Created ${relDir}/${MARKER_FILE2}`);
256
912
  } else {
257
- console.log(`${relDir}/${MARKER_FILE} already exists (skipped)`);
913
+ console.log(`${relDir}/${MARKER_FILE2} already exists (skipped)`);
258
914
  }
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})`);
915
+ const gitattrsPath = join5(dir, ".gitattributes");
916
+ if (existsSync3(gitattrsPath)) {
917
+ const content = readFileSync4(gitattrsPath, "utf-8");
918
+ if (content.includes("filter=mdenc")) {
919
+ console.log(`${relDir}/.gitattributes already has filter=mdenc (skipped)`);
266
920
  } else {
267
- console.log(`${relDir}/.gitignore already has ${GITIGNORE_PATTERN} (skipped)`);
921
+ writeFileSync3(gitattrsPath, `${content.trimEnd()}
922
+ ${GITATTR_PATTERN}
923
+ `);
924
+ console.log(`Updated ${relDir}/.gitattributes`);
268
925
  }
269
926
  } 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
- }
927
+ writeFileSync3(gitattrsPath, `${GITATTR_PATTERN}
928
+ `);
929
+ console.log(`Created ${relDir}/.gitattributes`);
286
930
  }
287
- const toStage = [
288
- relative(repoRoot, confPath),
289
- relative(repoRoot, gitignorePath)
290
- ];
931
+ const toStage = [relative(repoRoot, confPath), relative(repoRoot, gitattrsPath)];
291
932
  gitAdd(repoRoot, toStage);
292
933
  console.log(`Marked ${relDir}/ for mdenc encryption`);
934
+ if (!filterReady) {
935
+ console.log(`
936
+ Warning: git filter not configured yet. Run "mdenc init" to enable encryption.`);
937
+ }
293
938
  }
294
939
 
295
940
  // 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";
941
+ import { execFileSync as execFileSync4 } from "child_process";
942
+ import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
943
+ import { join as join6, relative as relative2 } from "path";
944
+ function getFilterConfig(repoRoot) {
945
+ const get = (key) => {
946
+ try {
947
+ return execFileSync4("git", ["config", "--get", key], {
948
+ cwd: repoRoot,
949
+ encoding: "utf-8",
950
+ stdio: ["pipe", "pipe", "pipe"]
951
+ }).trim();
952
+ } catch {
953
+ return null;
954
+ }
955
+ };
956
+ return {
957
+ process: get("filter.mdenc.process"),
958
+ clean: get("filter.mdenc.clean"),
959
+ smudge: get("filter.mdenc.smudge"),
960
+ required: get("filter.mdenc.required") === "true",
961
+ textconv: get("diff.mdenc.textconv")
962
+ };
963
+ }
300
964
  function statusCommand() {
301
965
  const repoRoot = findGitRoot();
302
966
  const password = resolvePassword(repoRoot);
@@ -310,36 +974,24 @@ function statusCommand() {
310
974
  const relDir = relative2(repoRoot, dir) || ".";
311
975
  console.log(` ${relDir}/`);
312
976
  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
- }
977
+ for (const f of mdFiles) {
978
+ const content = readFileSync5(join6(dir, f), "utf-8");
979
+ if (content.startsWith("mdenc:v1")) {
980
+ console.log(` ${f} [encrypted \u2014 needs smudge]`);
327
981
  } else {
328
- console.log(` ${base}.md [not yet encrypted]`);
982
+ console.log(` ${f} [plaintext]`);
329
983
  }
330
984
  }
331
- for (const base of mdencBases) {
332
- if (!mdBases.has(base)) {
333
- console.log(` ${base}.mdenc [needs decryption]`);
334
- }
985
+ if (mdFiles.length === 0) {
986
+ console.log(" (no .md files)");
335
987
  }
336
- const gitignorePath = join3(dir, ".gitignore");
337
- if (!existsSync3(gitignorePath)) {
338
- console.log(` WARNING: no .gitignore in this directory`);
988
+ const gitattrsPath = join6(dir, ".gitattributes");
989
+ if (!existsSync4(gitattrsPath)) {
990
+ console.log(" WARNING: no .gitattributes in this directory");
339
991
  } 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`);
992
+ const content = readFileSync5(gitattrsPath, "utf-8");
993
+ if (!content.includes("filter=mdenc")) {
994
+ console.log(" WARNING: .gitattributes missing filter=mdenc pattern");
343
995
  }
344
996
  }
345
997
  console.log();
@@ -351,127 +1003,37 @@ function statusCommand() {
351
1003
  } else {
352
1004
  console.log("Password: available");
353
1005
  }
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');
1006
+ const config = getFilterConfig(repoRoot);
1007
+ if (!config.process && !config.clean) {
1008
+ console.log("Filter: NOT CONFIGURED");
1009
+ console.log(' Run "mdenc init" to configure');
370
1010
  } else {
371
- console.log("Hooks: all installed");
1011
+ console.log("Filter: configured");
1012
+ if (!config.required) {
1013
+ console.log(" WARNING: filter.mdenc.required is not set to true");
1014
+ }
372
1015
  }
373
1016
  }
374
1017
 
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() {
1018
+ // src/git/textconv.ts
1019
+ import { readFileSync as readFileSync6 } from "fs";
1020
+ async function textconvCommand(filePath) {
1021
+ const content = readFileSync6(filePath, "utf-8");
1022
+ if (!content.startsWith("mdenc:v1")) {
1023
+ process.stdout.write(content);
1024
+ return;
1025
+ }
380
1026
  const repoRoot = findGitRoot();
381
1027
  const password = resolvePassword(repoRoot);
382
1028
  if (!password) {
383
- console.error(
384
- "mdenc: no password available (set MDENC_PASSWORD or create .mdenc-password)"
385
- );
386
- process.exit(1);
387
- }
388
- const markedDirs = findMarkedDirs(repoRoot);
389
- if (markedDirs.length === 0) {
390
- console.error("mdenc: no marked directories found");
391
- process.exit(1);
1029
+ process.stdout.write(content);
1030
+ return;
392
1031
  }
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
1032
  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");
1033
+ const plaintext = await decrypt(content, password);
1034
+ process.stdout.write(plaintext);
1035
+ } catch {
1036
+ process.stdout.write(content);
475
1037
  }
476
1038
  }
477
1039
 
@@ -551,12 +1113,17 @@ function usage() {
551
1113
  mdenc verify <file> Verify file integrity
552
1114
 
553
1115
  Git integration:
554
- mdenc init Set up git hooks for automatic encryption
1116
+ mdenc init Set up git filter for automatic encryption
555
1117
  mdenc mark <directory> Mark a directory for encryption
556
1118
  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`);
1119
+ mdenc remove-filter Remove git filter configuration
1120
+ mdenc genpass [--force] Generate a random password into .mdenc-password
1121
+
1122
+ Internal (called by git):
1123
+ mdenc filter-process Long-running filter process
1124
+ mdenc filter-clean <path> Single-file clean filter
1125
+ mdenc filter-smudge <path> Single-file smudge filter
1126
+ mdenc textconv <file> Output plaintext for git diff`);
560
1127
  process.exit(1);
561
1128
  }
562
1129
  async function main() {
@@ -571,10 +1138,10 @@ async function main() {
571
1138
  const outputIdx = args.indexOf("-o");
572
1139
  const outputFile = outputIdx >= 0 ? args[outputIdx + 1] : void 0;
573
1140
  const password = await getPasswordWithConfirmation();
574
- const plaintext = readFileSync6(inputFile, "utf-8");
1141
+ const plaintext = readFileSync7(inputFile, "utf-8");
575
1142
  const encrypted = await encrypt(plaintext, password);
576
1143
  if (outputFile) {
577
- writeFileSync5(outputFile, encrypted);
1144
+ writeFileSync4(outputFile, encrypted);
578
1145
  } else {
579
1146
  process.stdout.write(encrypted);
580
1147
  }
@@ -586,10 +1153,10 @@ async function main() {
586
1153
  const outputIdx = args.indexOf("-o");
587
1154
  const outputFile = outputIdx >= 0 ? args[outputIdx + 1] : void 0;
588
1155
  const password = await getPassword();
589
- const fileContent = readFileSync6(inputFile, "utf-8");
1156
+ const fileContent = readFileSync7(inputFile, "utf-8");
590
1157
  const decrypted = await decrypt(fileContent, password);
591
1158
  if (outputFile) {
592
- writeFileSync5(outputFile, decrypted);
1159
+ writeFileSync4(outputFile, decrypted);
593
1160
  } else {
594
1161
  process.stdout.write(decrypted);
595
1162
  }
@@ -599,7 +1166,7 @@ async function main() {
599
1166
  if (!args[1]) usage();
600
1167
  const inputFile = args[1];
601
1168
  const password = await getPassword();
602
- const fileContent = readFileSync6(inputFile, "utf-8");
1169
+ const fileContent = readFileSync7(inputFile, "utf-8");
603
1170
  const valid = await verifySeal(fileContent, password);
604
1171
  if (valid) {
605
1172
  console.error("Seal verified: OK");
@@ -615,7 +1182,7 @@ async function main() {
615
1182
  break;
616
1183
  case "mark": {
617
1184
  if (!args[1]) {
618
- console.error("Usage: mdenc mark <directory>");
1185
+ console.error('Usage: mdenc mark <directory>\n\nRun "mdenc mark --help" for details.');
619
1186
  process.exit(1);
620
1187
  }
621
1188
  markCommand(args[1]);
@@ -624,27 +1191,28 @@ async function main() {
624
1191
  case "status":
625
1192
  statusCommand();
626
1193
  break;
627
- case "watch":
628
- await watchCommand();
629
- break;
630
- case "remove-hooks":
631
- removeHooksCommand();
1194
+ case "remove-filter":
1195
+ removeFilterCommand();
632
1196
  break;
633
1197
  case "genpass":
634
1198
  genpassCommand(args.includes("--force"));
635
1199
  break;
636
- // Git hook handlers (called by hook scripts, not directly by user)
637
- case "pre-commit":
638
- await preCommitHook();
1200
+ // Git filter commands (called by git, not directly by user)
1201
+ case "filter-process":
1202
+ await filterProcessMain();
639
1203
  break;
640
- case "post-checkout":
641
- await postCheckoutHook();
1204
+ case "filter-clean":
1205
+ await simpleCleanFilter(args[1] ?? "");
642
1206
  break;
643
- case "post-merge":
644
- await postMergeHook();
1207
+ case "filter-smudge":
1208
+ await simpleSmudgeFilter();
645
1209
  break;
646
- case "post-rewrite":
647
- await postRewriteHook();
1210
+ case "textconv":
1211
+ if (!args[1]) {
1212
+ console.error("Usage: mdenc textconv <file>");
1213
+ process.exit(1);
1214
+ }
1215
+ await textconvCommand(args[1]);
648
1216
  break;
649
1217
  default:
650
1218
  console.error(`Unknown command: ${command}`);