memoir-cli 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.yes || false;
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
+ }
@@ -0,0 +1,192 @@
1
+ import chalk from 'chalk';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import crypto from 'crypto';
6
+ import ora from 'ora';
7
+ import boxen from 'boxen';
8
+ import gradient from 'gradient-string';
9
+ import inquirer from 'inquirer';
10
+ import { getSession } from '../cloud/auth.js';
11
+ import { extractMemories, adapters } from '../adapters/index.js';
12
+ import { encryptDirectory, createVerifyToken } from '../security/encryption.js';
13
+ import { bundleDir } from '../cloud/storage.js';
14
+ import { SUPABASE_URL, SUPABASE_ANON_KEY, STORAGE_BUCKET } from '../cloud/constants.js';
15
+
16
+ export async function shareCommand(options = {}) {
17
+ // Must be logged in to share
18
+ const session = await getSession();
19
+ if (!session) {
20
+ console.log('\n' + boxen(
21
+ chalk.red('✖ Not logged in\n\n') +
22
+ chalk.white('Sharing requires a memoir cloud account.\n') +
23
+ chalk.white('Run ') + chalk.cyan.bold('memoir login') + chalk.white(' to sign in.'),
24
+ { padding: 1, borderStyle: 'round', borderColor: 'red' }
25
+ ) + '\n');
26
+ return;
27
+ }
28
+
29
+ console.log();
30
+ const spinner = ora({ text: chalk.gray('Scanning for AI tools...'), spinner: 'dots' }).start();
31
+
32
+ const stagingDir = path.join(os.tmpdir(), `memoir-share-${Date.now()}`);
33
+ await fs.ensureDir(stagingDir);
34
+
35
+ let encryptedDir = null;
36
+
37
+ try {
38
+ // Scan and extract memories (same as push)
39
+ const onlyFilter = options.only ? options.only.split(',').map(t => t.trim().toLowerCase()) : null;
40
+ const foundAny = await extractMemories(stagingDir, spinner, onlyFilter);
41
+
42
+ if (!foundAny) {
43
+ spinner.stop();
44
+ console.log('\n' + boxen(
45
+ chalk.yellow('No AI tools detected on this machine.\n\n') +
46
+ chalk.gray('Supported: Claude, Gemini, Codex, Cursor, Copilot, Windsurf, Aider'),
47
+ { padding: 1, borderStyle: 'round', borderColor: 'yellow' }
48
+ ) + '\n');
49
+ return;
50
+ }
51
+
52
+ // Count what was found
53
+ const found = [];
54
+ for (const adapter of adapters) {
55
+ if (adapter.customExtract) {
56
+ for (const file of adapter.files) {
57
+ if (await fs.pathExists(path.join(adapter.source, file))) {
58
+ found.push(adapter.name);
59
+ break;
60
+ }
61
+ }
62
+ } else if (await fs.pathExists(adapter.source)) {
63
+ found.push(adapter.name);
64
+ }
65
+ }
66
+
67
+ // Ask for encryption passphrase
68
+ spinner.stop();
69
+ const { passphrase } = await inquirer.prompt([{
70
+ type: 'password',
71
+ name: 'passphrase',
72
+ message: '🔒 Set a passphrase for this share link (recipient will need it):',
73
+ mask: '*',
74
+ validate: (input) => input.length >= 6 ? true : 'Passphrase must be at least 6 characters'
75
+ }]);
76
+
77
+ spinner.start(chalk.gray('Encrypting...'));
78
+
79
+ encryptedDir = path.join(os.tmpdir(), `memoir-share-enc-${Date.now()}`);
80
+ await fs.ensureDir(encryptedDir);
81
+ await encryptDirectory(stagingDir, encryptedDir, passphrase, spinner);
82
+
83
+ // Save verify token so recipient can check passphrase
84
+ const token = await createVerifyToken(passphrase);
85
+ await fs.writeFile(path.join(encryptedDir, 'verify.enc'), token);
86
+
87
+ // Bundle and upload to Supabase Storage
88
+ spinner.text = chalk.gray('Uploading share bundle...');
89
+ const gzipped = await bundleDir(encryptedDir);
90
+
91
+ const shareToken = crypto.randomUUID();
92
+ const storagePath = `shares/${session.user.id}/${shareToken}.gz`;
93
+
94
+ const uploadRes = await fetch(`${SUPABASE_URL}/storage/v1/object/${STORAGE_BUCKET}/${storagePath}`, {
95
+ method: 'POST',
96
+ headers: {
97
+ 'Authorization': `Bearer ${session.access_token}`,
98
+ 'apikey': SUPABASE_ANON_KEY,
99
+ 'Content-Type': 'application/octet-stream',
100
+ },
101
+ body: gzipped,
102
+ });
103
+
104
+ if (!uploadRes.ok) {
105
+ const err = await uploadRes.text();
106
+ throw new Error(`Upload failed: ${err}`);
107
+ }
108
+
109
+ // Parse options
110
+ const expiresHours = parseInt(options.expires) || 24;
111
+ const maxUses = parseInt(options.uses) || 5;
112
+ const expiresAt = new Date(Date.now() + expiresHours * 60 * 60 * 1000).toISOString();
113
+
114
+ // Store share metadata in shared_links table
115
+ spinner.text = chalk.gray('Creating share link...');
116
+ const metaRes = await fetch(`${SUPABASE_URL}/rest/v1/shared_links`, {
117
+ method: 'POST',
118
+ headers: {
119
+ 'Authorization': `Bearer ${session.access_token}`,
120
+ 'apikey': SUPABASE_ANON_KEY,
121
+ 'Content-Type': 'application/json',
122
+ 'Prefer': 'return=representation',
123
+ },
124
+ body: JSON.stringify({
125
+ token: shareToken,
126
+ backup_id: storagePath,
127
+ created_by: session.user.id,
128
+ expires_at: expiresAt,
129
+ max_uses: maxUses,
130
+ use_count: 0,
131
+ tools: found,
132
+ size_bytes: gzipped.length,
133
+ }),
134
+ });
135
+
136
+ if (!metaRes.ok) {
137
+ const err = await metaRes.text();
138
+ throw new Error(`Failed to create share link: ${err}`);
139
+ }
140
+
141
+ spinner.stop();
142
+
143
+ // Count total files
144
+ let totalFiles = 0;
145
+ for (const adapter of adapters) {
146
+ const adapterDir = path.join(stagingDir, adapter.name.toLowerCase().replace(/ /g, '-'));
147
+ if (await fs.pathExists(adapterDir)) {
148
+ const countDir = async (dir) => {
149
+ let c = 0;
150
+ const entries = await fs.readdir(dir, { withFileTypes: true });
151
+ for (const e of entries) {
152
+ if (e.isDirectory()) c += await countDir(path.join(dir, e.name));
153
+ else c++;
154
+ }
155
+ return c;
156
+ };
157
+ totalFiles += await countDir(adapterDir);
158
+ }
159
+ }
160
+
161
+ // Format expiry for display
162
+ const expiryDate = new Date(expiresAt);
163
+ const expiryStr = expiryDate.toLocaleString();
164
+
165
+ // Success output
166
+ const shareUrl = `https://memoir.sh/share/${shareToken}`;
167
+ const restoreCmd = `memoir restore --from ${shareToken}`;
168
+ const toolList = found.map(t => chalk.cyan(' ✔ ' + t)).join('\n');
169
+
170
+ console.log('\n' + boxen(
171
+ gradient.pastel(' Shared! ') + '\n\n' +
172
+ toolList + '\n' +
173
+ chalk.white(`${totalFiles} files from ${found.length} tool${found.length !== 1 ? 's' : ''}`) + '\n' +
174
+ chalk.green(' 🔒 E2E encrypted') + '\n\n' +
175
+ chalk.white.bold('Share link:') + '\n' +
176
+ chalk.cyan(` ${shareUrl}`) + '\n\n' +
177
+ chalk.white.bold('Recipient runs:') + '\n' +
178
+ chalk.cyan(` ${restoreCmd}`) + '\n\n' +
179
+ chalk.gray(`Expires: ${expiryStr} (${expiresHours}h)`) + '\n' +
180
+ chalk.gray(`Max uses: ${maxUses}`),
181
+ { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
182
+ ) + '\n');
183
+
184
+ } catch (error) {
185
+ spinner.fail(chalk.red('Share failed: ') + error.message);
186
+ } finally {
187
+ await fs.remove(stagingDir);
188
+ if (encryptedDir) {
189
+ await fs.remove(encryptedDir).catch(() => {});
190
+ }
191
+ }
192
+ }
@@ -0,0 +1,107 @@
1
+ import chalk from 'chalk';
2
+ import boxen from 'boxen';
3
+ import gradient from 'gradient-string';
4
+ import { getSession, getSubscription } from '../cloud/auth.js';
5
+
6
+ export async function upgradeCommand() {
7
+ const session = await getSession();
8
+ let currentPlan = 'free';
9
+ let email = null;
10
+
11
+ if (session) {
12
+ email = session.user.email;
13
+ try {
14
+ const sub = await getSubscription(session);
15
+ currentPlan = sub.status === 'pro' ? 'pro' : 'free';
16
+ } catch {
17
+ // Fall through as free
18
+ }
19
+ }
20
+
21
+ // Build comparison table
22
+ const col1 = 22;
23
+ const col2 = 22;
24
+
25
+ const pad = (str, width) => {
26
+ // Strip ANSI for length calculation
27
+ const stripped = str.replace(/\u001b\[[0-9;]*m/g, '');
28
+ const diff = width - stripped.length;
29
+ return diff > 0 ? str + ' '.repeat(diff) : str;
30
+ };
31
+
32
+ const freeLabel = currentPlan === 'free' ? 'Free (current)' : 'Free';
33
+ const proLabel = currentPlan === 'pro' ? 'Pro (current)' : 'Pro $15/mo';
34
+ const teamsLabel = 'Teams $29/seat';
35
+
36
+ const header =
37
+ pad(chalk.bold.white(freeLabel), col1) +
38
+ pad(chalk.bold.cyan(proLabel), col2) +
39
+ chalk.bold.magenta(teamsLabel);
40
+
41
+ const sep = chalk.gray('─'.repeat(col1 + col2 + 18));
42
+
43
+ const rows = [
44
+ [chalk.gray('3 cloud backups'), chalk.white('50 cloud backups'), chalk.white('Unlimited backups')],
45
+ [chalk.gray('Local only'), chalk.white('Unlimited machines'), chalk.white('Shared team context')],
46
+ [chalk.gray('Manual snapshots'), chalk.white('Auto snapshots'), chalk.white('Team dashboard')],
47
+ [chalk.gray('Community support'), chalk.white('Priority support'), chalk.white('Audit log')],
48
+ [chalk.gray('—'), chalk.white('E2E encryption'), chalk.white('SSO & RBAC')],
49
+ [chalk.gray('—'), chalk.white('Version history'), chalk.white('Priority onboarding')],
50
+ ];
51
+
52
+ const tableRows = rows.map(([a, b, c]) =>
53
+ pad(a, col1) + pad(b, col2) + c
54
+ ).join('\n');
55
+
56
+ const table =
57
+ '\n' + header + '\n' +
58
+ sep + '\n' +
59
+ tableRows + '\n';
60
+
61
+ // Status line
62
+ let statusLine;
63
+ if (!session) {
64
+ statusLine = chalk.yellow('Not logged in.') + chalk.gray(' Run ') + chalk.cyan('memoir login') + chalk.gray(' first.');
65
+ } else if (currentPlan === 'pro') {
66
+ statusLine = chalk.green('You\'re on Pro!') + ' ' + chalk.gray('Teams is coming soon — join the waitlist at memoir.sh/teams');
67
+ } else {
68
+ statusLine = chalk.gray('Logged in as ') + chalk.cyan(email);
69
+ }
70
+
71
+ console.log('\n' + boxen(
72
+ gradient.pastel(' memoir upgrade ') + '\n\n' +
73
+ table + '\n' +
74
+ statusLine,
75
+ { padding: 1, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
76
+ ));
77
+
78
+ // If free and logged in, open pricing page
79
+ if (session && currentPlan === 'free') {
80
+ console.log('\n' + chalk.cyan(' Opening pricing page...') + '\n');
81
+
82
+ const { exec } = await import('child_process');
83
+ const url = 'https://memoir.sh/pricing';
84
+
85
+ const platform = process.platform;
86
+ let cmd;
87
+ if (platform === 'darwin') {
88
+ cmd = `open "${url}"`;
89
+ } else if (platform === 'win32') {
90
+ cmd = `start "${url}"`;
91
+ } else {
92
+ cmd = `xdg-open "${url}"`;
93
+ }
94
+
95
+ exec(cmd, () => {});
96
+
97
+ console.log(
98
+ chalk.gray(' Once you\'ve completed payment, run ') +
99
+ chalk.cyan('memoir login') +
100
+ chalk.gray(' to refresh your plan.') + '\n'
101
+ );
102
+ } else if (!session) {
103
+ console.log('\n' + chalk.gray(' Sign up at ') + chalk.cyan('memoir.sh/pricing') + chalk.gray(' or run ') + chalk.cyan('memoir login') + chalk.gray(' to get started.') + '\n');
104
+ } else {
105
+ console.log();
106
+ }
107
+ }