@n8n-as-code/core 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.
Files changed (38) hide show
  1. package/README.md +22 -0
  2. package/dist/index.d.ts +11 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +11 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/services/directory-utils.d.ts +35 -0
  7. package/dist/services/directory-utils.d.ts.map +1 -0
  8. package/dist/services/directory-utils.js +75 -0
  9. package/dist/services/directory-utils.js.map +1 -0
  10. package/dist/services/n8n-api-client.d.ts +23 -0
  11. package/dist/services/n8n-api-client.d.ts.map +1 -0
  12. package/dist/services/n8n-api-client.js +199 -0
  13. package/dist/services/n8n-api-client.js.map +1 -0
  14. package/dist/services/schema-generator.d.ts +9 -0
  15. package/dist/services/schema-generator.d.ts.map +1 -0
  16. package/dist/services/schema-generator.js +79 -0
  17. package/dist/services/schema-generator.js.map +1 -0
  18. package/dist/services/state-manager.d.ts +42 -0
  19. package/dist/services/state-manager.d.ts.map +1 -0
  20. package/dist/services/state-manager.js +99 -0
  21. package/dist/services/state-manager.js.map +1 -0
  22. package/dist/services/sync-manager.d.ts +101 -0
  23. package/dist/services/sync-manager.d.ts.map +1 -0
  24. package/dist/services/sync-manager.js +692 -0
  25. package/dist/services/sync-manager.js.map +1 -0
  26. package/dist/services/trash-service.d.ts +17 -0
  27. package/dist/services/trash-service.d.ts.map +1 -0
  28. package/dist/services/trash-service.js +41 -0
  29. package/dist/services/trash-service.js.map +1 -0
  30. package/dist/services/workflow-sanitizer.d.ts +18 -0
  31. package/dist/services/workflow-sanitizer.d.ts.map +1 -0
  32. package/dist/services/workflow-sanitizer.js +80 -0
  33. package/dist/services/workflow-sanitizer.js.map +1 -0
  34. package/dist/types.d.ts +41 -0
  35. package/dist/types.d.ts.map +1 -0
  36. package/dist/types.js +9 -0
  37. package/dist/types.js.map +1 -0
  38. package/package.json +41 -0
@@ -0,0 +1,692 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import EventEmitter from 'events';
4
+ import deepEqual from 'deep-equal';
5
+ import * as chokidar from 'chokidar';
6
+ import { WorkflowSanitizer } from './workflow-sanitizer.js';
7
+ import { createInstanceIdentifier, createFallbackInstanceIdentifier } from './directory-utils.js';
8
+ import { StateManager } from './state-manager.js';
9
+ import { TrashService } from './trash-service.js';
10
+ import { WorkflowSyncStatus } from '../types.js';
11
+ export class SyncManager extends EventEmitter {
12
+ client;
13
+ config;
14
+ // Maps filename -> Workflow ID
15
+ fileToIdMap = new Map();
16
+ // Maps filePath -> Content (to detect self-written changes)
17
+ selfWrittenCache = new Map();
18
+ // Busy writing flag to avoid loops
19
+ isWriting = new Set();
20
+ // Pending deletions (IDs) to prevent immediate re‑download
21
+ pendingDeletions = new Set();
22
+ watcher = null;
23
+ pollInterval = null;
24
+ stateManager = null;
25
+ trashService = null;
26
+ constructor(client, config) {
27
+ super();
28
+ this.client = client;
29
+ this.config = config;
30
+ // Create base directory if it doesn't exist
31
+ if (!fs.existsSync(this.config.directory)) {
32
+ fs.mkdirSync(this.config.directory, { recursive: true });
33
+ }
34
+ }
35
+ /**
36
+ * Get the path to the instance configuration file
37
+ */
38
+ getInstanceConfigPath() {
39
+ return path.join(this.config.directory, 'n8n-as-code-instance.json');
40
+ }
41
+ /**
42
+ * Load instance configuration from disk
43
+ */
44
+ loadInstanceConfig() {
45
+ const configPath = this.getInstanceConfigPath();
46
+ if (fs.existsSync(configPath)) {
47
+ try {
48
+ const content = fs.readFileSync(configPath, 'utf-8');
49
+ return JSON.parse(content);
50
+ }
51
+ catch (error) {
52
+ console.warn('Could not read instance config, using defaults:', error);
53
+ }
54
+ }
55
+ return {};
56
+ }
57
+ /**
58
+ * Save instance configuration to disk
59
+ */
60
+ saveInstanceConfig(config) {
61
+ const configPath = this.getInstanceConfigPath();
62
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
63
+ }
64
+ /**
65
+ * Ensure instance identifier is set and persistent
66
+ */
67
+ async ensureInstanceIdentifier() {
68
+ // Check if instance identifier is already provided in config
69
+ if (this.config.instanceIdentifier && this.stateManager) {
70
+ return this.config.instanceIdentifier;
71
+ }
72
+ // Try to load from persistent storage
73
+ const instanceConfig = this.loadInstanceConfig();
74
+ if (instanceConfig.instanceIdentifier) {
75
+ this.config.instanceIdentifier = instanceConfig.instanceIdentifier;
76
+ this.stateManager = new StateManager(this.getInstanceDirectory());
77
+ this.trashService = new TrashService(this.getInstanceDirectory());
78
+ return instanceConfig.instanceIdentifier;
79
+ }
80
+ // Generate new instance identifier
81
+ const newIdentifier = await this.initializeInstanceIdentifier();
82
+ // Save to persistent storage
83
+ instanceConfig.instanceIdentifier = newIdentifier;
84
+ instanceConfig.lastUsed = new Date().toISOString();
85
+ this.saveInstanceConfig(instanceConfig);
86
+ // Update config
87
+ this.config.instanceIdentifier = newIdentifier;
88
+ this.stateManager = new StateManager(this.getInstanceDirectory());
89
+ this.trashService = new TrashService(this.getInstanceDirectory());
90
+ return newIdentifier;
91
+ }
92
+ async initializeInstanceIdentifier() {
93
+ const host = this.client['client']?.defaults?.baseURL || 'unknown';
94
+ try {
95
+ // Try to get user information for friendly naming
96
+ const user = await this.client.getCurrentUser();
97
+ if (user) {
98
+ // Use host from client configuration
99
+ return createInstanceIdentifier(host, user);
100
+ }
101
+ }
102
+ catch (error) {
103
+ console.warn('Could not get user info for instance identifier:', error);
104
+ }
105
+ const apiKey = this.client['client']?.defaults?.headers?.['X-N8N-API-KEY'] || 'unknown';
106
+ return createFallbackInstanceIdentifier(host, String(apiKey));
107
+ }
108
+ getInstanceDirectory() {
109
+ if (!this.config.instanceIdentifier) {
110
+ // This should not happen if callers await ensureInstanceIdentifier
111
+ const instanceConfig = this.loadInstanceConfig();
112
+ if (instanceConfig.instanceIdentifier) {
113
+ this.config.instanceIdentifier = instanceConfig.instanceIdentifier;
114
+ }
115
+ else {
116
+ throw new Error('Instance identifier not available. Please wait for initialization.');
117
+ }
118
+ }
119
+ return path.join(this.config.directory, this.config.instanceIdentifier);
120
+ }
121
+ getFilePath(filename) {
122
+ return path.join(this.getInstanceDirectory(), filename);
123
+ }
124
+ safeName(name) {
125
+ return name.replace(/[\/\\:]/g, '_').replace(/\s+/g, ' ').trim();
126
+ }
127
+ normalizeContent(content) {
128
+ return content.replace(/\r\n/g, '\n').trim();
129
+ }
130
+ markAsSelfWritten(filePath, content) {
131
+ this.selfWrittenCache.set(filePath, this.normalizeContent(content));
132
+ }
133
+ isSelfWritten(filePath, currentContent) {
134
+ if (!this.selfWrittenCache.has(filePath))
135
+ return false;
136
+ const cached = this.selfWrittenCache.get(filePath);
137
+ const current = this.normalizeContent(currentContent);
138
+ return cached === current;
139
+ }
140
+ async loadRemoteState() {
141
+ this.emit('log', '🔄 [SyncManager] Loading remote state...');
142
+ const remoteWorkflows = await this.client.getAllWorkflows();
143
+ // Populate map
144
+ for (const wf of remoteWorkflows) {
145
+ if (this.shouldIgnore(wf))
146
+ continue;
147
+ const filename = `${this.safeName(wf.name)}.json`;
148
+ this.fileToIdMap.set(filename, wf.id);
149
+ }
150
+ console.log(`[DEBUG] loadRemoteState populated ${this.fileToIdMap.size} entries`);
151
+ }
152
+ /**
153
+ * Retrieves the status of all workflows (local and remote)
154
+ */
155
+ async getWorkflowsStatus() {
156
+ await this.ensureInstanceIdentifier();
157
+ await this.loadRemoteState();
158
+ const statuses = [];
159
+ // 1. Check all Remote Workflows (and compare with local)
160
+ const remoteWorkflows = await this.client.getAllWorkflows();
161
+ for (const wf of remoteWorkflows) {
162
+ if (this.shouldIgnore(wf))
163
+ continue;
164
+ const filename = `${this.safeName(wf.name)}.json`;
165
+ const filePath = this.getFilePath(filename);
166
+ let status = WorkflowSyncStatus.SYNCED;
167
+ if (!fs.existsSync(filePath)) {
168
+ status = WorkflowSyncStatus.MISSING_LOCAL;
169
+ }
170
+ else {
171
+ // Check local modification using state tracking
172
+ const localContent = this.readLocalFile(filePath);
173
+ const localClean = WorkflowSanitizer.cleanForStorage(localContent);
174
+ const isSynced = this.stateManager?.isLocalSynced(wf.id, localClean) ?? true;
175
+ if (!isSynced) {
176
+ status = WorkflowSyncStatus.LOCAL_MODIFIED;
177
+ }
178
+ }
179
+ statuses.push({
180
+ id: wf.id,
181
+ name: wf.name,
182
+ filename: filename,
183
+ active: wf.active,
184
+ status: status
185
+ });
186
+ }
187
+ // 2. Check Local Files (for Orphans)
188
+ const instanceDirectory = this.getInstanceDirectory();
189
+ if (fs.existsSync(instanceDirectory)) {
190
+ const localFiles = fs.readdirSync(instanceDirectory).filter(f => f.endsWith('.json') && !f.startsWith('.'));
191
+ for (const file of localFiles) {
192
+ const alreadyListed = statuses.find(s => s.filename === file);
193
+ if (!alreadyListed) {
194
+ const filePath = this.getFilePath(file);
195
+ const content = this.readLocalFile(filePath);
196
+ const name = content?.name || path.parse(file).name;
197
+ statuses.push({
198
+ id: '',
199
+ name: name,
200
+ filename: file,
201
+ active: false,
202
+ status: WorkflowSyncStatus.MISSING_REMOTE
203
+ });
204
+ }
205
+ }
206
+ }
207
+ return statuses.sort((a, b) => a.name.localeCompare(b.name));
208
+ }
209
+ formatSummary(counts) {
210
+ const summary = [];
211
+ if (counts.new && counts.new > 0)
212
+ summary.push(`${counts.new} new`);
213
+ if (counts.updated && counts.updated > 0)
214
+ summary.push(`${counts.updated} updated`);
215
+ if (counts.conflict && counts.conflict > 0)
216
+ summary.push(`${counts.conflict} conflicts`);
217
+ if (counts.upToDate && counts.upToDate > 0)
218
+ summary.push(`${counts.upToDate} up-to-date`);
219
+ return summary.length > 0 ? summary.join(', ') : 'No changes';
220
+ }
221
+ /**
222
+ * Scans n8n instance and updates local files (Downstream Sync)
223
+ */
224
+ async syncDown() {
225
+ await this.ensureInstanceIdentifier();
226
+ this.emit('log', '🔄 [SyncManager] Starting Downstream Sync...');
227
+ const remoteWorkflows = await this.client.getAllWorkflows();
228
+ // Sort: Active first to prioritize their naming
229
+ remoteWorkflows.sort((a, b) => (a.active === b.active ? 0 : a.active ? -1 : 1));
230
+ const processedFiles = new Set();
231
+ const counts = { updated: 0, new: 0, upToDate: 0, conflict: 0 };
232
+ for (const wf of remoteWorkflows) {
233
+ if (this.shouldIgnore(wf))
234
+ continue;
235
+ const filename = `${this.safeName(wf.name)}.json`;
236
+ if (processedFiles.has(filename))
237
+ continue;
238
+ processedFiles.add(filename);
239
+ this.fileToIdMap.set(filename, wf.id);
240
+ const result = await this.pullWorkflowWithConflictResolution(filename, wf.id, wf.updatedAt || wf.createdAt);
241
+ if (result === 'updated')
242
+ counts.updated++;
243
+ else if (result === 'new')
244
+ counts.new++;
245
+ else if (result === 'up-to-date')
246
+ counts.upToDate++;
247
+ else if (result === 'conflict')
248
+ counts.conflict++;
249
+ }
250
+ // 3. Process remote deletions
251
+ const deletionCounts = await this.processRemoteDeletions(remoteWorkflows);
252
+ this.emit('log', `📥 [SyncManager] Sync complete: ${this.formatSummary(counts)}${deletionCounts > 0 ? `, ${deletionCounts} remote deleted` : ''}`);
253
+ }
254
+ /**
255
+ * Identifies and handles workflows deleted on n8n but still present locally and tracked in state
256
+ */
257
+ async processRemoteDeletions(remoteWorkflows) {
258
+ if (!this.stateManager || !this.trashService)
259
+ return 0;
260
+ const remoteIds = new Set(remoteWorkflows.map(wf => wf.id));
261
+ const trackedIds = this.stateManager.getTrackedWorkflowIds();
262
+ let deletedCount = 0;
263
+ for (const id of trackedIds) {
264
+ if (!remoteIds.has(id)) {
265
+ // Workflow deleted remotely!
266
+ const state = this.stateManager.getWorkflowState(id);
267
+ if (!state)
268
+ continue;
269
+ // Find local file for this ID
270
+ const filename = Array.from(this.fileToIdMap.entries())
271
+ .find(([_, fid]) => fid === id)?.[0];
272
+ if (filename) {
273
+ const filePath = this.getFilePath(filename);
274
+ if (fs.existsSync(filePath)) {
275
+ this.emit('log', `🗑️ [Remote->Local] Remote workflow deleted: "${filename}". Moving local file to .archive`);
276
+ await this.trashService.archiveFile(filePath, filename);
277
+ this.stateManager.removeWorkflowState(id);
278
+ this.fileToIdMap.delete(filename);
279
+ this.emit('change', { type: 'remote-deletion', filename, id });
280
+ deletedCount++;
281
+ }
282
+ }
283
+ else {
284
+ // ID tracked but no file found? Just clean state
285
+ this.stateManager.removeWorkflowState(id);
286
+ }
287
+ }
288
+ }
289
+ return deletedCount;
290
+ }
291
+ /**
292
+ * Scans n8n instance and updates local files with conflict resolution
293
+ */
294
+ async syncDownWithConflictResolution() {
295
+ await this.ensureInstanceIdentifier();
296
+ const remoteWorkflows = await this.client.getAllWorkflows();
297
+ remoteWorkflows.sort((a, b) => (a.active === b.active ? 0 : a.active ? -1 : 1));
298
+ const processedFiles = new Set();
299
+ const counts = { updated: 0, new: 0, upToDate: 0, conflict: 0 };
300
+ for (const wf of remoteWorkflows) {
301
+ if (this.shouldIgnore(wf))
302
+ continue;
303
+ const filename = `${this.safeName(wf.name)}.json`;
304
+ if (processedFiles.has(filename))
305
+ continue;
306
+ processedFiles.add(filename);
307
+ this.fileToIdMap.set(filename, wf.id);
308
+ const result = await this.pullWorkflowWithConflictResolution(filename, wf.id, wf.updatedAt || wf.createdAt);
309
+ if (result === 'updated')
310
+ counts.updated++;
311
+ else if (result === 'new')
312
+ counts.new++;
313
+ else if (result === 'up-to-date')
314
+ counts.upToDate++;
315
+ else if (result === 'conflict')
316
+ counts.conflict++;
317
+ }
318
+ // 3. Process remote deletions
319
+ await this.processRemoteDeletions(remoteWorkflows);
320
+ if (counts.updated > 0 || counts.new > 0 || counts.conflict > 0) {
321
+ this.emit('log', `📥 [SyncManager] Applied: ${this.formatSummary({ new: counts.new, updated: counts.updated, conflict: counts.conflict })}`);
322
+ }
323
+ }
324
+ /**
325
+ * Pulls a single workflow by ID and writes to filename
326
+ * @param force If true, overwrites local changes without checking for conflicts
327
+ */
328
+ async pullWorkflow(filename, id, force = false) {
329
+ const fullWf = await this.client.getWorkflow(id);
330
+ if (!fullWf)
331
+ return;
332
+ const cleanRemote = WorkflowSanitizer.cleanForStorage(fullWf);
333
+ const filePath = this.getFilePath(filename);
334
+ if (!force && fs.existsSync(filePath)) {
335
+ const localContent = this.readLocalFile(filePath);
336
+ const localClean = WorkflowSanitizer.cleanForStorage(localContent);
337
+ const isLocalSynced = this.stateManager?.isLocalSynced(id, localClean) ?? false;
338
+ if (!isLocalSynced) {
339
+ if (!deepEqual(localClean, cleanRemote)) {
340
+ this.emit('conflict', {
341
+ id,
342
+ filename,
343
+ localContent: localClean,
344
+ remoteContent: cleanRemote
345
+ });
346
+ return;
347
+ }
348
+ }
349
+ }
350
+ await this.writeLocalFile(filePath, cleanRemote, filename, id);
351
+ }
352
+ /**
353
+ * Pulls a single workflow with conflict resolution
354
+ * @returns 'updated' if file was updated, 'skipped' if no change or conflict, 'new' if file was created
355
+ */
356
+ async pullWorkflowWithConflictResolution(filename, id, remoteUpdatedAt) {
357
+ // Skip if this workflow is pending deletion (user hasn't decided yet)
358
+ if (this.pendingDeletions.has(id)) {
359
+ console.log(`[DEBUG] Skipping pull for ${filename} because ID ${id} is pending deletion`);
360
+ return 'skipped';
361
+ }
362
+ const fullWf = await this.client.getWorkflow(id);
363
+ if (!fullWf)
364
+ return 'skipped';
365
+ const cleanRemote = WorkflowSanitizer.cleanForStorage(fullWf);
366
+ const filePath = this.getFilePath(filename);
367
+ if (fs.existsSync(filePath)) {
368
+ const localContent = this.readLocalFile(filePath);
369
+ const localClean = WorkflowSanitizer.cleanForStorage(localContent);
370
+ const isRemoteSynced = this.stateManager?.isRemoteSynced(id, cleanRemote) ?? false;
371
+ if (isRemoteSynced) {
372
+ return 'up-to-date';
373
+ }
374
+ const isLocalSynced = this.stateManager?.isLocalSynced(id, localClean) ?? false;
375
+ if (!isLocalSynced) {
376
+ this.emit('log', `⚠️ [Conflict] Workflow "${filename}" changed both locally and on n8n. Skipping auto-pull.`);
377
+ this.emit('conflict', {
378
+ id,
379
+ filename,
380
+ localContent: localClean,
381
+ remoteContent: cleanRemote
382
+ });
383
+ return 'conflict';
384
+ }
385
+ this.emit('log', `📥 [n8n->Local] Updated: "${filename}" (Remote changes detected)`);
386
+ await this.writeLocalFile(filePath, cleanRemote, filename, id);
387
+ return 'updated';
388
+ }
389
+ else {
390
+ this.emit('log', `📥 [n8n->Local] New: "${filename}"`);
391
+ await this.writeLocalFile(filePath, cleanRemote, filename, id);
392
+ return 'new';
393
+ }
394
+ }
395
+ /**
396
+ * Writes file to disk only if changed
397
+ */
398
+ async writeLocalFile(filePath, contentObj, filename, id) {
399
+ const contentStr = JSON.stringify(contentObj, null, 2);
400
+ const doWrite = (isNew) => {
401
+ this.isWriting.add(filePath);
402
+ this.markAsSelfWritten(filePath, contentStr);
403
+ const dir = path.dirname(filePath);
404
+ if (!fs.existsSync(dir)) {
405
+ fs.mkdirSync(dir, { recursive: true });
406
+ }
407
+ fs.writeFileSync(filePath, contentStr);
408
+ this.stateManager?.updateWorkflowState(id, contentObj);
409
+ this.emit('change', { type: 'remote-to-local', filename, id });
410
+ setTimeout(() => this.isWriting.delete(filePath), 1000);
411
+ };
412
+ if (!fs.existsSync(filePath)) {
413
+ doWrite(true);
414
+ return;
415
+ }
416
+ const localContent = fs.readFileSync(filePath, 'utf8');
417
+ try {
418
+ const localObj = JSON.parse(localContent);
419
+ const cleanLocal = WorkflowSanitizer.cleanForStorage(localObj);
420
+ if (!deepEqual(cleanLocal, contentObj)) {
421
+ doWrite(false);
422
+ }
423
+ }
424
+ catch (e) {
425
+ doWrite(false);
426
+ }
427
+ }
428
+ shouldIgnore(wf) {
429
+ if (!this.config.syncInactive && !wf.active)
430
+ return true;
431
+ if (wf.tags) {
432
+ const hasIgnoredTag = wf.tags.some(t => this.config.ignoredTags.includes(t.name.toLowerCase()));
433
+ if (hasIgnoredTag)
434
+ return true;
435
+ }
436
+ return false;
437
+ }
438
+ /**
439
+ * Uploads local files that don't exist remotely (Upstream Sync - Init)
440
+ */
441
+ async syncUpMissing() {
442
+ await this.ensureInstanceIdentifier();
443
+ this.emit('log', '🔄 [SyncManager] Checking for orphans...');
444
+ const instanceDirectory = this.getInstanceDirectory();
445
+ if (!fs.existsSync(instanceDirectory))
446
+ return;
447
+ const localFiles = fs.readdirSync(instanceDirectory).filter(f => f.endsWith('.json') && !f.startsWith('.'));
448
+ for (const file of localFiles) {
449
+ if (this.fileToIdMap.has(file))
450
+ continue;
451
+ const filePath = this.getFilePath(file);
452
+ await this.handleLocalFileChange(filePath);
453
+ }
454
+ }
455
+ /**
456
+ * Full Upstream Sync: Updates existing and Creates new.
457
+ */
458
+ async syncUp() {
459
+ await this.ensureInstanceIdentifier();
460
+ this.emit('log', '📤 [SyncManager] Starting Upstream Sync (Push)...');
461
+ const instanceDirectory = this.getInstanceDirectory();
462
+ if (!fs.existsSync(instanceDirectory))
463
+ return;
464
+ const localFiles = fs.readdirSync(instanceDirectory).filter(f => f.endsWith('.json') && !f.startsWith('.'));
465
+ const counts = { updated: 0, created: 0, upToDate: 0, conflict: 0 };
466
+ for (const file of localFiles) {
467
+ const filePath = this.getFilePath(file);
468
+ const result = await this.handleLocalFileChange(filePath, true);
469
+ if (result === 'updated')
470
+ counts.updated++;
471
+ else if (result === 'created')
472
+ counts.created++;
473
+ else if (result === 'up-to-date')
474
+ counts.upToDate++;
475
+ else if (result === 'conflict')
476
+ counts.conflict++;
477
+ }
478
+ this.emit('log', `📤 [SyncManager] Push complete: ${this.formatSummary(counts)}`);
479
+ }
480
+ /**
481
+ * Handles local file deletion (detected by watcher)
482
+ */
483
+ async handleLocalFileDeletion(filePath) {
484
+ const filename = path.basename(filePath);
485
+ if (!filename.endsWith('.json') || filename.startsWith('.n8n-state'))
486
+ return;
487
+ console.log(`[DEBUG] handleLocalFileDeletion called for ${filename}`);
488
+ console.log(`[DEBUG] fileToIdMap entries: ${Array.from(this.fileToIdMap.entries()).map(([f, i]) => `${f}=${i}`).join(', ')}`);
489
+ const id = this.fileToIdMap.get(filename);
490
+ if (id) {
491
+ this.pendingDeletions.add(id);
492
+ this.emit('log', `🗑️ [Local->Remote] Local file deleted: "${filename}". (ID: ${id})`);
493
+ this.emit('local-deletion', { id, filename, filePath });
494
+ }
495
+ else {
496
+ console.log(`[DEBUG] No ID found for ${filename} in fileToIdMap`);
497
+ }
498
+ }
499
+ /**
500
+ * Actually deletes the remote workflow and cleans up local state
501
+ */
502
+ async deleteRemoteWorkflow(id, filename) {
503
+ try {
504
+ // First archive the remote version locally for safety
505
+ const remoteWf = await this.client.getWorkflow(id);
506
+ if (remoteWf && this.trashService) {
507
+ await this.trashService.archiveWorkflow(remoteWf, filename);
508
+ }
509
+ const success = await this.client.deleteWorkflow(id);
510
+ if (success) {
511
+ this.stateManager?.removeWorkflowState(id);
512
+ this.fileToIdMap.delete(filename);
513
+ this.pendingDeletions.delete(id);
514
+ this.emit('log', `✅ [n8n] Workflow ${id} deleted successfully.`);
515
+ return true;
516
+ }
517
+ return false;
518
+ }
519
+ catch (error) {
520
+ this.emit('log', `❌ Failed to delete remote workflow ${id}: ${error.message}`);
521
+ return false;
522
+ }
523
+ }
524
+ /**
525
+ * Restore a deleted local file from remote
526
+ */
527
+ async restoreLocalFile(id, filename) {
528
+ try {
529
+ const remoteWf = await this.client.getWorkflow(id);
530
+ if (!remoteWf)
531
+ throw new Error('Remote workflow not found');
532
+ const cleanRemote = WorkflowSanitizer.cleanForStorage(remoteWf);
533
+ const filePath = this.getFilePath(filename);
534
+ await this.writeLocalFile(filePath, cleanRemote, filename, id);
535
+ this.pendingDeletions.delete(id);
536
+ this.emit('log', `✅ [Local] Workflow "${filename}" restored from n8n.`);
537
+ return true;
538
+ }
539
+ catch (error) {
540
+ this.emit('log', `❌ Failed to restore local file ${filename}: ${error.message}`);
541
+ return false;
542
+ }
543
+ }
544
+ /**
545
+ * Handle FS watcher events
546
+ */
547
+ async handleLocalFileChange(filePath, silent = false) {
548
+ await this.ensureInstanceIdentifier();
549
+ const filename = path.basename(filePath);
550
+ if (!filename.endsWith('.json') || filename.startsWith('.n8n-state'))
551
+ return 'skipped';
552
+ if (this.isWriting.has(filePath)) {
553
+ return 'skipped';
554
+ }
555
+ const rawContent = this.readRawFile(filePath);
556
+ if (!rawContent) {
557
+ return 'skipped';
558
+ }
559
+ if (this.isSelfWritten(filePath, rawContent)) {
560
+ return 'skipped';
561
+ }
562
+ const id = this.fileToIdMap.get(filename);
563
+ const nameFromFile = path.parse(filename).name;
564
+ let json;
565
+ try {
566
+ json = JSON.parse(rawContent);
567
+ }
568
+ catch (e) {
569
+ return 'skipped'; // Invalid JSON
570
+ }
571
+ const payload = WorkflowSanitizer.cleanForPush(json);
572
+ try {
573
+ if (id) {
574
+ const remoteRaw = await this.client.getWorkflow(id);
575
+ if (!remoteRaw)
576
+ throw new Error('Remote workflow not found');
577
+ const remoteClean = WorkflowSanitizer.cleanForStorage(remoteRaw);
578
+ const localClean = WorkflowSanitizer.cleanForStorage(json);
579
+ const workflowState = this.stateManager?.getWorkflowState(id);
580
+ if (workflowState) {
581
+ const isRemoteSynced = this.stateManager?.isRemoteSynced(id, remoteClean);
582
+ if (!isRemoteSynced) {
583
+ this.emit('log', `⚠️ [Conflict] Remote workflow "${filename}" has been modified on n8n. Push aborted to prevent overwriting.`);
584
+ this.emit('conflict', { id, filename, localContent: localClean, remoteContent: remoteClean });
585
+ return 'conflict';
586
+ }
587
+ }
588
+ if (deepEqual(remoteClean, localClean)) {
589
+ return 'up-to-date';
590
+ }
591
+ if (!payload.name)
592
+ payload.name = nameFromFile;
593
+ this.emit('log', `📤 [Local->n8n] Update: "${filename}" (ID: ${id})`);
594
+ const updatedWf = await this.client.updateWorkflow(id, payload);
595
+ if (updatedWf && updatedWf.id) {
596
+ this.emit('log', `✅ Update OK (ID: ${updatedWf.id})`);
597
+ this.stateManager?.updateWorkflowState(id, localClean);
598
+ this.emit('change', { type: 'local-to-remote', filename, id });
599
+ return 'updated';
600
+ }
601
+ else {
602
+ return 'skipped';
603
+ }
604
+ }
605
+ else {
606
+ const safePayloadName = this.safeName(payload.name || '');
607
+ if (safePayloadName !== nameFromFile) {
608
+ if (!silent)
609
+ this.emit('log', `⚠️ Name mismatch on creation. Using filename: "${nameFromFile}"`);
610
+ payload.name = nameFromFile;
611
+ }
612
+ else {
613
+ payload.name = payload.name || nameFromFile;
614
+ }
615
+ this.emit('log', `✨ [Local->n8n] Create: "${filename}"`);
616
+ const newWf = await this.client.createWorkflow(payload);
617
+ this.emit('log', `✅ Created (ID: ${newWf.id})`);
618
+ this.fileToIdMap.set(filename, newWf.id);
619
+ this.emit('change', { type: 'local-to-remote', filename, id: newWf.id });
620
+ return 'created';
621
+ }
622
+ }
623
+ catch (error) {
624
+ const errorMsg = error.response?.data?.message || error.message || 'Unknown error';
625
+ this.emit('log', `❌ Sync Up Failed for "${filename}": ${errorMsg}`);
626
+ this.emit('error', `Sync Up Error: ${errorMsg}`);
627
+ return 'skipped';
628
+ }
629
+ }
630
+ readLocalFile(filePath) {
631
+ try {
632
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
633
+ }
634
+ catch {
635
+ return null;
636
+ }
637
+ }
638
+ readRawFile(filePath) {
639
+ try {
640
+ return fs.readFileSync(filePath, 'utf8');
641
+ }
642
+ catch {
643
+ return null;
644
+ }
645
+ }
646
+ async startWatch() {
647
+ if (this.watcher || this.pollInterval)
648
+ return;
649
+ this.emit('log', `🚀 [SyncManager] Starting Watcher (Poll: ${this.config.pollIntervalMs}ms)`);
650
+ await this.ensureInstanceIdentifier();
651
+ const instanceDirectory = this.getInstanceDirectory();
652
+ if (!fs.existsSync(instanceDirectory)) {
653
+ fs.mkdirSync(instanceDirectory, { recursive: true });
654
+ }
655
+ await this.syncDown();
656
+ await this.syncUp();
657
+ console.log(`[DEBUG] startWatch: fileToIdMap size = ${this.fileToIdMap.size}`);
658
+ console.log(`[DEBUG] entries: ${Array.from(this.fileToIdMap.entries()).map(([f, i]) => `${f}=${i}`).join(', ')}`);
659
+ this.watcher = chokidar.watch(instanceDirectory, {
660
+ ignored: /(^|[\/\\])\../,
661
+ persistent: true,
662
+ ignoreInitial: true,
663
+ awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 }
664
+ });
665
+ this.watcher
666
+ .on('change', (p) => this.handleLocalFileChange(p))
667
+ .on('add', (p) => this.handleLocalFileChange(p))
668
+ .on('unlink', (p) => this.handleLocalFileDeletion(p));
669
+ if (this.config.pollIntervalMs > 0) {
670
+ this.pollInterval = setInterval(async () => {
671
+ try {
672
+ await this.syncDownWithConflictResolution();
673
+ }
674
+ catch (e) {
675
+ this.emit('error', `Remote poll failed: ${e.message}`);
676
+ }
677
+ }, this.config.pollIntervalMs);
678
+ }
679
+ }
680
+ stopWatch() {
681
+ if (this.watcher) {
682
+ this.watcher.close();
683
+ this.watcher = null;
684
+ }
685
+ if (this.pollInterval) {
686
+ clearInterval(this.pollInterval);
687
+ this.pollInterval = null;
688
+ }
689
+ this.emit('log', '🛑 [SyncManager] Watcher Stopped.');
690
+ }
691
+ }
692
+ //# sourceMappingURL=sync-manager.js.map