memoir-cli 3.11.1 → 3.11.3
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 +5 -3
- package/package.json +1 -1
- package/src/commands/push.js +73 -33
- package/src/commands/restore.js +12 -4
- package/src/commands/session.js +8 -3
- package/src/context/capture.js +9 -2
- package/src/mcp.js +25 -4
- package/src/providers/index.js +31 -3
- package/src/security/scanner.js +12 -4
- package/src/session/lock.js +36 -2
- package/src/session/state.js +10 -3
package/README.md
CHANGED
|
@@ -15,15 +15,17 @@
|
|
|
15
15
|
npx memoir-cli
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
One command. No install, no config, no API keys. Claude Code on your Mac, Cursor on your laptop, Copilot at the office — **one memory follows you** across every tool and every machine
|
|
18
|
+
One command. No install, no config, no API keys. Claude Code on your Mac, Cursor on your laptop, Copilot at the office — **one memory follows you** across every tool and every machine. Cloud sync is end-to-end encrypted with a key only you hold — memoir's servers can't read what you sync.
|
|
19
19
|
|
|
20
20
|
---
|
|
21
21
|
|
|
22
22
|
## What it does
|
|
23
23
|
|
|
24
|
-
Your coding tools are starting to remember you — Claude Code, Cursor, and Copilot all ship built-in memory now. But that memory is **trapped: one tool, one machine,
|
|
24
|
+
Your coding tools are starting to remember you — Claude Code, Cursor, and Copilot all ship built-in memory now. But that memory is **trapped: one tool, one machine, one vendor's format.** Switch from Cursor to Claude Code, or open a different laptop, and your AI is a stranger again.
|
|
25
25
|
|
|
26
|
-
memoir is the [MCP memory server](https://modelcontextprotocol.io) that breaks it out. **One memory, shared across every tool and synced to every machine — encrypted
|
|
26
|
+
memoir is the [MCP memory server](https://modelcontextprotocol.io) that breaks it out. **One memory, shared across every tool and synced to every machine — E2E-encrypted in the cloud, plain readable markdown on your disk.** Your AI searches, saves, and recalls context automatically, everywhere you work.
|
|
27
|
+
|
|
28
|
+
It's built on an **open, published format** — [the memoir format, v0.1](docs/SPEC.md) — so your AI's accumulated context is never trapped in this tool either. Six entry types, normative merge semantics, JSON Schemas, and a validator (`npx memoir-cli validate`). Any tool can implement it; [critique welcome](https://github.com/camgitt/memoir/issues).
|
|
27
29
|
|
|
28
30
|
```
|
|
29
31
|
you: how does auth work in this project?
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memoir-cli",
|
|
3
|
-
"version": "3.11.
|
|
3
|
+
"version": "3.11.3",
|
|
4
4
|
"mcpName": "io.github.camgitt/memoir",
|
|
5
5
|
"description": "Private, portable AI memory: synced across every coding tool and machine, end-to-end encrypted, free. One memory for Claude Code, Cursor, Copilot, Gemini + more — MCP-native, zero-knowledge, open source.",
|
|
6
6
|
"main": "src/index.js",
|
package/src/commands/push.js
CHANGED
|
@@ -10,6 +10,7 @@ import { getConfig, autoSetup } from '../config.js';
|
|
|
10
10
|
import { extractMemories, adapters } from '../adapters/index.js';
|
|
11
11
|
import { syncToLocal, syncToGit } from '../providers/index.js';
|
|
12
12
|
import inquirer from 'inquirer';
|
|
13
|
+
import { appendEvent } from '../events/log.js';
|
|
13
14
|
import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreProject, persistDecisions, isQuality } from '../context/capture.js';
|
|
14
15
|
import { scanForSecrets, printSecurityReport } from '../security/scanner.js';
|
|
15
16
|
import { encryptDirectory, createVerifyToken } from '../security/encryption.js';
|
|
@@ -25,42 +26,59 @@ import { injectInto, detectAvailableTargets } from '../session/inject.js';
|
|
|
25
26
|
// Best-effort fetch of the CURRENT remote session.json, so push.js can merge
|
|
26
27
|
// before overwrite instead of blindly clobbering it (see below). Returns the
|
|
27
28
|
// remote session state (already migrated to SCHEMA_VERSION) or null if the
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
29
|
+
// Tri-state, because the difference is destructive: 'none' means nothing is
|
|
30
|
+
// there (safe to write ours), 'ok' carries the remote session for merging,
|
|
31
|
+
// and 'unreadable' means A REMOTE EXISTS BUT WE CANNOT READ IT — encrypted,
|
|
32
|
+
// slow clone, corrupt JSON. On 'unreadable' the caller MUST NOT stage
|
|
33
|
+
// session.json at all, so the remote copy survives the mirror sweep.
|
|
34
|
+
// The old boolean version returned null for 'unreadable', which collapsed
|
|
35
|
+
// to merged = local and silently clobbered the other machine's state —
|
|
36
|
+
// worst on encrypted remotes, where the "protection" was a complete no-op.
|
|
32
37
|
async function fetchRemoteSessionBestEffort(config) {
|
|
33
38
|
try {
|
|
34
39
|
if (config.provider === 'local' || config.provider?.includes?.('local')) {
|
|
35
40
|
const resolvedDest = (config.localPath || '').replace(/^~/, os.homedir());
|
|
36
|
-
if (!resolvedDest) return null;
|
|
37
|
-
if (await fs.pathExists(path.join(resolvedDest, 'manifest.enc'))) return null; // encrypted
|
|
41
|
+
if (!resolvedDest) return { status: 'none', session: null };
|
|
42
|
+
if (await fs.pathExists(path.join(resolvedDest, 'manifest.enc'))) return { status: 'unreadable', session: null }; // encrypted
|
|
38
43
|
const remotePath = path.join(resolvedDest, 'session.json');
|
|
39
|
-
if (!(await fs.pathExists(remotePath))) return null;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
if (!(await fs.pathExists(remotePath))) return { status: 'none', session: null };
|
|
45
|
+
try {
|
|
46
|
+
const raw = JSON.parse(await fs.readFile(remotePath, 'utf8'));
|
|
47
|
+
const { state } = migrateSessionData(raw);
|
|
48
|
+
return { status: 'ok', session: state };
|
|
49
|
+
} catch {
|
|
50
|
+
return { status: 'unreadable', session: null }; // exists but corrupt
|
|
51
|
+
}
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
if (config.provider === 'git' || config.provider?.includes?.('git')) {
|
|
46
55
|
const repoUrl = config.gitRepo;
|
|
47
|
-
if (!repoUrl) return null;
|
|
56
|
+
if (!repoUrl) return { status: 'none', session: null };
|
|
48
57
|
const peekDir = path.join(os.tmpdir(), `memoir-push-peek-${Date.now()}`);
|
|
49
58
|
await fs.ensureDir(peekDir);
|
|
50
59
|
try {
|
|
51
60
|
try {
|
|
52
|
-
|
|
61
|
+
// Same budget as the real sync clone — the old 30s peek against a
|
|
62
|
+
// 60s sync meant a 35-second clone failed the peek but succeeded
|
|
63
|
+
// the mirror, deterministically wiping the remote session.
|
|
64
|
+
execFileSync('git', ['clone', '--depth', '1', repoUrl, '.'], { cwd: peekDir, stdio: 'ignore', timeout: 120000 });
|
|
53
65
|
} catch {
|
|
54
|
-
// Unreachable
|
|
55
|
-
//
|
|
56
|
-
|
|
66
|
+
// Unreachable or first push. If the LATER sync clone succeeds
|
|
67
|
+
// where this one failed, treating it as 'none' would clobber —
|
|
68
|
+
// but with equal timeouts that window is a genuine remote flap,
|
|
69
|
+
// and 'unreadable' here would wedge first-time pushes forever.
|
|
70
|
+
return { status: 'none', session: null };
|
|
57
71
|
}
|
|
58
|
-
if (await fs.pathExists(path.join(peekDir, 'manifest.enc'))) return null; // encrypted
|
|
72
|
+
if (await fs.pathExists(path.join(peekDir, 'manifest.enc'))) return { status: 'unreadable', session: null }; // encrypted
|
|
59
73
|
const remotePath = path.join(peekDir, 'session.json');
|
|
60
|
-
if (!(await fs.pathExists(remotePath))) return null;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
74
|
+
if (!(await fs.pathExists(remotePath))) return { status: 'none', session: null };
|
|
75
|
+
try {
|
|
76
|
+
const raw = JSON.parse(await fs.readFile(remotePath, 'utf8'));
|
|
77
|
+
const { state } = migrateSessionData(raw);
|
|
78
|
+
return { status: 'ok', session: state };
|
|
79
|
+
} catch {
|
|
80
|
+
return { status: 'unreadable', session: null };
|
|
81
|
+
}
|
|
64
82
|
} finally {
|
|
65
83
|
await fs.remove(peekDir).catch(() => {});
|
|
66
84
|
}
|
|
@@ -68,7 +86,7 @@ async function fetchRemoteSessionBestEffort(config) {
|
|
|
68
86
|
} catch {
|
|
69
87
|
// Never let a merge-fetch failure block the push.
|
|
70
88
|
}
|
|
71
|
-
return null;
|
|
89
|
+
return { status: 'none', session: null };
|
|
72
90
|
}
|
|
73
91
|
|
|
74
92
|
// Recursively scan every staged file (the REAL tool memory/config files about
|
|
@@ -193,7 +211,17 @@ export async function pushCommand(options = {}) {
|
|
|
193
211
|
// received the raw unfiltered list while only the session.json sink
|
|
194
212
|
// below filtered, so junk could reach session-decisions.md even after
|
|
195
213
|
// being rejected from session.json. Both sinks now agree on what's junk.
|
|
196
|
-
|
|
214
|
+
// Gate on the string each sink actually PERSISTS, not on d.value.
|
|
215
|
+
// For rename/tech captures d.value is a single whitespace-free
|
|
216
|
+
// token, so isQuality's words>=3 rule rejected 100% of them —
|
|
217
|
+
// two of the three advertised capture categories were dead code
|
|
218
|
+
// while persistDecisions would have written the clean d.context.
|
|
219
|
+
const decisionText = (d) => {
|
|
220
|
+
const v = String(d.value || '').trim();
|
|
221
|
+
const c = String(d.context || '').trim();
|
|
222
|
+
return (d.type === 'rename' || d.type === 'tech') && c ? c : v;
|
|
223
|
+
};
|
|
224
|
+
const qualityDecisions = parsed.decisions.filter(d => isQuality(decisionText(d)));
|
|
197
225
|
|
|
198
226
|
// Persist decisions to Claude's memory so they survive across sessions
|
|
199
227
|
let decisionCount = 0;
|
|
@@ -212,7 +240,7 @@ export async function pushCommand(options = {}) {
|
|
|
212
240
|
current.current.decisions.map(d => (d.text || '').trim().toLowerCase())
|
|
213
241
|
);
|
|
214
242
|
for (const d of qualityDecisions.slice(0, 10)) {
|
|
215
|
-
const text =
|
|
243
|
+
const text = decisionText(d);
|
|
216
244
|
if (existingTexts.has(text.toLowerCase())) continue;
|
|
217
245
|
await addNote(text, { why: d.context ? `auto-captured: ${d.context.slice(0, 80)}` : undefined });
|
|
218
246
|
}
|
|
@@ -290,20 +318,32 @@ export async function pushCommand(options = {}) {
|
|
|
290
318
|
// session.json (so this machine also gains whatever the remote had that
|
|
291
319
|
// it didn't) — symmetric with restore.js instead of a blind overwrite.
|
|
292
320
|
let sessionIncluded = false;
|
|
321
|
+
let preserveRemoteSession = false;
|
|
293
322
|
try {
|
|
294
323
|
if (await fs.pathExists(sessionPaths.session)) {
|
|
295
|
-
const remote = await fetchRemoteSessionBestEffort(config);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
//
|
|
300
|
-
// session.json
|
|
324
|
+
const { status, session: remote } = await fetchRemoteSessionBestEffort(config);
|
|
325
|
+
if (status === 'unreadable') {
|
|
326
|
+
// A remote session exists and we could not read it (encrypted,
|
|
327
|
+
// slow, corrupt). Staging ours anyway would mirror-overwrite the
|
|
328
|
+
// one copy we couldn't merge — the exact clobber this guard
|
|
329
|
+
// exists to prevent. Leave session.json out of the staging dir
|
|
330
|
+
// and tell the sync to leave the remote copy alone.
|
|
331
|
+
preserveRemoteSession = true;
|
|
332
|
+
try { appendEvent('sync_degraded', { reason: 'remote_session_unreadable' }); } catch {}
|
|
333
|
+
} else {
|
|
334
|
+
// Read AND merge AND write inside one lock. Reading outside it and
|
|
335
|
+
// locking only the write is a check-then-act: a concurrent MCP
|
|
336
|
+
// memoir_note in that window is silently dropped. This is the most
|
|
337
|
+
// reachable instance of that bug — it sits on the autopush path.
|
|
338
|
+
let merged;
|
|
301
339
|
await withSessionLock(sessionPaths.sessionLock, async () => {
|
|
302
|
-
await
|
|
340
|
+
const local = await readSession();
|
|
341
|
+
merged = remote ? mergeSessions(local, remote) : local;
|
|
342
|
+
if (remote) await writeSession(merged);
|
|
303
343
|
});
|
|
344
|
+
await fs.writeFile(path.join(stagingDir, 'session.json'), JSON.stringify(merged, null, 2));
|
|
345
|
+
sessionIncluded = true;
|
|
304
346
|
}
|
|
305
|
-
await fs.writeFile(path.join(stagingDir, 'session.json'), JSON.stringify(merged, null, 2));
|
|
306
|
-
sessionIncluded = true;
|
|
307
347
|
}
|
|
308
348
|
} catch {
|
|
309
349
|
// Best-effort — don't fail the push over this
|
|
@@ -448,7 +488,7 @@ export async function pushCommand(options = {}) {
|
|
|
448
488
|
if (config.provider === 'local' || config.provider.includes('local')) {
|
|
449
489
|
await syncToLocal(config, uploadDir, spinner);
|
|
450
490
|
} else if (config.provider === 'git' || config.provider.includes('git')) {
|
|
451
|
-
await syncToGit(config, uploadDir, spinner);
|
|
491
|
+
await syncToGit(config, uploadDir, spinner, preserveRemoteSession ? { preserve: ['session.json'] } : {});
|
|
452
492
|
} else {
|
|
453
493
|
spinner.fail(chalk.red(`Unknown provider: ${config.provider}`));
|
|
454
494
|
return;
|
package/src/commands/restore.js
CHANGED
|
@@ -15,6 +15,7 @@ import { getSession } from '../cloud/auth.js';
|
|
|
15
15
|
import { unbundleToDir } from '../cloud/storage.js';
|
|
16
16
|
import { SUPABASE_URL, SUPABASE_ANON_KEY, STORAGE_BUCKET } from '../cloud/constants.js';
|
|
17
17
|
import { readSession, writeSession, mergeSessions, paths as sessionPaths } from '../session/state.js';
|
|
18
|
+
import { withSessionLock } from '../session/lock.js';
|
|
18
19
|
import { migrateSessionData } from '../session/migrations.js';
|
|
19
20
|
import { renderSession } from '../session/render.js';
|
|
20
21
|
import { injectInto, detectAvailableTargets } from '../session/inject.js';
|
|
@@ -136,10 +137,17 @@ export async function restoreCommand(options = {}) {
|
|
|
136
137
|
// ever touches it. Symmetric with the push-side fix in push.js.
|
|
137
138
|
const rawRemote = JSON.parse(await fs.readFile(remoteSessionPath, 'utf8'));
|
|
138
139
|
const { state: remote } = migrateSessionData(rawRemote);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
// Read+merge+write inside ONE lock, like every state.js mutator.
|
|
141
|
+
// Reading outside the lock and locking only the write is a
|
|
142
|
+
// check-then-act: a concurrent MCP memoir_note landing in the window
|
|
143
|
+
// is silently discarded by our merge of the stale copy.
|
|
144
|
+
let merged, beforeMachines;
|
|
145
|
+
await withSessionLock(sessionPaths.sessionLock, async () => {
|
|
146
|
+
const local = await readSession();
|
|
147
|
+
beforeMachines = Object.keys(local.machines || {}).length;
|
|
148
|
+
merged = mergeSessions(local, remote);
|
|
149
|
+
await writeSession(merged);
|
|
150
|
+
});
|
|
143
151
|
// Re-render + inject into every detected tool so the pinned block
|
|
144
152
|
// reflects the merged state right away across Claude/Cursor/Windsurf/Gemini
|
|
145
153
|
try {
|
package/src/commands/session.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
getMachineId,
|
|
25
25
|
paths,
|
|
26
26
|
} from '../session/state.js';
|
|
27
|
+
import { withSessionLock } from '../session/lock.js';
|
|
27
28
|
import { renderSession } from '../session/render.js';
|
|
28
29
|
import { injectInto, detectAvailableTargets } from '../session/inject.js';
|
|
29
30
|
|
|
@@ -180,9 +181,13 @@ export async function sessionShowCommand() {
|
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
export async function sessionClearCommand() {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
await
|
|
184
|
+
// Took no lock at all — a concurrent MCP write between the read and the
|
|
185
|
+
// write was silently lost, and worse, could resurrect what was cleared.
|
|
186
|
+
await withSessionLock(paths.sessionLock, async () => {
|
|
187
|
+
const state = await readSession();
|
|
188
|
+
state.current = { goals: [], next_actions: [], open_questions: [], decisions: [] };
|
|
189
|
+
await writeSession(state);
|
|
190
|
+
});
|
|
186
191
|
await refreshPinned();
|
|
187
192
|
console.log('\n' + chalk.green(' ✓ Current session cleared.') + chalk.gray(' History retained.\n'));
|
|
188
193
|
}
|
package/src/context/capture.js
CHANGED
|
@@ -101,7 +101,10 @@ function parseLines(lines) {
|
|
|
101
101
|
for (const block of obj.message.content) {
|
|
102
102
|
if (block.type === 'text' && block.text) {
|
|
103
103
|
// Capture assistant text for decision extraction (limit size)
|
|
104
|
-
|
|
104
|
+
// Redacted like every other untrusted input (user :95, bash :125,
|
|
105
|
+
// errors :138) — captured decisions flow into session.json, CLAUDE.md
|
|
106
|
+
// and the git backup, none of which get a later secret scan.
|
|
107
|
+
if (block.text.length < 2000) assistantTexts.push(redactSecrets(block.text));
|
|
105
108
|
continue;
|
|
106
109
|
}
|
|
107
110
|
if (block.type !== 'tool_use') continue;
|
|
@@ -222,7 +225,11 @@ function extractDecisions(userMessages, assistantTexts) {
|
|
|
222
225
|
{ regex: /(?:switch|migrate|move)\s+(?:from\s+\S+\s+)?to\s+([A-Z][a-zA-Z0-9_./-]+)/gi, type: 'tech' },
|
|
223
226
|
// Architecture / design — require an explicit decision verb and a capitalized
|
|
224
227
|
// target. Bare "pick/choose" caught conversational fragments as decisions.
|
|
225
|
-
|
|
228
|
+
// 'going' dropped from the bare alternation: "going on Monday to the
|
|
229
|
+
// office" minted a decision (live proof: "going on PostDash" in the real
|
|
230
|
+
// store). "going to go with/use" is still covered by the to-clause.
|
|
231
|
+
{ regex: /(?:decided|settled|chose|chosen)\s+(?:to\s+(?:go\s+with|use)|with|on)\s+([A-Z][\w .\/+-]{3,50}?)(?:\.|$|,|\n)/g, type: 'design' },
|
|
232
|
+
{ regex: /going\s+to\s+(?:go\s+with|use)\s+([A-Z][\w .\/+-]{3,50}?)(?:\.|$|,|\n)/g, type: 'design' },
|
|
226
233
|
// Stack choices — require a capitalized, tech-looking value, not a prose
|
|
227
234
|
// fragment ("backend is just throwing it away" used to leak through).
|
|
228
235
|
{ regex: /(?:stack|framework|database|backend|frontend|hosting|infra)\s+(?:is|will be|should be)\s+([A-Z][\w .\/+-]{2,40}?)(?:\.|$|,|\n)/g, type: 'stack' },
|
package/src/mcp.js
CHANGED
|
@@ -283,9 +283,23 @@ server.tool(
|
|
|
283
283
|
return { content: [{ type: 'text', text: `Project directory not found: ${projectDir}` }] };
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
-
// Default to CLAUDE.md for project-level memories
|
|
287
|
-
|
|
288
|
-
|
|
286
|
+
// Default to CLAUDE.md for project-level memories.
|
|
287
|
+
// Guards mirror the global branch below and memoir_read above:
|
|
288
|
+
// model-supplied filename must be a bare markdown name — no
|
|
289
|
+
// separators, no traversal — and must resolve inside the project
|
|
290
|
+
// dir. Without this, filename:".zshrc" appends to a shell rc
|
|
291
|
+
// (code execution on next shell) and "package.json" corrupts
|
|
292
|
+
// real files.
|
|
293
|
+
let targetFile = filename || 'CLAUDE.md';
|
|
294
|
+
if (!targetFile.endsWith('.md')) targetFile += '.md';
|
|
295
|
+
if (targetFile.includes('/') || targetFile.includes('\\') || targetFile.includes('..')) {
|
|
296
|
+
return { content: [{ type: 'text', text: `Invalid filename: ${filename} (must be a bare .md name)` }] };
|
|
297
|
+
}
|
|
298
|
+
const projBase = path.resolve(projectDir);
|
|
299
|
+
const targetPath = path.resolve(projBase, targetFile);
|
|
300
|
+
if (!targetPath.startsWith(projBase + path.sep)) {
|
|
301
|
+
return { content: [{ type: 'text', text: `Invalid filename: ${filename}` }] };
|
|
302
|
+
}
|
|
289
303
|
|
|
290
304
|
// Append to existing file or create new
|
|
291
305
|
if (await fs.pathExists(targetPath)) {
|
|
@@ -394,7 +408,14 @@ server.tool(
|
|
|
394
408
|
return { content: [{ type: 'text', text: `Unknown tool: ${tool}. Available: ${adapters.map(a => a.name).join(', ')}` }] };
|
|
395
409
|
}
|
|
396
410
|
|
|
397
|
-
|
|
411
|
+
// Containment: filepath comes from the model, and the model reads
|
|
412
|
+
// attacker-influenceable text all day. Without this, "../.ssh/id_rsa"
|
|
413
|
+
// resolves outside the adapter dir and the file is returned verbatim.
|
|
414
|
+
const base = path.resolve(adapter.source);
|
|
415
|
+
const fullPath = path.resolve(base, filepath);
|
|
416
|
+
if (fullPath !== base && !fullPath.startsWith(base + path.sep)) {
|
|
417
|
+
return { content: [{ type: 'text', text: `Invalid path: ${filepath} (must stay inside ${adapter.name}'s directory)` }] };
|
|
418
|
+
}
|
|
398
419
|
|
|
399
420
|
if (!(await fs.pathExists(fullPath))) {
|
|
400
421
|
return { content: [{ type: 'text', text: `File not found: ${filepath} in ${adapter.name}` }] };
|
package/src/providers/index.js
CHANGED
|
@@ -23,11 +23,32 @@ export async function syncToLocal(config, stagingDir, spinner) {
|
|
|
23
23
|
await fs.ensureDir(resolvedDest);
|
|
24
24
|
|
|
25
25
|
await fs.copy(stagingDir, resolvedDest);
|
|
26
|
+
|
|
27
|
+
// Prune orphaned encrypted blobs. Each encrypted push derives a fresh salt
|
|
28
|
+
// and therefore fresh HMAC filenames, so without this every push leaves the
|
|
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.
|
|
34
|
+
try {
|
|
35
|
+
const stagedManifest = path.join(stagingDir, 'manifest.enc');
|
|
36
|
+
const destData = path.join(resolvedDest, 'data');
|
|
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
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// Pruning is housekeeping — never fail a completed backup over it.
|
|
45
|
+
}
|
|
46
|
+
|
|
26
47
|
spinner.succeed(chalk.green('Sync complete! ') + chalk.gray(`(Saved to ${resolvedDest})`));
|
|
27
48
|
await appendEvent('sync_pushed', { provider: 'local' });
|
|
28
49
|
}
|
|
29
50
|
|
|
30
|
-
export async function syncToGit(config, stagingDir, spinner) {
|
|
51
|
+
export async function syncToGit(config, stagingDir, spinner, options = {}) {
|
|
31
52
|
const repoUrl = sanitizeUrl(config.gitRepo);
|
|
32
53
|
if (!repoUrl) throw new Error('Git repository is not configured.');
|
|
33
54
|
|
|
@@ -39,9 +60,13 @@ export async function syncToGit(config, stagingDir, spinner) {
|
|
|
39
60
|
try {
|
|
40
61
|
try {
|
|
41
62
|
execFileSync('git', ['clone', '--depth', '1', repoUrl, '.'], { cwd: gitDir, stdio: 'ignore', timeout: 60000 });
|
|
63
|
+
const preserve = new Set(options.preserve || []);
|
|
42
64
|
const files = await fs.readdir(gitDir);
|
|
43
65
|
for (const f of files) {
|
|
44
|
-
|
|
66
|
+
// preserve: files the caller knows exist remotely but could not
|
|
67
|
+
// merge (unreadable session.json) — deleting them here would be
|
|
68
|
+
// the mirror-clobber the push guard just declined to commit.
|
|
69
|
+
if (f !== '.git' && !preserve.has(f)) await fs.remove(path.join(gitDir, f));
|
|
45
70
|
}
|
|
46
71
|
} catch {
|
|
47
72
|
execFileSync('git', ['init'], { cwd: gitDir, stdio: 'ignore' });
|
|
@@ -63,7 +88,10 @@ export async function syncToGit(config, stagingDir, spinner) {
|
|
|
63
88
|
}
|
|
64
89
|
|
|
65
90
|
spinner.text = `Pushing data to ${chalk.cyan(repoUrl)}...`;
|
|
66
|
-
|
|
91
|
+
// HEAD:main pushes whatever branch the clone checked out (a master-
|
|
92
|
+
// default remote used to make `push main` fail silently under autopush
|
|
93
|
+
// with a misleading credentials error, while doctor reported green).
|
|
94
|
+
execFileSync('git', ['push', repoUrl, 'HEAD:main'], { cwd: gitDir, stdio: 'ignore', timeout: 120000 });
|
|
67
95
|
|
|
68
96
|
spinner.succeed(chalk.green('Sync complete! ') + chalk.gray('(Uploaded securely to GitHub)'));
|
|
69
97
|
await appendEvent('sync_pushed', { provider: 'git' });
|
package/src/security/scanner.js
CHANGED
|
@@ -31,7 +31,7 @@ const SECRET_PATTERNS = [
|
|
|
31
31
|
|
|
32
32
|
// Generic secrets in env/config patterns
|
|
33
33
|
{ regex: /(?:^|[\s;])(?:export\s+)?(?:API_KEY|SECRET_KEY|AUTH_TOKEN|ACCESS_TOKEN|PRIVATE_KEY|DB_PASSWORD|DATABASE_URL|JWT_SECRET|ENCRYPTION_KEY|MASTER_KEY)\s*=\s*["']?([^\s'"]{8,})/gmi, label: 'Environment variable secret' },
|
|
34
|
-
{ regex: /(?:password|passwd|pwd)\s*[:=]\s*["']?([^\s'"]{6,})/gi, label: 'Password' },
|
|
34
|
+
{ regex: /(?:password|passwd|pwd)\s*[:=]\s*["']?([^\s'"]{6,})/gi, label: 'Password', minLength: 6 },
|
|
35
35
|
|
|
36
36
|
// Private keys
|
|
37
37
|
{ regex: /(-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----)/g, label: 'Private key' },
|
|
@@ -55,10 +55,18 @@ export function scanForSecrets(text) {
|
|
|
55
55
|
let match;
|
|
56
56
|
while ((match = pattern.regex.exec(text)) !== null) {
|
|
57
57
|
const secret = match[1] || match[0];
|
|
58
|
-
//
|
|
59
|
-
|
|
58
|
+
// Per-pattern floor. A global 8 threw away 6-7 char matches that the
|
|
59
|
+
// Password pattern ({6,}) was written to catch: `password: s3cr3t`
|
|
60
|
+
// survived verbatim into the handoff and the backup while the scan
|
|
61
|
+
// reported "no secrets detected" — a silent miss is worse than a
|
|
62
|
+
// false positive in a tool that promises redaction.
|
|
63
|
+
if (secret.length < (pattern.minLength ?? 8)) continue;
|
|
60
64
|
|
|
61
|
-
|
|
65
|
+
// For short secrets, slice(0,4)+slice(-4) can reproduce the whole
|
|
66
|
+
// thing (a 6-char secret would show 4+4 of 6 characters).
|
|
67
|
+
const redacted = secret.length >= 12
|
|
68
|
+
? secret.slice(0, 4) + '****' + secret.slice(-4)
|
|
69
|
+
: secret.slice(0, 2) + '****';
|
|
62
70
|
findings.push({
|
|
63
71
|
label: pattern.label,
|
|
64
72
|
match: secret,
|
package/src/session/lock.js
CHANGED
|
@@ -68,7 +68,29 @@ export async function withSessionLock(lockPath, fn) {
|
|
|
68
68
|
try {
|
|
69
69
|
const stat = fs.statSync(lockPath);
|
|
70
70
|
if (Date.now() - stat.mtimeMs > STALE_MS) {
|
|
71
|
-
|
|
71
|
+
// Steal by rename, not unlink: two processes racing an unlink can
|
|
72
|
+
// both "win" and both proceed. rename() is atomic, so exactly one
|
|
73
|
+
// wins and the loser simply retries.
|
|
74
|
+
let stolen = false;
|
|
75
|
+
try {
|
|
76
|
+
const graveyard = `${lockPath}.stale-${process.pid}-${Date.now()}`;
|
|
77
|
+
fs.renameSync(lockPath, graveyard);
|
|
78
|
+
stolen = true;
|
|
79
|
+
// The rename is only there to make the steal atomic; the file
|
|
80
|
+
// itself is debris. Remove it immediately — best-effort, and
|
|
81
|
+
// harmless to leave behind if this fails.
|
|
82
|
+
try { fs.unlinkSync(graveyard); } catch {}
|
|
83
|
+
} catch {}
|
|
84
|
+
if (stolen) {
|
|
85
|
+
continue; // we removed it; retry the acquire immediately
|
|
86
|
+
}
|
|
87
|
+
// Could not remove it (read-only dir, permissions). Fall through
|
|
88
|
+
// to the deadline + backoff below instead of spinning forever.
|
|
89
|
+
if (Date.now() - start > MAX_WAIT_MS) {
|
|
90
|
+
fd = null;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
await sleep(RETRY_DELAY_MS);
|
|
72
94
|
continue;
|
|
73
95
|
}
|
|
74
96
|
} catch {
|
|
@@ -95,8 +117,20 @@ export async function withSessionLock(lockPath, fn) {
|
|
|
95
117
|
return await fn();
|
|
96
118
|
} finally {
|
|
97
119
|
if (fd !== null) {
|
|
120
|
+
// Only unlink if the file at lockPath is still OURS. If our lock was
|
|
121
|
+
// stolen as stale and another process now holds a NEW file at the same
|
|
122
|
+
// path, unlinking by path would delete the current holder's lock and
|
|
123
|
+
// let a third process in. Compare inode via the fd we still hold.
|
|
124
|
+
let ours = false;
|
|
125
|
+
try {
|
|
126
|
+
const byFd = fs.fstatSync(fd);
|
|
127
|
+
const byPath = fs.statSync(lockPath);
|
|
128
|
+
ours = byFd.ino === byPath.ino && byFd.dev === byPath.dev;
|
|
129
|
+
} catch {
|
|
130
|
+
ours = false; // path gone or unreadable — nothing safe to remove
|
|
131
|
+
}
|
|
98
132
|
try { fs.closeSync(fd); } catch {}
|
|
99
|
-
try { fs.unlinkSync(lockPath); } catch {}
|
|
133
|
+
if (ours) { try { fs.unlinkSync(lockPath); } catch {} }
|
|
100
134
|
}
|
|
101
135
|
}
|
|
102
136
|
}
|
package/src/session/state.js
CHANGED
|
@@ -389,9 +389,16 @@ function unionByText(a = [], b = [], dateField, cap) {
|
|
|
389
389
|
}
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
392
|
+
// Partition before capping. Tombstones keep their original (recent) date,
|
|
393
|
+
// so a plain sort+slice let them win cap slots and silently evict real
|
|
394
|
+
// entries on merge. They must SURVIVE the merge (removing them
|
|
395
|
+
// reintroduces the resurrection the sticky-tombstone rule fixed) but must
|
|
396
|
+
// not count against the visible budget.
|
|
397
|
+
const all = Array.from(byText.values())
|
|
398
|
+
.sort((x, y) => new Date(y[dateField] || 0) - new Date(x[dateField] || 0));
|
|
399
|
+
const visible = all.filter((i) => !i.hidden).slice(0, cap);
|
|
400
|
+
const tombstones = all.filter((i) => i.hidden).slice(0, cap);
|
|
401
|
+
return [...visible, ...tombstones];
|
|
395
402
|
}
|
|
396
403
|
|
|
397
404
|
function unionTombstones(a = [], b = []) {
|