memoir-cli 3.1.1 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GAMEPLAN.md +235 -0
- package/LAUNCH_POSTS.md +247 -0
- package/MARKETING.md +143 -0
- package/POSTS-READY-TO-GO.md +215 -0
- package/README.md +95 -89
- package/bin/memoir.js +78 -3
- package/demo.svg +201 -0
- package/landing-page-v2.html +690 -0
- package/mcp-publisher +0 -0
- package/package.json +28 -23
- package/server.json +20 -0
- package/src/commands/projects.js +240 -0
- package/src/commands/push.js +5 -3
- package/src/commands/restore.js +197 -3
- package/src/commands/share.js +192 -0
- package/src/commands/upgrade.js +107 -0
- package/src/context/capture.js +77 -0
- package/src/mcp.js +429 -0
- package/src/providers/index.js +6 -6
- package/src/security/encryption.js +98 -46
- package/src/workspace/tracker.js +4 -4
package/package.json
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memoir-cli",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"
|
|
3
|
+
"version": "3.2.1",
|
|
4
|
+
"mcpName": "io.github.camgitt/memoir",
|
|
5
|
+
"description": "Persistent memory for AI coding tools via MCP. Your AI remembers across sessions, tools, and machines. Works with Claude, Cursor, Gemini, Windsurf, and 7 more tools.",
|
|
5
6
|
"main": "src/index.js",
|
|
6
7
|
"type": "module",
|
|
7
8
|
"bin": {
|
|
8
|
-
"memoir": "bin/memoir.js"
|
|
9
|
+
"memoir": "bin/memoir.js",
|
|
10
|
+
"memoir-mcp": "src/mcp.js"
|
|
9
11
|
},
|
|
10
12
|
"repository": {
|
|
11
13
|
"type": "git",
|
|
12
14
|
"url": "https://github.com/camgitt/memoir.git"
|
|
13
15
|
},
|
|
14
|
-
"homepage": "https://
|
|
16
|
+
"homepage": "https://memoir.sh",
|
|
15
17
|
"bugs": {
|
|
16
18
|
"url": "https://github.com/camgitt/memoir/issues"
|
|
17
19
|
},
|
|
@@ -20,49 +22,52 @@
|
|
|
20
22
|
"test": "bash test-local.sh"
|
|
21
23
|
},
|
|
22
24
|
"keywords": [
|
|
25
|
+
"mcp",
|
|
26
|
+
"mcp-server",
|
|
27
|
+
"model-context-protocol",
|
|
28
|
+
"ai-memory",
|
|
29
|
+
"persistent-memory",
|
|
23
30
|
"ai",
|
|
24
31
|
"cli",
|
|
25
|
-
"sync",
|
|
26
|
-
"memory",
|
|
27
|
-
"backup",
|
|
28
|
-
"restore",
|
|
29
|
-
"migrate",
|
|
30
|
-
"translate",
|
|
31
32
|
"claude",
|
|
33
|
+
"claude-code",
|
|
34
|
+
"cursor",
|
|
35
|
+
"windsurf",
|
|
32
36
|
"gemini",
|
|
37
|
+
"gemini-cli",
|
|
33
38
|
"codex",
|
|
34
|
-
"cursor",
|
|
35
39
|
"copilot",
|
|
36
|
-
"
|
|
40
|
+
"chatgpt",
|
|
41
|
+
"openai",
|
|
37
42
|
"aider",
|
|
38
43
|
"zed",
|
|
39
44
|
"cline",
|
|
40
45
|
"continue-dev",
|
|
41
|
-
"profiles",
|
|
42
|
-
"ai-memory",
|
|
43
46
|
"ai-tools",
|
|
44
|
-
"dotfiles",
|
|
45
|
-
"developer-tools",
|
|
46
|
-
"claude-code",
|
|
47
|
-
"gemini-cli",
|
|
48
|
-
"openai",
|
|
49
|
-
"chatgpt",
|
|
50
47
|
"ai-assistant",
|
|
51
48
|
"coding-assistant",
|
|
49
|
+
"developer-tools",
|
|
52
50
|
"context-sync",
|
|
51
|
+
"memory-layer",
|
|
52
|
+
"cross-tool",
|
|
53
|
+
"sync",
|
|
54
|
+
"backup",
|
|
55
|
+
"restore",
|
|
56
|
+
"migrate",
|
|
53
57
|
"session-handoff",
|
|
54
|
-
"
|
|
55
|
-
"
|
|
58
|
+
"dotfiles",
|
|
59
|
+
"profiles"
|
|
56
60
|
],
|
|
57
61
|
"author": "camgitt",
|
|
58
62
|
"license": "MIT",
|
|
59
63
|
"dependencies": {
|
|
64
|
+
"@modelcontextprotocol/sdk": "^1.28.0",
|
|
60
65
|
"boxen": "^7.1.1",
|
|
61
66
|
"chalk": "^5.3.0",
|
|
62
67
|
"commander": "^12.0.0",
|
|
63
68
|
"fs-extra": "^11.2.0",
|
|
64
69
|
"gradient-string": "^3.0.0",
|
|
65
70
|
"inquirer": "^9.2.15",
|
|
66
|
-
"ora": "^7.0.1"
|
|
71
|
+
"ora": "^7.0.1"
|
|
67
72
|
}
|
|
68
73
|
}
|
package/server.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.camgitt/memoir",
|
|
4
|
+
"description": "Persistent memory for AI coding tools via MCP. Remembers across sessions and machines.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/camgitt/memoir",
|
|
7
|
+
"source": "github"
|
|
8
|
+
},
|
|
9
|
+
"version": "3.2.0",
|
|
10
|
+
"packages": [
|
|
11
|
+
{
|
|
12
|
+
"registryType": "npm",
|
|
13
|
+
"identifier": "memoir-cli",
|
|
14
|
+
"version": "3.2.0",
|
|
15
|
+
"transport": {
|
|
16
|
+
"type": "stdio"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import boxen from 'boxen';
|
|
6
|
+
import { execFileSync } from 'child_process';
|
|
7
|
+
|
|
8
|
+
const home = os.homedir();
|
|
9
|
+
const TODOS_PATH = path.join(home, '.config', 'memoir', 'project-todos.json');
|
|
10
|
+
|
|
11
|
+
// ─── Helpers ───
|
|
12
|
+
|
|
13
|
+
async function loadTodos() {
|
|
14
|
+
try {
|
|
15
|
+
return await fs.readJson(TODOS_PATH);
|
|
16
|
+
} catch {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function saveTodos(todos) {
|
|
22
|
+
await fs.ensureDir(path.dirname(TODOS_PATH));
|
|
23
|
+
await fs.writeJson(TODOS_PATH, todos, { spaces: 2 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function git(args, cwd) {
|
|
27
|
+
try {
|
|
28
|
+
return execFileSync('git', args, {
|
|
29
|
+
cwd,
|
|
30
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
31
|
+
timeout: 5000,
|
|
32
|
+
}).toString().trim();
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function timeAgo(dateStr) {
|
|
39
|
+
const diff = Date.now() - new Date(dateStr).getTime();
|
|
40
|
+
const mins = Math.floor(diff / 60000);
|
|
41
|
+
if (mins < 60) return `${mins}m ago`;
|
|
42
|
+
const hrs = Math.floor(mins / 60);
|
|
43
|
+
if (hrs < 24) return `${hrs}h ago`;
|
|
44
|
+
const days = Math.floor(hrs / 24);
|
|
45
|
+
if (days < 30) return `${days}d ago`;
|
|
46
|
+
return `${Math.floor(days / 30)}mo ago`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Scan ───
|
|
50
|
+
|
|
51
|
+
async function discoverProjects() {
|
|
52
|
+
const maxDepth = 3;
|
|
53
|
+
const skipDirs = new Set([
|
|
54
|
+
'node_modules', '.git', '.next', '.vercel', 'dist', 'build',
|
|
55
|
+
'__pycache__', '.venv', 'venv', '.cache', '.npm', '.bun',
|
|
56
|
+
'Library', '.Trash', 'Applications', 'Pictures', 'Music',
|
|
57
|
+
'Movies', 'Public', 'Downloads', '.local', '.cargo', '.rustup',
|
|
58
|
+
'.docker', '.ssh', '.config', '.claude', '.gemini',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const projectMarkers = [
|
|
62
|
+
'package.json', 'Cargo.toml', 'go.mod', 'pyproject.toml',
|
|
63
|
+
'requirements.txt', 'Gemfile', 'pom.xml', 'build.gradle',
|
|
64
|
+
'Makefile', 'CMakeLists.txt', '.project', 'CLAUDE.md',
|
|
65
|
+
'GEMINI.md', 'AGENTS.md',
|
|
66
|
+
'.gitignore', 'index.html', 'main.py', 'app.py', 'index.js',
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const projects = [];
|
|
70
|
+
|
|
71
|
+
const scanDir = async (dir, depth = 0) => {
|
|
72
|
+
if (depth > maxDepth) return;
|
|
73
|
+
let entries;
|
|
74
|
+
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
75
|
+
|
|
76
|
+
const hasMarker = entries.some(e => !e.isDirectory() && projectMarkers.includes(e.name));
|
|
77
|
+
if (hasMarker && dir !== home) {
|
|
78
|
+
projects.push(dir);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if (!entry.isDirectory()) continue;
|
|
84
|
+
if (entry.name.startsWith('.') && entry.name !== '.github') continue;
|
|
85
|
+
if (skipDirs.has(entry.name)) continue;
|
|
86
|
+
await scanDir(path.join(dir, entry.name), depth + 1);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
await scanDir(home);
|
|
91
|
+
return projects;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getProjectStatus(dir) {
|
|
95
|
+
const name = path.basename(dir);
|
|
96
|
+
const hasGit = fs.pathExistsSync(path.join(dir, '.git'));
|
|
97
|
+
|
|
98
|
+
const info = { name, path: dir, hasGit, branch: null, dirty: false, logs: [] };
|
|
99
|
+
|
|
100
|
+
if (!hasGit) return info;
|
|
101
|
+
|
|
102
|
+
info.branch = git(['branch', '--show-current'], dir) || 'unknown';
|
|
103
|
+
|
|
104
|
+
const status = git(['status', '--porcelain'], dir);
|
|
105
|
+
info.dirty = status ? status.length > 0 : false;
|
|
106
|
+
|
|
107
|
+
const logRaw = git(['log', '-5', '--format=%ad|%s', '--date=format:%b %d, %H:%M'], dir);
|
|
108
|
+
if (logRaw) {
|
|
109
|
+
info.logs = logRaw.split('\n').filter(Boolean).map(line => {
|
|
110
|
+
const sep = line.indexOf('|');
|
|
111
|
+
return { date: line.slice(0, sep), msg: line.slice(sep + 1) };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const lastDate = git(['log', '-1', '--format=%aI'], dir);
|
|
116
|
+
if (lastDate) info.lastActivity = lastDate;
|
|
117
|
+
|
|
118
|
+
return info;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Commands ───
|
|
122
|
+
|
|
123
|
+
export async function projectsListCommand(options) {
|
|
124
|
+
const dirs = await discoverProjects();
|
|
125
|
+
const todos = await loadTodos();
|
|
126
|
+
|
|
127
|
+
// Gather status for all projects
|
|
128
|
+
const projects = dirs.map(d => {
|
|
129
|
+
const s = getProjectStatus(d);
|
|
130
|
+
s.todos = todos[s.name] || [];
|
|
131
|
+
return s;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Sort by last activity (most recent first)
|
|
135
|
+
projects.sort((a, b) => {
|
|
136
|
+
if (!a.lastActivity && !b.lastActivity) return 0;
|
|
137
|
+
if (!a.lastActivity) return 1;
|
|
138
|
+
if (!b.lastActivity) return -1;
|
|
139
|
+
return new Date(b.lastActivity) - new Date(a.lastActivity);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (options.json) {
|
|
143
|
+
console.log(JSON.stringify(projects, null, 2));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log('\n' + boxen(
|
|
148
|
+
chalk.white.bold(' projects ') + chalk.gray(` ${projects.length} found`),
|
|
149
|
+
{ padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
|
|
150
|
+
));
|
|
151
|
+
|
|
152
|
+
const limit = options.all ? projects.length : Math.min(projects.length, 15);
|
|
153
|
+
|
|
154
|
+
for (let i = 0; i < limit; i++) {
|
|
155
|
+
const p = projects[i];
|
|
156
|
+
const dot = p.hasGit
|
|
157
|
+
? (p.dirty ? chalk.yellow('●') : chalk.green('●'))
|
|
158
|
+
: chalk.gray('●');
|
|
159
|
+
|
|
160
|
+
const age = p.lastActivity ? chalk.gray(` ${timeAgo(p.lastActivity)}`) : '';
|
|
161
|
+
const branchTag = p.branch && p.branch !== 'main' && p.branch !== 'master'
|
|
162
|
+
? chalk.magenta(` [${p.branch}]`)
|
|
163
|
+
: '';
|
|
164
|
+
const dirtyTag = p.dirty ? chalk.yellow(' *') : '';
|
|
165
|
+
const todoTag = p.todos.length > 0
|
|
166
|
+
? chalk.yellow(` (${p.todos.length} todo${p.todos.length > 1 ? 's' : ''})`)
|
|
167
|
+
: '';
|
|
168
|
+
|
|
169
|
+
console.log(`\n ${dot} ${chalk.white.bold(p.name)}${branchTag}${dirtyTag}${todoTag}${age}`);
|
|
170
|
+
|
|
171
|
+
// Show last few commits
|
|
172
|
+
const logCount = options.verbose ? 5 : 2;
|
|
173
|
+
for (let j = 0; j < Math.min(p.logs.length, logCount); j++) {
|
|
174
|
+
const log = p.logs[j];
|
|
175
|
+
console.log(` ${chalk.gray(log.date)} ${chalk.dim(log.msg)}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Show todos inline
|
|
179
|
+
if (p.todos.length > 0 && options.verbose) {
|
|
180
|
+
for (const t of p.todos) {
|
|
181
|
+
console.log(` ${chalk.yellow('□')} ${chalk.yellow(t)}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!options.all && projects.length > 15) {
|
|
187
|
+
console.log(chalk.gray(`\n ... and ${projects.length - 15} more (use --all to show all)`));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
console.log(chalk.gray(`\n ${chalk.green('●')} clean ${chalk.yellow('●')} dirty ${chalk.gray('●')} no git\n`));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function projectsTodoCommand(projectName, text, options) {
|
|
194
|
+
const todos = await loadTodos();
|
|
195
|
+
|
|
196
|
+
// List todos for a project
|
|
197
|
+
if (!text && !options.done && !options.clear) {
|
|
198
|
+
const items = todos[projectName] || [];
|
|
199
|
+
if (items.length === 0) {
|
|
200
|
+
console.log(chalk.gray(`\n No todos for ${projectName}\n`));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
console.log(chalk.white.bold(`\n ${projectName} todos:\n`));
|
|
204
|
+
items.forEach((t, i) => {
|
|
205
|
+
console.log(` ${chalk.gray(`${i + 1}.`)} ${chalk.yellow('□')} ${t}`);
|
|
206
|
+
});
|
|
207
|
+
console.log('');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Mark done
|
|
212
|
+
if (options.done !== undefined) {
|
|
213
|
+
const idx = parseInt(options.done, 10) - 1;
|
|
214
|
+
const items = todos[projectName] || [];
|
|
215
|
+
if (idx < 0 || idx >= items.length) {
|
|
216
|
+
console.error(chalk.red(`\n ✖ Invalid index. ${projectName} has ${items.length} todo(s).\n`));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const removed = items.splice(idx, 1)[0];
|
|
220
|
+
todos[projectName] = items;
|
|
221
|
+
if (items.length === 0) delete todos[projectName];
|
|
222
|
+
await saveTodos(todos);
|
|
223
|
+
console.log(chalk.green(`\n ✔ Done: ${removed}\n`));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Clear all
|
|
228
|
+
if (options.clear) {
|
|
229
|
+
delete todos[projectName];
|
|
230
|
+
await saveTodos(todos);
|
|
231
|
+
console.log(chalk.green(`\n ✔ Cleared all todos for ${projectName}\n`));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Add todo
|
|
236
|
+
if (!todos[projectName]) todos[projectName] = [];
|
|
237
|
+
todos[projectName].push(text);
|
|
238
|
+
await saveTodos(todos);
|
|
239
|
+
console.log(chalk.green(`\n ✔ Added to ${projectName}: ${text}\n`));
|
|
240
|
+
}
|
package/src/commands/push.js
CHANGED
|
@@ -179,14 +179,16 @@ export async function pushCommand(options = {}) {
|
|
|
179
179
|
mask: '*',
|
|
180
180
|
validate: (input) => input.length >= 6 ? true : 'Passphrase must be at least 6 characters'
|
|
181
181
|
}]);
|
|
182
|
-
spinner.start(chalk.gray('
|
|
182
|
+
spinner.start(chalk.gray('Deriving encryption key...'));
|
|
183
183
|
|
|
184
184
|
encryptedDir = path.join(os.tmpdir(), `memoir-encrypted-${Date.now()}`);
|
|
185
185
|
await fs.ensureDir(encryptedDir);
|
|
186
|
-
await encryptDirectory(stagingDir, encryptedDir, passphrase);
|
|
186
|
+
const encryptedCount = await encryptDirectory(stagingDir, encryptedDir, passphrase, spinner);
|
|
187
|
+
spinner.succeed(chalk.green(spinner.text));
|
|
188
|
+
spinner.start();
|
|
187
189
|
|
|
188
190
|
// Save verify token so restore can check passphrase before decrypting
|
|
189
|
-
const token = createVerifyToken(passphrase);
|
|
191
|
+
const token = await createVerifyToken(passphrase);
|
|
190
192
|
await fs.writeFile(path.join(encryptedDir, 'verify.enc'), token);
|
|
191
193
|
|
|
192
194
|
uploadDir = encryptedDir;
|
package/src/commands/restore.js
CHANGED
|
@@ -11,10 +11,18 @@ import { fetchFromLocal, fetchFromGit } from '../providers/restore.js';
|
|
|
11
11
|
import { decryptDirectory, verifyPassphrase } from '../security/encryption.js';
|
|
12
12
|
import { detectLocalHomeKey } from '../adapters/restore.js';
|
|
13
13
|
import { restoreWorkspace } from '../workspace/tracker.js';
|
|
14
|
+
import { getSession } from '../cloud/auth.js';
|
|
15
|
+
import { unbundleToDir } from '../cloud/storage.js';
|
|
16
|
+
import { SUPABASE_URL, SUPABASE_ANON_KEY, STORAGE_BUCKET } from '../cloud/constants.js';
|
|
14
17
|
|
|
15
18
|
const home = os.homedir();
|
|
16
19
|
|
|
17
20
|
export async function restoreCommand(options = {}) {
|
|
21
|
+
// Handle --from <token> for shared links
|
|
22
|
+
if (options.from) {
|
|
23
|
+
return restoreFromShare(options);
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
const config = await getConfig(options.profile);
|
|
19
27
|
|
|
20
28
|
if (!config) {
|
|
@@ -37,7 +45,7 @@ export async function restoreCommand(options = {}) {
|
|
|
37
45
|
|
|
38
46
|
const onlyFilter = options.only ? options.only.split(',').map(t => t.trim().toLowerCase()) : null;
|
|
39
47
|
|
|
40
|
-
const autoYes = options.
|
|
48
|
+
const autoYes = !options.interactive;
|
|
41
49
|
|
|
42
50
|
if (config.provider === 'local' || config.provider.includes('local')) {
|
|
43
51
|
restored = await fetchFromLocal(config, stagingDir, spinner, onlyFilter, autoYes);
|
|
@@ -68,7 +76,7 @@ export async function restoreCommand(options = {}) {
|
|
|
68
76
|
|
|
69
77
|
if (await fs.pathExists(verifyPath)) {
|
|
70
78
|
const token = await fs.readFile(verifyPath);
|
|
71
|
-
if (!verifyPassphrase(token, passphrase)) {
|
|
79
|
+
if (!(await verifyPassphrase(token, passphrase))) {
|
|
72
80
|
console.log(chalk.red(' Wrong passphrase. Try again.'));
|
|
73
81
|
passphrase = null;
|
|
74
82
|
continue;
|
|
@@ -85,7 +93,7 @@ export async function restoreCommand(options = {}) {
|
|
|
85
93
|
spinner.start(chalk.gray('Decrypting...'));
|
|
86
94
|
const decryptedDir = path.join(os.tmpdir(), `memoir-decrypted-${Date.now()}`);
|
|
87
95
|
try {
|
|
88
|
-
const count = await decryptDirectory(stagingDir, decryptedDir, passphrase);
|
|
96
|
+
const count = await decryptDirectory(stagingDir, decryptedDir, passphrase, spinner);
|
|
89
97
|
spinner.succeed(chalk.green(`Decrypted ${count} files`));
|
|
90
98
|
|
|
91
99
|
// Now restore from decrypted dir
|
|
@@ -244,3 +252,189 @@ export async function restoreCommand(options = {}) {
|
|
|
244
252
|
await fs.remove(stagingDir);
|
|
245
253
|
}
|
|
246
254
|
}
|
|
255
|
+
|
|
256
|
+
async function restoreFromShare(options) {
|
|
257
|
+
const shareToken = options.from;
|
|
258
|
+
|
|
259
|
+
console.log();
|
|
260
|
+
const spinner = ora({ text: chalk.gray('Fetching share link...'), spinner: 'dots' }).start();
|
|
261
|
+
|
|
262
|
+
const stagingDir = path.join(os.tmpdir(), `memoir-share-restore-${Date.now()}`);
|
|
263
|
+
await fs.ensureDir(stagingDir);
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
// Fetch share metadata from Supabase
|
|
267
|
+
const metaRes = await fetch(
|
|
268
|
+
`${SUPABASE_URL}/rest/v1/shared_links?select=*&token=eq.${shareToken}&limit=1`,
|
|
269
|
+
{
|
|
270
|
+
headers: {
|
|
271
|
+
'apikey': SUPABASE_ANON_KEY,
|
|
272
|
+
'Content-Type': 'application/json',
|
|
273
|
+
},
|
|
274
|
+
}
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
if (!metaRes.ok) {
|
|
278
|
+
throw new Error('Failed to fetch share link');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const links = await metaRes.json();
|
|
282
|
+
if (!links || links.length === 0) {
|
|
283
|
+
spinner.stop();
|
|
284
|
+
console.log('\n' + boxen(
|
|
285
|
+
chalk.red('✖ Share link not found\n\n') +
|
|
286
|
+
chalk.gray('The link may have expired or been deleted.'),
|
|
287
|
+
{ padding: 1, borderStyle: 'round', borderColor: 'red' }
|
|
288
|
+
) + '\n');
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const shareLink = links[0];
|
|
293
|
+
|
|
294
|
+
// Check expiry
|
|
295
|
+
if (new Date(shareLink.expires_at) < new Date()) {
|
|
296
|
+
spinner.stop();
|
|
297
|
+
console.log('\n' + boxen(
|
|
298
|
+
chalk.red('✖ Share link expired\n\n') +
|
|
299
|
+
chalk.gray(`This link expired on ${new Date(shareLink.expires_at).toLocaleString()}`),
|
|
300
|
+
{ padding: 1, borderStyle: 'round', borderColor: 'red' }
|
|
301
|
+
) + '\n');
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Check use count
|
|
306
|
+
if (shareLink.use_count >= shareLink.max_uses) {
|
|
307
|
+
spinner.stop();
|
|
308
|
+
console.log('\n' + boxen(
|
|
309
|
+
chalk.red('✖ Share link exhausted\n\n') +
|
|
310
|
+
chalk.gray(`This link has been used ${shareLink.use_count}/${shareLink.max_uses} times.`),
|
|
311
|
+
{ padding: 1, borderStyle: 'round', borderColor: 'red' }
|
|
312
|
+
) + '\n');
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Show share info
|
|
317
|
+
spinner.stop();
|
|
318
|
+
const tools = shareLink.tools || [];
|
|
319
|
+
if (tools.length > 0) {
|
|
320
|
+
console.log(chalk.cyan('\n Shared tools: ') + chalk.white(tools.join(', ')));
|
|
321
|
+
}
|
|
322
|
+
console.log(chalk.gray(` Uses: ${shareLink.use_count + 1}/${shareLink.max_uses}`) +
|
|
323
|
+
chalk.gray(` | Expires: ${new Date(shareLink.expires_at).toLocaleString()}`));
|
|
324
|
+
console.log();
|
|
325
|
+
|
|
326
|
+
// Download the backup
|
|
327
|
+
spinner.start(chalk.gray('Downloading share bundle...'));
|
|
328
|
+
|
|
329
|
+
// Try authenticated first, fall back to anon key
|
|
330
|
+
const session = await getSession();
|
|
331
|
+
const authHeaders = session
|
|
332
|
+
? { 'Authorization': `Bearer ${session.access_token}`, 'apikey': SUPABASE_ANON_KEY }
|
|
333
|
+
: { 'apikey': SUPABASE_ANON_KEY };
|
|
334
|
+
|
|
335
|
+
const dlRes = await fetch(`${SUPABASE_URL}/storage/v1/object/${STORAGE_BUCKET}/${shareLink.backup_id}`, {
|
|
336
|
+
headers: authHeaders,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
if (!dlRes.ok) {
|
|
340
|
+
throw new Error(`Download failed: ${await dlRes.text()}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const gzipped = Buffer.from(await dlRes.arrayBuffer());
|
|
344
|
+
await unbundleToDir(gzipped, stagingDir);
|
|
345
|
+
|
|
346
|
+
// Decrypt — backup is always encrypted for shares
|
|
347
|
+
const manifestPath = path.join(stagingDir, 'manifest.enc');
|
|
348
|
+
if (!await fs.pathExists(manifestPath)) {
|
|
349
|
+
throw new Error('Share bundle is missing encryption manifest');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
spinner.stop();
|
|
353
|
+
console.log(chalk.cyan(' 🔒 This share is encrypted'));
|
|
354
|
+
|
|
355
|
+
// Verify passphrase
|
|
356
|
+
const verifyPath = path.join(stagingDir, 'verify.enc');
|
|
357
|
+
let passphrase;
|
|
358
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
359
|
+
const { pass } = await inquirer.prompt([{
|
|
360
|
+
type: 'password',
|
|
361
|
+
name: 'pass',
|
|
362
|
+
message: 'Decryption passphrase:',
|
|
363
|
+
mask: '*',
|
|
364
|
+
}]);
|
|
365
|
+
passphrase = pass;
|
|
366
|
+
|
|
367
|
+
if (await fs.pathExists(verifyPath)) {
|
|
368
|
+
const token = await fs.readFile(verifyPath);
|
|
369
|
+
if (!(await verifyPassphrase(token, passphrase))) {
|
|
370
|
+
console.log(chalk.red(' Wrong passphrase. Try again.'));
|
|
371
|
+
passphrase = null;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (!passphrase) {
|
|
379
|
+
console.log(chalk.red('\n Too many failed attempts.'));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
spinner.start(chalk.gray('Decrypting...'));
|
|
384
|
+
const decryptedDir = path.join(os.tmpdir(), `memoir-share-decrypted-${Date.now()}`);
|
|
385
|
+
let restored = false;
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
const count = await decryptDirectory(stagingDir, decryptedDir, passphrase, spinner);
|
|
389
|
+
spinner.succeed(chalk.green(`Decrypted ${count} files`));
|
|
390
|
+
|
|
391
|
+
// Restore from decrypted dir
|
|
392
|
+
spinner.start(chalk.gray('Restoring...'));
|
|
393
|
+
const onlyFilter = options.only ? options.only.split(',').map(t => t.trim().toLowerCase()) : null;
|
|
394
|
+
const autoYes = !options.interactive;
|
|
395
|
+
const { restoreMemories } = await import('../adapters/restore.js');
|
|
396
|
+
restored = await restoreMemories(decryptedDir, spinner, onlyFilter, autoYes);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
spinner.fail(chalk.red('Decryption failed: ') + err.message);
|
|
399
|
+
return;
|
|
400
|
+
} finally {
|
|
401
|
+
await fs.remove(decryptedDir);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Increment use_count
|
|
405
|
+
try {
|
|
406
|
+
const patchHeaders = { 'apikey': SUPABASE_ANON_KEY, 'Content-Type': 'application/json' };
|
|
407
|
+
if (session) patchHeaders['Authorization'] = `Bearer ${session.access_token}`;
|
|
408
|
+
|
|
409
|
+
await fetch(`${SUPABASE_URL}/rest/v1/shared_links?token=eq.${shareToken}`, {
|
|
410
|
+
method: 'PATCH',
|
|
411
|
+
headers: patchHeaders,
|
|
412
|
+
body: JSON.stringify({ use_count: shareLink.use_count + 1 }),
|
|
413
|
+
});
|
|
414
|
+
} catch {
|
|
415
|
+
// Best-effort — don't fail restore if count update fails
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
spinner.stop();
|
|
419
|
+
|
|
420
|
+
if (restored) {
|
|
421
|
+
console.log('\n' + boxen(
|
|
422
|
+
gradient.pastel(' Done! ') + '\n\n' +
|
|
423
|
+
chalk.white('Shared memories restored successfully.') + '\n' +
|
|
424
|
+
chalk.gray('Restart your AI tools to pick up the changes.'),
|
|
425
|
+
{ padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
|
|
426
|
+
) + '\n');
|
|
427
|
+
} else {
|
|
428
|
+
console.log('\n' + boxen(
|
|
429
|
+
chalk.yellow('Nothing was restored.\n\n') +
|
|
430
|
+
chalk.gray('You may have skipped all the restore prompts.'),
|
|
431
|
+
{ padding: 1, borderStyle: 'round', borderColor: 'yellow' }
|
|
432
|
+
) + '\n');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
} catch (error) {
|
|
436
|
+
spinner.fail(chalk.red('Restore from share failed: ') + error.message);
|
|
437
|
+
} finally {
|
|
438
|
+
await fs.remove(stagingDir);
|
|
439
|
+
}
|
|
440
|
+
}
|