memoir-cli 3.12.0 → 3.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -137
- package/bin/memoir-work.js +9 -0
- package/bin/memoir.js +50 -8
- package/docs/AUDIT-REMEDIATION.md +55 -0
- package/docs/CASE_TAPE_AMNESIA.md +39 -0
- package/docs/HANDOFF-SECURITY-AUDIT.md +106 -0
- package/docs/LOCAL-HANDOFF-VALIDATION.md +129 -0
- package/docs/MCP-V2-MIGRATION.md +17 -0
- package/docs/PROJECT-HANDOFF.md +255 -0
- package/docs/PROJECT-VIEW-DEBUG.md +66 -0
- package/docs/PROJECT-VIEW-VALIDATION.md +136 -0
- package/docs/RELEASE-3.14-VALIDATION.md +36 -0
- package/docs/RELIABILITY-ROLLOUT.md +57 -0
- package/docs/RETRIEVAL-INDEX.md +45 -0
- package/docs/RETRIEVAL-RESULTS.md +26 -0
- package/docs/SPEC.md +684 -0
- package/evals/CONTINUITY-PROTOCOL.md +45 -0
- package/evals/cases.json +200 -0
- package/evals/results/retrieval-2026-09-05.json +5333 -0
- package/evals/retrieval-performance.mjs +99 -0
- package/evals/run.mjs +87 -0
- package/package.json +13 -5
- package/src/adapters/index.js +13 -6
- package/src/adapters/restore.js +83 -36
- package/src/cloud/storage.js +130 -93
- package/src/commands/activate.js +18 -7
- package/src/commands/cloud.js +55 -4
- package/src/commands/consolidate.js +49 -10
- package/src/commands/diff.js +2 -2
- package/src/commands/doctor.js +3 -3
- package/src/commands/push.js +156 -161
- package/src/commands/recall.js +1 -1
- package/src/commands/restore.js +32 -44
- package/src/commands/resume.js +15 -164
- package/src/commands/session.js +51 -9
- package/src/commands/snapshot.js +6 -7
- package/src/commands/status.js +23 -1
- package/src/commands/upgrade.js +11 -9
- package/src/commands/validate.js +3 -0
- package/src/commands/view.js +2 -2
- package/src/commands/why.js +4 -3
- package/src/config.js +9 -40
- package/src/context/capture.js +126 -32
- package/src/context/handoffs.js +72 -0
- package/src/events/summary.js +122 -0
- package/src/integrations/setup.js +88 -0
- package/src/mcp.js +105 -152
- package/src/memory/lexical-index.js +65 -0
- package/src/memory/repository.js +16 -0
- package/src/memory/scope.js +65 -0
- package/src/memory/search.js +165 -70
- package/src/memory/store.js +141 -0
- package/src/providers/index.js +182 -51
- package/src/providers/restore.js +5 -1
- package/src/security/encryption.js +34 -60
- package/src/security/files.js +155 -0
- package/src/session/brief.js +47 -0
- package/src/session/inject.js +12 -6
- package/src/session/lock.js +39 -118
- package/src/session/migrations.js +6 -0
- package/src/session/render.js +34 -4
- package/src/session/state.js +200 -33
- package/src/work/cli.js +64 -0
- package/src/work/errors.js +8 -0
- package/src/work/server.js +28 -0
- package/src/work/setup.js +96 -0
- package/src/work/store.js +340 -0
- package/src/work/ui/app.js +205 -0
- package/src/work/ui/index.html +30 -0
- package/src/work/ui/style.css +3 -0
- package/src/work/view.js +93 -0
- package/src/workspace/tracker.js +84 -332
- package/supabase/migrations/202609050001_backup_versions.sql +50 -0
package/src/providers/index.js
CHANGED
|
@@ -4,6 +4,8 @@ import os from 'os';
|
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { execFileSync } from 'child_process';
|
|
6
6
|
import { appendEvent } from '../events/log.js';
|
|
7
|
+
import { listSafeFiles, readSafeFile, writeSafeFile, restoreFileSet } from '../security/files.js';
|
|
8
|
+
import { withSessionLock } from '../session/lock.js';
|
|
7
9
|
|
|
8
10
|
function sanitizeUrl(url) {
|
|
9
11
|
// Reject URLs with shell metacharacters
|
|
@@ -13,99 +15,228 @@ function sanitizeUrl(url) {
|
|
|
13
15
|
return url;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
export async function
|
|
17
|
-
const
|
|
18
|
-
|
|
18
|
+
export async function withLocalBackupLock(config, fn) {
|
|
19
|
+
const dest = path.resolve(config.localPath.replace(/^~/, os.homedir()));
|
|
20
|
+
return withSessionLock(dest + '.memoir-lock', fn, { maxWaitMs: 5000 });
|
|
21
|
+
}
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
export async function syncToLocal(config, stagingDir, spinner, options = {}) {
|
|
24
|
+
if (!config.localPath) throw new Error('Local path is not configured.');
|
|
25
|
+
const dest = path.resolve(config.localPath.replace(/^~/, os.homedir()));
|
|
26
|
+
if (dest === path.parse(dest).root || dest === path.resolve(os.homedir()) || path.resolve(stagingDir).startsWith(dest + path.sep)) throw new Error('Choose a dedicated backup directory');
|
|
27
|
+
const sync = async () => {
|
|
28
|
+
const stat = await fs.lstat(dest).catch(err => { if (err.code !== 'ENOENT') throw err; return null; });
|
|
29
|
+
if (stat?.isSymbolicLink()) throw new Error('Backup destination must not be a symlink');
|
|
30
|
+
const encrypted = await fs.pathExists(path.join(stagingDir, 'manifest.enc'));
|
|
31
|
+
const exists = await fs.pathExists(dest);
|
|
32
|
+
const wasEncrypted = exists && await fs.pathExists(path.join(dest, 'manifest.enc'));
|
|
33
|
+
if (encrypted) {
|
|
34
|
+
if (exists && !wasEncrypted && (await fs.readdir(dest)).length && !options.verifiedReplacement) throw new Error('Use memoir push to verify and migrate this plaintext backup before replacing it.');
|
|
35
|
+
const parent = path.dirname(dest);
|
|
36
|
+
await fs.ensureDir(parent);
|
|
37
|
+
const next = await fs.mkdtemp(path.join(parent, '.memoir-sync-'));
|
|
38
|
+
const old = next + '-previous';
|
|
39
|
+
try {
|
|
40
|
+
await listSafeFiles(stagingDir);
|
|
41
|
+
await fs.copy(stagingDir, next);
|
|
42
|
+
if (exists) await fs.rename(dest, old);
|
|
43
|
+
try { await fs.rename(next, dest); }
|
|
44
|
+
catch (err) { if (exists) await fs.rename(old, dest); throw err; }
|
|
45
|
+
if (exists) await fs.remove(old);
|
|
46
|
+
} finally { await fs.remove(next).catch(() => {}); }
|
|
47
|
+
} else {
|
|
48
|
+
if (wasEncrypted) throw new Error('Cannot append plaintext to an encrypted backup. Use memoir push with encryption enabled.');
|
|
49
|
+
const files = [];
|
|
50
|
+
for (const rel of await listSafeFiles(stagingDir)) files.push({ path: rel, content: await readSafeFile(stagingDir, rel) });
|
|
51
|
+
await restoreFileSet(dest, files);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
if (options.lockHeld) await sync();
|
|
55
|
+
else await withLocalBackupLock(config, sync);
|
|
56
|
+
spinner.succeed(chalk.green('Sync complete! ') + chalk.gray('(Saved to ' + dest + ')'));
|
|
57
|
+
await appendEvent('sync_pushed', { provider: 'local' });
|
|
58
|
+
}
|
|
21
59
|
|
|
22
|
-
|
|
23
|
-
|
|
60
|
+
// ── Git sync ─────────────────────────────────────────────────────
|
|
61
|
+
//
|
|
62
|
+
// One cheap clone per push. `--depth 1 --filter=blob:none --no-checkout`
|
|
63
|
+
// fetches commits and trees only — no file contents, no working tree. The
|
|
64
|
+
// remote of the author's own store is 720MB with ~40MB of workspace bundles
|
|
65
|
+
// at HEAD; the old full shallow clone, done TWICE per push (the merge peek in
|
|
66
|
+
// push.js and then this function), pulled all of it through on every Stop
|
|
67
|
+
// hook and a quarter of pushes died on the 60s timeout (183 sync_failed in
|
|
68
|
+
// 15 days, none with a reason). Blobs are now fetched on demand only for the
|
|
69
|
+
// files a caller actually reads (`git checkout HEAD -- session.json`).
|
|
70
|
+
// Remotes that don't support filters (plain file:// paths, old hosts) print
|
|
71
|
+
// a warning and fall back to a normal shallow clone — never worse than before.
|
|
72
|
+
//
|
|
73
|
+
// Mirror semantics are unchanged and come for free: after a --no-checkout
|
|
74
|
+
// clone the index is empty and every remote file is a staged deletion until
|
|
75
|
+
// something re-adds it, so `copy staging → git add -A` yields exactly
|
|
76
|
+
// "staging + the files the caller asked to preserve".
|
|
77
|
+
|
|
78
|
+
export const CLONE_TIMEOUT_MS = 60000;
|
|
79
|
+
const GIT_QUIET = ['ignore', 'ignore', 'pipe']; // stderr kept for classifyGitError
|
|
80
|
+
|
|
81
|
+
export function cloneForSync(repoUrl, dir, { timeout = CLONE_TIMEOUT_MS } = {}) {
|
|
82
|
+
execFileSync('git', ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', repoUrl, '.'], {
|
|
83
|
+
cwd: dir, stdio: GIT_QUIET, timeout,
|
|
84
|
+
});
|
|
85
|
+
// Memoir writes main; read that same branch even when remote HEAD is master.
|
|
86
|
+
const main = execFileSync('git', ['ls-remote', '--heads', 'origin', 'refs/heads/main'], {
|
|
87
|
+
cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout,
|
|
88
|
+
}).trim();
|
|
89
|
+
if (main) {
|
|
90
|
+
execFileSync('git', ['fetch', '--depth', '1', 'origin', 'refs/heads/main'], { cwd: dir, stdio: GIT_QUIET, timeout });
|
|
91
|
+
execFileSync('git', ['update-ref', 'refs/heads/main', 'FETCH_HEAD'], { cwd: dir, stdio: GIT_QUIET, timeout });
|
|
92
|
+
execFileSync('git', ['symbolic-ref', 'HEAD', 'refs/heads/main'], { cwd: dir, stdio: GIT_QUIET, timeout });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
24
95
|
|
|
25
|
-
|
|
96
|
+
// Does the remote HEAD carry `file`? Reads the tree only — no blob fetch.
|
|
97
|
+
export function remoteHasFile(dir, file) {
|
|
98
|
+
try {
|
|
99
|
+
const out = execFileSync('git', ['ls-tree', '--name-only', 'HEAD', '--', file], {
|
|
100
|
+
cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000,
|
|
101
|
+
});
|
|
102
|
+
return out.trim().length > 0;
|
|
103
|
+
} catch {
|
|
104
|
+
return false; // unborn HEAD (empty remote) or not a repo
|
|
105
|
+
}
|
|
106
|
+
}
|
|
26
107
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// previous push's data/*.enc behind forever and localPath grows without
|
|
30
|
-
// bound. Only runs for a full encrypted sync (manifest.enc present in what
|
|
31
|
-
// we just wrote) — `memoir snapshot` also calls syncToLocal with a staging
|
|
32
|
-
// dir of a single handoff file, and blanket-emptying the destination there
|
|
33
|
-
// would delete the user's backup.
|
|
108
|
+
// Materialise one remote file into the working tree (fetches its blob).
|
|
109
|
+
export function checkoutFromRemote(dir, file) {
|
|
34
110
|
try {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (await fs.pathExists(stagedManifest) && await fs.pathExists(destData)) {
|
|
38
|
-
const keep = new Set(await fs.readdir(path.join(stagingDir, 'data')).catch(() => []));
|
|
39
|
-
for (const f of await fs.readdir(destData)) {
|
|
40
|
-
if (!keep.has(f)) await fs.remove(path.join(destData, f)).catch(() => {});
|
|
41
|
-
}
|
|
42
|
-
}
|
|
111
|
+
execFileSync('git', ['checkout', 'HEAD', '--', file], { cwd: dir, stdio: 'ignore', timeout: 30000 });
|
|
112
|
+
return true;
|
|
43
113
|
} catch {
|
|
44
|
-
|
|
114
|
+
return false;
|
|
45
115
|
}
|
|
116
|
+
}
|
|
46
117
|
|
|
47
|
-
|
|
48
|
-
|
|
118
|
+
// A short enum for the event log — never the raw stderr (it can carry the
|
|
119
|
+
// repo URL, a username, a local path). Enough to tell "the network is down"
|
|
120
|
+
// from "two pushes raced" from "the token expired" when reading events.jsonl.
|
|
121
|
+
export function classifyGitError(err) {
|
|
122
|
+
const text = `${err?.message || ''}\n${err?.stderr || ''}`.toLowerCase();
|
|
123
|
+
if (err?.code === 'ETIMEDOUT' || err?.signal === 'SIGTERM' || /timed? ?out/.test(text)) return 'timeout';
|
|
124
|
+
if (/non-fast-forward|fetch first|\[rejected\]/.test(text)) return 'non_fast_forward';
|
|
125
|
+
// Match HTTP diagnostics, not a coincidental 403 in a path or timestamp.
|
|
126
|
+
if (/authentication failed|could not read username|could not read password|permission denied|terminal prompts disabled|invalid credentials|requested url returned error: 403\b|http(?:\/[\d.]+)?(?: error| status(?: code)?)?[: ]+403\b/.test(text)) return 'auth';
|
|
127
|
+
if (/could not resolve host|unable to access|connection (?:refused|reset|timed)|network is unreachable|early eof|remote end hung up/.test(text)) return 'network';
|
|
128
|
+
if (/repository not found|does not appear to be a git repository|does not exist|no such file/.test(text)) return 'not_found';
|
|
129
|
+
if (/invalid characters/.test(text)) return 'bad_url';
|
|
130
|
+
return 'unknown';
|
|
49
131
|
}
|
|
50
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Push `stagingDir` to the git remote as the new HEAD:main.
|
|
135
|
+
*
|
|
136
|
+
* options:
|
|
137
|
+
* cloneDir — a directory already prepared by cloneForSync (push.js's merge
|
|
138
|
+
* peek). Reused instead of cloning again; removed when done.
|
|
139
|
+
* preserve — remote files to keep even though staging lacks them
|
|
140
|
+
* (an unreadable session.json the caller declined to overwrite).
|
|
141
|
+
* additive — overlay staging onto the remote tree instead of mirroring it
|
|
142
|
+
* (`memoir snapshot` uploads ONE handoff file; mirroring would
|
|
143
|
+
* have wiped every other file in the backup).
|
|
144
|
+
*/
|
|
51
145
|
export async function syncToGit(config, stagingDir, spinner, options = {}) {
|
|
52
146
|
const repoUrl = sanitizeUrl(config.gitRepo);
|
|
53
147
|
if (!repoUrl) throw new Error('Git repository is not configured.');
|
|
54
148
|
|
|
55
149
|
spinner.text = `Authenticating and syncing with Git remote: ${chalk.cyan(repoUrl)}`;
|
|
56
150
|
|
|
57
|
-
const
|
|
151
|
+
const reuse = Boolean(options.cloneDir) && await fs.pathExists(path.join(options.cloneDir, '.git'));
|
|
152
|
+
const gitDir = reuse ? options.cloneDir : path.join(os.tmpdir(), `memoir-git-${Date.now()}`);
|
|
58
153
|
await fs.ensureDir(gitDir);
|
|
154
|
+
const started = Date.now();
|
|
59
155
|
|
|
60
156
|
try {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
157
|
+
if (!reuse) {
|
|
158
|
+
try {
|
|
159
|
+
cloneForSync(repoUrl, gitDir);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
// Cloning an EMPTY remote succeeds (git warns and continues), so a
|
|
162
|
+
// failure here is a real one. Unreachable / unauthorised / timed out
|
|
163
|
+
// must surface as such — the old unconditional `git init` fallback
|
|
164
|
+
// turned every one of them into a later, misleading
|
|
165
|
+
// "non-fast-forward" or "credentials" failure. Only a not_found /
|
|
166
|
+
// unknown failure (a host that refuses to clone an empty repo) still
|
|
167
|
+
// falls back to init so a first push can create the history.
|
|
168
|
+
const reason = classifyGitError(err);
|
|
169
|
+
if (reason === 'timeout' || reason === 'auth' || reason === 'network') throw err;
|
|
170
|
+
execFileSync('git', ['init'], { cwd: gitDir, stdio: 'ignore' });
|
|
171
|
+
execFileSync('git', ['branch', '-m', 'main'], { cwd: gitDir, stdio: 'ignore' });
|
|
70
172
|
}
|
|
71
|
-
} catch {
|
|
72
|
-
execFileSync('git', ['init'], { cwd: gitDir, stdio: 'ignore' });
|
|
73
|
-
execFileSync('git', ['branch', '-m', 'main'], { cwd: gitDir, stdio: 'ignore' });
|
|
74
173
|
}
|
|
75
174
|
|
|
76
|
-
|
|
175
|
+
// Files the caller wants kept although staging lacks them: check them
|
|
176
|
+
// out (one blob each) so `git add -A` below sees them in the tree.
|
|
177
|
+
const preserved = [];
|
|
178
|
+
for (const p of options.preserve || []) {
|
|
179
|
+
if (!checkoutFromRemote(gitDir, p)) throw new Error('Could not preserve remote file');
|
|
180
|
+
preserved.push({ path: p, content: await readSafeFile(gitDir, p) });
|
|
181
|
+
}
|
|
182
|
+
if (!options.additive) {
|
|
183
|
+
// The merge peek may have materialized plaintext. A complete encrypted
|
|
184
|
+
// replacement must remove it from the current tree before adding blobs.
|
|
185
|
+
for (const entry of await fs.readdir(gitDir)) {
|
|
186
|
+
if (entry !== '.git') await fs.remove(path.join(gitDir, entry));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
77
189
|
|
|
78
|
-
|
|
190
|
+
|
|
191
|
+
if (options.additive) {
|
|
192
|
+
// Populate the index from HEAD (trees only, no blobs) so everything
|
|
193
|
+
// the remote already has stays in the commit; then add ONLY what
|
|
194
|
+
// staging brought. `git add -A` over an empty working tree would
|
|
195
|
+
// stage every other remote file as deleted.
|
|
196
|
+
try { execFileSync('git', ['read-tree', 'HEAD'], { cwd: gitDir, stdio: 'ignore', timeout: 30000 }); } catch {}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const stagedFiles = await listSafeFiles(stagingDir);
|
|
200
|
+
await fs.copy(stagingDir, gitDir);
|
|
201
|
+
for (const entry of preserved) await writeSafeFile(gitDir, entry.path, entry.content);
|
|
202
|
+
|
|
203
|
+
if (options.additive) {
|
|
204
|
+
if (stagedFiles.length) execFileSync('git', ['add', '--pathspec-from-file=-', '--pathspec-file-nul'], {
|
|
205
|
+
cwd: gitDir, input: Buffer.from(stagedFiles.join('\0') + '\0'), stdio: ['pipe', 'ignore', 'pipe'], timeout: 30000,
|
|
206
|
+
});
|
|
207
|
+
} else {
|
|
208
|
+
execFileSync('git', ['add', '-A'], { cwd: gitDir, stdio: 'ignore', timeout: 30000 });
|
|
209
|
+
}
|
|
79
210
|
execFileSync('git', ['config', 'user.name', 'memoir'], { cwd: gitDir, stdio: 'ignore', timeout: 5000 });
|
|
80
211
|
execFileSync('git', ['config', 'user.email', 'bot@memoir.dev'], { cwd: gitDir, stdio: 'ignore', timeout: 5000 });
|
|
81
212
|
|
|
82
213
|
const timestamp = new Date().toISOString().split('T')[0];
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
} catch {
|
|
214
|
+
const changed = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: gitDir, encoding: 'utf8', timeout: 30000 });
|
|
215
|
+
if (!changed.trim()) {
|
|
86
216
|
spinner.succeed(chalk.green('Already up to date! ') + chalk.gray('No changes to push.'));
|
|
87
217
|
return;
|
|
88
218
|
}
|
|
219
|
+
execFileSync('git', ['commit', '-q', '-m', 'memoir backup ' + timestamp], { cwd: gitDir, stdio: GIT_QUIET, timeout: 30000 });
|
|
89
220
|
|
|
90
221
|
spinner.text = `Pushing data to ${chalk.cyan(repoUrl)}...`;
|
|
91
222
|
// HEAD:main pushes whatever branch the clone checked out (a master-
|
|
92
223
|
// default remote used to make `push main` fail silently under autopush
|
|
93
224
|
// with a misleading credentials error, while doctor reported green).
|
|
94
|
-
execFileSync('git', ['push', repoUrl, 'HEAD:main'], { cwd: gitDir, stdio:
|
|
225
|
+
execFileSync('git', ['push', '-q', repoUrl, 'HEAD:main'], { cwd: gitDir, stdio: GIT_QUIET, timeout: 120000 });
|
|
95
226
|
|
|
96
227
|
spinner.succeed(chalk.green('Sync complete! ') + chalk.gray('(Uploaded securely to GitHub)'));
|
|
97
|
-
await appendEvent('sync_pushed', { provider: 'git' });
|
|
228
|
+
await appendEvent('sync_pushed', { provider: 'git', ms: Date.now() - started, reused_clone: reuse });
|
|
98
229
|
} catch (err) {
|
|
99
230
|
// Makes a silently-swallowed push failure (a non-fast-forward rejection
|
|
100
231
|
// from two racing pushes, a network error, bad credentials, etc.)
|
|
101
232
|
// visible in the event log instead of vanishing into the detached
|
|
102
|
-
// autopush child's ignored stdio.
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
await appendEvent('sync_failed', { provider: 'git' });
|
|
233
|
+
// autopush child's ignored stdio. `reason` is a short enum, never the
|
|
234
|
+
// raw error text/repo URL — those can contain usernames/paths.
|
|
235
|
+
const reason = classifyGitError(err);
|
|
236
|
+
await appendEvent('sync_failed', { provider: 'git', reason, ms: Date.now() - started });
|
|
106
237
|
if (err.message.includes('invalid characters')) throw err;
|
|
107
|
-
throw new Error(
|
|
238
|
+
throw new Error(`Failed to push to git repository (${reason}). Ensure your credentials are configured and the repository exists.`);
|
|
108
239
|
} finally {
|
|
109
|
-
await fs.remove(gitDir);
|
|
240
|
+
await fs.remove(gitDir).catch(() => {});
|
|
110
241
|
}
|
|
111
242
|
}
|
package/src/providers/restore.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'fs-extra';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import { execFileSync } from 'child_process';
|
|
6
|
+
import { cloneForSync, checkoutFromRemote } from './index.js';
|
|
6
7
|
import { restoreMemories } from '../adapters/restore.js';
|
|
7
8
|
|
|
8
9
|
export async function fetchFromLocal(config, stagingDir, spinner, onlyFilter = null, autoYes = false) {
|
|
@@ -18,6 +19,7 @@ export async function fetchFromLocal(config, stagingDir, spinner, onlyFilter = n
|
|
|
18
19
|
spinner.text = `Fetching data from local directory: ${chalk.cyan(resolvedSource)}`;
|
|
19
20
|
await fs.copy(resolvedSource, stagingDir);
|
|
20
21
|
|
|
22
|
+
if (await fs.pathExists(path.join(stagingDir, 'manifest.enc'))) return false;
|
|
21
23
|
return await restoreMemories(stagingDir, spinner, onlyFilter, autoYes);
|
|
22
24
|
}
|
|
23
25
|
|
|
@@ -28,10 +30,12 @@ export async function fetchFromGit(config, stagingDir, spinner, onlyFilter = nul
|
|
|
28
30
|
spinner.text = `Cloning memory from Git remote: ${chalk.cyan(repoUrl)}`;
|
|
29
31
|
|
|
30
32
|
try {
|
|
31
|
-
|
|
33
|
+
cloneForSync(repoUrl, stagingDir);
|
|
34
|
+
if (!checkoutFromRemote(stagingDir, '.')) throw new Error('Could not read backup main branch');
|
|
32
35
|
} catch (err) {
|
|
33
36
|
throw new Error('Failed to pull from git repository. Ensure your SSH keys are configured and the repository is accessible.');
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
if (await fs.pathExists(path.join(stagingDir, 'manifest.enc'))) return false;
|
|
36
40
|
return await restoreMemories(stagingDir, spinner, onlyFilter, autoYes);
|
|
37
41
|
}
|
|
@@ -2,6 +2,7 @@ import crypto from 'crypto';
|
|
|
2
2
|
import { promisify } from 'util';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
4
|
import path from 'path';
|
|
5
|
+
import { listSafeFiles, readSafeFile, restoreFileSet, relativeFile, MAX_SNAPSHOT_FILES, MAX_SNAPSHOT_BYTES, MAX_FILE_BYTES } from './files.js';
|
|
5
6
|
|
|
6
7
|
const scryptAsync = promisify(crypto.scrypt);
|
|
7
8
|
|
|
@@ -50,6 +51,7 @@ export async function encryptBuffer(plaintext, passphrase) {
|
|
|
50
51
|
* Decrypt a buffer. Throws on wrong passphrase or tampered data.
|
|
51
52
|
*/
|
|
52
53
|
export async function decryptBuffer(data, passphrase) {
|
|
54
|
+
if (!Buffer.isBuffer(data) || data.length < 68) throw new Error('Truncated encrypted file');
|
|
53
55
|
const magic = data.subarray(0, 8);
|
|
54
56
|
if (!magic.equals(MAGIC)) {
|
|
55
57
|
throw new Error('Not a memoir-encrypted file (bad header)');
|
|
@@ -88,23 +90,16 @@ export async function encryptDirectory(srcDir, destDir, passphrase, spinner = nu
|
|
|
88
90
|
// Phase 2: Index files
|
|
89
91
|
if (spinner) spinner.text = 'Indexing files...';
|
|
90
92
|
const fileList = [];
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (entry.isDirectory()) {
|
|
97
|
-
await index(fullPath, relPath);
|
|
98
|
-
} else {
|
|
99
|
-
const stat = await fs.stat(fullPath);
|
|
100
|
-
fileList.push({ fullPath, relPath, size: stat.size });
|
|
101
|
-
}
|
|
102
|
-
}
|
|
93
|
+
for (const relPath of await listSafeFiles(srcDir)) {
|
|
94
|
+
const fullPath = path.join(srcDir, relPath);
|
|
95
|
+
const stat = await fs.stat(fullPath);
|
|
96
|
+
if (stat.size > MAX_FILE_BYTES) throw new Error('Snapshot file size limit exceeded');
|
|
97
|
+
fileList.push({ fullPath, relPath, size: stat.size });
|
|
103
98
|
}
|
|
104
|
-
await index(srcDir);
|
|
105
99
|
|
|
106
100
|
const totalFiles = fileList.length;
|
|
107
101
|
const totalBytes = fileList.reduce((sum, f) => sum + f.size, 0);
|
|
102
|
+
if (totalBytes > MAX_SNAPSHOT_BYTES) throw new Error('Snapshot size limit exceeded');
|
|
108
103
|
|
|
109
104
|
// Phase 3: Encrypt files
|
|
110
105
|
const manifest = {};
|
|
@@ -118,9 +113,10 @@ export async function encryptDirectory(srcDir, destDir, passphrase, spinner = nu
|
|
|
118
113
|
.digest('hex')
|
|
119
114
|
.slice(0, 24);
|
|
120
115
|
|
|
121
|
-
const plaintext = await
|
|
116
|
+
const plaintext = await readSafeFile(srcDir, relPath);
|
|
122
117
|
const iv = crypto.randomBytes(IV_LENGTH);
|
|
123
118
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
|
|
119
|
+
cipher.setAAD(Buffer.from(relPath, 'utf8'));
|
|
124
120
|
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
125
121
|
const tag = cipher.getAuthTag();
|
|
126
122
|
|
|
@@ -143,7 +139,7 @@ export async function encryptDirectory(srcDir, destDir, passphrase, spinner = nu
|
|
|
143
139
|
|
|
144
140
|
// Phase 4: Encrypt manifest
|
|
145
141
|
if (spinner) spinner.text = 'Encrypting file manifest...';
|
|
146
|
-
const manifestJson = Buffer.from(JSON.stringify(manifest));
|
|
142
|
+
const manifestJson = Buffer.from(JSON.stringify({ version: 2, files: manifest }));
|
|
147
143
|
const manifestEncrypted = await encryptBuffer(manifestJson, passphrase);
|
|
148
144
|
await fs.writeFile(path.join(destDir, 'manifest.enc'), manifestEncrypted);
|
|
149
145
|
|
|
@@ -166,53 +162,31 @@ function formatBytes(bytes) {
|
|
|
166
162
|
* Decrypt an encrypted directory back to plaintext.
|
|
167
163
|
*/
|
|
168
164
|
export async function decryptDirectory(encDir, destDir, passphrase, spinner = null) {
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// Phase 2: Derive key
|
|
178
|
-
if (spinner) spinner.text = 'Deriving decryption key (scrypt)...';
|
|
179
|
-
const salt = await fs.readFile(path.join(encDir, 'salt'));
|
|
165
|
+
const manifestData = await readSafeFile(encDir, 'manifest.enc');
|
|
166
|
+
const decoded = JSON.parse((await decryptBuffer(manifestData, passphrase)).toString('utf8'));
|
|
167
|
+
const version = decoded.version === 2 ? 2 : 1;
|
|
168
|
+
const manifest = version === 2 ? decoded.files : decoded;
|
|
169
|
+
if (!manifest || Array.isArray(manifest) || typeof manifest !== 'object' || Object.keys(manifest).length > MAX_SNAPSHOT_FILES) throw new Error('Invalid encrypted manifest');
|
|
170
|
+
const salt = await readSafeFile(encDir, 'salt');
|
|
171
|
+
if (salt.length !== SALT_LENGTH) throw new Error('Invalid encryption salt');
|
|
180
172
|
const { key } = await deriveKey(passphrase, salt);
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const ciphertext = data.subarray(IV_LENGTH + TAG_LENGTH);
|
|
196
|
-
|
|
197
|
-
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
|
|
198
|
-
decipher.setAuthTag(tag);
|
|
199
|
-
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
200
|
-
|
|
201
|
-
const outPath = path.join(destDir, relPath);
|
|
202
|
-
await fs.ensureDir(path.dirname(outPath));
|
|
203
|
-
await fs.writeFile(outPath, decrypted);
|
|
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
|
-
}
|
|
173
|
+
const files = [];
|
|
174
|
+
let bytes = 0;
|
|
175
|
+
for (const [hashedName, storedPath] of Object.entries(manifest)) {
|
|
176
|
+
if (!/^[a-f0-9]{24}$/.test(hashedName)) throw new Error('Invalid encrypted blob name');
|
|
177
|
+
const relPath = relativeFile(storedPath);
|
|
178
|
+
const data = await readSafeFile(encDir, 'data/' + hashedName + '.enc', { maxBytes: MAX_FILE_BYTES + IV_LENGTH + TAG_LENGTH });
|
|
179
|
+
if (data.length < IV_LENGTH + TAG_LENGTH) throw new Error('Truncated encrypted blob');
|
|
180
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, data.subarray(0, IV_LENGTH), { authTagLength: TAG_LENGTH });
|
|
181
|
+
if (version === 2) decipher.setAAD(Buffer.from(storedPath, 'utf8'));
|
|
182
|
+
decipher.setAuthTag(data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH));
|
|
183
|
+
const content = Buffer.concat([decipher.update(data.subarray(IV_LENGTH + TAG_LENGTH)), decipher.final()]);
|
|
184
|
+
bytes += content.length;
|
|
185
|
+
if (bytes > MAX_SNAPSHOT_BYTES) throw new Error('Snapshot size limit exceeded');
|
|
186
|
+
files.push({ path: relPath, content });
|
|
211
187
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (spinner) spinner.text = `Decrypted ${count} files (${formatBytes(bytesProcessed)}) in ${elapsed}s`;
|
|
215
|
-
|
|
188
|
+
const count = await restoreFileSet(destDir, files);
|
|
189
|
+
if (spinner) spinner.text = 'Decrypted and verified ' + count + ' files (' + formatBytes(bytes) + ')';
|
|
216
190
|
return count;
|
|
217
191
|
}
|
|
218
192
|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { constants } from 'fs';
|
|
5
|
+
|
|
6
|
+
export const MAX_FILE_BYTES = 16 * 1024 * 1024;
|
|
7
|
+
export const MAX_SNAPSHOT_BYTES = 256 * 1024 * 1024;
|
|
8
|
+
export const MAX_SNAPSHOT_FILES = 50_000;
|
|
9
|
+
|
|
10
|
+
// Validate both Windows and POSIX paths regardless of the restoring OS.
|
|
11
|
+
export function relativeFile(value) {
|
|
12
|
+
if (typeof value !== 'string' || !value || /[\x00-\x1f:]/.test(value) || path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) throw new Error('Invalid relative memory path');
|
|
13
|
+
const parts = value.replace(/\\/g, '/').split('/');
|
|
14
|
+
if (parts.some(p => !p || p === '.' || p === '..' || p.toLowerCase() === '.git' || /[. ]$/.test(p) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i.test(p))) throw new Error('Invalid relative memory path');
|
|
15
|
+
return parts.join('/');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function memoryFilename(value) {
|
|
19
|
+
const name = relativeFile(value.endsWith('.md') ? value : value + '.md');
|
|
20
|
+
if (name.includes('/')) throw new Error('Memory filename must be a bare .md name');
|
|
21
|
+
return name;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Platform root aliases such as /tmp are supported; child symlinks are not.
|
|
25
|
+
export async function safePath(root, relative, { createParents = false } = {}) {
|
|
26
|
+
const rel = relativeFile(relative);
|
|
27
|
+
if (createParents) await fs.ensureDir(root, { mode: 0o700 });
|
|
28
|
+
const base = await fs.realpath(root);
|
|
29
|
+
const parts = rel.split('/');
|
|
30
|
+
let current = base;
|
|
31
|
+
for (let i = 0; i < parts.length; i++) {
|
|
32
|
+
current = path.join(current, parts[i]);
|
|
33
|
+
let st;
|
|
34
|
+
try { st = await fs.lstat(current); }
|
|
35
|
+
catch (err) {
|
|
36
|
+
if (err.code !== 'ENOENT') throw err;
|
|
37
|
+
if (createParents && i < parts.length - 1) {
|
|
38
|
+
await fs.mkdir(current, { mode: 0o700 }).catch(err => { if (err.code !== 'EEXIST') throw err; });
|
|
39
|
+
st = await fs.lstat(current);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (st?.isSymbolicLink()) throw new Error('Symlinks are not allowed in memory paths');
|
|
43
|
+
if (st && i < parts.length - 1 && !st.isDirectory()) throw new Error('Memory parent is not a directory');
|
|
44
|
+
if (st && i === parts.length - 1 && !st.isFile()) throw new Error('Memory path is not a regular file');
|
|
45
|
+
}
|
|
46
|
+
return current;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function readSafeFile(root, relative, { maxBytes = MAX_FILE_BYTES } = {}) {
|
|
50
|
+
const full = await safePath(root, relative);
|
|
51
|
+
const fd = await fs.open(full, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
52
|
+
try {
|
|
53
|
+
const st = await fs.fstat(fd);
|
|
54
|
+
if (!st.isFile() || st.size > maxBytes) throw new Error('Memory file is not regular or exceeds the size limit');
|
|
55
|
+
await safePath(root, relative);
|
|
56
|
+
return await fs.readFile(fd);
|
|
57
|
+
} finally { await fs.close(fd); }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// One inventory refresh may inspect thousands of files sharing the same
|
|
61
|
+
// parents. Validate each parent once within this short-lived context, while
|
|
62
|
+
// always lstat'ing the leaf. This is a metadata optimization only: content
|
|
63
|
+
// reads still use readSafeFile, and search revalidates returned sources with
|
|
64
|
+
// safePath. Never retain this context across queries or use it for writes.
|
|
65
|
+
export async function createReadInventory(root) {
|
|
66
|
+
const base = await fs.realpath(root);
|
|
67
|
+
const parents = new Map();
|
|
68
|
+
const parent = dir => {
|
|
69
|
+
if (!parents.has(dir)) parents.set(dir, (async () => {
|
|
70
|
+
if (dir !== base) await parent(path.dirname(dir));
|
|
71
|
+
const st = await fs.lstat(dir);
|
|
72
|
+
if (!st.isDirectory() || st.isSymbolicLink()) throw new Error('Memory parent is not a regular directory');
|
|
73
|
+
})());
|
|
74
|
+
return parents.get(dir);
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
root: base,
|
|
78
|
+
async stat(relative) {
|
|
79
|
+
const rel = relativeFile(relative);
|
|
80
|
+
const full = path.join(base, rel);
|
|
81
|
+
await parent(path.dirname(full));
|
|
82
|
+
const stat = await fs.lstat(full);
|
|
83
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_FILE_BYTES) throw new Error('Memory file is not regular or exceeds the size limit');
|
|
84
|
+
return { full, stat, relative: rel };
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function writeSafeFile(root, relative, content) {
|
|
90
|
+
const full = await safePath(root, relative, { createParents: true });
|
|
91
|
+
const tmp = path.join(path.dirname(full), '.memoir-write-' + crypto.randomUUID());
|
|
92
|
+
try {
|
|
93
|
+
await fs.writeFile(tmp, content, { flag: 'wx', mode: 0o600 });
|
|
94
|
+
await safePath(root, relative);
|
|
95
|
+
await fs.rename(tmp, full);
|
|
96
|
+
} finally { await fs.remove(tmp).catch(() => {}); }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function listSafeFiles(root) {
|
|
100
|
+
const result = [];
|
|
101
|
+
const base = await fs.realpath(root);
|
|
102
|
+
async function walk(dir, prefix = '') {
|
|
103
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
104
|
+
const rel = relativeFile(prefix + entry.name);
|
|
105
|
+
if (entry.isSymbolicLink()) throw new Error('Snapshot contains a symlink: ' + rel);
|
|
106
|
+
if (entry.isDirectory()) await walk(path.join(dir, entry.name), rel + '/');
|
|
107
|
+
else if (entry.isFile()) result.push(rel);
|
|
108
|
+
else throw new Error('Snapshot contains a non-regular file: ' + rel);
|
|
109
|
+
if (result.length > MAX_SNAPSHOT_FILES) throw new Error('Snapshot file limit exceeded');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
await walk(base);
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Validate everything, build a replacement beside the destination, then swap.
|
|
117
|
+
// Corrupt/incomplete snapshots cannot partially overwrite the prior destination.
|
|
118
|
+
export async function restoreFileSet(dest, entries) {
|
|
119
|
+
if (!Array.isArray(entries) || entries.length > MAX_SNAPSHOT_FILES) throw new Error('Invalid snapshot file list');
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
let bytes = 0;
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
entry.path = relativeFile(entry.path);
|
|
124
|
+
const key = entry.path.normalize('NFC').toLowerCase();
|
|
125
|
+
if (seen.has(key)) throw new Error('Duplicate snapshot path');
|
|
126
|
+
seen.add(key);
|
|
127
|
+
if (!Buffer.isBuffer(entry.content) || entry.content.length > MAX_FILE_BYTES) throw new Error('Snapshot file limit exceeded');
|
|
128
|
+
bytes += entry.content.length;
|
|
129
|
+
if (bytes > MAX_SNAPSHOT_BYTES) throw new Error('Snapshot size limit exceeded');
|
|
130
|
+
}
|
|
131
|
+
for (const key of seen) {
|
|
132
|
+
const parts = key.split('/');
|
|
133
|
+
while (parts.length > 1) {
|
|
134
|
+
parts.pop();
|
|
135
|
+
if (seen.has(parts.join('/'))) throw new Error('Conflicting snapshot paths');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const absolute = path.resolve(dest);
|
|
139
|
+
await fs.ensureDir(path.dirname(absolute));
|
|
140
|
+
const prior = await fs.lstat(absolute).catch(err => { if (err.code !== 'ENOENT') throw err; return null; });
|
|
141
|
+
if (prior && (!prior.isDirectory() || prior.isSymbolicLink())) throw new Error('Restore destination must be a directory, not a symlink');
|
|
142
|
+
if (prior) for (const entry of entries) await safePath(absolute, entry.path);
|
|
143
|
+
const staged = await fs.mkdtemp(path.join(path.dirname(absolute), '.memoir-restore-'));
|
|
144
|
+
const previous = staged + '-previous';
|
|
145
|
+
let moved = false;
|
|
146
|
+
try {
|
|
147
|
+
if (prior) await fs.copy(absolute, staged, { dereference: false });
|
|
148
|
+
for (const entry of entries) await writeSafeFile(staged, entry.path, entry.content);
|
|
149
|
+
if (prior) { await fs.rename(absolute, previous); moved = true; }
|
|
150
|
+
try { await fs.rename(staged, absolute); }
|
|
151
|
+
catch (err) { if (moved) await fs.rename(previous, absolute); throw err; }
|
|
152
|
+
if (moved) await fs.remove(previous);
|
|
153
|
+
} finally { await fs.remove(staged).catch(() => {}); }
|
|
154
|
+
return entries.length;
|
|
155
|
+
}
|