memoir-cli 2.2.0 → 2.5.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.
@@ -0,0 +1,112 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { SUPABASE_URL, SUPABASE_ANON_KEY } from './constants.js';
5
+
6
+ const isWin = process.platform === 'win32';
7
+ const configDir = isWin
8
+ ? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'memoir')
9
+ : path.join(os.homedir(), '.config', 'memoir');
10
+ const AUTH_FILE = path.join(configDir, 'auth.json');
11
+
12
+ async function supaFetch(endpoint, options = {}) {
13
+ const url = `${SUPABASE_URL}${endpoint}`;
14
+ const res = await fetch(url, {
15
+ ...options,
16
+ headers: {
17
+ 'apikey': SUPABASE_ANON_KEY,
18
+ 'Content-Type': 'application/json',
19
+ ...options.headers,
20
+ },
21
+ });
22
+ return res;
23
+ }
24
+
25
+ export async function signUp(email, password) {
26
+ const res = await supaFetch('/auth/v1/signup', {
27
+ method: 'POST',
28
+ body: JSON.stringify({ email, password }),
29
+ });
30
+ const data = await res.json();
31
+ if (!res.ok) throw new Error(data.error_description || data.msg || 'Sign up failed');
32
+ return data;
33
+ }
34
+
35
+ export async function signIn(email, password) {
36
+ const res = await supaFetch('/auth/v1/token?grant_type=password', {
37
+ method: 'POST',
38
+ body: JSON.stringify({ email, password }),
39
+ });
40
+ const data = await res.json();
41
+ if (!res.ok) throw new Error(data.error_description || data.msg || 'Sign in failed');
42
+ return data;
43
+ }
44
+
45
+ export async function refreshSession(refreshToken) {
46
+ const res = await supaFetch('/auth/v1/token?grant_type=refresh_token', {
47
+ method: 'POST',
48
+ body: JSON.stringify({ refresh_token: refreshToken }),
49
+ });
50
+ const data = await res.json();
51
+ if (!res.ok) throw new Error(data.error_description || data.msg || 'Token refresh failed');
52
+ return data;
53
+ }
54
+
55
+ export async function saveSession(session) {
56
+ await fs.ensureDir(configDir);
57
+ const payload = {
58
+ access_token: session.access_token,
59
+ refresh_token: session.refresh_token,
60
+ expires_at: Date.now() + (session.expires_in * 1000),
61
+ user: {
62
+ id: session.user.id,
63
+ email: session.user.email,
64
+ },
65
+ };
66
+ await fs.writeFile(AUTH_FILE, JSON.stringify(payload, null, 2), { mode: 0o600 });
67
+ return payload;
68
+ }
69
+
70
+ export async function getSession() {
71
+ if (!await fs.pathExists(AUTH_FILE)) return null;
72
+
73
+ const stored = await fs.readJson(AUTH_FILE);
74
+
75
+ // If token expires within 60 seconds, refresh
76
+ if (stored.expires_at < Date.now() + 60000) {
77
+ try {
78
+ const refreshed = await refreshSession(stored.refresh_token);
79
+ return await saveSession(refreshed);
80
+ } catch {
81
+ // Refresh failed — session is dead
82
+ await fs.remove(AUTH_FILE);
83
+ return null;
84
+ }
85
+ }
86
+
87
+ return stored;
88
+ }
89
+
90
+ export async function logout() {
91
+ if (await fs.pathExists(AUTH_FILE)) {
92
+ await fs.remove(AUTH_FILE);
93
+ }
94
+ }
95
+
96
+ export async function isLoggedIn() {
97
+ const session = await getSession();
98
+ return !!session;
99
+ }
100
+
101
+ export async function getSubscription(session) {
102
+ const res = await supaFetch('/rest/v1/subscriptions?select=*&user_id=eq.' + session.user.id, {
103
+ headers: {
104
+ 'Authorization': `Bearer ${session.access_token}`,
105
+ },
106
+ });
107
+ const data = await res.json();
108
+ if (!res.ok || !data.length) return { status: 'free' };
109
+ return data[0];
110
+ }
111
+
112
+ export { AUTH_FILE, supaFetch };
@@ -0,0 +1,5 @@
1
+ export const SUPABASE_URL = 'https://oqrkxytbahfwjhcbyzrx.supabase.co';
2
+ export const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xcmt4eXRiYWhmd2poY2J5enJ4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMyMTQ4MzMsImV4cCI6MjA4ODc5MDgzM30.jOKOi73OJgIgi1zj0VOIQkGp0xqS3ee4gfCjpdqCnvM';
3
+ export const STORAGE_BUCKET = 'memoir-backups';
4
+ export const MAX_BACKUPS_FREE = 3;
5
+ export const MAX_BACKUPS_PRO = 50;
@@ -0,0 +1,212 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { createGzip, createGunzip } from 'zlib';
5
+ import { pipeline } from 'stream/promises';
6
+ import { Readable, Writable } from 'stream';
7
+ import { SUPABASE_URL, SUPABASE_ANON_KEY, STORAGE_BUCKET, MAX_BACKUPS_FREE, MAX_BACKUPS_PRO } from './constants.js';
8
+
9
+ // Bundle a directory into a JSON manifest + gzip
10
+ async function bundleDir(dir) {
11
+ const files = [];
12
+
13
+ async function walk(currentDir, prefix = '') {
14
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
15
+ for (const entry of entries) {
16
+ const fullPath = path.join(currentDir, entry.name);
17
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
18
+ if (entry.isDirectory()) {
19
+ await walk(fullPath, relPath);
20
+ } else {
21
+ const content = await fs.readFile(fullPath);
22
+ files.push({
23
+ path: relPath,
24
+ content: content.toString('base64'),
25
+ });
26
+ }
27
+ }
28
+ }
29
+
30
+ await walk(dir);
31
+ const json = JSON.stringify(files);
32
+ const buffer = Buffer.from(json, 'utf-8');
33
+
34
+ // Gzip
35
+ return new Promise((resolve, reject) => {
36
+ const chunks = [];
37
+ const gzip = createGzip({ level: 9 });
38
+ gzip.on('data', chunk => chunks.push(chunk));
39
+ gzip.on('end', () => resolve(Buffer.concat(chunks)));
40
+ gzip.on('error', reject);
41
+ gzip.end(buffer);
42
+ });
43
+ }
44
+
45
+ // Unbundle gzipped JSON back to a directory
46
+ async function unbundleToDir(gzipped, destDir) {
47
+ const decompressed = await new Promise((resolve, reject) => {
48
+ const chunks = [];
49
+ const gunzip = createGunzip();
50
+ gunzip.on('data', chunk => chunks.push(chunk));
51
+ gunzip.on('end', () => resolve(Buffer.concat(chunks)));
52
+ gunzip.on('error', reject);
53
+ gunzip.end(gzipped);
54
+ });
55
+
56
+ const files = JSON.parse(decompressed.toString('utf-8'));
57
+
58
+ for (const file of files) {
59
+ const fullPath = path.join(destDir, file.path);
60
+ await fs.ensureDir(path.dirname(fullPath));
61
+ await fs.writeFile(fullPath, Buffer.from(file.content, 'base64'));
62
+ }
63
+
64
+ return files.length;
65
+ }
66
+
67
+ // Upload backup to Supabase Storage + insert metadata
68
+ export async function uploadBackup(stagingDir, session, toolResults) {
69
+ const gzipped = await bundleDir(stagingDir);
70
+
71
+ const backupId = crypto.randomUUID();
72
+ const storagePath = `${session.user.id}/${backupId}.gz`;
73
+
74
+ // Upload to Storage
75
+ const uploadRes = await fetch(`${SUPABASE_URL}/storage/v1/object/${STORAGE_BUCKET}/${storagePath}`, {
76
+ method: 'POST',
77
+ headers: {
78
+ 'Authorization': `Bearer ${session.access_token}`,
79
+ 'apikey': SUPABASE_ANON_KEY,
80
+ 'Content-Type': 'application/octet-stream',
81
+ },
82
+ body: gzipped,
83
+ });
84
+
85
+ if (!uploadRes.ok) {
86
+ const err = await uploadRes.text();
87
+ throw new Error(`Upload failed: ${err}`);
88
+ }
89
+
90
+ // Get next version number
91
+ const versionRes = await fetch(
92
+ `${SUPABASE_URL}/rest/v1/backups?select=version&user_id=eq.${session.user.id}&order=version.desc&limit=1`,
93
+ {
94
+ headers: {
95
+ 'Authorization': `Bearer ${session.access_token}`,
96
+ 'apikey': SUPABASE_ANON_KEY,
97
+ },
98
+ }
99
+ );
100
+ const versionData = await versionRes.json();
101
+ const nextVersion = (versionData.length > 0 ? versionData[0].version : 0) + 1;
102
+
103
+ // Count files in staging dir
104
+ let fileCount = 0;
105
+ const countFiles = async (dir) => {
106
+ const entries = await fs.readdir(dir, { withFileTypes: true });
107
+ for (const e of entries) {
108
+ if (e.isDirectory()) await countFiles(path.join(dir, e.name));
109
+ else fileCount++;
110
+ }
111
+ };
112
+ await countFiles(stagingDir);
113
+
114
+ // Insert metadata
115
+ const tools = toolResults.map(r => r.adapter.name);
116
+ const metaRes = await fetch(`${SUPABASE_URL}/rest/v1/backups`, {
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
+ user_id: session.user.id,
126
+ tool_count: tools.length,
127
+ file_count: fileCount,
128
+ size_bytes: gzipped.length,
129
+ tools,
130
+ storage_path: storagePath,
131
+ machine_name: os.hostname(),
132
+ version: nextVersion,
133
+ }),
134
+ });
135
+
136
+ if (!metaRes.ok) {
137
+ const err = await metaRes.text();
138
+ throw new Error(`Failed to save backup metadata: ${err}`);
139
+ }
140
+
141
+ const backup = (await metaRes.json())[0];
142
+ return { ...backup, sizeBytes: gzipped.length };
143
+ }
144
+
145
+ // Download a specific backup
146
+ export async function downloadBackup(backup, destDir, session) {
147
+ const res = await fetch(`${SUPABASE_URL}/storage/v1/object/${STORAGE_BUCKET}/${backup.storage_path}`, {
148
+ headers: {
149
+ 'Authorization': `Bearer ${session.access_token}`,
150
+ 'apikey': SUPABASE_ANON_KEY,
151
+ },
152
+ });
153
+
154
+ if (!res.ok) throw new Error(`Download failed: ${await res.text()}`);
155
+
156
+ const gzipped = Buffer.from(await res.arrayBuffer());
157
+ const fileCount = await unbundleToDir(gzipped, destDir);
158
+ return fileCount;
159
+ }
160
+
161
+ // List backups for user
162
+ export async function listBackups(session) {
163
+ const res = await fetch(
164
+ `${SUPABASE_URL}/rest/v1/backups?select=*&user_id=eq.${session.user.id}&order=created_at.desc`,
165
+ {
166
+ headers: {
167
+ 'Authorization': `Bearer ${session.access_token}`,
168
+ 'apikey': SUPABASE_ANON_KEY,
169
+ },
170
+ }
171
+ );
172
+
173
+ if (!res.ok) throw new Error('Failed to fetch backup history');
174
+ return res.json();
175
+ }
176
+
177
+ // Delete old backups beyond the limit
178
+ export async function cleanupOldBackups(session, isPro) {
179
+ const maxBackups = isPro ? MAX_BACKUPS_PRO : MAX_BACKUPS_FREE;
180
+ const backups = await listBackups(session);
181
+
182
+ if (backups.length <= maxBackups) return 0;
183
+
184
+ const toDelete = backups.slice(maxBackups);
185
+ let deleted = 0;
186
+
187
+ for (const backup of toDelete) {
188
+ // Delete from storage
189
+ await fetch(`${SUPABASE_URL}/storage/v1/object/${STORAGE_BUCKET}/${backup.storage_path}`, {
190
+ method: 'DELETE',
191
+ headers: {
192
+ 'Authorization': `Bearer ${session.access_token}`,
193
+ 'apikey': SUPABASE_ANON_KEY,
194
+ },
195
+ });
196
+
197
+ // Delete metadata row
198
+ await fetch(`${SUPABASE_URL}/rest/v1/backups?id=eq.${backup.id}`, {
199
+ method: 'DELETE',
200
+ headers: {
201
+ 'Authorization': `Bearer ${session.access_token}`,
202
+ 'apikey': SUPABASE_ANON_KEY,
203
+ },
204
+ });
205
+
206
+ deleted++;
207
+ }
208
+
209
+ return deleted;
210
+ }
211
+
212
+ export { bundleDir, unbundleToDir };
@@ -0,0 +1,173 @@
1
+ import chalk from 'chalk';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import ora from 'ora';
6
+ import boxen from 'boxen';
7
+ import gradient from 'gradient-string';
8
+ import { getSession, getSubscription } from '../cloud/auth.js';
9
+ import { uploadBackup, downloadBackup, listBackups, cleanupOldBackups } from '../cloud/storage.js';
10
+ import { extractMemories } from '../adapters/index.js';
11
+ import { MAX_BACKUPS_FREE } from '../cloud/constants.js';
12
+
13
+ export async function cloudPushCommand(options = {}) {
14
+ const session = await getSession();
15
+ if (!session) {
16
+ console.log('\n' + boxen(
17
+ chalk.red('✖ Not logged in') + '\n\n' +
18
+ chalk.white('Run ') + chalk.cyan('memoir login') + chalk.white(' first.'),
19
+ { padding: 1, borderStyle: 'round', borderColor: 'red' }
20
+ ) + '\n');
21
+ return;
22
+ }
23
+
24
+ const sub = await getSubscription(session);
25
+ const isPro = sub.status === 'pro';
26
+
27
+ // Check backup count for free users
28
+ if (!isPro) {
29
+ const existing = await listBackups(session);
30
+ if (existing.length >= MAX_BACKUPS_FREE) {
31
+ console.log('\n' + boxen(
32
+ chalk.yellow('Free plan limit reached') + '\n\n' +
33
+ chalk.white(`You have ${existing.length}/${MAX_BACKUPS_FREE} backups.`) + '\n' +
34
+ chalk.white('Oldest backup will be replaced.') + '\n\n' +
35
+ chalk.gray('Upgrade to Pro for 50 backups + version history.'),
36
+ { padding: 1, borderStyle: 'round', borderColor: 'yellow' }
37
+ ) + '\n');
38
+ }
39
+ }
40
+
41
+ console.log();
42
+ const spinner = ora({ text: chalk.gray('Scanning AI tools...'), spinner: 'dots' }).start();
43
+
44
+ const stagingDir = path.join(os.tmpdir(), `memoir-cloud-${Date.now()}`);
45
+ await fs.ensureDir(stagingDir);
46
+
47
+ try {
48
+ const onlyFilter = options.only ? options.only.split(',').map(t => t.trim().toLowerCase()) : null;
49
+ const foundAny = await extractMemories(stagingDir, spinner, onlyFilter);
50
+
51
+ if (!foundAny) {
52
+ spinner.fail(chalk.yellow('No AI tools found to back up.'));
53
+ return;
54
+ }
55
+
56
+ // Collect tool results for metadata
57
+ const toolResults = [];
58
+ const entries = await fs.readdir(stagingDir, { withFileTypes: true });
59
+ for (const entry of entries) {
60
+ if (entry.isDirectory()) {
61
+ toolResults.push({ adapter: { name: entry.name } });
62
+ }
63
+ }
64
+
65
+ spinner.start(chalk.gray('Uploading to memoir cloud...'));
66
+
67
+ const backup = await uploadBackup(stagingDir, session, toolResults);
68
+
69
+ // Cleanup old backups
70
+ const deleted = await cleanupOldBackups(session, isPro);
71
+
72
+ spinner.stop();
73
+
74
+ const sizeStr = backup.sizeBytes < 1024
75
+ ? `${backup.sizeBytes}B`
76
+ : backup.sizeBytes < 1024 * 1024
77
+ ? `${(backup.sizeBytes / 1024).toFixed(1)}KB`
78
+ : `${(backup.sizeBytes / (1024 * 1024)).toFixed(1)}MB`;
79
+
80
+ console.log(boxen(
81
+ gradient.pastel(' Backed up to cloud ') + '\n\n' +
82
+ chalk.green('✔ ') + chalk.white(`Version ${backup.version}`) + '\n' +
83
+ chalk.gray(` ${backup.file_count} files, ${sizeStr}`) + '\n' +
84
+ chalk.gray(` from ${os.hostname()}`) +
85
+ (deleted > 0 ? '\n' + chalk.gray(` ${deleted} old backup${deleted > 1 ? 's' : ''} cleaned up`) : '') + '\n\n' +
86
+ chalk.gray('Restore with: ') + chalk.cyan('memoir cloud restore'),
87
+ { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
88
+ ) + '\n');
89
+
90
+ } catch (error) {
91
+ spinner.fail(chalk.red('Cloud push failed: ') + error.message);
92
+ } finally {
93
+ await fs.remove(stagingDir);
94
+ }
95
+ }
96
+
97
+ export async function cloudRestoreCommand(options = {}) {
98
+ const session = await getSession();
99
+ if (!session) {
100
+ console.log('\n' + boxen(
101
+ chalk.red('✖ Not logged in') + '\n\n' +
102
+ chalk.white('Run ') + chalk.cyan('memoir login') + chalk.white(' first.'),
103
+ { padding: 1, borderStyle: 'round', borderColor: 'red' }
104
+ ) + '\n');
105
+ return;
106
+ }
107
+
108
+ console.log();
109
+ const spinner = ora({ text: chalk.gray('Fetching from memoir cloud...'), spinner: 'dots' }).start();
110
+
111
+ try {
112
+ const backups = await listBackups(session);
113
+
114
+ if (backups.length === 0) {
115
+ spinner.fail(chalk.yellow('No backups found in the cloud.'));
116
+ console.log(chalk.gray('\n Run ') + chalk.cyan('memoir cloud push') + chalk.gray(' to create your first backup.\n'));
117
+ return;
118
+ }
119
+
120
+ // Use specified version or latest
121
+ let backup;
122
+ if (options.version) {
123
+ backup = backups.find(b => b.version === parseInt(options.version));
124
+ if (!backup) {
125
+ spinner.fail(chalk.red(`Version ${options.version} not found.`));
126
+ console.log(chalk.gray('\n Run ') + chalk.cyan('memoir history') + chalk.gray(' to see available versions.\n'));
127
+ return;
128
+ }
129
+ } else {
130
+ backup = backups[0]; // Latest
131
+ }
132
+
133
+ spinner.text = chalk.gray(`Downloading version ${backup.version}...`);
134
+
135
+ const stagingDir = path.join(os.tmpdir(), `memoir-cloud-restore-${Date.now()}`);
136
+ await fs.ensureDir(stagingDir);
137
+
138
+ const fileCount = await downloadBackup(backup, stagingDir, session);
139
+
140
+ spinner.text = chalk.gray('Restoring files...');
141
+
142
+ // Use the existing restore logic
143
+ const { restoreMemories } = await import('../adapters/restore.js');
144
+ const onlyFilter = options.only ? options.only.split(',').map(t => t.trim().toLowerCase()) : null;
145
+ const autoYes = options.yes || false;
146
+
147
+ const restored = await restoreMemories(stagingDir, spinner, onlyFilter, autoYes);
148
+
149
+ spinner.stop();
150
+
151
+ if (restored) {
152
+ const date = new Date(backup.created_at).toLocaleDateString();
153
+ console.log(boxen(
154
+ gradient.pastel(' Restored from cloud ') + '\n\n' +
155
+ chalk.green('✔ ') + chalk.white(`Version ${backup.version}`) + chalk.gray(` from ${date}`) + '\n' +
156
+ chalk.gray(` ${backup.tools.join(', ')}`) + '\n' +
157
+ (backup.machine_name ? chalk.gray(` Originally from ${backup.machine_name}`) + '\n' : '') + '\n' +
158
+ chalk.gray('Restart your AI tools to pick up the changes.'),
159
+ { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
160
+ ) + '\n');
161
+ } else {
162
+ console.log('\n' + boxen(
163
+ chalk.yellow('Nothing was restored.'),
164
+ { padding: 1, borderStyle: 'round', borderColor: 'yellow' }
165
+ ) + '\n');
166
+ }
167
+
168
+ await fs.remove(stagingDir);
169
+
170
+ } catch (error) {
171
+ spinner.fail(chalk.red('Cloud restore failed: ') + error.message);
172
+ }
173
+ }
@@ -50,8 +50,8 @@ function simpleDiff(oldText, newText) {
50
50
  return output;
51
51
  }
52
52
 
53
- export async function diffCommand() {
54
- const config = await getConfig();
53
+ export async function diffCommand(options = {}) {
54
+ const config = await getConfig(options.profile);
55
55
  if (!config) {
56
56
  console.log(chalk.red('\n✖ Not configured yet. Run: memoir init\n'));
57
57
  return;
@@ -74,7 +74,7 @@ async function scanForSecrets(files) {
74
74
  return warnings;
75
75
  }
76
76
 
77
- export async function doctorCommand() {
77
+ export async function doctorCommand(options = {}) {
78
78
  const spinner = ora({ text: 'Running diagnostics...', color: 'cyan' }).start();
79
79
  const lines = [];
80
80
  let passCount = 0;
@@ -87,7 +87,7 @@ export async function doctorCommand() {
87
87
 
88
88
  // 1. Config check
89
89
  spinner.text = 'Checking configuration...';
90
- const config = await getConfig();
90
+ const config = await getConfig(options.profile);
91
91
  if (config) {
92
92
  const providerLabel = config.provider === 'git' ? 'git' : 'local';
93
93
  const dest = config.provider === 'git' ? config.gitRepo : config.localPath;
@@ -0,0 +1,65 @@
1
+ import chalk from 'chalk';
2
+ import boxen from 'boxen';
3
+ import gradient from 'gradient-string';
4
+ import { getSession } from '../cloud/auth.js';
5
+ import { listBackups } from '../cloud/storage.js';
6
+
7
+ export async function historyCommand() {
8
+ const session = await getSession();
9
+ if (!session) {
10
+ console.log('\n' + boxen(
11
+ chalk.red('✖ Not logged in') + '\n\n' +
12
+ chalk.white('Run ') + chalk.cyan('memoir login') + chalk.white(' first.'),
13
+ { padding: 1, borderStyle: 'round', borderColor: 'red' }
14
+ ) + '\n');
15
+ return;
16
+ }
17
+
18
+ try {
19
+ const backups = await listBackups(session);
20
+
21
+ if (backups.length === 0) {
22
+ console.log('\n' + boxen(
23
+ chalk.yellow('No backups yet.') + '\n\n' +
24
+ chalk.gray('Run ') + chalk.cyan('memoir cloud push') + chalk.gray(' to create your first backup.'),
25
+ { padding: 1, borderStyle: 'round', borderColor: 'yellow' }
26
+ ) + '\n');
27
+ return;
28
+ }
29
+
30
+ console.log();
31
+
32
+ const lines = backups.map((b, i) => {
33
+ const date = new Date(b.created_at);
34
+ const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
35
+ const timeStr = date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
36
+
37
+ const sizeStr = b.size_bytes < 1024
38
+ ? `${b.size_bytes}B`
39
+ : b.size_bytes < 1024 * 1024
40
+ ? `${(b.size_bytes / 1024).toFixed(1)}KB`
41
+ : `${(b.size_bytes / (1024 * 1024)).toFixed(1)}MB`;
42
+
43
+ const latest = i === 0 ? chalk.green(' ← latest') : '';
44
+ const machine = b.machine_name ? chalk.gray(` from ${b.machine_name}`) : '';
45
+
46
+ return (
47
+ chalk.white.bold(` v${b.version}`) + ` ${dateStr} ${timeStr}` + latest + '\n' +
48
+ chalk.gray(` ${b.file_count} files, ${sizeStr}`) + machine + '\n' +
49
+ chalk.gray(` ${b.tools.join(', ')}`)
50
+ );
51
+ });
52
+
53
+ console.log(boxen(
54
+ gradient.pastel(' memoir history ') + '\n\n' +
55
+ chalk.gray(`${session.user.email} — ${backups.length} backup${backups.length !== 1 ? 's' : ''}`) + '\n\n' +
56
+ lines.join('\n\n') + '\n\n' +
57
+ chalk.gray('─'.repeat(36)) + '\n' +
58
+ chalk.gray('Restore a version: ') + chalk.cyan('memoir cloud restore --version 3'),
59
+ { padding: 1, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
60
+ ) + '\n');
61
+
62
+ } catch (error) {
63
+ console.log('\n' + chalk.red('Error: ') + error.message + '\n');
64
+ }
65
+ }
@@ -0,0 +1,93 @@
1
+ import chalk from 'chalk';
2
+ import boxen from 'boxen';
3
+ import gradient from 'gradient-string';
4
+ import inquirer from 'inquirer';
5
+ import { signIn, signUp, saveSession, getSession, logout, getSubscription } from '../cloud/auth.js';
6
+
7
+ export async function loginCommand() {
8
+ // Check if already logged in
9
+ const existing = await getSession();
10
+ if (existing) {
11
+ const sub = await getSubscription(existing);
12
+ console.log('\n' + boxen(
13
+ gradient.pastel(' memoir cloud ') + '\n\n' +
14
+ chalk.green('✔ Already logged in as ') + chalk.cyan(existing.user.email) + '\n' +
15
+ chalk.gray('Plan: ') + (sub.status === 'pro' ? chalk.green('Pro') : chalk.yellow('Free')),
16
+ { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
17
+ ) + '\n');
18
+ return;
19
+ }
20
+
21
+ console.log();
22
+
23
+ const { action } = await inquirer.prompt([{
24
+ type: 'list',
25
+ name: 'action',
26
+ message: 'Sign in or create account?',
27
+ choices: [
28
+ { name: 'Sign in (existing account)', value: 'signin' },
29
+ { name: 'Create account', value: 'signup' },
30
+ ],
31
+ }]);
32
+
33
+ const { email } = await inquirer.prompt([{
34
+ type: 'input',
35
+ name: 'email',
36
+ message: 'Email:',
37
+ validate: v => v.includes('@') ? true : 'Enter a valid email',
38
+ }]);
39
+
40
+ const { password } = await inquirer.prompt([{
41
+ type: 'password',
42
+ name: 'password',
43
+ message: 'Password:',
44
+ mask: '*',
45
+ validate: v => v.length >= 6 ? true : 'Password must be at least 6 characters',
46
+ }]);
47
+
48
+ try {
49
+ let session;
50
+
51
+ if (action === 'signup') {
52
+ const result = await signUp(email, password);
53
+ if (result.access_token) {
54
+ session = await saveSession(result);
55
+ } else {
56
+ // Email confirmation required
57
+ console.log('\n' + boxen(
58
+ chalk.green('✔ Account created!') + '\n\n' +
59
+ chalk.white('Check your email to confirm, then run ') + chalk.cyan('memoir login') + chalk.white(' again.'),
60
+ { padding: 1, borderStyle: 'round', borderColor: 'green' }
61
+ ) + '\n');
62
+ return;
63
+ }
64
+ } else {
65
+ const result = await signIn(email, password);
66
+ session = await saveSession(result);
67
+ }
68
+
69
+ const sub = await getSubscription(session);
70
+
71
+ console.log('\n' + boxen(
72
+ gradient.pastel(' memoir cloud ') + '\n\n' +
73
+ chalk.green('✔ Logged in as ') + chalk.cyan(session.user.email) + '\n' +
74
+ chalk.gray('Plan: ') + (sub.status === 'pro' ? chalk.green('Pro') : chalk.yellow('Free')) + '\n\n' +
75
+ chalk.gray('Try: ') + chalk.cyan('memoir cloud push') + chalk.gray(' to back up to the cloud'),
76
+ { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
77
+ ) + '\n');
78
+
79
+ } catch (error) {
80
+ console.log('\n' + boxen(
81
+ chalk.red('✖ ' + error.message),
82
+ { padding: 1, borderStyle: 'round', borderColor: 'red' }
83
+ ) + '\n');
84
+ }
85
+ }
86
+
87
+ export async function logoutCommand() {
88
+ await logout();
89
+ console.log('\n' + boxen(
90
+ chalk.green('✔ Logged out'),
91
+ { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
92
+ ) + '\n');
93
+ }