nstantpage-agent 0.5.21 → 0.5.22

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.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * agentSync.ts — File sync logic for the local agent.
3
+ *
4
+ * Mirrors the gateway's syncStatus.ts but runs entirely on the user's machine.
5
+ * Computes disk checksums, fetches backend checksums via HTTP, compares them,
6
+ * and provides push-to-backend capability.
7
+ *
8
+ * Also manages an fs.watch file watcher that triggers sync-dirty notifications
9
+ * through the tunnel to the gateway (which relays to the frontend WebSocket).
10
+ */
11
+ export type SyncDirection = 'in-sync' | 'backend-ahead' | 'disk-ahead' | 'diverged';
12
+ export interface SyncStatusResult {
13
+ inSync: boolean;
14
+ direction: SyncDirection;
15
+ diskChecksum: string | null;
16
+ backendChecksum: string | null;
17
+ diskFileCount: number;
18
+ backendFileCount: number;
19
+ modifiedFiles: string[];
20
+ diskOnlyFiles: string[];
21
+ backendOnlyFiles: string[];
22
+ lastSyncedVersionId: string | null;
23
+ lastSyncedAt: number | null;
24
+ diskDirty: boolean;
25
+ computedAt: number;
26
+ }
27
+ export interface PerFileStatus {
28
+ path: string;
29
+ status: 'same' | 'modified' | 'disk-only' | 'backend-only';
30
+ diskSha?: string;
31
+ backendSha?: string;
32
+ }
33
+ export interface AgentSyncOptions {
34
+ projectDir: string;
35
+ projectId: string;
36
+ backendUrl: string;
37
+ /** Called when file watcher detects changes — should send sync-dirty via tunnel */
38
+ onSyncDirty?: (projectId: string) => void;
39
+ }
40
+ export declare class AgentSync {
41
+ private projectDir;
42
+ private projectId;
43
+ private backendUrl;
44
+ private onSyncDirty?;
45
+ private diskDirty;
46
+ private diskChecksumCache;
47
+ private backendChecksumCache;
48
+ private lastSyncTimestamp;
49
+ private syncedBaselineChecksums;
50
+ private fileWatcher;
51
+ private debounceTimer;
52
+ private pendingChangedFiles;
53
+ private readonly DISK_CACHE_TTL_MS;
54
+ private readonly BACKEND_CACHE_TTL_MS;
55
+ constructor(options: AgentSyncOptions);
56
+ startFileWatcher(): void;
57
+ stopFileWatcher(): void;
58
+ private markDiskDirty;
59
+ private computeDiskChecksums;
60
+ private getDiskData;
61
+ private fetchBackendChecksums;
62
+ private filterBackendChecksums;
63
+ getSyncStatus(): Promise<SyncStatusResult>;
64
+ getPerFileStatus(): Promise<PerFileStatus[]>;
65
+ getDiskChanges(): Promise<{
66
+ modified: Record<string, string>;
67
+ added: Record<string, string>;
68
+ deleted: string[];
69
+ }>;
70
+ pushToBackend(): Promise<{
71
+ success: boolean;
72
+ filesPushed: number;
73
+ filesDeleted: number;
74
+ modifiedFiles: string[];
75
+ addedFiles: string[];
76
+ deletedFiles: string[];
77
+ versionId?: string;
78
+ message?: string;
79
+ }>;
80
+ markSynced(versionId: string): void;
81
+ private snapshotBaseline;
82
+ /** Read a single file from disk (for diff view) */
83
+ readDiskFile(filePath: string): Promise<string | null>;
84
+ destroy(): void;
85
+ }
@@ -0,0 +1,590 @@
1
+ /**
2
+ * agentSync.ts — File sync logic for the local agent.
3
+ *
4
+ * Mirrors the gateway's syncStatus.ts but runs entirely on the user's machine.
5
+ * Computes disk checksums, fetches backend checksums via HTTP, compares them,
6
+ * and provides push-to-backend capability.
7
+ *
8
+ * Also manages an fs.watch file watcher that triggers sync-dirty notifications
9
+ * through the tunnel to the gateway (which relays to the frontend WebSocket).
10
+ */
11
+ import crypto from 'crypto';
12
+ import path from 'path';
13
+ import fs from 'fs/promises';
14
+ import { watch, existsSync } from 'fs';
15
+ // ─── Skip Patterns (same as gateway syncStatus.ts) ────────────
16
+ const SKIP_DIRS = new Set([
17
+ 'node_modules', '.git', 'dist', 'build', '.next', 'out',
18
+ '.vite-cache', '.cache', '.turbo', 'coverage', 'tools',
19
+ 'script', '.github',
20
+ ]);
21
+ const SKIP_FILES = new Set([
22
+ '.DS_Store', 'Thumbs.db', '.diagramgpt-meta.json',
23
+ 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock',
24
+ '.npmrc', '.gitignore', 'README.md',
25
+ ]);
26
+ const SKIP_EXTENSIONS = new Set([
27
+ '.map', '.log', '.tmp', '.swp', '.swo', '.svg', '.png', '.jpg', '.jpeg',
28
+ '.gif', '.ico', '.woff', '.woff2', '.ttf', '.eot',
29
+ ]);
30
+ const TEMPLATE_SCAFFOLD_FILES = new Set([
31
+ 'vite.config.ts', 'vite.config.js',
32
+ 'postcss.config.ts', 'postcss.config.js',
33
+ 'tailwind.config.ts', 'tailwind.config.js',
34
+ 'tsconfig.json', 'tsconfig.app.json', 'tsconfig.node.json',
35
+ 'eslint.config.js', '.eslintrc.js', '.eslintrc.cjs',
36
+ 'drizzle.config.ts',
37
+ 'pnpm-workspace.yaml',
38
+ 'project-manifest.json',
39
+ 'babel-plugin-inject-jsx-meta.ts',
40
+ '.env', '.npmrc', '.gitignore',
41
+ 'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock',
42
+ 'README.md', 'index.html',
43
+ ]);
44
+ function shouldSkip(name, isDir) {
45
+ if (isDir)
46
+ return SKIP_DIRS.has(name);
47
+ if (SKIP_FILES.has(name))
48
+ return true;
49
+ if (name.startsWith('.'))
50
+ return true;
51
+ const ext = path.extname(name);
52
+ return SKIP_EXTENSIONS.has(ext);
53
+ }
54
+ function isUserCreatedFile(filePath) {
55
+ if (filePath.startsWith('src/') || filePath.startsWith('server/') ||
56
+ filePath.startsWith('shared/') || filePath.startsWith('public/')) {
57
+ return true;
58
+ }
59
+ const basename = path.basename(filePath);
60
+ if (TEMPLATE_SCAFFOLD_FILES.has(basename))
61
+ return false;
62
+ return true;
63
+ }
64
+ // ─── Checksum Computation ─────────────────────────────────────
65
+ function sha256(content) {
66
+ return crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
67
+ }
68
+ function computeGlobalChecksum(fileContents) {
69
+ const hash = crypto.createHash('sha256');
70
+ const sortedPaths = [...fileContents.keys()].sort();
71
+ for (const filePath of sortedPaths) {
72
+ hash.update(filePath);
73
+ hash.update(fileContents.get(filePath));
74
+ }
75
+ return hash.digest('hex');
76
+ }
77
+ export class AgentSync {
78
+ projectDir;
79
+ projectId;
80
+ backendUrl;
81
+ onSyncDirty;
82
+ // State
83
+ diskDirty = false;
84
+ diskChecksumCache = null;
85
+ backendChecksumCache = null;
86
+ lastSyncTimestamp = null;
87
+ syncedBaselineChecksums = null;
88
+ fileWatcher = null;
89
+ debounceTimer = null;
90
+ pendingChangedFiles = new Set();
91
+ // Cache TTLs
92
+ DISK_CACHE_TTL_MS = 5000;
93
+ BACKEND_CACHE_TTL_MS = 15000;
94
+ constructor(options) {
95
+ this.projectDir = options.projectDir;
96
+ this.projectId = options.projectId;
97
+ this.backendUrl = options.backendUrl;
98
+ this.onSyncDirty = options.onSyncDirty;
99
+ }
100
+ // ─── File Watcher ─────────────────────────────────────────
101
+ startFileWatcher() {
102
+ if (this.fileWatcher)
103
+ return;
104
+ if (!existsSync(this.projectDir))
105
+ return;
106
+ /** Track whether we're currently writing files (skip self-triggered events) */
107
+ let selfWriting = false;
108
+ try {
109
+ this.fileWatcher = watch(this.projectDir, { recursive: true }, (eventType, filename) => {
110
+ if (!filename || selfWriting)
111
+ return;
112
+ const parts = filename.split(path.sep);
113
+ if (parts.some(p => SKIP_DIRS.has(p)))
114
+ return;
115
+ const basename = path.basename(filename);
116
+ if (shouldSkip(basename, false))
117
+ return;
118
+ // Accumulate changed filenames
119
+ this.pendingChangedFiles.add(filename);
120
+ if (this.debounceTimer)
121
+ clearTimeout(this.debounceTimer);
122
+ this.debounceTimer = setTimeout(() => {
123
+ const changedFiles = [...this.pendingChangedFiles];
124
+ this.pendingChangedFiles.clear();
125
+ this.markDiskDirty();
126
+ console.log(` [AgentSync] File change detected: ${changedFiles.length} file(s) — ${changedFiles.slice(0, 5).join(', ')}${changedFiles.length > 5 ? '...' : ''}`);
127
+ }, 500);
128
+ });
129
+ this.fileWatcher.on('error', (err) => {
130
+ console.warn(` [AgentSync] File watcher error:`, err.message);
131
+ this.stopFileWatcher();
132
+ });
133
+ console.log(` [AgentSync] File watcher started for ${this.projectDir}`);
134
+ }
135
+ catch (err) {
136
+ console.warn(` [AgentSync] Could not start file watcher: ${err.message}`);
137
+ }
138
+ }
139
+ stopFileWatcher() {
140
+ if (this.fileWatcher) {
141
+ this.fileWatcher.close();
142
+ this.fileWatcher = null;
143
+ }
144
+ if (this.debounceTimer) {
145
+ clearTimeout(this.debounceTimer);
146
+ this.debounceTimer = null;
147
+ }
148
+ }
149
+ // ─── Disk Dirty State ─────────────────────────────────────
150
+ markDiskDirty() {
151
+ const wasDirty = this.diskDirty;
152
+ this.diskDirty = true;
153
+ this.diskChecksumCache = null; // Force recompute
154
+ // Only notify on transition from clean to dirty
155
+ if (!wasDirty && this.onSyncDirty) {
156
+ this.onSyncDirty(this.projectId);
157
+ }
158
+ }
159
+ // ─── Disk Checksums ───────────────────────────────────────
160
+ async computeDiskChecksums() {
161
+ const checksums = new Map();
162
+ const fileContents = new Map();
163
+ if (!existsSync(this.projectDir)) {
164
+ return { checksums, fileContents, globalChecksum: '' };
165
+ }
166
+ // Phase 1: Collect all file paths
167
+ const filePaths = [];
168
+ const collectPaths = async (dir) => {
169
+ let entries;
170
+ try {
171
+ entries = await fs.readdir(dir, { withFileTypes: true });
172
+ }
173
+ catch {
174
+ return;
175
+ }
176
+ for (const entry of entries) {
177
+ if (shouldSkip(entry.name, entry.isDirectory()))
178
+ continue;
179
+ const fullPath = path.join(dir, entry.name);
180
+ if (entry.isDirectory()) {
181
+ await collectPaths(fullPath);
182
+ }
183
+ else {
184
+ const relPath = path.relative(this.projectDir, fullPath);
185
+ if (relPath.startsWith('dist/') || relPath.startsWith('build/'))
186
+ continue;
187
+ filePaths.push(fullPath);
188
+ }
189
+ }
190
+ };
191
+ await collectPaths(this.projectDir);
192
+ // Phase 2: Read files in parallel batches of 50
193
+ const BATCH_SIZE = 50;
194
+ for (let i = 0; i < filePaths.length; i += BATCH_SIZE) {
195
+ const batch = filePaths.slice(i, i + BATCH_SIZE);
196
+ await Promise.all(batch.map(async (fullPath) => {
197
+ try {
198
+ const content = await fs.readFile(fullPath, 'utf-8');
199
+ const relPath = path.relative(this.projectDir, fullPath);
200
+ checksums.set(relPath, sha256(content));
201
+ fileContents.set(relPath, content);
202
+ }
203
+ catch {
204
+ // Binary file or unreadable — skip
205
+ }
206
+ }));
207
+ }
208
+ const globalChecksum = computeGlobalChecksum(fileContents);
209
+ return { checksums, fileContents, globalChecksum };
210
+ }
211
+ async getDiskData() {
212
+ // Use cache if fresh and not dirty
213
+ if (this.diskChecksumCache && !this.diskDirty &&
214
+ Date.now() - this.diskChecksumCache.computedAt < this.DISK_CACHE_TTL_MS) {
215
+ return this.diskChecksumCache;
216
+ }
217
+ const computed = await this.computeDiskChecksums();
218
+ this.diskChecksumCache = { ...computed, computedAt: Date.now() };
219
+ if (this.diskDirty)
220
+ this.diskDirty = false;
221
+ return computed;
222
+ }
223
+ // ─── Backend Checksums ────────────────────────────────────
224
+ async fetchBackendChecksums() {
225
+ // Check cache first
226
+ if (this.backendChecksumCache &&
227
+ Date.now() - this.backendChecksumCache.fetchedAt < this.BACKEND_CACHE_TTL_MS) {
228
+ return {
229
+ checksums: this.backendChecksumCache.checksums,
230
+ globalChecksum: this.backendChecksumCache.globalChecksum,
231
+ versionId: this.backendChecksumCache.versionId,
232
+ fileCount: this.backendChecksumCache.checksums.size,
233
+ };
234
+ }
235
+ try {
236
+ const url = `${this.backendUrl}/api/sandbox/file-checksums?projectId=${this.projectId}`;
237
+ const controller = new AbortController();
238
+ const timeout = setTimeout(() => controller.abort(), 10000);
239
+ const response = await fetch(url, { signal: controller.signal });
240
+ clearTimeout(timeout);
241
+ if (!response.ok) {
242
+ console.warn(` [AgentSync] file-checksums returned ${response.status}`);
243
+ return null;
244
+ }
245
+ const data = await response.json();
246
+ const checksums = new Map(Object.entries(data.files));
247
+ const result = {
248
+ checksums,
249
+ globalChecksum: data.checksum,
250
+ versionId: String(data.versionId),
251
+ fileCount: data.fileCount,
252
+ };
253
+ this.backendChecksumCache = {
254
+ ...result,
255
+ fetchedAt: Date.now(),
256
+ };
257
+ return result;
258
+ }
259
+ catch (error) {
260
+ if (error.name === 'AbortError') {
261
+ console.warn(` [AgentSync] Backend checksum request timed out`);
262
+ }
263
+ else {
264
+ console.warn(` [AgentSync] Error fetching backend checksums:`, error.message);
265
+ }
266
+ return null;
267
+ }
268
+ }
269
+ // ─── Filter backend checksums (same skip rules as disk scan) ──
270
+ filterBackendChecksums(backendChecksums) {
271
+ const filtered = new Map();
272
+ for (const [filePath, sha] of backendChecksums) {
273
+ const basename = path.basename(filePath);
274
+ const ext = path.extname(filePath);
275
+ if (SKIP_FILES.has(basename))
276
+ continue;
277
+ if (basename.startsWith('.'))
278
+ continue;
279
+ if (SKIP_EXTENSIONS.has(ext))
280
+ continue;
281
+ filtered.set(filePath, sha);
282
+ }
283
+ return filtered;
284
+ }
285
+ // ─── Sync Status ──────────────────────────────────────────
286
+ async getSyncStatus() {
287
+ const diskResult = await this.getDiskData();
288
+ const backendResult = await this.fetchBackendChecksums();
289
+ if (!backendResult) {
290
+ return {
291
+ inSync: true,
292
+ direction: 'in-sync',
293
+ diskChecksum: diskResult.globalChecksum || null,
294
+ backendChecksum: null,
295
+ diskFileCount: diskResult.checksums.size,
296
+ backendFileCount: 0,
297
+ modifiedFiles: [],
298
+ diskOnlyFiles: [],
299
+ backendOnlyFiles: [],
300
+ lastSyncedVersionId: this.lastSyncTimestamp?.versionId || null,
301
+ lastSyncedAt: this.lastSyncTimestamp?.timestamp || null,
302
+ diskDirty: this.diskDirty,
303
+ computedAt: Date.now(),
304
+ };
305
+ }
306
+ const filteredBackend = this.filterBackendChecksums(backendResult.checksums);
307
+ const baseline = this.syncedBaselineChecksums;
308
+ const modifiedFiles = [];
309
+ const diskOnlyFiles = [];
310
+ const backendOnlyFiles = [];
311
+ if (baseline) {
312
+ // Baseline mode: detect user edits since last sync
313
+ for (const [filePath, baselineSha] of baseline) {
314
+ const currentDiskSha = diskResult.checksums.get(filePath);
315
+ if (!currentDiskSha) {
316
+ if (filteredBackend.has(filePath)) {
317
+ modifiedFiles.push(filePath);
318
+ }
319
+ }
320
+ else if (currentDiskSha !== baselineSha) {
321
+ modifiedFiles.push(filePath);
322
+ }
323
+ }
324
+ for (const [filePath] of diskResult.checksums) {
325
+ if (!baseline.has(filePath) && isUserCreatedFile(filePath)) {
326
+ diskOnlyFiles.push(filePath);
327
+ }
328
+ }
329
+ for (const [filePath, backendSha] of filteredBackend) {
330
+ if (!baseline.has(filePath) && !diskResult.checksums.has(filePath)) {
331
+ backendOnlyFiles.push(filePath);
332
+ }
333
+ else if (baseline.has(filePath) && diskResult.checksums.has(filePath)) {
334
+ const baselineSha = baseline.get(filePath);
335
+ const diskSha = diskResult.checksums.get(filePath);
336
+ if (backendSha !== baselineSha && diskSha === baselineSha) {
337
+ if (!modifiedFiles.includes(filePath))
338
+ modifiedFiles.push(filePath);
339
+ }
340
+ }
341
+ }
342
+ }
343
+ else {
344
+ // No baseline: direct disk vs backend comparison
345
+ for (const [filePath, backendSha] of filteredBackend) {
346
+ const diskSha = diskResult.checksums.get(filePath);
347
+ if (!diskSha) {
348
+ backendOnlyFiles.push(filePath);
349
+ }
350
+ else if (diskSha !== backendSha) {
351
+ modifiedFiles.push(filePath);
352
+ }
353
+ }
354
+ for (const [filePath] of diskResult.checksums) {
355
+ if (!filteredBackend.has(filePath) && isUserCreatedFile(filePath)) {
356
+ diskOnlyFiles.push(filePath);
357
+ }
358
+ }
359
+ }
360
+ // Determine direction
361
+ const inSync = modifiedFiles.length === 0 && diskOnlyFiles.length === 0 && backendOnlyFiles.length === 0;
362
+ let direction = 'in-sync';
363
+ if (!inSync) {
364
+ const hasDiskEdits = modifiedFiles.length > 0 || diskOnlyFiles.length > 0;
365
+ const hasBackendEdits = backendOnlyFiles.length > 0;
366
+ let modifiedFromBackend = false;
367
+ if (modifiedFiles.length > 0 && baseline) {
368
+ for (const filePath of modifiedFiles) {
369
+ const baselineSha = baseline.get(filePath);
370
+ const diskSha = diskResult.checksums.get(filePath);
371
+ const backendSha = filteredBackend.get(filePath);
372
+ if (baselineSha && diskSha === baselineSha && backendSha && backendSha !== baselineSha) {
373
+ modifiedFromBackend = true;
374
+ break;
375
+ }
376
+ }
377
+ }
378
+ if ((hasDiskEdits && !modifiedFromBackend) && hasBackendEdits) {
379
+ direction = 'diverged';
380
+ }
381
+ else if (hasDiskEdits && !modifiedFromBackend && !hasBackendEdits) {
382
+ direction = 'disk-ahead';
383
+ }
384
+ else if (hasBackendEdits || modifiedFromBackend) {
385
+ direction = 'backend-ahead';
386
+ }
387
+ else {
388
+ direction = 'disk-ahead';
389
+ }
390
+ }
391
+ return {
392
+ inSync,
393
+ direction,
394
+ diskChecksum: diskResult.globalChecksum || null,
395
+ backendChecksum: backendResult.globalChecksum || null,
396
+ diskFileCount: diskResult.checksums.size,
397
+ backendFileCount: backendResult.checksums.size,
398
+ modifiedFiles: modifiedFiles.sort(),
399
+ diskOnlyFiles: diskOnlyFiles.sort(),
400
+ backendOnlyFiles: backendOnlyFiles.sort(),
401
+ lastSyncedVersionId: this.lastSyncTimestamp?.versionId || null,
402
+ lastSyncedAt: this.lastSyncTimestamp?.timestamp || null,
403
+ diskDirty: this.diskDirty,
404
+ computedAt: Date.now(),
405
+ };
406
+ }
407
+ // ─── Per-File Status (Detailed) ───────────────────────────
408
+ async getPerFileStatus() {
409
+ const diskComputed = await this.computeDiskChecksums();
410
+ const backendResult = await this.fetchBackendChecksums();
411
+ if (!backendResult)
412
+ return [];
413
+ const baseline = this.syncedBaselineChecksums;
414
+ const result = [];
415
+ if (baseline) {
416
+ for (const [filePath, baselineSha] of baseline) {
417
+ const currentDiskSha = diskComputed.checksums.get(filePath);
418
+ const backendSha = backendResult.checksums.get(filePath);
419
+ if (!currentDiskSha) {
420
+ if (backendSha) {
421
+ result.push({ path: filePath, status: 'modified', backendSha });
422
+ }
423
+ }
424
+ else if (currentDiskSha !== baselineSha) {
425
+ result.push({ path: filePath, status: 'modified', diskSha: currentDiskSha, backendSha });
426
+ }
427
+ }
428
+ for (const [filePath, diskSha] of diskComputed.checksums) {
429
+ if (!baseline.has(filePath) && isUserCreatedFile(filePath)) {
430
+ result.push({ path: filePath, status: 'disk-only', diskSha });
431
+ }
432
+ }
433
+ if (this.lastSyncTimestamp && this.lastSyncTimestamp.versionId !== backendResult.versionId) {
434
+ for (const [filePath, backendSha] of backendResult.checksums) {
435
+ if (!baseline.has(filePath) && !diskComputed.checksums.has(filePath)) {
436
+ result.push({ path: filePath, status: 'backend-only', backendSha });
437
+ }
438
+ }
439
+ }
440
+ }
441
+ else {
442
+ const sortedBackend = [...backendResult.checksums.entries()].sort((a, b) => a[0].localeCompare(b[0]));
443
+ for (const [filePath, backendSha] of sortedBackend) {
444
+ const diskSha = diskComputed.checksums.get(filePath);
445
+ if (diskSha) {
446
+ result.push({
447
+ path: filePath,
448
+ status: diskSha === backendSha ? 'same' : 'modified',
449
+ diskSha,
450
+ backendSha,
451
+ });
452
+ }
453
+ else {
454
+ result.push({ path: filePath, status: 'backend-only', backendSha });
455
+ }
456
+ }
457
+ const sortedDisk = [...diskComputed.checksums.entries()].sort((a, b) => a[0].localeCompare(b[0]));
458
+ for (const [filePath, diskSha] of sortedDisk) {
459
+ if (!backendResult.checksums.has(filePath) && isUserCreatedFile(filePath)) {
460
+ result.push({ path: filePath, status: 'disk-only', diskSha });
461
+ }
462
+ }
463
+ }
464
+ result.sort((a, b) => a.path.localeCompare(b.path));
465
+ return result;
466
+ }
467
+ // ─── Disk Changes (for push-to-backend) ───────────────────
468
+ async getDiskChanges() {
469
+ const diskData = await this.getDiskData();
470
+ const backendResult = await this.fetchBackendChecksums();
471
+ const modified = {};
472
+ const added = {};
473
+ const deleted = [];
474
+ if (!backendResult)
475
+ return { modified, added, deleted };
476
+ for (const [filePath, backendSha] of backendResult.checksums) {
477
+ const diskSha = diskData.checksums.get(filePath);
478
+ if (!diskSha) {
479
+ deleted.push(filePath);
480
+ }
481
+ else if (diskSha !== backendSha) {
482
+ const content = diskData.fileContents.get(filePath);
483
+ if (content !== undefined)
484
+ modified[filePath] = content;
485
+ }
486
+ }
487
+ for (const [filePath] of diskData.checksums) {
488
+ if (!backendResult.checksums.has(filePath) && isUserCreatedFile(filePath)) {
489
+ if (this.syncedBaselineChecksums) {
490
+ const baselineSha = this.syncedBaselineChecksums.get(filePath);
491
+ if (baselineSha && baselineSha === diskData.checksums.get(filePath)) {
492
+ continue; // Unchanged since initial sync — template noise
493
+ }
494
+ }
495
+ const content = diskData.fileContents.get(filePath);
496
+ if (content !== undefined)
497
+ added[filePath] = content;
498
+ }
499
+ }
500
+ return { modified, added, deleted };
501
+ }
502
+ // ─── Push to Backend ──────────────────────────────────────
503
+ async pushToBackend() {
504
+ const changes = await this.getDiskChanges();
505
+ const totalChanges = Object.keys(changes.modified).length +
506
+ Object.keys(changes.added).length +
507
+ changes.deleted.length;
508
+ if (totalChanges === 0) {
509
+ return {
510
+ success: true,
511
+ filesPushed: 0,
512
+ filesDeleted: 0,
513
+ modifiedFiles: [],
514
+ addedFiles: [],
515
+ deletedFiles: [],
516
+ message: 'Already in sync — no changes to push',
517
+ };
518
+ }
519
+ const allFiles = { ...changes.modified, ...changes.added };
520
+ const pushUrl = `${this.backendUrl}/api/sandbox/push-files`;
521
+ const pushResponse = await fetch(pushUrl, {
522
+ method: 'POST',
523
+ headers: { 'Content-Type': 'application/json' },
524
+ body: JSON.stringify({
525
+ projectId: parseInt(this.projectId, 10),
526
+ files: allFiles,
527
+ deletedFiles: changes.deleted,
528
+ }),
529
+ });
530
+ if (!pushResponse.ok) {
531
+ const errorText = await pushResponse.text();
532
+ throw new Error(`Backend push-files failed: ${pushResponse.status} ${errorText}`);
533
+ }
534
+ const pushResult = await pushResponse.json();
535
+ // Invalidate caches
536
+ this.diskChecksumCache = null;
537
+ this.backendChecksumCache = null;
538
+ // Mark as synced
539
+ if (pushResult.versionId) {
540
+ this.markSynced(String(pushResult.versionId));
541
+ }
542
+ console.log(` [AgentSync] Pushed ${Object.keys(allFiles).length} files, deleted ${changes.deleted.length}`);
543
+ return {
544
+ success: true,
545
+ filesPushed: Object.keys(allFiles).length,
546
+ filesDeleted: changes.deleted.length,
547
+ modifiedFiles: Object.keys(changes.modified),
548
+ addedFiles: Object.keys(changes.added),
549
+ deletedFiles: changes.deleted,
550
+ versionId: pushResult.versionId ? String(pushResult.versionId) : undefined,
551
+ };
552
+ }
553
+ // ─── Sync Markers ────────────────────────────────────────
554
+ markSynced(versionId) {
555
+ this.lastSyncTimestamp = { versionId, timestamp: Date.now() };
556
+ this.diskDirty = false;
557
+ this.diskChecksumCache = null;
558
+ this.backendChecksumCache = null;
559
+ // Snapshot current disk checksums as baseline (fire-and-forget)
560
+ this.snapshotBaseline().catch(err => console.warn(` [AgentSync] Failed to snapshot baseline:`, err.message));
561
+ }
562
+ async snapshotBaseline() {
563
+ const computed = await this.computeDiskChecksums();
564
+ this.syncedBaselineChecksums = computed.checksums;
565
+ this.diskChecksumCache = { ...computed, computedAt: Date.now() };
566
+ console.log(` [AgentSync] Baseline snapshot: ${computed.checksums.size} files`);
567
+ }
568
+ /** Read a single file from disk (for diff view) */
569
+ async readDiskFile(filePath) {
570
+ try {
571
+ const normalized = path.normalize(filePath).replace(/\.\.\//g, '');
572
+ const fullPath = path.join(this.projectDir, normalized);
573
+ if (!fullPath.startsWith(this.projectDir))
574
+ return null;
575
+ return await fs.readFile(fullPath, 'utf-8');
576
+ }
577
+ catch {
578
+ return null;
579
+ }
580
+ }
581
+ // ─── Cleanup ──────────────────────────────────────────────
582
+ destroy() {
583
+ this.stopFileWatcher();
584
+ this.diskChecksumCache = null;
585
+ this.backendChecksumCache = null;
586
+ this.syncedBaselineChecksums = null;
587
+ this.pendingChangedFiles.clear();
588
+ }
589
+ }
590
+ //# sourceMappingURL=agentSync.js.map
package/dist/cli.js CHANGED
@@ -25,7 +25,7 @@ const program = new Command();
25
25
  program
26
26
  .name('nstantpage')
27
27
  .description('Local development agent for nstantpage.com — run projects on your machine, preview in the cloud')
28
- .version('0.5.21');
28
+ .version('0.5.22');
29
29
  program
30
30
  .command('login')
31
31
  .description('Authenticate with nstantpage.com')