memoir-cli 3.1.1 → 3.2.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/GAMEPLAN.md +235 -0
- package/README.md +33 -2
- package/bin/memoir.js +78 -3
- package/package.json +9 -4
- package/src/commands/projects.js +240 -0
- package/src/commands/push.js +5 -3
- package/src/commands/restore.js +197 -3
- package/src/commands/share.js +192 -0
- package/src/commands/upgrade.js +107 -0
- package/src/context/capture.js +77 -0
- package/src/mcp.js +429 -0
- package/src/providers/index.js +6 -6
- package/src/security/encryption.js +98 -46
- package/src/workspace/tracker.js +4 -4
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import crypto from 'crypto';
|
|
2
|
+
import { promisify } from 'util';
|
|
2
3
|
import fs from 'fs-extra';
|
|
3
4
|
import path from 'path';
|
|
4
5
|
|
|
6
|
+
const scryptAsync = promisify(crypto.scrypt);
|
|
7
|
+
|
|
5
8
|
// --- Constants ---
|
|
6
9
|
const ALGORITHM = 'aes-256-gcm';
|
|
7
10
|
const IV_LENGTH = 12; // 96 bits, recommended for GCM
|
|
@@ -14,11 +17,11 @@ const MAGIC = Buffer.from('MEMOIR01'); // 8-byte header for format versioning
|
|
|
14
17
|
// --- Key Derivation ---
|
|
15
18
|
|
|
16
19
|
/**
|
|
17
|
-
* Derive a 256-bit key from a passphrase using scrypt.
|
|
20
|
+
* Derive a 256-bit key from a passphrase using scrypt (async, non-blocking).
|
|
18
21
|
*/
|
|
19
|
-
export function deriveKey(passphrase, salt = null) {
|
|
22
|
+
export async function deriveKey(passphrase, salt = null) {
|
|
20
23
|
if (!salt) salt = crypto.randomBytes(SALT_LENGTH);
|
|
21
|
-
const key =
|
|
24
|
+
const key = await scryptAsync(passphrase, salt, KEY_LENGTH, {
|
|
22
25
|
N: SCRYPT_COST,
|
|
23
26
|
r: 8,
|
|
24
27
|
p: 1,
|
|
@@ -32,8 +35,8 @@ export function deriveKey(passphrase, salt = null) {
|
|
|
32
35
|
* Encrypt a buffer with AES-256-GCM.
|
|
33
36
|
* Output format: MEMOIR01 | salt (32) | iv (12) | authTag (16) | ciphertext
|
|
34
37
|
*/
|
|
35
|
-
export function encryptBuffer(plaintext, passphrase) {
|
|
36
|
-
const { key, salt } = deriveKey(passphrase);
|
|
38
|
+
export async function encryptBuffer(plaintext, passphrase) {
|
|
39
|
+
const { key, salt } = await deriveKey(passphrase);
|
|
37
40
|
const iv = crypto.randomBytes(IV_LENGTH);
|
|
38
41
|
|
|
39
42
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
|
|
@@ -46,7 +49,7 @@ export function encryptBuffer(plaintext, passphrase) {
|
|
|
46
49
|
/**
|
|
47
50
|
* Decrypt a buffer. Throws on wrong passphrase or tampered data.
|
|
48
51
|
*/
|
|
49
|
-
export function decryptBuffer(data, passphrase) {
|
|
52
|
+
export async function decryptBuffer(data, passphrase) {
|
|
50
53
|
const magic = data.subarray(0, 8);
|
|
51
54
|
if (!magic.equals(MAGIC)) {
|
|
52
55
|
throw new Error('Not a memoir-encrypted file (bad header)');
|
|
@@ -58,7 +61,7 @@ export function decryptBuffer(data, passphrase) {
|
|
|
58
61
|
const tag = data.subarray(offset, offset + TAG_LENGTH); offset += TAG_LENGTH;
|
|
59
62
|
const ciphertext = data.subarray(offset);
|
|
60
63
|
|
|
61
|
-
const { key } = deriveKey(passphrase, salt);
|
|
64
|
+
const { key } = await deriveKey(passphrase, salt);
|
|
62
65
|
|
|
63
66
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
|
|
64
67
|
decipher.setAuthTag(tag);
|
|
@@ -72,76 +75,116 @@ export function decryptBuffer(data, passphrase) {
|
|
|
72
75
|
* Encrypt all files in srcDir → destDir.
|
|
73
76
|
* File names are HMAC-hashed (hidden). Manifest maps hashes → real paths.
|
|
74
77
|
*/
|
|
75
|
-
export async function encryptDirectory(srcDir, destDir, passphrase) {
|
|
76
|
-
const
|
|
78
|
+
export async function encryptDirectory(srcDir, destDir, passphrase, spinner = null) {
|
|
79
|
+
const startTime = Date.now();
|
|
80
|
+
|
|
81
|
+
// Phase 1: Derive encryption key
|
|
82
|
+
if (spinner) spinner.text = 'Deriving encryption key (scrypt)...';
|
|
83
|
+
const { key, salt } = await deriveKey(passphrase);
|
|
84
|
+
|
|
77
85
|
const dataDir = path.join(destDir, 'data');
|
|
78
86
|
await fs.ensureDir(dataDir);
|
|
79
87
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
async function
|
|
88
|
+
// Phase 2: Index files
|
|
89
|
+
if (spinner) spinner.text = 'Indexing files...';
|
|
90
|
+
const fileList = [];
|
|
91
|
+
async function index(dir, relBase = '') {
|
|
84
92
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
85
93
|
for (const entry of entries) {
|
|
86
94
|
const fullPath = path.join(dir, entry.name);
|
|
87
95
|
const relPath = path.join(relBase, entry.name);
|
|
88
96
|
if (entry.isDirectory()) {
|
|
89
|
-
await
|
|
97
|
+
await index(fullPath, relPath);
|
|
90
98
|
} else {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
.createHmac('sha256', key)
|
|
94
|
-
.update(relPath)
|
|
95
|
-
.digest('hex')
|
|
96
|
-
.slice(0, 24);
|
|
97
|
-
|
|
98
|
-
const plaintext = await fs.readFile(fullPath);
|
|
99
|
-
const iv = crypto.randomBytes(IV_LENGTH);
|
|
100
|
-
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
|
|
101
|
-
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
102
|
-
const tag = cipher.getAuthTag();
|
|
103
|
-
|
|
104
|
-
// Write: iv | tag | ciphertext (salt shared via manifest file)
|
|
105
|
-
await fs.writeFile(
|
|
106
|
-
path.join(dataDir, `${hashedName}.enc`),
|
|
107
|
-
Buffer.concat([iv, tag, encrypted])
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
manifest[hashedName] = relPath;
|
|
111
|
-
count++;
|
|
99
|
+
const stat = await fs.stat(fullPath);
|
|
100
|
+
fileList.push({ fullPath, relPath, size: stat.size });
|
|
112
101
|
}
|
|
113
102
|
}
|
|
114
103
|
}
|
|
104
|
+
await index(srcDir);
|
|
105
|
+
|
|
106
|
+
const totalFiles = fileList.length;
|
|
107
|
+
const totalBytes = fileList.reduce((sum, f) => sum + f.size, 0);
|
|
108
|
+
|
|
109
|
+
// Phase 3: Encrypt files
|
|
110
|
+
const manifest = {};
|
|
111
|
+
let count = 0;
|
|
112
|
+
let bytesProcessed = 0;
|
|
113
|
+
|
|
114
|
+
for (const { fullPath, relPath, size } of fileList) {
|
|
115
|
+
const hashedName = crypto
|
|
116
|
+
.createHmac('sha256', key)
|
|
117
|
+
.update(relPath)
|
|
118
|
+
.digest('hex')
|
|
119
|
+
.slice(0, 24);
|
|
120
|
+
|
|
121
|
+
const plaintext = await fs.readFile(fullPath);
|
|
122
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
123
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
|
|
124
|
+
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
125
|
+
const tag = cipher.getAuthTag();
|
|
126
|
+
|
|
127
|
+
await fs.writeFile(
|
|
128
|
+
path.join(dataDir, `${hashedName}.enc`),
|
|
129
|
+
Buffer.concat([iv, tag, encrypted])
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
manifest[hashedName] = relPath;
|
|
133
|
+
count++;
|
|
134
|
+
bytesProcessed += size;
|
|
115
135
|
|
|
116
|
-
|
|
136
|
+
if (spinner) {
|
|
137
|
+
const pct = Math.round((count / totalFiles) * 100);
|
|
138
|
+
const sizeStr = formatBytes(bytesProcessed);
|
|
139
|
+
const totalStr = formatBytes(totalBytes);
|
|
140
|
+
spinner.text = `Encrypting (AES-256-GCM) ${count}/${totalFiles} files — ${sizeStr}/${totalStr} [${pct}%]`;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
117
143
|
|
|
118
|
-
// Encrypt
|
|
144
|
+
// Phase 4: Encrypt manifest
|
|
145
|
+
if (spinner) spinner.text = 'Encrypting file manifest...';
|
|
119
146
|
const manifestJson = Buffer.from(JSON.stringify(manifest));
|
|
120
|
-
const manifestEncrypted = encryptBuffer(manifestJson, passphrase);
|
|
147
|
+
const manifestEncrypted = await encryptBuffer(manifestJson, passphrase);
|
|
121
148
|
await fs.writeFile(path.join(destDir, 'manifest.enc'), manifestEncrypted);
|
|
122
149
|
|
|
123
150
|
// Salt is not secret — store it so decrypt can re-derive the same key
|
|
124
151
|
await fs.writeFile(path.join(destDir, 'salt'), salt);
|
|
125
152
|
|
|
153
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
154
|
+
if (spinner) spinner.text = `Encrypted ${count} files (${formatBytes(totalBytes)}) in ${elapsed}s`;
|
|
155
|
+
|
|
126
156
|
return count;
|
|
127
157
|
}
|
|
128
158
|
|
|
159
|
+
function formatBytes(bytes) {
|
|
160
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
161
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
162
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
163
|
+
}
|
|
164
|
+
|
|
129
165
|
/**
|
|
130
166
|
* Decrypt an encrypted directory back to plaintext.
|
|
131
167
|
*/
|
|
132
|
-
export async function decryptDirectory(encDir, destDir, passphrase) {
|
|
133
|
-
|
|
168
|
+
export async function decryptDirectory(encDir, destDir, passphrase, spinner = null) {
|
|
169
|
+
const startTime = Date.now();
|
|
170
|
+
|
|
171
|
+
// Phase 1: Decrypt manifest
|
|
172
|
+
if (spinner) spinner.text = 'Decrypting file manifest...';
|
|
134
173
|
const manifestData = await fs.readFile(path.join(encDir, 'manifest.enc'));
|
|
135
|
-
const manifestJson = decryptBuffer(manifestData, passphrase);
|
|
174
|
+
const manifestJson = await decryptBuffer(manifestData, passphrase);
|
|
136
175
|
const manifest = JSON.parse(manifestJson.toString('utf8'));
|
|
137
176
|
|
|
138
|
-
//
|
|
177
|
+
// Phase 2: Derive key
|
|
178
|
+
if (spinner) spinner.text = 'Deriving decryption key (scrypt)...';
|
|
139
179
|
const salt = await fs.readFile(path.join(encDir, 'salt'));
|
|
140
|
-
const { key } = deriveKey(passphrase, salt);
|
|
180
|
+
const { key } = await deriveKey(passphrase, salt);
|
|
141
181
|
|
|
142
182
|
const dataDir = path.join(encDir, 'data');
|
|
183
|
+
const totalFiles = Object.keys(manifest).length;
|
|
143
184
|
let count = 0;
|
|
185
|
+
let bytesProcessed = 0;
|
|
144
186
|
|
|
187
|
+
// Phase 3: Decrypt files
|
|
145
188
|
for (const [hashedName, relPath] of Object.entries(manifest)) {
|
|
146
189
|
const encFilePath = path.join(dataDir, `${hashedName}.enc`);
|
|
147
190
|
if (!(await fs.pathExists(encFilePath))) continue;
|
|
@@ -159,8 +202,17 @@ export async function decryptDirectory(encDir, destDir, passphrase) {
|
|
|
159
202
|
await fs.ensureDir(path.dirname(outPath));
|
|
160
203
|
await fs.writeFile(outPath, decrypted);
|
|
161
204
|
count++;
|
|
205
|
+
bytesProcessed += decrypted.length;
|
|
206
|
+
|
|
207
|
+
if (spinner) {
|
|
208
|
+
const pct = Math.round((count / totalFiles) * 100);
|
|
209
|
+
spinner.text = `Decrypting ${count}/${totalFiles} files — ${formatBytes(bytesProcessed)} [${pct}%]`;
|
|
210
|
+
}
|
|
162
211
|
}
|
|
163
212
|
|
|
213
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
214
|
+
if (spinner) spinner.text = `Decrypted ${count} files (${formatBytes(bytesProcessed)}) in ${elapsed}s`;
|
|
215
|
+
|
|
164
216
|
return count;
|
|
165
217
|
}
|
|
166
218
|
|
|
@@ -168,13 +220,13 @@ export async function decryptDirectory(encDir, destDir, passphrase) {
|
|
|
168
220
|
* Quick passphrase verification token — encrypt a known string,
|
|
169
221
|
* try to decrypt it to check if passphrase is correct before decrypting everything.
|
|
170
222
|
*/
|
|
171
|
-
export function createVerifyToken(passphrase) {
|
|
223
|
+
export async function createVerifyToken(passphrase) {
|
|
172
224
|
return encryptBuffer(Buffer.from('memoir-ok'), passphrase);
|
|
173
225
|
}
|
|
174
226
|
|
|
175
|
-
export function verifyPassphrase(token, passphrase) {
|
|
227
|
+
export async function verifyPassphrase(token, passphrase) {
|
|
176
228
|
try {
|
|
177
|
-
const result = decryptBuffer(token, passphrase);
|
|
229
|
+
const result = await decryptBuffer(token, passphrase);
|
|
178
230
|
return result.toString('utf8') === 'memoir-ok';
|
|
179
231
|
} catch {
|
|
180
232
|
return false;
|
package/src/workspace/tracker.js
CHANGED
|
@@ -293,23 +293,23 @@ async function getProjectInfo(dir) {
|
|
|
293
293
|
info.hasGit = true;
|
|
294
294
|
try {
|
|
295
295
|
const remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
|
|
296
|
-
cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
|
|
296
|
+
cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
|
|
297
297
|
}).toString().trim();
|
|
298
298
|
info.gitRemote = remote;
|
|
299
299
|
} catch {}
|
|
300
300
|
|
|
301
301
|
try {
|
|
302
302
|
info.branch = execFileSync('git', ['branch', '--show-current'], {
|
|
303
|
-
cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
|
|
303
|
+
cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
|
|
304
304
|
}).toString().trim();
|
|
305
305
|
} catch {}
|
|
306
306
|
|
|
307
307
|
try {
|
|
308
308
|
info.lastCommit = execFileSync('git', ['log', '-1', '--format=%H'], {
|
|
309
|
-
cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
|
|
309
|
+
cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
|
|
310
310
|
}).toString().trim();
|
|
311
311
|
info.lastCommitMessage = execFileSync('git', ['log', '-1', '--format=%s'], {
|
|
312
|
-
cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
|
|
312
|
+
cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
|
|
313
313
|
}).toString().trim();
|
|
314
314
|
} catch {}
|
|
315
315
|
|