decisionnode 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/dist/ai/gemini.d.ts +15 -0
- package/dist/ai/gemini.js +56 -0
- package/dist/ai/rag.d.ts +79 -0
- package/dist/ai/rag.js +268 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1724 -0
- package/dist/cloud.d.ts +177 -0
- package/dist/cloud.js +631 -0
- package/dist/env.d.ts +47 -0
- package/dist/env.js +139 -0
- package/dist/history.d.ts +34 -0
- package/dist/history.js +159 -0
- package/dist/maintenance.d.ts +7 -0
- package/dist/maintenance.js +49 -0
- package/dist/marketplace.d.ts +46 -0
- package/dist/marketplace.js +300 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +621 -0
- package/dist/setup.d.ts +6 -0
- package/dist/setup.js +132 -0
- package/dist/store.d.ts +126 -0
- package/dist/store.js +555 -0
- package/dist/types.d.ts +60 -0
- package/dist/types.js +9 -0
- package/package.json +57 -0
package/dist/cloud.js
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
// Cloud sync helpers for DecisionNode Cloud Sync (Pro) subscribers
|
|
2
|
+
// Provides cloud authentication, sync, and embedding services
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import http from 'http';
|
|
7
|
+
import { exec } from 'child_process';
|
|
8
|
+
// Cloud configuration file location
|
|
9
|
+
const CLOUD_CONFIG_DIR = path.join(os.homedir(), '.decisionnode');
|
|
10
|
+
const CLOUD_CONFIG_FILE = path.join(CLOUD_CONFIG_DIR, 'cloud.json');
|
|
11
|
+
// Supabase/Marketplace URLs
|
|
12
|
+
const SUPABASE_URL = process.env.DECISIONNODE_SUPABASE_URL || '';
|
|
13
|
+
const MARKETPLACE_URL = process.env.DECISIONNODE_MARKETPLACE_URL || 'https://decisionnode.dev';
|
|
14
|
+
/**
|
|
15
|
+
* Load cloud configuration from disk
|
|
16
|
+
* Automatically refreshes token if expired
|
|
17
|
+
*/
|
|
18
|
+
export async function loadCloudConfig() {
|
|
19
|
+
try {
|
|
20
|
+
const content = await fs.readFile(CLOUD_CONFIG_FILE, 'utf-8');
|
|
21
|
+
let config = JSON.parse(content);
|
|
22
|
+
// Check if token needs refresh
|
|
23
|
+
if (config.access_token && config.refresh_token && config.token_expires_at) {
|
|
24
|
+
// Refresh if expired or expiring in less than 5 minutes
|
|
25
|
+
const now = Math.floor(Date.now() / 1000);
|
|
26
|
+
if (now >= config.token_expires_at - 300) {
|
|
27
|
+
config = await refreshToken(config);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return config;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Refresh access token using refresh token
|
|
38
|
+
*/
|
|
39
|
+
async function refreshToken(config) {
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: {
|
|
44
|
+
'Content-Type': 'application/json',
|
|
45
|
+
'apikey': config.anon_key || '',
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({ refresh_token: config.refresh_token }),
|
|
48
|
+
});
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
console.error('Token refresh failed (logging out):', await response.text());
|
|
51
|
+
// Clear invalid tokens to prevent 401 loops and force re-login
|
|
52
|
+
config.access_token = undefined;
|
|
53
|
+
config.refresh_token = undefined;
|
|
54
|
+
config.token_expires_at = undefined;
|
|
55
|
+
await saveCloudConfig(config);
|
|
56
|
+
return config;
|
|
57
|
+
}
|
|
58
|
+
const data = await response.json();
|
|
59
|
+
// Update config with new tokens
|
|
60
|
+
const newConfig = {
|
|
61
|
+
...config,
|
|
62
|
+
access_token: data.access_token,
|
|
63
|
+
refresh_token: data.refresh_token,
|
|
64
|
+
token_expires_at: data.expires_at || Math.floor(Date.now() / 1000) + data.expires_in,
|
|
65
|
+
};
|
|
66
|
+
await saveCloudConfig(newConfig);
|
|
67
|
+
return newConfig;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
console.error('Token refresh error (logging out):', error);
|
|
71
|
+
// Clear tokens on network/other errors if we suspect token is bad?
|
|
72
|
+
// Safer to just keep old config on network error, but if it was 4xx (above) we clear.
|
|
73
|
+
// For network error, maybe we shouldn't clear, just fail.
|
|
74
|
+
// But if token IS expired, we can't use it anyway.
|
|
75
|
+
return config;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Save cloud configuration to disk
|
|
80
|
+
*/
|
|
81
|
+
export async function saveCloudConfig(config) {
|
|
82
|
+
await fs.mkdir(CLOUD_CONFIG_DIR, { recursive: true });
|
|
83
|
+
await fs.writeFile(CLOUD_CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Check if user is authenticated with cloud
|
|
87
|
+
*/
|
|
88
|
+
export async function isCloudAuthenticated() {
|
|
89
|
+
const config = await loadCloudConfig();
|
|
90
|
+
if (!config.access_token)
|
|
91
|
+
return false;
|
|
92
|
+
// Check if subscription expired (for Pro features)
|
|
93
|
+
if (config.subscription_tier === 'pro' && config.subscription_expires_at) {
|
|
94
|
+
const expiresAt = new Date(config.subscription_expires_at);
|
|
95
|
+
if (expiresAt < new Date()) {
|
|
96
|
+
// Subscription expired, downgrade to free locally
|
|
97
|
+
config.subscription_tier = 'free';
|
|
98
|
+
await saveCloudConfig(config);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Check if user has Pro subscription
|
|
105
|
+
*/
|
|
106
|
+
export async function isProSubscriber() {
|
|
107
|
+
const config = await loadCloudConfig();
|
|
108
|
+
if (config.subscription_tier !== 'pro')
|
|
109
|
+
return false;
|
|
110
|
+
// Check expiration
|
|
111
|
+
if (config.subscription_expires_at) {
|
|
112
|
+
const expiresAt = new Date(config.subscription_expires_at);
|
|
113
|
+
if (expiresAt < new Date())
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Get cloud embedding for a query (Pro only)
|
|
120
|
+
* Falls back to null if not available
|
|
121
|
+
*/
|
|
122
|
+
export async function getCloudEmbedding(text, projectName) {
|
|
123
|
+
const config = await loadCloudConfig();
|
|
124
|
+
if (!config.access_token) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
// Check Pro status (cloud embedding requires Pro)
|
|
128
|
+
if (config.subscription_tier !== 'pro') {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const response = await fetch(`${SUPABASE_URL}/functions/v1/embed-query`, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: {
|
|
135
|
+
'Content-Type': 'application/json',
|
|
136
|
+
'Authorization': `Bearer ${config.access_token}`,
|
|
137
|
+
'apikey': config.anon_key || '',
|
|
138
|
+
},
|
|
139
|
+
body: JSON.stringify({ query: text, project_name: projectName }),
|
|
140
|
+
});
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
const errorText = await response.text();
|
|
143
|
+
if (response.status === 402) {
|
|
144
|
+
// Payment required - subscription issue
|
|
145
|
+
console.error('Cloud embedding requires Pro subscription');
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const data = await response.json();
|
|
150
|
+
return data.embedding || null;
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
console.error('Cloud embedding error:', error);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Sync decisions to cloud (Pro only)
|
|
159
|
+
*/
|
|
160
|
+
export async function syncDecisionsToCloud(projectName, decisions) {
|
|
161
|
+
const config = await loadCloudConfig();
|
|
162
|
+
if (!config.access_token || config.subscription_tier !== 'pro') {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const response = await fetch(`${SUPABASE_URL}/functions/v1/sync-decisions`, {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: {
|
|
169
|
+
'Content-Type': 'application/json',
|
|
170
|
+
'Authorization': `Bearer ${config.access_token}`,
|
|
171
|
+
'apikey': config.anon_key || '',
|
|
172
|
+
},
|
|
173
|
+
body: JSON.stringify({ project_name: projectName, decisions }),
|
|
174
|
+
});
|
|
175
|
+
if (!response.ok) {
|
|
176
|
+
console.error('Sync failed:', await response.text());
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
const result = await response.json();
|
|
180
|
+
// Update last sync time
|
|
181
|
+
config.last_sync = new Date().toISOString();
|
|
182
|
+
await saveCloudConfig(config);
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
console.error('Sync error:', error);
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Get cloud sync status - which decisions are synced
|
|
192
|
+
*/
|
|
193
|
+
export async function getCloudSyncStatus(projectName) {
|
|
194
|
+
const config = await loadCloudConfig();
|
|
195
|
+
if (!config.access_token || config.subscription_tier !== 'pro') {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const response = await fetch(`${SUPABASE_URL}/functions/v1/sync-status`, {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: {
|
|
202
|
+
'Content-Type': 'application/json',
|
|
203
|
+
'Authorization': `Bearer ${config.access_token}`,
|
|
204
|
+
'apikey': config.anon_key || '',
|
|
205
|
+
},
|
|
206
|
+
body: JSON.stringify({ project_name: projectName }),
|
|
207
|
+
});
|
|
208
|
+
if (!response.ok) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
return await response.json();
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Open URL in default browser
|
|
219
|
+
*/
|
|
220
|
+
function openBrowser(url) {
|
|
221
|
+
let command;
|
|
222
|
+
if (process.platform === 'win32') {
|
|
223
|
+
// On Windows, start requires a title argument if the URL is quoted
|
|
224
|
+
// escaping & is handled by the quotes
|
|
225
|
+
command = `start "" "${url}"`;
|
|
226
|
+
}
|
|
227
|
+
else if (process.platform === 'darwin') {
|
|
228
|
+
command = `open "${url}"`;
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
command = `xdg-open "${url}"`;
|
|
232
|
+
}
|
|
233
|
+
exec(command, (error) => {
|
|
234
|
+
if (error) {
|
|
235
|
+
console.error('Failed to open browser:', error);
|
|
236
|
+
console.log(`\nPlease open this URL manually:\n${url}\n`);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Login to cloud service
|
|
242
|
+
* Opens browser for authentication, waits for callback
|
|
243
|
+
*/
|
|
244
|
+
export async function loginToCloud() {
|
|
245
|
+
console.log('\n🔐 DecisionNode Login');
|
|
246
|
+
console.log('━'.repeat(40));
|
|
247
|
+
// Generate a random auth code for this session
|
|
248
|
+
const authCode = Math.random().toString(36).substring(2, 15);
|
|
249
|
+
const port = 19283; // Random high port for callback
|
|
250
|
+
return new Promise((resolve) => {
|
|
251
|
+
// Create local server to receive callback
|
|
252
|
+
const server = http.createServer(async (req, res) => {
|
|
253
|
+
const url = new URL(req.url || '', `http://localhost:${port}`);
|
|
254
|
+
if (url.pathname === '/callback') {
|
|
255
|
+
const token = url.searchParams.get('token');
|
|
256
|
+
const refreshToken = url.searchParams.get('refresh_token');
|
|
257
|
+
const tokenExpiresAt = url.searchParams.get('token_expires_at');
|
|
258
|
+
const anonKey = url.searchParams.get('anon_key');
|
|
259
|
+
const userId = url.searchParams.get('user_id');
|
|
260
|
+
const username = url.searchParams.get('username');
|
|
261
|
+
const email = url.searchParams.get('email');
|
|
262
|
+
const tier = url.searchParams.get('tier');
|
|
263
|
+
const expiresAt = url.searchParams.get('expires_at');
|
|
264
|
+
if (token) {
|
|
265
|
+
// Save the config
|
|
266
|
+
await saveCloudConfig({
|
|
267
|
+
access_token: token,
|
|
268
|
+
refresh_token: refreshToken || undefined,
|
|
269
|
+
token_expires_at: tokenExpiresAt ? parseInt(tokenExpiresAt) : undefined,
|
|
270
|
+
anon_key: anonKey || undefined,
|
|
271
|
+
user_id: userId || undefined,
|
|
272
|
+
username: username || undefined,
|
|
273
|
+
email: email || undefined,
|
|
274
|
+
subscription_tier: tier || 'free',
|
|
275
|
+
subscription_expires_at: expiresAt || undefined,
|
|
276
|
+
});
|
|
277
|
+
// Send success response
|
|
278
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
279
|
+
res.end(`
|
|
280
|
+
<!DOCTYPE html>
|
|
281
|
+
<html>
|
|
282
|
+
<head>
|
|
283
|
+
<meta charset="utf-8">
|
|
284
|
+
<style>
|
|
285
|
+
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
|
286
|
+
background: #09090b; color: white; display: flex;
|
|
287
|
+
justify-content: center; align-items: center; height: 100vh; }
|
|
288
|
+
.success { text-align: center; }
|
|
289
|
+
h1 { color: #22c55e; }
|
|
290
|
+
</style>
|
|
291
|
+
</head>
|
|
292
|
+
<body>
|
|
293
|
+
<div class="success">
|
|
294
|
+
<h1>✅ Logged In!</h1>
|
|
295
|
+
<p>You can close this window and return to the CLI.</p>
|
|
296
|
+
</div>
|
|
297
|
+
</body>
|
|
298
|
+
</html>
|
|
299
|
+
`);
|
|
300
|
+
server.close();
|
|
301
|
+
console.log('\n✅ Login successful!');
|
|
302
|
+
console.log(` Logged in as: ${username || email || 'Unknown'}`);
|
|
303
|
+
console.log(` Subscription: ${tier === 'pro' ? '⭐ Pro' : 'Free'}\n`);
|
|
304
|
+
resolve(true);
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
res.writeHead(400);
|
|
308
|
+
res.end('Missing token');
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
res.writeHead(404);
|
|
313
|
+
res.end('Not found');
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
server.listen(port, () => {
|
|
317
|
+
const authUrl = `${MARKETPLACE_URL}/cli-auth?code=${authCode}&callback=http://localhost:${port}/callback`;
|
|
318
|
+
console.log('\nOpening browser for authentication...');
|
|
319
|
+
console.log(`\nIf browser doesn't open, visit:\n ${authUrl}\n`);
|
|
320
|
+
openBrowser(authUrl);
|
|
321
|
+
});
|
|
322
|
+
// Timeout after 5 minutes
|
|
323
|
+
setTimeout(() => {
|
|
324
|
+
server.close();
|
|
325
|
+
console.log('\n❌ Login timed out. Please try again.\n');
|
|
326
|
+
resolve(false);
|
|
327
|
+
}, 5 * 60 * 1000);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Logout from cloud service
|
|
332
|
+
*/
|
|
333
|
+
export async function logoutFromCloud() {
|
|
334
|
+
await saveCloudConfig({});
|
|
335
|
+
console.log('✅ Logged out from DecisionNode');
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Get cloud status with detailed info
|
|
339
|
+
*/
|
|
340
|
+
export async function getCloudStatus() {
|
|
341
|
+
const config = await loadCloudConfig();
|
|
342
|
+
return {
|
|
343
|
+
authenticated: await isCloudAuthenticated(),
|
|
344
|
+
isPro: await isProSubscriber(),
|
|
345
|
+
userId: config.user_id,
|
|
346
|
+
username: config.username,
|
|
347
|
+
email: config.email,
|
|
348
|
+
expiresAt: config.subscription_expires_at,
|
|
349
|
+
lastSync: config.last_sync,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Refresh user profile from cloud (updates subscription status)
|
|
354
|
+
*/
|
|
355
|
+
export async function refreshCloudProfile() {
|
|
356
|
+
const config = await loadCloudConfig();
|
|
357
|
+
if (!config.access_token) {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
const response = await fetch(`${SUPABASE_URL}/functions/v1/get-profile`, {
|
|
362
|
+
method: 'GET',
|
|
363
|
+
headers: {
|
|
364
|
+
'Authorization': `Bearer ${config.access_token}`,
|
|
365
|
+
'apikey': config.anon_key || '',
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
if (!response.ok) {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
const profile = await response.json();
|
|
372
|
+
// Update local config with fresh data
|
|
373
|
+
config.subscription_tier = profile.subscription_tier || 'free';
|
|
374
|
+
config.subscription_expires_at = profile.subscription_expires_at;
|
|
375
|
+
config.username = profile.username;
|
|
376
|
+
config.email = profile.email;
|
|
377
|
+
await saveCloudConfig(config);
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Delete a decision from cloud (Pro only)
|
|
386
|
+
*/
|
|
387
|
+
export async function deleteDecisionFromCloud(decisionId) {
|
|
388
|
+
const config = await loadCloudConfig();
|
|
389
|
+
if (!config.access_token || config.subscription_tier !== 'pro') {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
// Use Supabase REST API directly
|
|
394
|
+
const response = await fetch(`${SUPABASE_URL}/rest/v1/user_decisions?decision_id=eq.${decisionId}`, {
|
|
395
|
+
method: 'DELETE',
|
|
396
|
+
headers: {
|
|
397
|
+
'Authorization': `Bearer ${config.access_token}`,
|
|
398
|
+
'apikey': config.anon_key || config.access_token, // RLS requires authenticated user, prefer anon key if available
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
return response.ok;
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
console.error('Delete error:', error);
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Pull decisions from cloud (Pro only)
|
|
410
|
+
*/
|
|
411
|
+
export async function pullDecisionsFromCloud(projectName) {
|
|
412
|
+
const config = await loadCloudConfig();
|
|
413
|
+
if (!config.access_token || config.subscription_tier !== 'pro') {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
// Use Edge Function "get-decisions" which handles auth manually (deployed with --no-verify-jwt)
|
|
418
|
+
// This avoids issues with Gateway JWT verification for potentially expired but refreshable tokens
|
|
419
|
+
// or just weird gateway behavior.
|
|
420
|
+
const response = await fetch(`${SUPABASE_URL}/functions/v1/get-decisions?project_name=${encodeURIComponent(projectName)}`, {
|
|
421
|
+
method: 'GET',
|
|
422
|
+
headers: {
|
|
423
|
+
'Authorization': `Bearer ${config.access_token}`,
|
|
424
|
+
'apikey': config.anon_key || '',
|
|
425
|
+
'Content-Type': 'application/json'
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
if (!response.ok) {
|
|
429
|
+
console.error('Pull failed:', await response.text());
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
return await response.json();
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
console.error('Pull error:', error);
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Get sync metadata file path for current project
|
|
441
|
+
*/
|
|
442
|
+
function getSyncMetadataPath(projectRoot) {
|
|
443
|
+
return path.join(projectRoot, 'sync-metadata.json');
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Load sync metadata from disk
|
|
447
|
+
*/
|
|
448
|
+
export async function loadSyncMetadata(projectRoot) {
|
|
449
|
+
try {
|
|
450
|
+
const content = await fs.readFile(getSyncMetadataPath(projectRoot), 'utf-8');
|
|
451
|
+
return JSON.parse(content);
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
return { lastSyncAt: '', decisions: {} };
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Save sync metadata to disk
|
|
459
|
+
*/
|
|
460
|
+
export async function saveSyncMetadata(projectRoot, metadata) {
|
|
461
|
+
await fs.writeFile(getSyncMetadataPath(projectRoot), JSON.stringify(metadata, null, 2));
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Get auto-sync setting
|
|
465
|
+
*/
|
|
466
|
+
export async function getAutoSyncEnabled() {
|
|
467
|
+
const config = await loadCloudConfig();
|
|
468
|
+
return config.auto_sync === true;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Set auto-sync setting
|
|
472
|
+
*/
|
|
473
|
+
export async function setAutoSyncEnabled(enabled) {
|
|
474
|
+
const config = await loadCloudConfig();
|
|
475
|
+
config.auto_sync = enabled;
|
|
476
|
+
await saveCloudConfig(config);
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Detect conflicts between local and cloud decisions
|
|
480
|
+
* Returns decisions that need to be pushed, pulled, or have conflicts
|
|
481
|
+
*/
|
|
482
|
+
export async function detectConflicts(projectRoot, localDecisions, cloudDecisions) {
|
|
483
|
+
const metadata = await loadSyncMetadata(projectRoot);
|
|
484
|
+
const toPush = [];
|
|
485
|
+
const toPull = [];
|
|
486
|
+
const conflicts = [];
|
|
487
|
+
// Build maps for comparison
|
|
488
|
+
const localMap = new Map(localDecisions.map(d => [d.id, d]));
|
|
489
|
+
const cloudMap = new Map(cloudDecisions.map(d => [d.decision_id, d]));
|
|
490
|
+
// Check each local decision
|
|
491
|
+
for (const local of localDecisions) {
|
|
492
|
+
const cloud = cloudMap.get(local.id);
|
|
493
|
+
const syncMeta = metadata.decisions[local.id];
|
|
494
|
+
if (!cloud) {
|
|
495
|
+
// Not in cloud - needs push
|
|
496
|
+
toPush.push(local.id);
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
// Exists in both - check for conflicts
|
|
500
|
+
const localUpdated = local.updatedAt ? new Date(local.updatedAt) : new Date(0);
|
|
501
|
+
const cloudUpdated = new Date(cloud.updated_at);
|
|
502
|
+
const lastSynced = syncMeta?.syncedAt ? new Date(syncMeta.syncedAt) : new Date(0);
|
|
503
|
+
const lastCloudUpdate = syncMeta?.cloudUpdatedAt ? new Date(syncMeta.cloudUpdatedAt) : lastSynced;
|
|
504
|
+
// Robust Change Detection:
|
|
505
|
+
// 1. Local Change: simple timestamp check (relative to local clock)
|
|
506
|
+
const localModifiedSinceSync = localUpdated > lastSynced;
|
|
507
|
+
// 2. Cloud Change:
|
|
508
|
+
let cloudModifiedSinceSync = false;
|
|
509
|
+
if (syncMeta?.cloudUpdatedAt) {
|
|
510
|
+
// Modern metadata: Trust the stored server-timestamp
|
|
511
|
+
const lastCloudUpdate = new Date(syncMeta.cloudUpdatedAt);
|
|
512
|
+
cloudModifiedSinceSync = cloudUpdated.getTime() > lastCloudUpdate.getTime();
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
// Legacy metadata (missing cloudUpdatedAt):
|
|
516
|
+
// We CANNOT trust lastSynced vs cloudUpdated if clock was skewed.
|
|
517
|
+
// Fallback: If local hasn't changed, but content differs, assume Cloud changed.
|
|
518
|
+
// This covers the case where user pulled/synced, clock was ahead, so lastSynced > cloudUpdated,
|
|
519
|
+
// causing us to miss future updates.
|
|
520
|
+
if (!localModifiedSinceSync && local.decision !== cloud.decision) {
|
|
521
|
+
cloudModifiedSinceSync = true;
|
|
522
|
+
}
|
|
523
|
+
else if (cloudUpdated > lastSynced) {
|
|
524
|
+
// Standard check (if clock happens to be fine)
|
|
525
|
+
cloudModifiedSinceSync = true;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// 3. Content Identity Optimization
|
|
529
|
+
// If timestamps say changed, but content is identical, ignore it (reduces noise)
|
|
530
|
+
if (cloudModifiedSinceSync && local.decision === cloud.decision) {
|
|
531
|
+
cloudModifiedSinceSync = false;
|
|
532
|
+
}
|
|
533
|
+
if (localModifiedSinceSync && cloudModifiedSinceSync) {
|
|
534
|
+
// Both modified AND content specific differs (checked above)
|
|
535
|
+
conflicts.push({
|
|
536
|
+
decisionId: local.id,
|
|
537
|
+
scope: local.scope,
|
|
538
|
+
localDecision: local.decision,
|
|
539
|
+
cloudDecision: cloud.decision,
|
|
540
|
+
localUpdatedAt: local.updatedAt || '',
|
|
541
|
+
cloudUpdatedAt: cloud.updated_at,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
else if (localModifiedSinceSync) {
|
|
545
|
+
// Only local modified - needs push
|
|
546
|
+
toPush.push(local.id);
|
|
547
|
+
}
|
|
548
|
+
else if (cloudModifiedSinceSync) {
|
|
549
|
+
// Only cloud modified - needs pull
|
|
550
|
+
toPull.push(cloud);
|
|
551
|
+
}
|
|
552
|
+
// If neither modified, no action needed
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
// Check for cloud-only decisions (not in local)
|
|
556
|
+
for (const cloud of cloudDecisions) {
|
|
557
|
+
if (!localMap.has(cloud.decision_id)) {
|
|
558
|
+
toPull.push(cloud);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return { toPush, toPull, conflicts };
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Save incoming changes from fetch command
|
|
565
|
+
*/
|
|
566
|
+
export async function saveIncomingChanges(projectRoot, changes) {
|
|
567
|
+
const filePath = path.join(projectRoot, 'incoming.json');
|
|
568
|
+
await fs.writeFile(filePath, JSON.stringify({
|
|
569
|
+
fetchedAt: new Date().toISOString(),
|
|
570
|
+
...changes
|
|
571
|
+
}, null, 2));
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Remove specific decisions from incoming changes list (after sync)
|
|
575
|
+
*/
|
|
576
|
+
export async function removeIncomingChanges(projectRoot, syncedIds) {
|
|
577
|
+
const filePath = path.join(projectRoot, 'incoming.json');
|
|
578
|
+
try {
|
|
579
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
580
|
+
const data = JSON.parse(content);
|
|
581
|
+
const pulledSet = new Set(syncedIds);
|
|
582
|
+
// Filter out synced items
|
|
583
|
+
data.toPull = (data.toPull || []).filter((d) => !pulledSet.has(d.decision_id || d.id));
|
|
584
|
+
data.conflicts = (data.conflicts || []).filter((c) => !pulledSet.has(c.decisionId));
|
|
585
|
+
await fs.writeFile(filePath, JSON.stringify(data, null, 2));
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
// Ignore if file doesn't exist
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Resolve a conflict by choosing local or cloud version
|
|
593
|
+
*/
|
|
594
|
+
export async function resolveConflict(projectRoot, decisionId, resolution, cloudDecision) {
|
|
595
|
+
const metadata = await loadSyncMetadata(projectRoot);
|
|
596
|
+
if (resolution === 'local') {
|
|
597
|
+
// Mark as needing push (will be handled by next sync)
|
|
598
|
+
// Just clear the conflict by updating sync metadata
|
|
599
|
+
metadata.decisions[decisionId] = {
|
|
600
|
+
syncedAt: new Date().toISOString(),
|
|
601
|
+
localUpdatedAt: new Date().toISOString(),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
else if (resolution === 'cloud' && cloudDecision) {
|
|
605
|
+
// Cloud wins - update sync metadata
|
|
606
|
+
// The actual update to local store is done by the caller
|
|
607
|
+
metadata.decisions[decisionId] = {
|
|
608
|
+
syncedAt: new Date().toISOString(),
|
|
609
|
+
cloudUpdatedAt: cloudDecision.updated_at,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
await saveSyncMetadata(projectRoot, metadata);
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Update sync metadata after successful sync
|
|
617
|
+
*/
|
|
618
|
+
export async function updateSyncMetadata(projectRoot, syncedIds, cloudDecisions) {
|
|
619
|
+
const metadata = await loadSyncMetadata(projectRoot);
|
|
620
|
+
const now = new Date().toISOString();
|
|
621
|
+
metadata.lastSyncAt = now;
|
|
622
|
+
// Update metadata for synced decisions
|
|
623
|
+
for (const id of syncedIds) {
|
|
624
|
+
const cloud = cloudDecisions.find(d => d.decision_id === id);
|
|
625
|
+
metadata.decisions[id] = {
|
|
626
|
+
syncedAt: now,
|
|
627
|
+
cloudUpdatedAt: cloud?.updated_at,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
await saveSyncMetadata(projectRoot, metadata);
|
|
631
|
+
}
|
package/dist/env.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Set the current project name (called by MCP tools)
|
|
3
|
+
*/
|
|
4
|
+
export declare function setCurrentProject(projectName: string): void;
|
|
5
|
+
/**
|
|
6
|
+
* Get the current project name
|
|
7
|
+
*/
|
|
8
|
+
export declare function getCurrentProject(): string;
|
|
9
|
+
/**
|
|
10
|
+
* Get the project-specific storage path
|
|
11
|
+
* ~/.decisionnode/.decisions/{projectname}/
|
|
12
|
+
* Does NOT create the folder - that's done when saving files
|
|
13
|
+
*/
|
|
14
|
+
export declare function getProjectPath(projectName?: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Ensure project folder exists (call before writing files)
|
|
17
|
+
*/
|
|
18
|
+
export declare function ensureProjectFolder(projectName?: string): void;
|
|
19
|
+
export declare const GLOBAL_STORE: string;
|
|
20
|
+
export declare const GLOBAL_PROJECT_NAME = "_global";
|
|
21
|
+
/**
|
|
22
|
+
* Get the path to the global decisions folder
|
|
23
|
+
* ~/.decisionnode/.decisions/_global/
|
|
24
|
+
*/
|
|
25
|
+
export declare function getGlobalDecisionsPath(): string;
|
|
26
|
+
/**
|
|
27
|
+
* Ensure the global decisions folder exists
|
|
28
|
+
*/
|
|
29
|
+
export declare function ensureGlobalFolder(): void;
|
|
30
|
+
/**
|
|
31
|
+
* Check if a decision ID is a global decision (prefixed with "global:")
|
|
32
|
+
*/
|
|
33
|
+
export declare function isGlobalId(id: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Strip the "global:" prefix from a decision ID
|
|
36
|
+
*/
|
|
37
|
+
export declare function stripGlobalPrefix(id: string): string;
|
|
38
|
+
export declare function getProjectRoot(): string;
|
|
39
|
+
export type SearchSensitivity = 'high' | 'medium';
|
|
40
|
+
/**
|
|
41
|
+
* Get the current search sensitivity level
|
|
42
|
+
*/
|
|
43
|
+
export declare function getSearchSensitivity(): SearchSensitivity;
|
|
44
|
+
/**
|
|
45
|
+
* Set the search sensitivity level
|
|
46
|
+
*/
|
|
47
|
+
export declare function setSearchSensitivity(level: SearchSensitivity): void;
|