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/work/view.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// A bounded local editor, never a shell or general filesystem endpoint.
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { workRoot, reviewWork, recordWork, retractWork, restoreWork } from './store.js';
|
|
7
|
+
import { workErrorMessage } from './errors.js';
|
|
8
|
+
|
|
9
|
+
const assets = new Map([
|
|
10
|
+
['/', ['index.html', 'text/html; charset=utf-8']],
|
|
11
|
+
['/app.js', ['app.js', 'text/javascript; charset=utf-8']],
|
|
12
|
+
['/style.css', ['style.css', 'text/css; charset=utf-8']],
|
|
13
|
+
]);
|
|
14
|
+
const actionSchema = z.object({
|
|
15
|
+
action: z.enum(['save', 'remove', 'restore']),
|
|
16
|
+
branch: z.string().max(1024).nullable(),
|
|
17
|
+
id: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/),
|
|
18
|
+
expected_revision: z.number().int().nonnegative(),
|
|
19
|
+
category: z.enum(['record', 'check']).default('record'),
|
|
20
|
+
fields: z.object({ kind: z.enum(['goal', 'answer', 'decision', 'next']), text: z.string().min(1).max(2000), answer: z.string().max(2000).optional(), why: z.string().max(2000).optional(), status: z.enum(['open', 'done']).default('open') }).strict().optional(),
|
|
21
|
+
}).strict();
|
|
22
|
+
|
|
23
|
+
const headers = {
|
|
24
|
+
'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff',
|
|
25
|
+
'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY',
|
|
26
|
+
'Cross-Origin-Resource-Policy': 'same-origin', 'Cross-Origin-Opener-Policy': 'same-origin',
|
|
27
|
+
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
|
|
28
|
+
'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'none'",
|
|
29
|
+
};
|
|
30
|
+
function reply(res, status, value, type = 'application/json; charset=utf-8') {
|
|
31
|
+
res.writeHead(status, { ...headers, 'Content-Type': type });
|
|
32
|
+
res.end(type.startsWith('application/json') ? JSON.stringify(value) : value);
|
|
33
|
+
}
|
|
34
|
+
async function body(req) {
|
|
35
|
+
const parts = []; let bytes = 0;
|
|
36
|
+
for await (const chunk of req) {
|
|
37
|
+
bytes += chunk.length;
|
|
38
|
+
if (bytes > 16384) throw new Error('Request is too large. Nothing was saved.');
|
|
39
|
+
parts.push(chunk);
|
|
40
|
+
}
|
|
41
|
+
return JSON.parse(Buffer.concat(parts).toString());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function startWorkView(project, { port = 0 } = {}) {
|
|
45
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Choose a port between 0 and 65535.');
|
|
46
|
+
const root = await workRoot(project);
|
|
47
|
+
await reviewWork(root); // Refuse damaged data before announcing a working view.
|
|
48
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
49
|
+
const expectedAuth = Buffer.from('Bearer ' + token);
|
|
50
|
+
let origin;
|
|
51
|
+
const server = http.createServer(async (req, res) => {
|
|
52
|
+
try {
|
|
53
|
+
// Reject alternate Host names (including DNS rebinding) and cross-site
|
|
54
|
+
// browser requests. No CORS permission is granted, including preflight.
|
|
55
|
+
if (req.headers.host !== new URL(origin).host) return reply(res, 403, { error: 'This view only accepts its local address.' });
|
|
56
|
+
if (req.headers.origin && req.headers.origin !== origin || req.headers['sec-fetch-site'] && !['same-origin', 'none'].includes(req.headers['sec-fetch-site'])) return reply(res, 403, { error: 'Open the local Memoir view directly.' });
|
|
57
|
+
const url = new URL(req.url, origin);
|
|
58
|
+
const asset = assets.get(url.pathname);
|
|
59
|
+
if (req.method === 'GET' && asset && !url.search) return reply(res, 200, await fs.readFile(new URL('./ui/' + asset[0], import.meta.url)), asset[1]);
|
|
60
|
+
if (!url.pathname.startsWith('/api/')) return reply(res, 404, { error: 'Not found.' });
|
|
61
|
+
const auth = Buffer.from(req.headers.authorization || '');
|
|
62
|
+
if (auth.length !== expectedAuth.length || !crypto.timingSafeEqual(auth, expectedAuth)) return reply(res, 401, { error: 'Reopen the view with its local link.' });
|
|
63
|
+
if (req.method === 'GET' && url.pathname === '/api/state' && !url.search) return reply(res, 200, await reviewWork(root));
|
|
64
|
+
if (req.method !== 'POST' || url.pathname !== '/api/action' || url.search) return reply(res, 405, { error: 'This action is unavailable.' });
|
|
65
|
+
if (req.headers.origin !== origin || req.headers['content-type'] !== 'application/json') return reply(res, 403, { error: 'Save changes from the local view.' });
|
|
66
|
+
const input = actionSchema.parse(await body(req));
|
|
67
|
+
const guard = { expectedBranch: input.branch };
|
|
68
|
+
if (input.action === 'save') {
|
|
69
|
+
if (input.category !== 'record' || !input.fields) return reply(res, 400, { error: 'Only project records can be edited.' });
|
|
70
|
+
const { answer, why, ...fields } = input.fields;
|
|
71
|
+
await recordWork(root, { ...fields, ...(answer ? { answer } : {}), ...(why ? { why } : {}), id: input.id, expected_revision: input.expected_revision, scope: 'project', source: 'Saved in the local project view; previous versions remain in history.' }, guard);
|
|
72
|
+
} else if (input.action === 'remove') {
|
|
73
|
+
await retractWork(root, input, guard);
|
|
74
|
+
} else {
|
|
75
|
+
if (input.category !== 'record') return reply(res, 400, { error: 'Run a new authorized check to replace a removed receipt.' });
|
|
76
|
+
await restoreWork(root, input, guard);
|
|
77
|
+
}
|
|
78
|
+
return reply(res, 200, await reviewWork(root));
|
|
79
|
+
} catch (error) {
|
|
80
|
+
const message = workErrorMessage(error);
|
|
81
|
+
const conflict = /branch changed|Record changed|Record was removed|expected revision|before retracting/.test(message);
|
|
82
|
+
if (!res.headersSent && !res.destroyed) reply(res, 409, conflict
|
|
83
|
+
? { error: 'Another session changed this item or branch. Review the latest version before saving. Your draft has been kept.', code: 'refresh_required' }
|
|
84
|
+
: { error: message });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
server.requestTimeout = 10000; server.headersTimeout = 10000; server.keepAliveTimeout = 1000;
|
|
88
|
+
await new Promise((resolve, reject) => {
|
|
89
|
+
server.once('error', reject);
|
|
90
|
+
server.listen(port, '127.0.0.1', () => { origin = `http://127.0.0.1:${server.address().port}`; resolve(); });
|
|
91
|
+
});
|
|
92
|
+
return { server, origin, url: `${origin}/#token=${token}`, close: () => new Promise(resolve => { server.close(resolve); server.closeIdleConnections?.(); }) };
|
|
93
|
+
}
|
package/src/workspace/tracker.js
CHANGED
|
@@ -1,350 +1,102 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
|
+
import crypto from 'crypto';
|
|
4
5
|
import { execFileSync } from 'child_process';
|
|
6
|
+
import { relativeFile, readSafeFile, writeSafeFile, restoreFileSet } from '../security/files.js';
|
|
7
|
+
import { scanForSecrets } from '../security/scanner.js';
|
|
8
|
+
import { projectIdentity } from '../memory/scope.js';
|
|
9
|
+
import { repositoryState } from '../memory/repository.js';
|
|
5
10
|
|
|
6
|
-
const
|
|
11
|
+
const skippedDirectories = new Set(['.git', 'node_modules', '.next', '.cache', '.venv', 'venv', 'dist', 'build', '__pycache__']);
|
|
12
|
+
const sensitive = /(^|\/)(\.env(?:\..*)?|\.npmrc|\.pypirc|credentials[^/]*|secrets?[^/]*|id_rsa|id_ed25519)$|\.(pem|key|p12|pfx)$/i;
|
|
7
13
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
* Tracks: git remote URL, branch, last commit, uncommitted changes (as patch).
|
|
11
|
-
* For non-git projects with AI configs, bundles them as tar.gz.
|
|
12
|
-
*/
|
|
14
|
+
// Explicit opt-in captures only the active project. No home-wide discovery,
|
|
15
|
+
// external archive extraction, or remote repository instructions are executed.
|
|
13
16
|
export async function scanWorkspace(stagingDir, spinner, opts = {}) {
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
'
|
|
19
|
-
|
|
20
|
-
'
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
'Makefile', 'CMakeLists.txt', '.project', 'CLAUDE.md',
|
|
30
|
-
'GEMINI.md', 'AGENTS.md', 'README.md',
|
|
31
|
-
// Also detect dirs with .git or multiple content files
|
|
32
|
-
'.gitignore', 'index.html', 'main.py', 'app.py', 'index.js',
|
|
33
|
-
];
|
|
34
|
-
|
|
35
|
-
// Also detect dirs with multiple markdown/code files as potential projects
|
|
36
|
-
const isContentProject = (entries) => {
|
|
37
|
-
const mdFiles = entries.filter(e => !e.isDirectory() && e.name.endsWith('.md'));
|
|
38
|
-
return mdFiles.length >= 2; // 2+ markdown files = likely a writing project
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const projects = [];
|
|
42
|
-
|
|
43
|
-
const scanDir = async (dir, depth = 0) => {
|
|
44
|
-
if (depth > maxDepth) return;
|
|
45
|
-
let entries;
|
|
46
|
-
try {
|
|
47
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
48
|
-
} catch { return; }
|
|
49
|
-
|
|
50
|
-
// Check if this dir is a project
|
|
51
|
-
const hasMarker = entries.some(e => !e.isDirectory() && projectMarkers.includes(e.name));
|
|
52
|
-
const hasContent = isContentProject(entries);
|
|
53
|
-
|
|
54
|
-
if ((hasMarker || hasContent) && dir !== home) {
|
|
55
|
-
const info = await getProjectInfo(dir);
|
|
56
|
-
if (info) projects.push(info);
|
|
57
|
-
// Don't recurse into sub-projects deeper than this
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Recurse into subdirectories
|
|
62
|
-
for (const entry of entries) {
|
|
63
|
-
if (!entry.isDirectory()) continue;
|
|
64
|
-
if (entry.name.startsWith('.') && entry.name !== '.github') continue;
|
|
65
|
-
if (skipDirs.has(entry.name)) continue;
|
|
66
|
-
await scanDir(path.join(dir, entry.name), depth + 1);
|
|
67
|
-
}
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
if (spinner) spinner.text = 'Scanning workspace for projects...';
|
|
71
|
-
await scanDir(home);
|
|
72
|
-
|
|
73
|
-
// Build manifest
|
|
74
|
-
const manifest = {
|
|
75
|
-
version: 1,
|
|
76
|
-
machine: os.hostname(),
|
|
77
|
-
platform: process.platform,
|
|
78
|
-
home: home,
|
|
79
|
-
scannedAt: new Date().toISOString(),
|
|
80
|
-
projects: []
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
const bundleDir = path.join(stagingDir, 'workspace-bundles');
|
|
84
|
-
|
|
85
|
-
for (const proj of projects) {
|
|
86
|
-
const entry = {
|
|
87
|
-
name: proj.name,
|
|
88
|
-
relativePath: proj.relativePath,
|
|
89
|
-
originalPath: proj.path,
|
|
90
|
-
type: proj.hasGit ? 'git' : 'bundle',
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
if (proj.hasGit) {
|
|
94
|
-
entry.gitRemote = proj.gitRemote;
|
|
95
|
-
entry.branch = proj.branch;
|
|
96
|
-
entry.lastCommit = proj.lastCommit;
|
|
97
|
-
entry.lastCommitMessage = proj.lastCommitMessage;
|
|
98
|
-
|
|
99
|
-
// Save uncommitted changes as patch
|
|
100
|
-
if (proj.hasDirtyWork && proj.lastCommit) {
|
|
101
|
-
try {
|
|
102
|
-
const patchDir = path.join(stagingDir, 'workspace-patches');
|
|
103
|
-
await fs.ensureDir(patchDir);
|
|
104
|
-
const diff = execFileSync('git', ['diff', 'HEAD'], {
|
|
105
|
-
cwd: proj.path,
|
|
106
|
-
maxBuffer: 10 * 1024 * 1024,
|
|
107
|
-
timeout: 10000
|
|
108
|
-
}).toString();
|
|
109
|
-
if (diff.trim()) {
|
|
110
|
-
const patchFile = `${proj.name}.patch`;
|
|
111
|
-
await fs.writeFile(path.join(patchDir, patchFile), diff);
|
|
112
|
-
entry.patchFile = patchFile;
|
|
113
|
-
}
|
|
114
|
-
} catch {
|
|
115
|
-
// Patch capture is best-effort
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
} else {
|
|
119
|
-
// Bundle non-git project (if small enough)
|
|
120
|
-
const size = await getDirSize(proj.path);
|
|
121
|
-
if (size <= maxBundleSize) {
|
|
122
|
-
try {
|
|
123
|
-
await fs.ensureDir(bundleDir);
|
|
124
|
-
const bundleName = `${proj.name}.tar.gz`;
|
|
125
|
-
execFileSync('tar', [
|
|
126
|
-
'czf', path.join(bundleDir, bundleName),
|
|
127
|
-
'-C', path.dirname(proj.path),
|
|
128
|
-
'--exclude', 'node_modules',
|
|
129
|
-
'--exclude', '.git',
|
|
130
|
-
'--exclude', '__pycache__',
|
|
131
|
-
'--exclude', '.venv',
|
|
132
|
-
'--exclude', 'dist',
|
|
133
|
-
'--exclude', 'build',
|
|
134
|
-
proj.name
|
|
135
|
-
], { stdio: 'ignore', timeout: 30000 });
|
|
136
|
-
entry.bundleFile = bundleName;
|
|
137
|
-
entry.bundleSize = (await fs.stat(path.join(bundleDir, bundleName))).size;
|
|
138
|
-
} catch {
|
|
139
|
-
// Bundle is best-effort
|
|
140
|
-
entry.bundleFailed = true;
|
|
141
|
-
}
|
|
142
|
-
} else {
|
|
143
|
-
entry.tooLarge = true;
|
|
144
|
-
entry.size = size;
|
|
17
|
+
const project = await fs.realpath(path.resolve(opts.project || process.env.MEMOIR_PROJECT_ROOT || process.cwd()));
|
|
18
|
+
const repository = repositoryState(project);
|
|
19
|
+
let candidates = [];
|
|
20
|
+
if (repository.head) {
|
|
21
|
+
candidates = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], {
|
|
22
|
+
cwd: project, encoding: 'utf8', timeout: 10000, maxBuffer: 16 * 1024 * 1024,
|
|
23
|
+
}).split('\0').filter(Boolean);
|
|
24
|
+
} else {
|
|
25
|
+
const walk = async (dir, prefix = '') => {
|
|
26
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
27
|
+
if (skippedDirectories.has(entry.name)) continue;
|
|
28
|
+
const rel = prefix + entry.name;
|
|
29
|
+
if (entry.isDirectory()) await walk(path.join(dir, entry.name), rel + '/');
|
|
30
|
+
else candidates.push(rel);
|
|
31
|
+
if (candidates.length > 50000) throw new Error('Workspace file count limit exceeded');
|
|
145
32
|
}
|
|
33
|
+
};
|
|
34
|
+
await walk(project);
|
|
35
|
+
}
|
|
36
|
+
if (candidates.length > 50000) throw new Error('Workspace file count limit exceeded');
|
|
37
|
+
const projectId = projectIdentity(project);
|
|
38
|
+
const key = crypto.createHash('sha256').update(projectId).digest('hex').slice(0, 16);
|
|
39
|
+
const files = [], omitted = [];
|
|
40
|
+
let bytes = 0;
|
|
41
|
+
for (const candidate of [...new Set(candidates)].sort()) {
|
|
42
|
+
const rel = relativeFile(candidate);
|
|
43
|
+
if (rel.split('/').some(p => skippedDirectories.has(p)) || sensitive.test(rel)) { omitted.push({ path: rel, reason: 'excluded' }); continue; }
|
|
44
|
+
let content;
|
|
45
|
+
try { content = await readSafeFile(project, rel); }
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (err.code === 'ENOENT') { omitted.push({ path: rel, reason: 'deleted' }); continue; }
|
|
48
|
+
throw err;
|
|
146
49
|
}
|
|
147
|
-
|
|
148
|
-
|
|
50
|
+
// A heuristic, not a guarantee: omitted matches remain visible in the manifest.
|
|
51
|
+
if (scanForSecrets(content.toString('utf8')).found.length) { omitted.push({ path: rel, reason: 'potential-secret' }); continue; }
|
|
52
|
+
bytes += content.length;
|
|
53
|
+
if (bytes > (opts.maxBundleSize || 50 * 1024 * 1024)) throw new Error('Workspace size limit exceeded');
|
|
54
|
+
const stored = 'workspace-files/' + key + '/' + rel;
|
|
55
|
+
await writeSafeFile(stagingDir, stored, content);
|
|
56
|
+
files.push({ path: rel, stored, sha256: crypto.createHash('sha256').update(content).digest('hex') });
|
|
149
57
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
);
|
|
156
|
-
|
|
58
|
+
const manifest = { version: 2, scannedAt: new Date().toISOString(), projects: [{
|
|
59
|
+
name: path.basename(project), identity: projectId, key, type: 'files',
|
|
60
|
+
repository, files, omitted, size: bytes,
|
|
61
|
+
}] };
|
|
62
|
+
await writeSafeFile(stagingDir, 'workspace.json', JSON.stringify(manifest, null, 2));
|
|
157
63
|
return manifest;
|
|
158
64
|
}
|
|
159
65
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
* Clones git projects, unpacks bundles, applies patches.
|
|
163
|
-
*/
|
|
66
|
+
// Recovery produces a separate directory for inspection, never overlays an
|
|
67
|
+
// existing checkout. The recorded commit is evidence, not a claim of Git history.
|
|
164
68
|
export async function restoreWorkspace(sourceDir, spinner, autoYes = false) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const manifest =
|
|
169
|
-
if (
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
for (const
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
cwd: localPath, stdio: 'ignore'
|
|
186
|
-
});
|
|
187
|
-
execFileSync('git', ['apply', patchPath], {
|
|
188
|
-
cwd: localPath, stdio: 'ignore'
|
|
189
|
-
});
|
|
190
|
-
results.patched.push({ name: proj.name, path: localPath });
|
|
191
|
-
} catch {
|
|
192
|
-
// Patch didn't apply cleanly — skip
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
results.skipped.push({ name: proj.name, path: localPath, reason: 'exists' });
|
|
197
|
-
continue;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (proj.type === 'git' && proj.gitRemote) {
|
|
201
|
-
// Clone the repo
|
|
202
|
-
if (spinner) spinner.text = `Cloning ${proj.name}...`;
|
|
203
|
-
try {
|
|
204
|
-
await fs.ensureDir(path.dirname(localPath));
|
|
205
|
-
execFileSync('git', ['clone', proj.gitRemote, localPath], {
|
|
206
|
-
stdio: 'ignore',
|
|
207
|
-
timeout: 120000
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
// Checkout the right branch
|
|
211
|
-
if (proj.branch && proj.branch !== 'main' && proj.branch !== 'master') {
|
|
212
|
-
try {
|
|
213
|
-
execFileSync('git', ['checkout', proj.branch], {
|
|
214
|
-
cwd: localPath, stdio: 'ignore'
|
|
215
|
-
});
|
|
216
|
-
} catch {}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Apply patch if available
|
|
220
|
-
if (proj.patchFile) {
|
|
221
|
-
const patchPath = path.join(sourceDir, 'workspace-patches', proj.patchFile);
|
|
222
|
-
if (await fs.pathExists(patchPath)) {
|
|
223
|
-
try {
|
|
224
|
-
execFileSync('git', ['apply', patchPath], {
|
|
225
|
-
cwd: localPath, stdio: 'ignore'
|
|
226
|
-
});
|
|
227
|
-
results.patched.push({ name: proj.name, path: localPath });
|
|
228
|
-
} catch {}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
results.cloned.push({ name: proj.name, path: localPath, remote: proj.gitRemote });
|
|
233
|
-
} catch (err) {
|
|
234
|
-
results.skipped.push({ name: proj.name, reason: `clone failed: ${err.message}` });
|
|
235
|
-
}
|
|
236
|
-
} else if (proj.bundleFile) {
|
|
237
|
-
// Unpack bundle
|
|
238
|
-
const bundlePath = path.join(sourceDir, 'workspace-bundles', proj.bundleFile);
|
|
239
|
-
if (await fs.pathExists(bundlePath)) {
|
|
240
|
-
if (spinner) spinner.text = `Unpacking ${proj.name}...`;
|
|
241
|
-
try {
|
|
242
|
-
await fs.ensureDir(path.dirname(localPath));
|
|
243
|
-
execFileSync('tar', ['xzf', bundlePath, '-C', path.dirname(localPath)], {
|
|
244
|
-
stdio: 'ignore',
|
|
245
|
-
timeout: 60000
|
|
246
|
-
});
|
|
247
|
-
results.unpacked.push({ name: proj.name, path: localPath });
|
|
248
|
-
} catch (err) {
|
|
249
|
-
results.skipped.push({ name: proj.name, reason: `unpack failed: ${err.message}` });
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
} else {
|
|
253
|
-
results.skipped.push({ name: proj.name, reason: proj.tooLarge ? 'too large' : 'no source' });
|
|
69
|
+
let raw;
|
|
70
|
+
try { raw = await readSafeFile(sourceDir, 'workspace.json'); }
|
|
71
|
+
catch (err) { if (err.code === 'ENOENT') return null; throw err; }
|
|
72
|
+
const manifest = JSON.parse(raw.toString());
|
|
73
|
+
if (manifest.version !== 2) throw new Error('Legacy workspace archives cannot be safely auto-restored. Keep the backup and inspect its archive separately, or create a new workspace snapshot.');
|
|
74
|
+
if (!Array.isArray(manifest.projects) || manifest.projects.length > 100) throw new Error('Invalid workspace manifest');
|
|
75
|
+
const plans = [];
|
|
76
|
+
let totalBytes = 0, totalFiles = 0;
|
|
77
|
+
for (const project of manifest.projects) {
|
|
78
|
+
if (!/^[a-f0-9]{16}$/.test(project.key) || !Array.isArray(project.files)) throw new Error('Invalid workspace project');
|
|
79
|
+
const entries = [];
|
|
80
|
+
for (const file of project.files) {
|
|
81
|
+
if (++totalFiles > 50000) throw new Error('Workspace file count limit exceeded');
|
|
82
|
+
const rel = relativeFile(file.path);
|
|
83
|
+
if (file.stored !== 'workspace-files/' + project.key + '/' + rel) throw new Error('Invalid workspace source path');
|
|
84
|
+
const content = await readSafeFile(sourceDir, file.stored);
|
|
85
|
+
totalBytes += content.length;
|
|
86
|
+
if (totalBytes > 256 * 1024 * 1024) throw new Error('Workspace size limit exceeded');
|
|
87
|
+
if (crypto.createHash('sha256').update(content).digest('hex') !== file.sha256) throw new Error('Workspace content failed verification');
|
|
88
|
+
entries.push({ path: rel, content });
|
|
254
89
|
}
|
|
90
|
+
plans.push({ project, entries });
|
|
255
91
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
// If the project had a relative path from home, use that
|
|
265
|
-
if (proj.relativePath) {
|
|
266
|
-
return path.join(home, proj.relativePath);
|
|
267
|
-
}
|
|
268
|
-
// Default: put it in home directory
|
|
269
|
-
return path.join(home, proj.name);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Get info about a project directory.
|
|
274
|
-
*/
|
|
275
|
-
async function getProjectInfo(dir) {
|
|
276
|
-
const name = path.basename(dir);
|
|
277
|
-
const relativePath = path.relative(home, dir);
|
|
278
|
-
const info = {
|
|
279
|
-
name,
|
|
280
|
-
path: dir,
|
|
281
|
-
relativePath,
|
|
282
|
-
hasGit: false,
|
|
283
|
-
gitRemote: null,
|
|
284
|
-
branch: null,
|
|
285
|
-
lastCommit: null,
|
|
286
|
-
lastCommitMessage: null,
|
|
287
|
-
hasDirtyWork: false,
|
|
288
|
-
};
|
|
289
|
-
|
|
290
|
-
// Check for git
|
|
291
|
-
const gitDir = path.join(dir, '.git');
|
|
292
|
-
if (await fs.pathExists(gitDir)) {
|
|
293
|
-
info.hasGit = true;
|
|
294
|
-
try {
|
|
295
|
-
const remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
|
|
296
|
-
cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
|
|
297
|
-
}).toString().trim();
|
|
298
|
-
info.gitRemote = remote;
|
|
299
|
-
} catch {}
|
|
300
|
-
|
|
301
|
-
try {
|
|
302
|
-
info.branch = execFileSync('git', ['branch', '--show-current'], {
|
|
303
|
-
cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
|
|
304
|
-
}).toString().trim();
|
|
305
|
-
} catch {}
|
|
306
|
-
|
|
307
|
-
try {
|
|
308
|
-
info.lastCommit = execFileSync('git', ['log', '-1', '--format=%H'], {
|
|
309
|
-
cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
|
|
310
|
-
}).toString().trim();
|
|
311
|
-
info.lastCommitMessage = execFileSync('git', ['log', '-1', '--format=%s'], {
|
|
312
|
-
cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
|
|
313
|
-
}).toString().trim();
|
|
314
|
-
} catch {}
|
|
315
|
-
|
|
316
|
-
try {
|
|
317
|
-
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
318
|
-
cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
|
|
319
|
-
}).toString().trim();
|
|
320
|
-
info.hasDirtyWork = status.length > 0;
|
|
321
|
-
} catch {}
|
|
92
|
+
const results = { cloned: [], unpacked: [], patched: [], skipped: [] };
|
|
93
|
+
for (const { project, entries } of plans) {
|
|
94
|
+
const parent = path.join(os.homedir(), 'memoir-restored');
|
|
95
|
+
await fs.ensureDir(parent);
|
|
96
|
+
const destination = await fs.mkdtemp(path.join(parent, project.key + '-'));
|
|
97
|
+
try { await restoreFileSet(destination, entries); }
|
|
98
|
+
catch (err) { await fs.remove(destination); throw err; }
|
|
99
|
+
results.unpacked.push({ name: project.name, path: destination, omitted: project.omitted?.length || 0 });
|
|
322
100
|
}
|
|
323
|
-
|
|
324
|
-
return info;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Get total size of a directory (excluding common heavy dirs).
|
|
329
|
-
*/
|
|
330
|
-
async function getDirSize(dir) {
|
|
331
|
-
let size = 0;
|
|
332
|
-
const skip = new Set(['node_modules', '.git', '__pycache__', '.venv', 'dist', 'build']);
|
|
333
|
-
|
|
334
|
-
const walk = async (d) => {
|
|
335
|
-
let entries;
|
|
336
|
-
try { entries = await fs.readdir(d, { withFileTypes: true }); } catch { return; }
|
|
337
|
-
for (const e of entries) {
|
|
338
|
-
if (e.isDirectory()) {
|
|
339
|
-
if (!skip.has(e.name)) await walk(path.join(d, e.name));
|
|
340
|
-
} else {
|
|
341
|
-
try {
|
|
342
|
-
const stat = await fs.stat(path.join(d, e.name));
|
|
343
|
-
size += stat.size;
|
|
344
|
-
} catch {}
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
};
|
|
348
|
-
await walk(dir);
|
|
349
|
-
return size;
|
|
101
|
+
return results;
|
|
350
102
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
-- Review and apply to the existing Memoir Supabase project before deploying
|
|
2
|
+
-- the new cloud writer. No old backups are deleted or renumbered.
|
|
3
|
+
-- If duplicate (user_id, version) rows exist, the unique index fails; inspect
|
|
4
|
+
-- and reconcile those records before retrying this transactional migration.
|
|
5
|
+
begin;
|
|
6
|
+
|
|
7
|
+
alter table public.backups add column if not exists encryption_format text not null default 'legacy';
|
|
8
|
+
alter table public.backups add column if not exists source_backup_id uuid;
|
|
9
|
+
|
|
10
|
+
create unique index if not exists memoir_backups_user_version
|
|
11
|
+
on public.backups (user_id, version);
|
|
12
|
+
|
|
13
|
+
create table if not exists public.memoir_backup_counters (
|
|
14
|
+
user_id uuid primary key references auth.users(id) on delete cascade,
|
|
15
|
+
next_version bigint not null check (next_version > 0)
|
|
16
|
+
);
|
|
17
|
+
alter table public.memoir_backup_counters enable row level security;
|
|
18
|
+
revoke all on public.memoir_backup_counters from public, anon, authenticated;
|
|
19
|
+
|
|
20
|
+
create or replace function public.memoir_next_backup_version()
|
|
21
|
+
returns bigint
|
|
22
|
+
language plpgsql
|
|
23
|
+
security definer
|
|
24
|
+
set search_path = ''
|
|
25
|
+
as $$
|
|
26
|
+
declare
|
|
27
|
+
actor uuid := auth.uid();
|
|
28
|
+
allocated bigint;
|
|
29
|
+
begin
|
|
30
|
+
if actor is null then
|
|
31
|
+
raise exception 'Authentication required';
|
|
32
|
+
end if;
|
|
33
|
+
|
|
34
|
+
insert into public.memoir_backup_counters as counter (user_id, next_version)
|
|
35
|
+
select actor, coalesce(max(b.version), 0) + 1
|
|
36
|
+
from public.backups b where b.user_id = actor
|
|
37
|
+
on conflict (user_id) do update
|
|
38
|
+
set next_version = greatest(
|
|
39
|
+
counter.next_version + 1,
|
|
40
|
+
(select coalesce(max(b.version), 0) + 1 from public.backups b where b.user_id = actor)
|
|
41
|
+
)
|
|
42
|
+
returning next_version into allocated;
|
|
43
|
+
|
|
44
|
+
return allocated;
|
|
45
|
+
end;
|
|
46
|
+
$$;
|
|
47
|
+
|
|
48
|
+
revoke all on function public.memoir_next_backup_version() from public, anon;
|
|
49
|
+
grant execute on function public.memoir_next_backup_version() to authenticated;
|
|
50
|
+
commit;
|