stuf-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -0
- package/crypto.js +30 -0
- package/index.js +527 -0
- package/package.json +39 -0
- package/pair.js +144 -0
- package/sync.js +120 -0
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# stuf-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for [stuf](https://stufapp.net) — manage your tasks with AI.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
Add to your Claude Code config (`.claude/settings.local.json` or via CLI):
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
claude mcp add stuf -s project -- npx stuf-mcp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or manually in settings:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"mcpServers": {
|
|
18
|
+
"stuf": {
|
|
19
|
+
"command": "npx",
|
|
20
|
+
"args": ["stuf-mcp"]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Pairing
|
|
27
|
+
|
|
28
|
+
On first use, call the `pair` tool. It opens a QR scanner in your browser:
|
|
29
|
+
|
|
30
|
+
1. Open stuf on your phone → Settings → Add Device
|
|
31
|
+
2. Scan the QR code in the browser
|
|
32
|
+
3. Call `pair_complete` to finish
|
|
33
|
+
|
|
34
|
+
Credentials are saved locally — you only need to pair once.
|
|
35
|
+
|
|
36
|
+
### Manual configuration
|
|
37
|
+
|
|
38
|
+
You can also configure via environment variables (skips pairing):
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"mcpServers": {
|
|
43
|
+
"stuf": {
|
|
44
|
+
"command": "npx",
|
|
45
|
+
"args": ["stuf-mcp"],
|
|
46
|
+
"env": {
|
|
47
|
+
"STUF_SERVER_URL": "https://your-sync-server",
|
|
48
|
+
"STUF_DEVICE_TOKEN": "your-device-token",
|
|
49
|
+
"STUF_ENCRYPTION_KEY": "your-base64-encryption-key"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Tools
|
|
57
|
+
|
|
58
|
+
| Tool | Description |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `pair` | Start QR pairing flow |
|
|
61
|
+
| `pair_complete` | Complete pairing after scanning |
|
|
62
|
+
| `list_tasks` | List tasks (filter by status, tag, project) |
|
|
63
|
+
| `add_task` | Add a task with notes, checklist, tags |
|
|
64
|
+
| `update_task` | Update task name, notes, checklist, tags |
|
|
65
|
+
| `complete_task` | Mark a task as done |
|
|
66
|
+
| `delete_task` | Delete a task |
|
|
67
|
+
| `check_item` | Toggle a checklist item |
|
|
68
|
+
| `snooze_task` | Snooze a task until a date |
|
|
69
|
+
| `unsnooze_task` | Remove snooze |
|
|
70
|
+
| `set_reminder` | Set a push notification reminder |
|
|
71
|
+
| `clear_reminder` | Remove a reminder |
|
|
72
|
+
| `reorder_tasks` | Move a task to a new position |
|
|
73
|
+
| `move_to_project` | Move task to/from a project |
|
|
74
|
+
| `add_project` / `delete_project` | Manage projects |
|
|
75
|
+
| `add_tag` / `delete_tag` | Manage tags |
|
|
76
|
+
| `list_projects` / `list_tags` | List projects and tags |
|
|
77
|
+
| `upcoming` | Show snoozed tasks |
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
package/crypto.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { webcrypto } from 'crypto';
|
|
2
|
+
|
|
3
|
+
const crypto = webcrypto;
|
|
4
|
+
|
|
5
|
+
let encryptionKey = null;
|
|
6
|
+
|
|
7
|
+
export async function setEncryptionKey(base64Key) {
|
|
8
|
+
const raw = Buffer.from(base64Key, 'base64');
|
|
9
|
+
encryptionKey = await crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function encrypt(data) {
|
|
13
|
+
if (!encryptionKey) throw new Error('Encryption key not set');
|
|
14
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
15
|
+
const encoded = new TextEncoder().encode(JSON.stringify(data));
|
|
16
|
+
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, encryptionKey, encoded);
|
|
17
|
+
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
|
|
18
|
+
combined.set(iv);
|
|
19
|
+
combined.set(new Uint8Array(ciphertext), iv.length);
|
|
20
|
+
return Buffer.from(combined).toString('base64');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function decrypt(base64Data) {
|
|
24
|
+
if (!encryptionKey) throw new Error('Encryption key not set');
|
|
25
|
+
const combined = Buffer.from(base64Data, 'base64');
|
|
26
|
+
const iv = combined.slice(0, 12);
|
|
27
|
+
const ciphertext = combined.slice(12);
|
|
28
|
+
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, encryptionKey, ciphertext);
|
|
29
|
+
return JSON.parse(new TextDecoder().decode(decrypted));
|
|
30
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import { configure, initialize, getDoc, applyAndPush, connectWebSocket, getServerUrl, getDeviceToken } from './sync.js';
|
|
9
|
+
import { setEncryptionKey } from './crypto.js';
|
|
10
|
+
import { startPairingServer } from './pair.js';
|
|
11
|
+
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const CONFIG_PATH = path.join(__dirname, '.stuf-mcp.json');
|
|
14
|
+
|
|
15
|
+
let synced = false;
|
|
16
|
+
|
|
17
|
+
const server = new McpServer({
|
|
18
|
+
name: 'stuf',
|
|
19
|
+
version: '1.0.0',
|
|
20
|
+
description: 'MCP server for stuf task management'
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// --- Pair tool ---
|
|
24
|
+
|
|
25
|
+
let pairingPromise = null;
|
|
26
|
+
|
|
27
|
+
server.tool('pair', 'Pair with a stuf space by scanning QR code from the stuf app', {}, async () => {
|
|
28
|
+
if (synced) {
|
|
29
|
+
return { content: [{ type: 'text', text: 'Already paired and synced.' }] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const { url, paired } = await startPairingServer();
|
|
34
|
+
pairingPromise = paired;
|
|
35
|
+
return { content: [{ type: 'text', text: `QR scanner opened at ${url}\n\nOpen stuf on your phone → Settings → Add Device → scan the QR code in the browser.\n\nThen call the "pair_complete" tool to finish.` }] };
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return { content: [{ type: 'text', text: `Pairing failed: ${err.message}` }] };
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
server.tool('pair_complete', 'Complete pairing after scanning QR code', {}, async () => {
|
|
42
|
+
if (synced) {
|
|
43
|
+
return { content: [{ type: 'text', text: 'Already paired and synced.' }] };
|
|
44
|
+
}
|
|
45
|
+
if (!pairingPromise) {
|
|
46
|
+
return { content: [{ type: 'text', text: 'No pairing in progress. Call "pair" first.' }] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const config = await pairingPromise;
|
|
51
|
+
await setEncryptionKey(config.encryptionKey);
|
|
52
|
+
configure(config.serverUrl, config.deviceToken);
|
|
53
|
+
await initialize();
|
|
54
|
+
connectWebSocket(() => {});
|
|
55
|
+
synced = true;
|
|
56
|
+
pairingPromise = null;
|
|
57
|
+
return { content: [{ type: 'text', text: 'Paired successfully! You can now manage tasks.' }] };
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return { content: [{ type: 'text', text: `Pairing failed: ${err.message}` }] };
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// --- Task tools ---
|
|
64
|
+
|
|
65
|
+
function requireSync() {
|
|
66
|
+
if (!synced) throw new Error('Not synced. Run the "pair" tool first.');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
server.tool('list_tasks', 'List all tasks, optionally filtered by status or tag', {
|
|
70
|
+
status: z.enum(['all', 'active', 'done']).optional().default('active').describe('Filter by status'),
|
|
71
|
+
tag: z.string().optional().describe('Filter by tag'),
|
|
72
|
+
project: z.string().optional().describe('Filter by project name')
|
|
73
|
+
}, async ({ status, tag, project }) => {
|
|
74
|
+
requireSync();
|
|
75
|
+
const doc = getDoc();
|
|
76
|
+
let tasks = doc.todos || [];
|
|
77
|
+
|
|
78
|
+
if (status === 'active') tasks = tasks.filter(t => !t.completed && !(t.snoozeUntil && t.snoozeUntil > Date.now()));
|
|
79
|
+
if (status === 'done') tasks = tasks.filter(t => t.completed);
|
|
80
|
+
if (tag) tasks = tasks.filter(t => t.tags && t.tags.includes(tag));
|
|
81
|
+
if (project) {
|
|
82
|
+
const proj = (doc.projects || []).find(p => p.name === project);
|
|
83
|
+
if (proj) tasks = tasks.filter(t => t.projectId === proj.id);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const formatted = tasks.map(t => {
|
|
87
|
+
const tags = t.tags?.length ? ` [${t.tags.join(', ')}]` : '';
|
|
88
|
+
const done = t.completed ? '✓' : '○';
|
|
89
|
+
const proj = t.projectId ? ` (${(doc.projects || []).find(p => p.id === t.projectId)?.name || ''})` : '';
|
|
90
|
+
let line = `${done} ${t.name}${tags}${proj}`;
|
|
91
|
+
if (t.notes) line += `\n 📝 ${t.notes.split('\n')[0]}${t.notes.includes('\n') ? '...' : ''}`;
|
|
92
|
+
if (t.checklist?.length) {
|
|
93
|
+
const items = t.checklist.map(c => ` ${c.completed ? '☑' : '☐'} ${c.text}`).join('\n');
|
|
94
|
+
line += '\n' + items;
|
|
95
|
+
}
|
|
96
|
+
return line;
|
|
97
|
+
}).join('\n');
|
|
98
|
+
|
|
99
|
+
return { content: [{ type: 'text', text: formatted || 'No tasks found.' }] };
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
server.tool('add_task', 'Add a new task', {
|
|
103
|
+
text: z.string().describe('Task name/title'),
|
|
104
|
+
notes: z.string().optional().describe('Notes/description (supports markdown)'),
|
|
105
|
+
checklist: z.array(z.string()).optional().describe('Checklist items'),
|
|
106
|
+
tags: z.array(z.string()).optional().describe('Tags for the task'),
|
|
107
|
+
project: z.string().optional().describe('Project name')
|
|
108
|
+
}, async ({ text, notes, checklist, tags, project }) => {
|
|
109
|
+
requireSync();
|
|
110
|
+
const doc = getDoc();
|
|
111
|
+
let projectId = undefined;
|
|
112
|
+
if (project) {
|
|
113
|
+
const proj = (doc.projects || []).find(p => p.name === project);
|
|
114
|
+
if (proj) projectId = proj.id;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
const maxOrder = (doc.todos || []).reduce((max, t) => Math.max(max, t.order || 0), 0);
|
|
119
|
+
|
|
120
|
+
await applyAndPush(d => {
|
|
121
|
+
// Auto-create missing tags
|
|
122
|
+
if (tags?.length) {
|
|
123
|
+
if (!d.tags) d.tags = [];
|
|
124
|
+
for (const tag of tags) {
|
|
125
|
+
if (!d.tags.find(t => t === tag)) d.tags.push(tag);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!d.todos) d.todos = [];
|
|
129
|
+
const task = { id: now, name: text, completed: false, created: now, updated: now, order: maxOrder + 1 };
|
|
130
|
+
if (notes) task.notes = notes;
|
|
131
|
+
if (tags?.length) task.tags = tags;
|
|
132
|
+
if (checklist?.length) {
|
|
133
|
+
task.checklist = checklist.map((item, i) => ({ id: now + i + 1, text: item, completed: false }));
|
|
134
|
+
}
|
|
135
|
+
if (projectId) task.projectId = projectId;
|
|
136
|
+
d.todos.push(task);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return { content: [{ type: 'text', text: `Added task: ${text}` }] };
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
server.tool('complete_task', 'Mark a task as done', {
|
|
143
|
+
text: z.string().describe('Task text (partial match)')
|
|
144
|
+
}, async ({ text }) => {
|
|
145
|
+
requireSync();
|
|
146
|
+
const doc = getDoc();
|
|
147
|
+
const task = (doc.todos || []).find(t => !t.completed && t.name.toLowerCase().includes(text.toLowerCase()));
|
|
148
|
+
if (!task) return { content: [{ type: 'text', text: `No active task matching "${text}" found.` }] };
|
|
149
|
+
|
|
150
|
+
await applyAndPush(d => {
|
|
151
|
+
const t = d.todos.find(t => t.id === task.id);
|
|
152
|
+
if (t) t.completed = true;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return { content: [{ type: 'text', text: `Completed: ${task.name}` }] };
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
server.tool('delete_task', 'Delete a task', {
|
|
159
|
+
text: z.string().describe('Task text (partial match)')
|
|
160
|
+
}, async ({ text }) => {
|
|
161
|
+
requireSync();
|
|
162
|
+
const doc = getDoc();
|
|
163
|
+
const idx = (doc.todos || []).findIndex(t => t.name.toLowerCase().includes(text.toLowerCase()));
|
|
164
|
+
if (idx === -1) return { content: [{ type: 'text', text: `No task matching "${text}" found.` }] };
|
|
165
|
+
|
|
166
|
+
const taskName = doc.todos[idx].name;
|
|
167
|
+
await applyAndPush(d => {
|
|
168
|
+
d.todos.deleteAt(idx);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return { content: [{ type: 'text', text: `Deleted: ${taskName}` }] };
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
server.tool('update_task', 'Update a task', {
|
|
175
|
+
search: z.string().describe('Task text to find (partial match)'),
|
|
176
|
+
text: z.string().optional().describe('New task name/title'),
|
|
177
|
+
notes: z.string().optional().describe('New notes/description (supports markdown)'),
|
|
178
|
+
checklist: z.array(z.string()).optional().describe('New checklist items (replaces existing)'),
|
|
179
|
+
tags: z.array(z.string()).optional().describe('New tags (replaces existing)')
|
|
180
|
+
}, async ({ search, text, notes, checklist, tags }) => {
|
|
181
|
+
requireSync();
|
|
182
|
+
const doc = getDoc();
|
|
183
|
+
const task = (doc.todos || []).find(t => t.name.toLowerCase().includes(search.toLowerCase()));
|
|
184
|
+
if (!task) return { content: [{ type: 'text', text: `No task matching "${search}" found.` }] };
|
|
185
|
+
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
await applyAndPush(d => {
|
|
188
|
+
// Auto-create missing tags
|
|
189
|
+
if (tags?.length) {
|
|
190
|
+
if (!d.tags) d.tags = [];
|
|
191
|
+
for (const tag of tags) {
|
|
192
|
+
if (!d.tags.find(t => t === tag)) d.tags.push(tag);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const t = d.todos.find(t => t.id === task.id);
|
|
196
|
+
if (t) {
|
|
197
|
+
if (text) t.name = text;
|
|
198
|
+
if (notes !== undefined) t.notes = notes || null;
|
|
199
|
+
if (tags) t.tags = tags;
|
|
200
|
+
if (checklist) {
|
|
201
|
+
t.checklist = checklist.map((item, i) => ({ id: now + i, text: item, completed: false }));
|
|
202
|
+
}
|
|
203
|
+
t.updated = now;
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return { content: [{ type: 'text', text: `Updated: ${text || task.name}` }] };
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
server.tool('check_item', 'Toggle a checklist item as completed/uncompleted', {
|
|
211
|
+
task: z.string().describe('Task text (partial match)'),
|
|
212
|
+
item: z.string().describe('Checklist item text (partial match)')
|
|
213
|
+
}, async ({ task: taskSearch, item: itemSearch }) => {
|
|
214
|
+
requireSync();
|
|
215
|
+
const doc = getDoc();
|
|
216
|
+
const task = (doc.todos || []).find(t => t.name.toLowerCase().includes(taskSearch.toLowerCase()));
|
|
217
|
+
if (!task) return { content: [{ type: 'text', text: `No task matching "${taskSearch}" found.` }] };
|
|
218
|
+
if (!task.checklist?.length) return { content: [{ type: 'text', text: `Task "${task.name}" has no checklist.` }] };
|
|
219
|
+
|
|
220
|
+
const checkItem = task.checklist.find(c => c.text.toLowerCase().includes(itemSearch.toLowerCase()));
|
|
221
|
+
if (!checkItem) return { content: [{ type: 'text', text: `No checklist item matching "${itemSearch}" found.` }] };
|
|
222
|
+
|
|
223
|
+
const newState = !checkItem.completed;
|
|
224
|
+
await applyAndPush(d => {
|
|
225
|
+
const t = d.todos.find(t => t.id === task.id);
|
|
226
|
+
if (t?.checklist) {
|
|
227
|
+
const c = t.checklist.find(c => c.id === checkItem.id);
|
|
228
|
+
if (c) c.completed = newState;
|
|
229
|
+
t.updated = Date.now();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
return { content: [{ type: 'text', text: `${newState ? '☑' : '☐'} ${checkItem.text}` }] };
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
server.tool('reorder_tasks', 'Move a task to a new position in the list', {
|
|
237
|
+
search: z.string().describe('Task text to find (partial match)'),
|
|
238
|
+
position: z.number().describe('New position (1-based, 1 = top)')
|
|
239
|
+
}, async ({ search, position }) => {
|
|
240
|
+
requireSync();
|
|
241
|
+
const doc = getDoc();
|
|
242
|
+
const tasks = (doc.todos || []).filter(t => !t.completed);
|
|
243
|
+
const sorted = [...tasks].sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
244
|
+
const task = sorted.find(t => t.name.toLowerCase().includes(search.toLowerCase()));
|
|
245
|
+
if (!task) return { content: [{ type: 'text', text: `No active task matching "${search}" found.` }] };
|
|
246
|
+
|
|
247
|
+
const targetIdx = Math.max(0, Math.min(position - 1, sorted.length - 1));
|
|
248
|
+
|
|
249
|
+
// Recompute order for all active tasks with the moved task in its new position
|
|
250
|
+
const filtered = sorted.filter(t => t.id !== task.id);
|
|
251
|
+
filtered.splice(targetIdx, 0, task);
|
|
252
|
+
const updates = filtered.map((t, i) => ({ id: t.id, order: i + 1 }));
|
|
253
|
+
|
|
254
|
+
await applyAndPush(d => {
|
|
255
|
+
for (const { id, order } of updates) {
|
|
256
|
+
const t = d.todos.find(t => t.id === id);
|
|
257
|
+
if (t) t.order = order;
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
return { content: [{ type: 'text', text: `Moved "${task.name}" to position ${position}` }] };
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
server.tool('snooze_task', 'Snooze a task until a specific date/time', {
|
|
265
|
+
search: z.string().describe('Task text (partial match)'),
|
|
266
|
+
until: z.string().describe('ISO date/time string (e.g. "2026-03-26", "2026-03-26T09:00")')
|
|
267
|
+
}, async ({ search, until }) => {
|
|
268
|
+
requireSync();
|
|
269
|
+
const doc = getDoc();
|
|
270
|
+
const task = (doc.todos || []).find(t => !t.completed && t.name.toLowerCase().includes(search.toLowerCase()));
|
|
271
|
+
if (!task) return { content: [{ type: 'text', text: `No active task matching "${search}" found.` }] };
|
|
272
|
+
|
|
273
|
+
const snoozeUntil = new Date(until).getTime();
|
|
274
|
+
if (isNaN(snoozeUntil)) return { content: [{ type: 'text', text: `Invalid date: ${until}` }] };
|
|
275
|
+
|
|
276
|
+
await applyAndPush(d => {
|
|
277
|
+
const t = d.todos.find(t => t.id === task.id);
|
|
278
|
+
if (t) {
|
|
279
|
+
t.snoozeUntil = snoozeUntil;
|
|
280
|
+
t.updated = Date.now();
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
return { content: [{ type: 'text', text: `Snoozed "${task.name}" until ${new Date(snoozeUntil).toLocaleString()}` }] };
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
server.tool('set_reminder', 'Set a reminder for a task at a specific date/time', {
|
|
288
|
+
search: z.string().describe('Task text (partial match)'),
|
|
289
|
+
at: z.string().describe('ISO date/time string for reminder (e.g. "2026-03-26T09:00")')
|
|
290
|
+
}, async ({ search, at }) => {
|
|
291
|
+
requireSync();
|
|
292
|
+
const doc = getDoc();
|
|
293
|
+
const task = (doc.todos || []).find(t => !t.completed && t.name.toLowerCase().includes(search.toLowerCase()));
|
|
294
|
+
if (!task) return { content: [{ type: 'text', text: `No active task matching "${search}" found.` }] };
|
|
295
|
+
|
|
296
|
+
const reminder = new Date(at).getTime();
|
|
297
|
+
if (isNaN(reminder)) return { content: [{ type: 'text', text: `Invalid date: ${at}` }] };
|
|
298
|
+
|
|
299
|
+
await applyAndPush(d => {
|
|
300
|
+
const t = d.todos.find(t => t.id === task.id);
|
|
301
|
+
if (t) {
|
|
302
|
+
t.reminder = reminder;
|
|
303
|
+
t.updated = Date.now();
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Register with server for push notification
|
|
308
|
+
try {
|
|
309
|
+
await fetch(`${getServerUrl()}/api/push/reminder`, {
|
|
310
|
+
method: 'POST',
|
|
311
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${getDeviceToken()}` },
|
|
312
|
+
body: JSON.stringify({ taskId: String(task.id), title: task.name, notifyAt: reminder })
|
|
313
|
+
});
|
|
314
|
+
} catch (err) {
|
|
315
|
+
// Push registration failed, but CRDT reminder is set
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { content: [{ type: 'text', text: `Reminder set for "${task.name}" at ${new Date(reminder).toLocaleString()}` }] };
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
server.tool('add_tag', 'Create a new global tag', {
|
|
322
|
+
name: z.string().describe('Tag name')
|
|
323
|
+
}, async ({ name }) => {
|
|
324
|
+
requireSync();
|
|
325
|
+
const doc = getDoc();
|
|
326
|
+
const exists = (doc.tags || []).find(t => t === name);
|
|
327
|
+
if (exists) return { content: [{ type: 'text', text: `Tag "${name}" already exists.` }] };
|
|
328
|
+
|
|
329
|
+
await applyAndPush(d => {
|
|
330
|
+
if (!d.tags) d.tags = [];
|
|
331
|
+
d.tags.push(name);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
return { content: [{ type: 'text', text: `Created tag: ${name}` }] };
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
server.tool('delete_tag', 'Delete a global tag', {
|
|
338
|
+
name: z.string().describe('Tag name')
|
|
339
|
+
}, async ({ name }) => {
|
|
340
|
+
requireSync();
|
|
341
|
+
const doc = getDoc();
|
|
342
|
+
const idx = (doc.tags || []).findIndex(t => t === name);
|
|
343
|
+
if (idx === -1) return { content: [{ type: 'text', text: `Tag "${name}" not found.` }] };
|
|
344
|
+
|
|
345
|
+
await applyAndPush(d => {
|
|
346
|
+
if (d.tags) d.tags.splice(idx, 1);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
return { content: [{ type: 'text', text: `Deleted tag: ${name}` }] };
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
server.tool('unsnooze_task', 'Remove snooze from a task', {
|
|
353
|
+
search: z.string().describe('Task text (partial match)')
|
|
354
|
+
}, async ({ search }) => {
|
|
355
|
+
requireSync();
|
|
356
|
+
const doc = getDoc();
|
|
357
|
+
const task = (doc.todos || []).find(t => t.name.toLowerCase().includes(search.toLowerCase()));
|
|
358
|
+
if (!task) return { content: [{ type: 'text', text: `No task matching "${search}" found.` }] };
|
|
359
|
+
|
|
360
|
+
await applyAndPush(d => {
|
|
361
|
+
const t = d.todos.find(t => t.id === task.id);
|
|
362
|
+
if (t) {
|
|
363
|
+
delete t.snoozeUntil;
|
|
364
|
+
t.updated = Date.now();
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
return { content: [{ type: 'text', text: `Unsnooze: ${task.name}` }] };
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
server.tool('clear_reminder', 'Remove reminder from a task', {
|
|
372
|
+
search: z.string().describe('Task text (partial match)')
|
|
373
|
+
}, async ({ search }) => {
|
|
374
|
+
requireSync();
|
|
375
|
+
const doc = getDoc();
|
|
376
|
+
const task = (doc.todos || []).find(t => t.name.toLowerCase().includes(search.toLowerCase()));
|
|
377
|
+
if (!task) return { content: [{ type: 'text', text: `No task matching "${search}" found.` }] };
|
|
378
|
+
|
|
379
|
+
await applyAndPush(d => {
|
|
380
|
+
const t = d.todos.find(t => t.id === task.id);
|
|
381
|
+
if (t) {
|
|
382
|
+
delete t.reminder;
|
|
383
|
+
t.updated = Date.now();
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// Cancel on server
|
|
388
|
+
try {
|
|
389
|
+
await fetch(`${getServerUrl()}/api/push/reminder`, {
|
|
390
|
+
method: 'DELETE',
|
|
391
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${getDeviceToken()}` },
|
|
392
|
+
body: JSON.stringify({ taskId: String(task.id) })
|
|
393
|
+
});
|
|
394
|
+
} catch (err) {
|
|
395
|
+
// Cancel failed, but CRDT reminder is cleared
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return { content: [{ type: 'text', text: `Reminder cleared: ${task.name}` }] };
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
server.tool('add_project', 'Create a new project', {
|
|
402
|
+
name: z.string().describe('Project name')
|
|
403
|
+
}, async ({ name }) => {
|
|
404
|
+
requireSync();
|
|
405
|
+
const doc = getDoc();
|
|
406
|
+
const exists = (doc.projects || []).find(p => p.name === name);
|
|
407
|
+
if (exists) return { content: [{ type: 'text', text: `Project "${name}" already exists.` }] };
|
|
408
|
+
|
|
409
|
+
await applyAndPush(d => {
|
|
410
|
+
if (!d.projects) d.projects = [];
|
|
411
|
+
d.projects.push({ id: Date.now(), name });
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
return { content: [{ type: 'text', text: `Created project: ${name}` }] };
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
server.tool('delete_project', 'Delete a project', {
|
|
418
|
+
name: z.string().describe('Project name')
|
|
419
|
+
}, async ({ name }) => {
|
|
420
|
+
requireSync();
|
|
421
|
+
const doc = getDoc();
|
|
422
|
+
const idx = (doc.projects || []).findIndex(p => p.name === name);
|
|
423
|
+
if (idx === -1) return { content: [{ type: 'text', text: `Project "${name}" not found.` }] };
|
|
424
|
+
|
|
425
|
+
await applyAndPush(d => {
|
|
426
|
+
if (d.projects) d.projects.splice(idx, 1);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
return { content: [{ type: 'text', text: `Deleted project: ${name}` }] };
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
server.tool('move_to_project', 'Move a task to a project (or out of a project)', {
|
|
433
|
+
search: z.string().describe('Task text (partial match)'),
|
|
434
|
+
project: z.string().optional().describe('Project name (omit to remove from project)')
|
|
435
|
+
}, async ({ search, project }) => {
|
|
436
|
+
requireSync();
|
|
437
|
+
const doc = getDoc();
|
|
438
|
+
const task = (doc.todos || []).find(t => t.name.toLowerCase().includes(search.toLowerCase()));
|
|
439
|
+
if (!task) return { content: [{ type: 'text', text: `No task matching "${search}" found.` }] };
|
|
440
|
+
|
|
441
|
+
let projectId = null;
|
|
442
|
+
if (project) {
|
|
443
|
+
const proj = (doc.projects || []).find(p => p.name === project);
|
|
444
|
+
if (!proj) return { content: [{ type: 'text', text: `Project "${project}" not found. Create it first with add_project.` }] };
|
|
445
|
+
projectId = proj.id;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
await applyAndPush(d => {
|
|
449
|
+
const t = d.todos.find(t => t.id === task.id);
|
|
450
|
+
if (t) {
|
|
451
|
+
if (projectId) {
|
|
452
|
+
t.projectId = projectId;
|
|
453
|
+
} else {
|
|
454
|
+
delete t.projectId;
|
|
455
|
+
}
|
|
456
|
+
t.updated = Date.now();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
return { content: [{ type: 'text', text: project ? `Moved "${task.name}" to project "${project}"` : `Removed "${task.name}" from project` }] };
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
server.tool('upcoming', 'Show upcoming tasks (snoozed tasks with future dates)', {}, async () => {
|
|
464
|
+
requireSync();
|
|
465
|
+
const doc = getDoc();
|
|
466
|
+
const now = Date.now();
|
|
467
|
+
const tasks = (doc.todos || [])
|
|
468
|
+
.filter(t => !t.completed && t.snoozeUntil && t.snoozeUntil > now)
|
|
469
|
+
.sort((a, b) => a.snoozeUntil - b.snoozeUntil);
|
|
470
|
+
|
|
471
|
+
if (tasks.length === 0) return { content: [{ type: 'text', text: 'No upcoming snoozed tasks.' }] };
|
|
472
|
+
|
|
473
|
+
const formatted = tasks.map(t => {
|
|
474
|
+
const date = new Date(t.snoozeUntil).toLocaleDateString();
|
|
475
|
+
const reminder = t.reminder ? ` 🔔 ${new Date(t.reminder).toLocaleString()}` : '';
|
|
476
|
+
return `○ ${t.name} — snoozed until ${date}${reminder}`;
|
|
477
|
+
}).join('\n');
|
|
478
|
+
|
|
479
|
+
return { content: [{ type: 'text', text: formatted }] };
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
server.tool('list_projects', 'List all projects', {}, async () => {
|
|
483
|
+
requireSync();
|
|
484
|
+
const doc = getDoc();
|
|
485
|
+
const projects = (doc.projects || []).map(p => p.name).join('\n');
|
|
486
|
+
return { content: [{ type: 'text', text: projects || 'No projects.' }] };
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
server.tool('list_tags', 'List all tags', {}, async () => {
|
|
490
|
+
requireSync();
|
|
491
|
+
const doc = getDoc();
|
|
492
|
+
const tags = (doc.tags || []).join(', ');
|
|
493
|
+
return { content: [{ type: 'text', text: tags || 'No tags.' }] };
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// --- Start ---
|
|
497
|
+
|
|
498
|
+
async function main() {
|
|
499
|
+
// Env vars override config file
|
|
500
|
+
const envUrl = process.env.STUF_SERVER_URL;
|
|
501
|
+
const envToken = process.env.STUF_DEVICE_TOKEN;
|
|
502
|
+
const envKey = process.env.STUF_ENCRYPTION_KEY;
|
|
503
|
+
|
|
504
|
+
if (envUrl && envToken && envKey) {
|
|
505
|
+
await setEncryptionKey(envKey);
|
|
506
|
+
configure(envUrl, envToken);
|
|
507
|
+
await initialize();
|
|
508
|
+
connectWebSocket(() => {});
|
|
509
|
+
synced = true;
|
|
510
|
+
} else if (fs.existsSync(CONFIG_PATH)) {
|
|
511
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
512
|
+
await setEncryptionKey(config.encryptionKey);
|
|
513
|
+
configure(config.serverUrl, config.deviceToken);
|
|
514
|
+
await initialize();
|
|
515
|
+
connectWebSocket(() => {});
|
|
516
|
+
synced = true;
|
|
517
|
+
}
|
|
518
|
+
// Otherwise: not synced, user must call "pair" tool
|
|
519
|
+
|
|
520
|
+
const transport = new StdioServerTransport();
|
|
521
|
+
await server.connect(transport);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
main().catch(err => {
|
|
525
|
+
console.error('Fatal:', err);
|
|
526
|
+
process.exit(1);
|
|
527
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stuf-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "MCP server for stuf — AI-powered task management",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"stuf-mcp": "index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"sync.js",
|
|
13
|
+
"crypto.js",
|
|
14
|
+
"pair.js",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "node index.js"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"mcp",
|
|
22
|
+
"stuf",
|
|
23
|
+
"tasks",
|
|
24
|
+
"todo",
|
|
25
|
+
"ai",
|
|
26
|
+
"claude"
|
|
27
|
+
],
|
|
28
|
+
"author": "asbjornenge",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/asbjornenge/stuf"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
36
|
+
"automerge": "^0.14.2",
|
|
37
|
+
"ws": "^8.18.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/pair.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import { webcrypto } from 'crypto';
|
|
3
|
+
import { exec } from 'child_process';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const CONFIG_PATH = path.join(__dirname, '.stuf-mcp.json');
|
|
10
|
+
|
|
11
|
+
function generateDeviceToken() {
|
|
12
|
+
const bytes = webcrypto.getRandomValues(new Uint8Array(32));
|
|
13
|
+
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function openBrowser(url) {
|
|
17
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
18
|
+
exec(`${cmd} ${url}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const html = `<!DOCTYPE html>
|
|
22
|
+
<html>
|
|
23
|
+
<head>
|
|
24
|
+
<meta charset="utf-8">
|
|
25
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
26
|
+
<title>stuf MCP Pairing</title>
|
|
27
|
+
<style>
|
|
28
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
29
|
+
body { background: #242424; color: #e0e0e0; font-family: -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; padding: 2rem; }
|
|
30
|
+
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
|
|
31
|
+
p { color: #999; margin-bottom: 1.5rem; text-align: center; }
|
|
32
|
+
#reader { width: 100%; max-width: 400px; border-radius: 12px; overflow: hidden; }
|
|
33
|
+
.success { color: #4ade80; font-size: 1.2rem; margin-top: 1rem; }
|
|
34
|
+
.error { color: #ef4444; margin-top: 1rem; }
|
|
35
|
+
</style>
|
|
36
|
+
</head>
|
|
37
|
+
<body>
|
|
38
|
+
<h1>stuf MCP Pairing</h1>
|
|
39
|
+
<p>Open stuf on your phone → Settings → Add Device<br>Then scan the QR code here</p>
|
|
40
|
+
<div id="reader"></div>
|
|
41
|
+
<div id="status"></div>
|
|
42
|
+
|
|
43
|
+
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
|
44
|
+
<script>
|
|
45
|
+
const scanner = new Html5Qrcode("reader");
|
|
46
|
+
scanner.start(
|
|
47
|
+
{ facingMode: "environment" },
|
|
48
|
+
{ fps: 10, qrbox: { width: 300, height: 300 } },
|
|
49
|
+
async (text) => {
|
|
50
|
+
try {
|
|
51
|
+
const data = JSON.parse(text);
|
|
52
|
+
if (!data.url || !data.encryptionKey || !data.inviteToken) throw new Error('Invalid QR');
|
|
53
|
+
scanner.stop();
|
|
54
|
+
document.getElementById('status').innerHTML = '<p class="success">Pairing...</p>';
|
|
55
|
+
const res = await fetch('/pair', {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'Content-Type': 'application/json' },
|
|
58
|
+
body: JSON.stringify(data)
|
|
59
|
+
});
|
|
60
|
+
const result = await res.json();
|
|
61
|
+
if (result.success) {
|
|
62
|
+
document.getElementById('status').innerHTML = '<p class="success">Paired! You can close this window.</p>';
|
|
63
|
+
} else {
|
|
64
|
+
document.getElementById('status').innerHTML = '<p class="error">Failed: ' + result.error + '</p>';
|
|
65
|
+
}
|
|
66
|
+
} catch (e) {
|
|
67
|
+
// Not a valid QR, ignore
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
</script>
|
|
72
|
+
</body>
|
|
73
|
+
</html>`;
|
|
74
|
+
|
|
75
|
+
export function startPairingServer() {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
let onPaired;
|
|
78
|
+
const paired = new Promise(r => { onPaired = r; });
|
|
79
|
+
|
|
80
|
+
const server = http.createServer(async (req, res) => {
|
|
81
|
+
if (req.method === 'GET' && req.url === '/') {
|
|
82
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
83
|
+
res.end(html);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (req.method === 'POST' && req.url === '/pair') {
|
|
88
|
+
let body = '';
|
|
89
|
+
req.on('data', chunk => body += chunk);
|
|
90
|
+
req.on('end', async () => {
|
|
91
|
+
try {
|
|
92
|
+
const { url, encryptionKey, inviteToken } = JSON.parse(body);
|
|
93
|
+
const deviceToken = generateDeviceToken();
|
|
94
|
+
|
|
95
|
+
const pairRes = await fetch(`${url}/api/pair/invite`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'Content-Type': 'application/json' },
|
|
98
|
+
body: JSON.stringify({ inviteToken, deviceToken })
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!pairRes.ok) {
|
|
102
|
+
const err = await pairRes.json().catch(() => ({}));
|
|
103
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
104
|
+
res.end(JSON.stringify({ success: false, error: err.error || 'Pairing failed' }));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const config = {
|
|
109
|
+
serverUrl: url,
|
|
110
|
+
deviceToken,
|
|
111
|
+
encryptionKey
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
115
|
+
|
|
116
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
117
|
+
res.end(JSON.stringify({ success: true }));
|
|
118
|
+
|
|
119
|
+
setTimeout(() => {
|
|
120
|
+
server.close();
|
|
121
|
+
onPaired(config);
|
|
122
|
+
}, 1000);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
125
|
+
res.end(JSON.stringify({ success: false, error: err.message }));
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
res.writeHead(404);
|
|
132
|
+
res.end();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
server.listen(0, () => {
|
|
136
|
+
const port = server.address().port;
|
|
137
|
+
const url = `http://localhost:${port}`;
|
|
138
|
+
openBrowser(url);
|
|
139
|
+
resolve({ url, paired });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
server.on('error', reject);
|
|
143
|
+
});
|
|
144
|
+
}
|
package/sync.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import Automerge from 'automerge';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
import { encrypt, decrypt } from './crypto.js';
|
|
4
|
+
|
|
5
|
+
let doc = Automerge.init();
|
|
6
|
+
let lastSeq = 0;
|
|
7
|
+
let serverUrl = null;
|
|
8
|
+
let deviceToken = null;
|
|
9
|
+
let ws = null;
|
|
10
|
+
let onChangeCallback = null;
|
|
11
|
+
|
|
12
|
+
export function configure(url, token) {
|
|
13
|
+
serverUrl = url.replace(/\/$/, '');
|
|
14
|
+
deviceToken = token;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getServerUrl() { return serverUrl; }
|
|
18
|
+
export function getDeviceToken() { return deviceToken; }
|
|
19
|
+
|
|
20
|
+
export function getDoc() {
|
|
21
|
+
return doc;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function headers() {
|
|
25
|
+
return {
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
'Authorization': `Bearer ${deviceToken}`
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function pullChanges() {
|
|
32
|
+
const res = await fetch(`${serverUrl}/api/changes?since=${lastSeq}`, { headers: headers() });
|
|
33
|
+
if (!res.ok) throw new Error(`Pull failed: ${res.status} ${await res.text()}`);
|
|
34
|
+
const { changes, lastSeq: newSeq } = await res.json();
|
|
35
|
+
for (const { data } of changes) {
|
|
36
|
+
try {
|
|
37
|
+
const change = await decrypt(data);
|
|
38
|
+
doc = Automerge.applyChanges(doc, [change]);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.warn(`Failed to apply change: ${err.message}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (newSeq !== undefined) lastSeq = newSeq;
|
|
44
|
+
return doc;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function pullSnapshot() {
|
|
48
|
+
const res = await fetch(`${serverUrl}/api/changes/snapshot`, { headers: headers() });
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
if (res.status === 404) return null;
|
|
51
|
+
throw new Error(`Snapshot pull failed: ${res.status}`);
|
|
52
|
+
}
|
|
53
|
+
const { data, seq } = await res.json();
|
|
54
|
+
if (data) {
|
|
55
|
+
const decrypted = await decrypt(data);
|
|
56
|
+
const bytes = new Uint8Array(decrypted);
|
|
57
|
+
doc = Automerge.load(bytes);
|
|
58
|
+
lastSeq = seq || 0;
|
|
59
|
+
}
|
|
60
|
+
return doc;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function pushChanges(oldDoc, newDoc) {
|
|
64
|
+
const changes = Automerge.getChanges(oldDoc, newDoc);
|
|
65
|
+
if (changes.length === 0) return;
|
|
66
|
+
const encrypted = await Promise.all(changes.map(c => encrypt(c)));
|
|
67
|
+
const res = await fetch(`${serverUrl}/api/changes`, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: headers(),
|
|
70
|
+
body: JSON.stringify({ changes: encrypted })
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok) throw new Error(`Push failed: ${res.status} ${await res.text()}`);
|
|
73
|
+
const { lastSeq: newSeq } = await res.json();
|
|
74
|
+
if (newSeq !== undefined) lastSeq = newSeq;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function applyChange(changeFn) {
|
|
78
|
+
const oldDoc = doc;
|
|
79
|
+
doc = Automerge.change(doc, changeFn);
|
|
80
|
+
return { oldDoc, newDoc: doc };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function applyAndPush(changeFn) {
|
|
84
|
+
const { oldDoc, newDoc } = applyChange(changeFn);
|
|
85
|
+
await pushChanges(oldDoc, newDoc);
|
|
86
|
+
return newDoc;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function connectWebSocket(onChange) {
|
|
90
|
+
onChangeCallback = onChange;
|
|
91
|
+
const wsUrl = serverUrl.replace(/^http/, 'ws') + `/api/ws?token=${deviceToken}`;
|
|
92
|
+
ws = new WebSocket(wsUrl);
|
|
93
|
+
ws.on('message', async (data) => {
|
|
94
|
+
try {
|
|
95
|
+
const msg = JSON.parse(data);
|
|
96
|
+
if (msg.type === 'new_changes') {
|
|
97
|
+
await pullChanges();
|
|
98
|
+
if (onChangeCallback) onChangeCallback(doc);
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.warn('WS message error:', err.message);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
ws.on('close', () => {
|
|
105
|
+
setTimeout(() => connectWebSocket(onChange), 5000);
|
|
106
|
+
});
|
|
107
|
+
ws.on('error', (err) => {
|
|
108
|
+
console.warn('WS error:', err.message);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function initialize() {
|
|
113
|
+
try {
|
|
114
|
+
await pullSnapshot();
|
|
115
|
+
} catch (e) {
|
|
116
|
+
// No snapshot available
|
|
117
|
+
}
|
|
118
|
+
await pullChanges();
|
|
119
|
+
return doc;
|
|
120
|
+
}
|