memoir-cli 2.4.0 → 2.5.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/README.md +123 -86
- package/bin/memoir.js +76 -0
- package/package.json +1 -1
- package/src/adapters/restore.js +48 -3
- package/src/cloud/auth.js +112 -0
- package/src/cloud/constants.js +5 -0
- package/src/cloud/storage.js +212 -0
- package/src/commands/cloud.js +173 -0
- package/src/commands/history.js +65 -0
- package/src/commands/login.js +93 -0
package/README.md
CHANGED
|
@@ -1,88 +1,129 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
3
|
# memoir
|
|
4
|
-
**Your AI Remembers Everything. Sync It Everywhere.**
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
**Sync your AI memory across every device and every tool.**
|
|
6
|
+
|
|
7
|
+
[](https://npmjs.org/package/memoir-cli)
|
|
8
|
+
[](https://npmjs.org/package/memoir-cli)
|
|
7
9
|
[](https://opensource.org/licenses/MIT)
|
|
10
|
+
[](https://nodejs.org)
|
|
11
|
+
|
|
12
|
+
Your AI tools forget you on every new machine. memoir fixes that.
|
|
8
13
|
|
|
9
|
-
|
|
14
|
+
[Website](https://memoir.sh) • [npm](https://npmjs.org/package/memoir-cli) • [Blog](https://memoir.sh/blog)
|
|
15
|
+
|
|
16
|
+
<br />
|
|
10
17
|
|
|
11
18
|

|
|
12
19
|
|
|
13
20
|
</div>
|
|
14
21
|
|
|
15
|
-
|
|
22
|
+
## Why memoir
|
|
16
23
|
|
|
17
|
-
|
|
24
|
+
You spend weeks teaching your AI tools how you code. Your CLAUDE.md is dialed in. Your .cursorrules are perfect. Your ChatGPT custom instructions know your stack.
|
|
18
25
|
|
|
19
|
-
|
|
26
|
+
Then you get a new laptop. **Everything is gone.**
|
|
20
27
|
|
|
21
|
-
|
|
28
|
+
memoir backs up, restores, and **translates** your AI memory across any machine and any tool. One command to save. One command to restore.
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
```bash
|
|
31
|
+
npm install -g memoir-cli
|
|
32
|
+
```
|
|
24
33
|
|
|
25
|
-
##
|
|
34
|
+
## Supported Tools (11)
|
|
35
|
+
|
|
36
|
+
| Tool | What gets synced |
|
|
37
|
+
|------|-----------------|
|
|
38
|
+
| **ChatGPT** | CHATGPT.md custom instructions |
|
|
39
|
+
| **Claude Code** | ~/.claude/ settings, memory, CLAUDE.md files |
|
|
40
|
+
| **Gemini CLI** | ~/.gemini/ config, GEMINI.md files |
|
|
41
|
+
| **OpenAI Codex** | ~/.codex/ config, AGENTS.md, codex.md |
|
|
42
|
+
| **Cursor** | Settings, keybindings, .cursorrules |
|
|
43
|
+
| **GitHub Copilot** | Config, copilot-instructions.md |
|
|
44
|
+
| **Windsurf** | Settings, keybindings, .windsurfrules |
|
|
45
|
+
| **Zed** | Settings, keymap, tasks |
|
|
46
|
+
| **Cline** | Settings, .clinerules |
|
|
47
|
+
| **Continue.dev** | Config, .continuerules |
|
|
48
|
+
| **Aider** | .aider.conf.yml, system prompt |
|
|
26
49
|
|
|
27
|
-
|
|
50
|
+
Plus **per-project configs** — memoir scans your filesystem for CLAUDE.md, GEMINI.md, CHATGPT.md, .cursorrules, and AGENTS.md across all your projects.
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
28
53
|
|
|
29
54
|
```bash
|
|
55
|
+
# Install
|
|
30
56
|
npm install -g memoir-cli
|
|
57
|
+
|
|
58
|
+
# First-time setup (GitHub repo or local)
|
|
59
|
+
memoir init
|
|
60
|
+
|
|
61
|
+
# Back up all your AI configs
|
|
62
|
+
memoir push
|
|
63
|
+
|
|
64
|
+
# Restore on a new machine
|
|
65
|
+
memoir restore
|
|
31
66
|
```
|
|
32
67
|
|
|
33
|
-
|
|
34
|
-
| Tool | Config synced |
|
|
35
|
-
|------|--------------|
|
|
36
|
-
| **ChatGPT** | `CHATGPT.md` — custom instructions, preferences |
|
|
37
|
-
| **Claude Code** | `~/.claude/` — settings, projects, memory files |
|
|
38
|
-
| **Gemini CLI** | `~/.gemini/` — settings, GEMINI.md |
|
|
39
|
-
| **OpenAI Codex** | `~/.codex/` — config, instructions |
|
|
40
|
-
| **Cursor** | Settings, keybindings, rules |
|
|
41
|
-
| **GitHub Copilot** | Config, settings |
|
|
42
|
-
| **Windsurf** | Settings, keybindings, rules |
|
|
43
|
-
| **Zed** | Settings, keymap, tasks |
|
|
44
|
-
| **Cline** | Settings, rules |
|
|
45
|
-
| **Continue.dev** | Config, rules |
|
|
46
|
-
| **Aider** | `.aider.conf.yml`, system prompt |
|
|
68
|
+
That's it. Every AI tool gets its memory back.
|
|
47
69
|
|
|
48
|
-
|
|
70
|
+
## Key Features
|
|
49
71
|
|
|
50
|
-
|
|
72
|
+
### Translate between AI tools
|
|
73
|
+
```bash
|
|
74
|
+
memoir migrate --from chatgpt --to claude
|
|
75
|
+
# AI-powered — rewrites conventions, not copy-paste
|
|
76
|
+
```
|
|
51
77
|
|
|
52
|
-
|
|
78
|
+
Works between any combination: ChatGPT, Claude, Gemini, Cursor, Copilot, Codex, Windsurf, Aider, and more. Translate to one tool or all at once:
|
|
53
79
|
|
|
54
|
-
### 1. Initialize
|
|
55
80
|
```bash
|
|
56
|
-
memoir
|
|
57
|
-
# Walks you through setup — GitHub repo or local folder
|
|
58
|
-
# Auto-creates a private repo if you have gh CLI
|
|
81
|
+
memoir migrate --from chatgpt --to all
|
|
59
82
|
```
|
|
60
83
|
|
|
61
|
-
###
|
|
84
|
+
### Session handoff
|
|
62
85
|
```bash
|
|
63
|
-
|
|
86
|
+
# Laptop dying? Capture your session
|
|
87
|
+
memoir snapshot
|
|
88
|
+
|
|
89
|
+
# Pick up on another machine
|
|
90
|
+
memoir resume --inject --to claude
|
|
64
91
|
```
|
|
65
92
|
|
|
66
|
-
###
|
|
93
|
+
### Profiles (personal / work)
|
|
67
94
|
```bash
|
|
68
|
-
memoir
|
|
95
|
+
memoir profile create work
|
|
96
|
+
memoir push --profile work
|
|
97
|
+
memoir profile switch personal
|
|
69
98
|
```
|
|
70
99
|
|
|
71
|
-
|
|
100
|
+
Each profile has its own repo and tool filters. Work configs never mix with personal.
|
|
101
|
+
|
|
102
|
+
### Cloud sync (Pro)
|
|
72
103
|
```bash
|
|
73
|
-
memoir
|
|
74
|
-
|
|
104
|
+
memoir login
|
|
105
|
+
memoir cloud push # encrypted cloud backup
|
|
106
|
+
memoir cloud restore # restore from any version
|
|
107
|
+
memoir history # view all backup versions
|
|
75
108
|
```
|
|
76
109
|
|
|
77
|
-
|
|
110
|
+
Free tier: 3 cloud backups. Pro ($5/mo): unlimited + version history.
|
|
111
|
+
|
|
112
|
+
### Security
|
|
113
|
+
```bash
|
|
114
|
+
memoir doctor
|
|
115
|
+
# Scans for secrets, API keys, .env files before pushing
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
memoir **never** syncs credentials, API keys, .env files, or auth tokens.
|
|
78
119
|
|
|
79
120
|
## All Commands
|
|
80
121
|
|
|
81
122
|
| Command | What it does |
|
|
82
123
|
|---------|-------------|
|
|
83
|
-
| `memoir init` | Setup wizard — GitHub or local
|
|
124
|
+
| `memoir init` | Setup wizard — GitHub or local storage |
|
|
84
125
|
| `memoir push` | Back up all AI configs |
|
|
85
|
-
| `memoir restore` | Restore on a new machine
|
|
126
|
+
| `memoir restore` | Restore on a new machine |
|
|
86
127
|
| `memoir status` | Show detected AI tools |
|
|
87
128
|
| `memoir doctor` | Diagnose issues, scan for secrets |
|
|
88
129
|
| `memoir view` | Preview what's in your backup |
|
|
@@ -91,28 +132,27 @@ memoir migrate --from claude --to gemini
|
|
|
91
132
|
| `memoir snapshot` | Capture current coding session |
|
|
92
133
|
| `memoir resume` | Pick up where you left off |
|
|
93
134
|
| `memoir profile` | Manage profiles (personal/work) |
|
|
135
|
+
| `memoir cloud push` | Back up to memoir cloud |
|
|
136
|
+
| `memoir cloud restore` | Restore from memoir cloud |
|
|
137
|
+
| `memoir history` | View cloud backup versions |
|
|
138
|
+
| `memoir login` | Sign in to memoir cloud |
|
|
94
139
|
| `memoir update` | Self-update to latest version |
|
|
95
140
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
## Profiles
|
|
141
|
+
## How memoir compares
|
|
99
142
|
|
|
100
|
-
|
|
143
|
+
| Feature | memoir | skillshare | ai-rulez | memories.sh |
|
|
144
|
+
|---------|--------|-----------|----------|-------------|
|
|
145
|
+
| Cross-device sync | **Yes** | Yes (git) | No | Yes |
|
|
146
|
+
| AI-powered translation | **Yes** | No | No | No |
|
|
147
|
+
| Tools supported | **11** | 40+ | 18 | 3 |
|
|
148
|
+
| Cloud backup | **Yes** | No | No | Yes ($15/mo) |
|
|
149
|
+
| Version history | **Yes** | No | No | No |
|
|
150
|
+
| Session handoff | **Yes** | No | No | No |
|
|
151
|
+
| Profiles | **Yes** | No | No | No |
|
|
152
|
+
| Secret scanning | **Yes** | Yes | No | No |
|
|
153
|
+
| Free & open source | **Yes** | Yes | Yes | No |
|
|
101
154
|
|
|
102
|
-
|
|
103
|
-
memoir profile create work # set up work profile with its own repo
|
|
104
|
-
memoir profile create personal # personal side projects
|
|
105
|
-
|
|
106
|
-
memoir push --profile work # sync only work configs
|
|
107
|
-
memoir restore --profile personal
|
|
108
|
-
|
|
109
|
-
memoir profile list # see all profiles
|
|
110
|
-
memoir profile switch work # change default
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
Each profile can filter which tools to sync, so your personal side project memory never mixes with work.
|
|
114
|
-
|
|
115
|
-
---
|
|
155
|
+
memoir is free and open source. Cloud sync starts at $0 (3 backups free).
|
|
116
156
|
|
|
117
157
|
## Common Workflows
|
|
118
158
|
|
|
@@ -122,46 +162,36 @@ Each profile can filter which tools to sync, so your personal side project memor
|
|
|
122
162
|
memoir push
|
|
123
163
|
|
|
124
164
|
# New machine
|
|
125
|
-
|
|
126
|
-
memoir
|
|
165
|
+
npm install -g memoir-cli
|
|
166
|
+
memoir init # connect to same repo
|
|
167
|
+
memoir restore # all configs restored in seconds
|
|
127
168
|
```
|
|
128
169
|
|
|
129
|
-
###
|
|
170
|
+
### Switching from ChatGPT to Claude
|
|
130
171
|
```bash
|
|
131
172
|
memoir migrate --from chatgpt --to claude
|
|
132
|
-
# Your
|
|
173
|
+
# Your custom instructions become a proper CLAUDE.md
|
|
133
174
|
```
|
|
134
175
|
|
|
135
|
-
###
|
|
176
|
+
### Daily sync between machines
|
|
136
177
|
```bash
|
|
137
|
-
memoir
|
|
138
|
-
#
|
|
178
|
+
memoir push # end of day on laptop
|
|
179
|
+
memoir restore # next morning on desktop
|
|
139
180
|
```
|
|
140
181
|
|
|
141
|
-
###
|
|
182
|
+
### Cross-platform (Mac ↔ Windows ↔ Linux)
|
|
142
183
|
```bash
|
|
143
|
-
|
|
144
|
-
memoir
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
---
|
|
148
|
-
|
|
149
|
-
## Security
|
|
150
|
-
|
|
151
|
-
Memoir **only** syncs config files, instructions, and memory markdown. It never touches credentials, API keys, `.env` files, or auth tokens.
|
|
152
|
-
|
|
153
|
-
Run `memoir doctor` to see exactly what would be synced and scan for accidental secrets before pushing.
|
|
154
|
-
|
|
155
|
-
---
|
|
184
|
+
# Push from Mac
|
|
185
|
+
memoir push
|
|
156
186
|
|
|
157
|
-
|
|
187
|
+
# Restore on Windows — paths remap automatically
|
|
188
|
+
memoir restore
|
|
189
|
+
```
|
|
158
190
|
|
|
159
|
-
|
|
160
|
-
- **Cloud sync** — no GitHub needed, encrypted backups
|
|
161
|
-
- **Teams** — shared coding standards across your whole team
|
|
162
|
-
- **Templates** — community-shared AI tool configs
|
|
191
|
+
## Requirements
|
|
163
192
|
|
|
164
|
-
|
|
193
|
+
- Node.js >= 18
|
|
194
|
+
- Works on macOS, Windows, Linux
|
|
165
195
|
|
|
166
196
|
## Contributing
|
|
167
197
|
|
|
@@ -172,6 +202,13 @@ Contributions welcome — especially new tool adapters and migration improvement
|
|
|
172
202
|
3. Commit and push
|
|
173
203
|
4. Open a PR
|
|
174
204
|
|
|
205
|
+
## Links
|
|
206
|
+
|
|
207
|
+
- **Website:** [memoir.sh](https://memoir.sh)
|
|
208
|
+
- **npm:** [memoir-cli](https://npmjs.org/package/memoir-cli)
|
|
209
|
+
- **Blog:** [memoir.sh/blog](https://memoir.sh/blog)
|
|
210
|
+
- **Issues:** [GitHub Issues](https://github.com/camgitt/memoir/issues)
|
|
211
|
+
|
|
175
212
|
## License
|
|
176
213
|
|
|
177
214
|
MIT
|
package/bin/memoir.js
CHANGED
|
@@ -14,6 +14,9 @@ import { migrateCommand } from '../src/commands/migrate.js';
|
|
|
14
14
|
import { snapshotCommand } from '../src/commands/snapshot.js';
|
|
15
15
|
import { resumeCommand } from '../src/commands/resume.js';
|
|
16
16
|
import { profileListCommand, profileCreateCommand, profileSwitchCommand, profileDeleteCommand } from '../src/commands/profile.js';
|
|
17
|
+
import { loginCommand, logoutCommand } from '../src/commands/login.js';
|
|
18
|
+
import { cloudPushCommand, cloudRestoreCommand } from '../src/commands/cloud.js';
|
|
19
|
+
import { historyCommand } from '../src/commands/history.js';
|
|
17
20
|
import { createRequire } from 'module';
|
|
18
21
|
|
|
19
22
|
const require = createRequire(import.meta.url);
|
|
@@ -60,6 +63,11 @@ if (process.argv.length <= 2) {
|
|
|
60
63
|
chalk.cyan(' memoir status ') + chalk.gray('— see detected AI tools') + '\n' +
|
|
61
64
|
chalk.cyan(' memoir profile ') + chalk.gray('— manage profiles (personal/work)') + '\n' +
|
|
62
65
|
chalk.cyan(' memoir update ') + chalk.gray('— update to latest version') + '\n\n' +
|
|
66
|
+
chalk.white.bold('Cloud (Pro):') + '\n' +
|
|
67
|
+
chalk.cyan(' memoir login ') + chalk.gray('— sign in to memoir cloud') + '\n' +
|
|
68
|
+
chalk.cyan(' memoir cloud push ') + chalk.gray('— back up to the cloud') + '\n' +
|
|
69
|
+
chalk.cyan(' memoir cloud restore ') + chalk.gray('— restore from cloud') + '\n' +
|
|
70
|
+
chalk.cyan(' memoir history ') + chalk.gray('— view backup versions') + '\n\n' +
|
|
63
71
|
chalk.gray(' Tip: use --profile work to sync a specific profile') + '\n\n' +
|
|
64
72
|
chalk.gray(`v${VERSION}`),
|
|
65
73
|
{ padding: 1, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
|
|
@@ -263,6 +271,74 @@ program
|
|
|
263
271
|
}
|
|
264
272
|
});
|
|
265
273
|
|
|
274
|
+
// Cloud auth
|
|
275
|
+
program
|
|
276
|
+
.command('login')
|
|
277
|
+
.description('Sign in to memoir cloud')
|
|
278
|
+
.action(async () => {
|
|
279
|
+
try {
|
|
280
|
+
await loginCommand();
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.error(chalk.red('\n✖ Error:'), err.message);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
program
|
|
288
|
+
.command('logout')
|
|
289
|
+
.description('Sign out of memoir cloud')
|
|
290
|
+
.action(async () => {
|
|
291
|
+
try {
|
|
292
|
+
await logoutCommand();
|
|
293
|
+
} catch (err) {
|
|
294
|
+
console.error(chalk.red('\n✖ Error:'), err.message);
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Cloud sync
|
|
300
|
+
const cloud = program.command('cloud').description('Cloud backup and restore (Pro)');
|
|
301
|
+
|
|
302
|
+
cloud
|
|
303
|
+
.command('push')
|
|
304
|
+
.description('Back up your AI memory to the cloud')
|
|
305
|
+
.option('--only <tools>', 'Only sync specific tools (comma-separated)')
|
|
306
|
+
.action(async (options) => {
|
|
307
|
+
try {
|
|
308
|
+
await cloudPushCommand(options);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
console.error(chalk.red('\n✖ Error:'), err.message);
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
cloud
|
|
316
|
+
.command('restore')
|
|
317
|
+
.description('Restore your AI memory from the cloud')
|
|
318
|
+
.option('--only <tools>', 'Only restore specific tools (comma-separated)')
|
|
319
|
+
.option('-y, --yes', 'Skip confirmation prompts')
|
|
320
|
+
.option('--version <number>', 'Restore a specific version')
|
|
321
|
+
.action(async (options) => {
|
|
322
|
+
try {
|
|
323
|
+
await cloudRestoreCommand(options);
|
|
324
|
+
} catch (err) {
|
|
325
|
+
console.error(chalk.red('\n✖ Error:'), err.message);
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
program
|
|
331
|
+
.command('history')
|
|
332
|
+
.description('View your cloud backup history')
|
|
333
|
+
.action(async () => {
|
|
334
|
+
try {
|
|
335
|
+
await historyCommand();
|
|
336
|
+
} catch (err) {
|
|
337
|
+
console.error(chalk.red('\n✖ Error:'), err.message);
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
266
342
|
// Profile management
|
|
267
343
|
const profile = program.command('profile').description('Manage profiles (personal, work, etc.)');
|
|
268
344
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memoir-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"description": "Sync AI memory across devices. Back up and restore Claude, Gemini, Codex, Cursor, Copilot, Windsurf configs. Snapshot coding sessions and resume on another machine. Migrate instructions between AI assistants.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
package/src/adapters/restore.js
CHANGED
|
@@ -70,18 +70,36 @@ function remapProjectPaths(backupDir, adapterSource) {
|
|
|
70
70
|
|
|
71
71
|
// Step 3: Identify foreign home keys in the backup
|
|
72
72
|
// A "home key" is a dir that: has memory/, OR is a prefix of other dirs, AND is not a sub-project
|
|
73
|
+
// Also detect alternate local encodings (e.g. -C-Users-X and C--Users-X are the same machine)
|
|
73
74
|
const foreignHomeKeys = new Set();
|
|
75
|
+
const localAltKeys = new Set(); // alternate encodings of local home dir
|
|
76
|
+
|
|
77
|
+
// Detect alternate local encodings by checking if a dir resolves to the same homedir
|
|
78
|
+
const home = os.homedir();
|
|
79
|
+
const homeNormalized = home.toLowerCase().replace(/[\\/:]/g, '');
|
|
74
80
|
|
|
75
81
|
for (const entry of backupEntries) {
|
|
76
|
-
// Skip dirs that already
|
|
82
|
+
// Skip dirs that already match the primary local key
|
|
77
83
|
if (entry === localHomeKey || entry.startsWith(localHomeKey + '-')) continue;
|
|
78
84
|
|
|
85
|
+
// Check if this entry is an alternate encoding of the local home dir
|
|
86
|
+
const entryNormalized = entry.replace(/^[-]/, '').toLowerCase().replace(/[-]/g, '');
|
|
87
|
+
if (entryNormalized === homeNormalized || homeNormalized.endsWith(entryNormalized) || entryNormalized.endsWith(homeNormalized)) {
|
|
88
|
+
// This is an alternate encoding of the local home — treat as local, not foreign
|
|
89
|
+
localAltKeys.add(entry);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
79
93
|
// Is this a sub-project of another backup dir? Then skip — its parent handles it
|
|
80
94
|
const isSubProject = backupEntries.some(other =>
|
|
81
95
|
other !== entry && entry.startsWith(other + '-')
|
|
82
96
|
);
|
|
83
97
|
if (isSubProject) continue;
|
|
84
98
|
|
|
99
|
+
// Is this a sub-project of an alternate local key? Skip too
|
|
100
|
+
const isAltSubProject = [...localAltKeys].some(alt => entry.startsWith(alt + '-'));
|
|
101
|
+
if (isAltSubProject) continue;
|
|
102
|
+
|
|
85
103
|
// Has memory/ subfolder = definitely a home key
|
|
86
104
|
const hasMemory = fs.existsSync(path.join(projectsDir, entry, 'memory'));
|
|
87
105
|
// Is a prefix of other dirs = likely a home key
|
|
@@ -116,6 +134,33 @@ function remapProjectPaths(backupDir, adapterSource) {
|
|
|
116
134
|
return remaps;
|
|
117
135
|
}
|
|
118
136
|
|
|
137
|
+
// Merge memory dirs from a foreign machine — copies files that don't exist locally,
|
|
138
|
+
// and for files that exist on both, keeps the newer version.
|
|
139
|
+
async function mergeMemoryDirs(src, dest) {
|
|
140
|
+
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
const srcPath = path.join(src, entry.name);
|
|
143
|
+
const destPath = path.join(dest, entry.name);
|
|
144
|
+
|
|
145
|
+
if (entry.isDirectory()) {
|
|
146
|
+
await fs.ensureDir(destPath);
|
|
147
|
+
await mergeMemoryDirs(srcPath, destPath);
|
|
148
|
+
} else {
|
|
149
|
+
if (await fs.pathExists(destPath)) {
|
|
150
|
+
// Both machines have this file — keep the newer one
|
|
151
|
+
const srcStat = await fs.stat(srcPath);
|
|
152
|
+
const destStat = await fs.stat(destPath);
|
|
153
|
+
if (srcStat.mtimeMs > destStat.mtimeMs) {
|
|
154
|
+
await fs.copy(srcPath, destPath);
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
// File only exists on foreign machine — always copy it
|
|
158
|
+
await fs.copy(srcPath, destPath);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
119
164
|
async function syncFiles(src, dest, changes) {
|
|
120
165
|
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
121
166
|
for (const entry of entries) {
|
|
@@ -194,8 +239,8 @@ export async function restoreMemories(sourceDir, spinner, onlyFilter = null, aut
|
|
|
194
239
|
const newDir = path.join(backupDir, 'projects', remap.newName);
|
|
195
240
|
if (await fs.pathExists(oldDir)) {
|
|
196
241
|
if (await fs.pathExists(newDir)) {
|
|
197
|
-
// Merge into existing directory
|
|
198
|
-
await
|
|
242
|
+
// Merge into existing directory — force-copy new files from foreign machine
|
|
243
|
+
await mergeMemoryDirs(oldDir, newDir);
|
|
199
244
|
await fs.remove(oldDir);
|
|
200
245
|
} else {
|
|
201
246
|
await fs.move(oldDir, newDir);
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|