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/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;