nstantpage-agent 0.8.4 → 0.8.6

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.
@@ -1,12 +1,14 @@
1
1
  /**
2
- * agentSync.ts — File sync logic for the local agent.
2
+ * agentSync.ts — Simplified file sync for the local agent.
3
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.
4
+ * CORE PRINCIPLE: Disk is ALWAYS the source of truth. ONE-WAY only.
7
5
  *
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).
6
+ * When files change on disk do a FULL SYNC (same as `nstantpage sync`):
7
+ * 1. POST /api/projects/{id}/sync new version + clear ALL FullFiles
8
+ * 2. Collect ALL disk files
9
+ * 3. POST /api/sandbox/push-files — push ALL files in batches
10
+ *
11
+ * No checksums, no comparisons, no diffs. Disk wins. Always.
10
12
  */
11
13
  export type SyncDirection = 'in-sync' | 'backend-ahead' | 'disk-ahead' | 'diverged';
12
14
  export interface SyncStatusResult {
@@ -42,31 +44,17 @@ export declare class AgentSync {
42
44
  private projectId;
43
45
  private backendUrl;
44
46
  private onSyncDirty?;
45
- private diskDirty;
46
- private diskChecksumCache;
47
- private backendChecksumCache;
48
- private lastSyncTimestamp;
49
- private syncedBaselineChecksums;
50
47
  private fileWatcher;
51
48
  private debounceTimer;
52
49
  private pendingChangedFiles;
53
- private readonly DISK_CACHE_TTL_MS;
54
- private readonly BACKEND_CACHE_TTL_MS;
50
+ private syncing;
51
+ private lastVersionId;
52
+ private lastSyncAt;
55
53
  constructor(options: AgentSyncOptions);
56
54
  startFileWatcher(): void;
57
55
  stopFileWatcher(): void;
58
- private markDiskDirty;
59
- private computeDiskChecksums;
60
- private getDiskData;
61
- private fetchBackendChecksums;
62
- private filterBackendChecksums;
63
56
  getSyncStatus(): Promise<SyncStatusResult>;
64
57
  getPerFileStatus(): Promise<PerFileStatus[]>;
65
- getDiskChanges(): Promise<{
66
- modified: Record<string, string>;
67
- added: Record<string, string>;
68
- deleted: string[];
69
- }>;
70
58
  pushToBackend(): Promise<{
71
59
  success: boolean;
72
60
  filesPushed: number;
@@ -78,13 +66,11 @@ export declare class AgentSync {
78
66
  message?: string;
79
67
  }>;
80
68
  markSynced(versionId: string): void;
81
- private snapshotBaseline;
82
69
  /** Read a single file from disk (for diff view) */
83
70
  readDiskFile(filePath: string): Promise<string | null>;
84
71
  /**
85
72
  * Pull files from the backend DB and write them to disk.
86
- * This fetches ALL project files and overwrites local versions.
87
- * Files that exist only on disk (not in DB) are left untouched.
73
+ * After writing, clears pending changes (the writes would trigger the watcher).
88
74
  */
89
75
  pullFromBackend(): Promise<{
90
76
  success: boolean;
package/dist/agentSync.js CHANGED
@@ -1,18 +1,19 @@
1
1
  /**
2
- * agentSync.ts — File sync logic for the local agent.
2
+ * agentSync.ts — Simplified file sync for the local agent.
3
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.
4
+ * CORE PRINCIPLE: Disk is ALWAYS the source of truth. ONE-WAY only.
7
5
  *
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).
6
+ * When files change on disk do a FULL SYNC (same as `nstantpage sync`):
7
+ * 1. POST /api/projects/{id}/sync new version + clear ALL FullFiles
8
+ * 2. Collect ALL disk files
9
+ * 3. POST /api/sandbox/push-files — push ALL files in batches
10
+ *
11
+ * No checksums, no comparisons, no diffs. Disk wins. Always.
10
12
  */
11
- import crypto from 'crypto';
12
13
  import path from 'path';
13
14
  import fs from 'fs/promises';
14
15
  import { watch, existsSync } from 'fs';
15
- // ─── Skip Patterns (same as gateway syncStatus.ts) ────────────
16
+ // ─── Skip Patterns ────────────────────────────────────────────
16
17
  const SKIP_DIRS = new Set([
17
18
  'node_modules', '.git', 'dist', 'build', '.next', 'out',
18
19
  '.vite-cache', '.cache', '.turbo', 'coverage', 'tools',
@@ -20,27 +21,20 @@ const SKIP_DIRS = new Set([
20
21
  ]);
21
22
  const SKIP_FILES = new Set([
22
23
  '.DS_Store', 'Thumbs.db', '.diagramgpt-meta.json',
23
- 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock',
24
- '.npmrc', '.gitignore', 'README.md',
25
24
  ]);
26
25
  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',
26
+ '.map', '.log', '.tmp', '.swp', '.swo',
27
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', '.tiff',
28
+ '.mp4', '.mp3', '.wav', '.ogg', '.webm', '.avi',
29
+ '.zip', '.tar', '.gz', '.rar', '.7z',
30
+ '.woff', '.woff2', '.ttf', '.eot', '.otf',
31
+ '.pdf', '.doc', '.docx', '.xls', '.xlsx',
32
+ '.exe', '.dll', '.so', '.dylib',
33
+ '.sqlite', '.db',
34
+ '.svg',
43
35
  ]);
36
+ /** Max file size to sync (1MB) */
37
+ const MAX_FILE_SIZE = 1_048_576;
44
38
  function shouldSkip(name, isDir) {
45
39
  if (isDir)
46
40
  return SKIP_DIRS.has(name) || name.startsWith('.');
@@ -48,31 +42,53 @@ function shouldSkip(name, isDir) {
48
42
  return true;
49
43
  if (name.startsWith('.'))
50
44
  return true;
51
- const ext = path.extname(name);
45
+ const ext = path.extname(name).toLowerCase();
52
46
  return SKIP_EXTENSIONS.has(ext);
53
47
  }
54
- function isUserCreatedFile(filePath) {
55
- if (filePath.startsWith('src/') || filePath.startsWith('server/') ||
56
- filePath.startsWith('shared/') || filePath.startsWith('public/')) {
57
- return true;
48
+ /** Check if a relative path should be skipped (checks every path component) */
49
+ function shouldSkipPath(relPath) {
50
+ const parts = relPath.split(/[\/\\]/);
51
+ for (let i = 0; i < parts.length - 1; i++) {
52
+ if (shouldSkip(parts[i], true))
53
+ return true;
58
54
  }
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');
55
+ return shouldSkip(parts[parts.length - 1], false);
67
56
  }
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));
57
+ // ─── Collect All Files (same as sync.ts) ──────────────────────
58
+ async function collectAllFiles(baseDir, currentDir) {
59
+ const files = [];
60
+ let entries;
61
+ try {
62
+ entries = await fs.readdir(currentDir, { withFileTypes: true });
74
63
  }
75
- return hash.digest('hex');
64
+ catch {
65
+ return files;
66
+ }
67
+ for (const entry of entries) {
68
+ const fullPath = path.join(currentDir, entry.name);
69
+ if (entry.isDirectory()) {
70
+ if (shouldSkip(entry.name, true))
71
+ continue;
72
+ const sub = await collectAllFiles(baseDir, fullPath);
73
+ files.push(...sub);
74
+ }
75
+ else if (entry.isFile()) {
76
+ if (shouldSkip(entry.name, false))
77
+ continue;
78
+ try {
79
+ const stat = await fs.stat(fullPath);
80
+ if (stat.size > MAX_FILE_SIZE)
81
+ continue;
82
+ const content = await fs.readFile(fullPath, 'utf-8');
83
+ if (content.includes('\0'))
84
+ continue; // binary
85
+ const relativePath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
86
+ files.push({ relativePath, content });
87
+ }
88
+ catch { /* unreadable — skip */ }
89
+ }
90
+ }
91
+ return files;
76
92
  }
77
93
  export class AgentSync {
78
94
  projectDir;
@@ -80,17 +96,12 @@ export class AgentSync {
80
96
  backendUrl;
81
97
  onSyncDirty;
82
98
  // State
83
- diskDirty = false;
84
- diskChecksumCache = null;
85
- backendChecksumCache = null;
86
- lastSyncTimestamp = null;
87
- syncedBaselineChecksums = null;
88
99
  fileWatcher = null;
89
100
  debounceTimer = null;
90
101
  pendingChangedFiles = new Set();
91
- // Cache TTLs
92
- DISK_CACHE_TTL_MS = 5000;
93
- BACKEND_CACHE_TTL_MS = 15000;
102
+ syncing = false;
103
+ lastVersionId = null;
104
+ lastSyncAt = null;
94
105
  constructor(options) {
95
106
  this.projectDir = options.projectDir;
96
107
  this.projectId = options.projectId;
@@ -103,28 +114,23 @@ export class AgentSync {
103
114
  return;
104
115
  if (!existsSync(this.projectDir))
105
116
  return;
106
- /** Track whether we're currently writing files (skip self-triggered events) */
107
- let selfWriting = false;
108
117
  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) || (p.startsWith('.') && p !== '.')))
118
+ this.fileWatcher = watch(this.projectDir, { recursive: true }, (_eventType, filename) => {
119
+ if (!filename)
114
120
  return;
115
- const basename = path.basename(filename);
116
- if (shouldSkip(basename, false))
121
+ if (shouldSkipPath(filename))
117
122
  return;
118
- // Accumulate changed filenames
119
123
  this.pendingChangedFiles.add(filename);
120
124
  if (this.debounceTimer)
121
125
  clearTimeout(this.debounceTimer);
122
126
  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);
127
+ const count = this.pendingChangedFiles.size;
128
+ const preview = [...this.pendingChangedFiles].slice(0, 5).join(', ');
129
+ console.log(` [AgentSync] Change detected: ${count} file(s) — ${preview}${count > 5 ? '...' : ''}`);
130
+ if (this.onSyncDirty) {
131
+ this.onSyncDirty(this.projectId);
132
+ }
133
+ }, 1000);
128
134
  });
129
135
  this.fileWatcher.on('error', (err) => {
130
136
  console.warn(` [AgentSync] File watcher error:`, err.message);
@@ -146,410 +152,126 @@ export class AgentSync {
146
152
  this.debounceTimer = null;
147
153
  }
148
154
  }
149
- // ─── Disk Dirty State ─────────────────────────────────────
150
- markDiskDirty() {
151
- this.diskDirty = true;
152
- this.diskChecksumCache = null; // Force recompute
153
- // Notify on every change — the debounce timer in the file watcher
154
- // already batches rapid changes, so this fires at most once per debounce window.
155
- if (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
- }
155
+ // ─── Sync Status ─────────────────────────────────────────
156
+ async getSyncStatus() {
157
+ const pending = this.pendingChangedFiles.size;
158
+ const isDirty = pending > 0 && !this.syncing;
159
+ return {
160
+ inSync: !isDirty,
161
+ direction: isDirty ? 'disk-ahead' : 'in-sync',
162
+ diskChecksum: null,
163
+ backendChecksum: null,
164
+ diskFileCount: 0,
165
+ backendFileCount: 0,
166
+ modifiedFiles: [...this.pendingChangedFiles].sort(),
167
+ diskOnlyFiles: [],
168
+ backendOnlyFiles: [],
169
+ lastSyncedVersionId: this.lastVersionId,
170
+ lastSyncedAt: this.lastSyncAt,
171
+ diskDirty: isDirty,
172
+ computedAt: Date.now(),
190
173
  };
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
174
  }
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;
175
+ // ─── Per-File Status ──────────────────────────────────────
176
+ async getPerFileStatus() {
177
+ return [...this.pendingChangedFiles].sort().map(f => ({
178
+ path: f,
179
+ status: 'modified',
180
+ }));
222
181
  }
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) {
182
+ // ─── Push to Backend (Full Sync) ──────────────────────────
183
+ //
184
+ // Does EXACTLY what `nstantpage sync` does:
185
+ // 1. POST /api/projects/{id}/sync — new version + clear ALL FullFiles
186
+ // 2. Collect ALL files from disk
187
+ // 3. POST /api/sandbox/push-files — push ALL files in batches of 100
188
+ async pushToBackend() {
189
+ if (this.pendingChangedFiles.size === 0) {
228
190
  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(),
191
+ success: true, filesPushed: 0, filesDeleted: 0,
192
+ modifiedFiles: [], addedFiles: [], deletedFiles: [],
193
+ message: 'Already in sync — no changes to push',
256
194
  };
257
- return result;
258
195
  }
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
- // Match disk scan: skip files inside SKIP_DIRS or dotfile directories
282
- const parts = filePath.split('/');
283
- if (parts.some(p => SKIP_DIRS.has(p) || (p.startsWith('.') && p !== '.')))
284
- continue;
285
- // Match disk scan: skip dist/ and build/ top-level paths
286
- if (filePath.startsWith('dist/') || filePath.startsWith('build/'))
287
- continue;
288
- filtered.set(filePath, sha);
289
- }
290
- return filtered;
291
- }
292
- // ─── Sync Status ──────────────────────────────────────────
293
- async getSyncStatus() {
294
- const diskResult = await this.getDiskData();
295
- const backendResult = await this.fetchBackendChecksums();
296
- if (!backendResult) {
196
+ if (this.syncing) {
297
197
  return {
298
- inSync: true,
299
- direction: 'in-sync',
300
- diskChecksum: diskResult.globalChecksum || null,
301
- backendChecksum: null,
302
- diskFileCount: diskResult.checksums.size,
303
- backendFileCount: 0,
304
- modifiedFiles: [],
305
- diskOnlyFiles: [],
306
- backendOnlyFiles: [],
307
- lastSyncedVersionId: this.lastSyncTimestamp?.versionId || null,
308
- lastSyncedAt: this.lastSyncTimestamp?.timestamp || null,
309
- diskDirty: this.diskDirty,
310
- computedAt: Date.now(),
198
+ success: true, filesPushed: 0, filesDeleted: 0,
199
+ modifiedFiles: [], addedFiles: [], deletedFiles: [],
200
+ message: 'Sync already in progress',
311
201
  };
312
202
  }
313
- const filteredBackend = this.filterBackendChecksums(backendResult.checksums);
314
- const baseline = this.syncedBaselineChecksums;
315
- const modifiedFiles = [];
316
- const diskOnlyFiles = [];
317
- const backendOnlyFiles = [];
318
- if (baseline) {
319
- // Baseline mode: detect user edits since last sync
320
- for (const [filePath, baselineSha] of baseline) {
321
- const currentDiskSha = diskResult.checksums.get(filePath);
322
- if (!currentDiskSha) {
323
- if (filteredBackend.has(filePath)) {
324
- modifiedFiles.push(filePath);
325
- }
326
- }
327
- else if (currentDiskSha !== baselineSha) {
328
- modifiedFiles.push(filePath);
329
- }
330
- }
331
- for (const [filePath] of diskResult.checksums) {
332
- if (!baseline.has(filePath) && isUserCreatedFile(filePath)) {
333
- diskOnlyFiles.push(filePath);
334
- }
335
- }
336
- for (const [filePath, backendSha] of filteredBackend) {
337
- if (!baseline.has(filePath) && !diskResult.checksums.has(filePath)) {
338
- backendOnlyFiles.push(filePath);
339
- }
340
- else if (baseline.has(filePath) && diskResult.checksums.has(filePath)) {
341
- const baselineSha = baseline.get(filePath);
342
- const diskSha = diskResult.checksums.get(filePath);
343
- if (backendSha !== baselineSha && diskSha === baselineSha) {
344
- if (!modifiedFiles.includes(filePath))
345
- modifiedFiles.push(filePath);
346
- }
347
- }
348
- }
349
- }
350
- else {
351
- // No baseline: direct disk vs backend comparison
352
- for (const [filePath, backendSha] of filteredBackend) {
353
- const diskSha = diskResult.checksums.get(filePath);
354
- if (!diskSha) {
355
- backendOnlyFiles.push(filePath);
356
- }
357
- else if (diskSha !== backendSha) {
358
- modifiedFiles.push(filePath);
359
- }
360
- }
361
- for (const [filePath] of diskResult.checksums) {
362
- if (!filteredBackend.has(filePath) && isUserCreatedFile(filePath)) {
363
- diskOnlyFiles.push(filePath);
364
- }
365
- }
366
- }
367
- // Determine direction
368
- // Disk is always the source of truth. DB only changes via push or AI coding agent
369
- // (which updates both disk+DB simultaneously). So direction is only ever
370
- // 'in-sync' or 'disk-ahead'. backendOnlyFiles are stale DB remnants, not "newer" files.
371
- const inSync = modifiedFiles.length === 0 && diskOnlyFiles.length === 0 && backendOnlyFiles.length === 0;
372
- const direction = inSync ? 'in-sync' : 'disk-ahead';
373
- return {
374
- inSync,
375
- direction,
376
- diskChecksum: diskResult.globalChecksum || null,
377
- backendChecksum: backendResult.globalChecksum || null,
378
- diskFileCount: diskResult.checksums.size,
379
- backendFileCount: backendResult.checksums.size,
380
- modifiedFiles: modifiedFiles.sort(),
381
- diskOnlyFiles: diskOnlyFiles.sort(),
382
- backendOnlyFiles: backendOnlyFiles.sort(),
383
- lastSyncedVersionId: this.lastSyncTimestamp?.versionId || null,
384
- lastSyncedAt: this.lastSyncTimestamp?.timestamp || null,
385
- diskDirty: this.diskDirty,
386
- computedAt: Date.now(),
387
- };
388
- }
389
- // ─── Per-File Status (Detailed) ───────────────────────────
390
- async getPerFileStatus() {
391
- const diskComputed = await this.computeDiskChecksums();
392
- const backendResult = await this.fetchBackendChecksums();
393
- if (!backendResult)
394
- return [];
395
- // Use filtered backend checksums (same skip rules as disk scan) for consistency with getSyncStatus
396
- const filteredBackend = this.filterBackendChecksums(backendResult.checksums);
397
- const baseline = this.syncedBaselineChecksums;
398
- const result = [];
399
- if (baseline) {
400
- for (const [filePath, baselineSha] of baseline) {
401
- const currentDiskSha = diskComputed.checksums.get(filePath);
402
- const backendSha = filteredBackend.get(filePath);
403
- if (!currentDiskSha) {
404
- if (backendSha) {
405
- result.push({ path: filePath, status: 'modified', backendSha });
406
- }
407
- }
408
- else if (currentDiskSha !== baselineSha) {
409
- result.push({ path: filePath, status: 'modified', diskSha: currentDiskSha, backendSha });
410
- }
411
- }
412
- for (const [filePath, diskSha] of diskComputed.checksums) {
413
- if (!baseline.has(filePath) && isUserCreatedFile(filePath)) {
414
- result.push({ path: filePath, status: 'disk-only', diskSha });
415
- }
416
- }
417
- if (this.lastSyncTimestamp && this.lastSyncTimestamp.versionId !== backendResult.versionId) {
418
- for (const [filePath, backendSha] of filteredBackend) {
419
- if (!baseline.has(filePath) && !diskComputed.checksums.has(filePath)) {
420
- result.push({ path: filePath, status: 'backend-only', backendSha });
421
- }
422
- }
423
- }
424
- }
425
- else {
426
- const sortedBackend = [...filteredBackend.entries()].sort((a, b) => a[0].localeCompare(b[0]));
427
- for (const [filePath, backendSha] of sortedBackend) {
428
- const diskSha = diskComputed.checksums.get(filePath);
429
- if (diskSha) {
430
- result.push({
431
- path: filePath,
432
- status: diskSha === backendSha ? 'same' : 'modified',
433
- diskSha,
434
- backendSha,
435
- });
436
- }
437
- else {
438
- result.push({ path: filePath, status: 'backend-only', backendSha });
439
- }
203
+ this.syncing = true;
204
+ try {
205
+ // Step 1: Create new version + clear all FullFiles
206
+ const syncRes = await fetch(`${this.backendUrl}/api/projects/${this.projectId}/sync`, {
207
+ method: 'POST',
208
+ headers: { 'Content-Type': 'application/json' },
209
+ });
210
+ if (!syncRes.ok) {
211
+ const text = await syncRes.text().catch(() => '');
212
+ throw new Error(`Sync init failed (${syncRes.status}): ${text}`);
440
213
  }
441
- const sortedDisk = [...diskComputed.checksums.entries()].sort((a, b) => a[0].localeCompare(b[0]));
442
- for (const [filePath, diskSha] of sortedDisk) {
443
- if (!filteredBackend.has(filePath) && isUserCreatedFile(filePath)) {
444
- result.push({ path: filePath, status: 'disk-only', diskSha });
214
+ const syncData = await syncRes.json();
215
+ console.log(` [AgentSync] New version ${syncData.versionNumber} created (old files cleared)`);
216
+ // Step 2: Collect ALL files from disk
217
+ const allFiles = await collectAllFiles(this.projectDir, this.projectDir);
218
+ // Step 3: Push in batches of 100
219
+ const BATCH_SIZE = 100;
220
+ let totalPushed = 0;
221
+ for (let i = 0; i < allFiles.length; i += BATCH_SIZE) {
222
+ const batch = allFiles.slice(i, i + BATCH_SIZE);
223
+ const filesMap = {};
224
+ for (const f of batch) {
225
+ filesMap[f.relativePath] = f.content;
445
226
  }
446
- }
447
- }
448
- result.sort((a, b) => a.path.localeCompare(b.path));
449
- return result;
450
- }
451
- // ─── Disk Changes (for push-to-backend) ───────────────────
452
- async getDiskChanges() {
453
- const diskData = await this.getDiskData();
454
- const backendResult = await this.fetchBackendChecksums();
455
- const modified = {};
456
- const added = {};
457
- const deleted = [];
458
- if (!backendResult)
459
- return { modified, added, deleted };
460
- // Use filtered backend checksums so dotfile dirs (.angular etc) and skip patterns are excluded
461
- const filteredBackend = this.filterBackendChecksums(backendResult.checksums);
462
- for (const [filePath, backendSha] of filteredBackend) {
463
- const diskSha = diskData.checksums.get(filePath);
464
- if (!diskSha) {
465
- deleted.push(filePath);
466
- }
467
- else if (diskSha !== backendSha) {
468
- const content = diskData.fileContents.get(filePath);
469
- if (content !== undefined)
470
- modified[filePath] = content;
471
- }
472
- }
473
- for (const [filePath] of diskData.checksums) {
474
- if (!filteredBackend.has(filePath) && isUserCreatedFile(filePath)) {
475
- if (this.syncedBaselineChecksums) {
476
- const baselineSha = this.syncedBaselineChecksums.get(filePath);
477
- if (baselineSha && baselineSha === diskData.checksums.get(filePath)) {
478
- continue; // Unchanged since initial sync — template noise
479
- }
227
+ const pushRes = await fetch(`${this.backendUrl}/api/sandbox/push-files`, {
228
+ method: 'POST',
229
+ headers: { 'Content-Type': 'application/json' },
230
+ body: JSON.stringify({
231
+ projectId: parseInt(this.projectId, 10),
232
+ files: filesMap,
233
+ }),
234
+ });
235
+ if (!pushRes.ok) {
236
+ const text = await pushRes.text().catch(() => '');
237
+ throw new Error(`Push batch failed (${pushRes.status}): ${text}`);
480
238
  }
481
- const content = diskData.fileContents.get(filePath);
482
- if (content !== undefined)
483
- added[filePath] = content;
239
+ totalPushed += batch.length;
484
240
  }
485
- }
486
- return { modified, added, deleted };
487
- }
488
- // ─── Push to Backend ──────────────────────────────────────
489
- async pushToBackend() {
490
- const changes = await this.getDiskChanges();
491
- const totalChanges = Object.keys(changes.modified).length +
492
- Object.keys(changes.added).length +
493
- changes.deleted.length;
494
- if (totalChanges === 0) {
241
+ // Done — clear pending
242
+ this.pendingChangedFiles.clear();
243
+ this.lastVersionId = String(syncData.versionId);
244
+ this.lastSyncAt = Date.now();
245
+ const fileNames = allFiles.map(f => f.relativePath);
246
+ console.log(` [AgentSync] Full sync complete: ${totalPushed} files pushed (version ${syncData.versionNumber})`);
495
247
  return {
496
248
  success: true,
497
- filesPushed: 0,
249
+ filesPushed: totalPushed,
498
250
  filesDeleted: 0,
499
- modifiedFiles: [],
251
+ modifiedFiles: fileNames,
500
252
  addedFiles: [],
501
253
  deletedFiles: [],
502
- message: 'Already in sync — no changes to push',
254
+ versionId: String(syncData.versionId),
503
255
  };
504
256
  }
505
- const allFiles = { ...changes.modified, ...changes.added };
506
- const pushUrl = `${this.backendUrl}/api/sandbox/push-files`;
507
- const pushResponse = await fetch(pushUrl, {
508
- method: 'POST',
509
- headers: { 'Content-Type': 'application/json' },
510
- body: JSON.stringify({
511
- projectId: parseInt(this.projectId, 10),
512
- files: allFiles,
513
- deletedFiles: changes.deleted,
514
- }),
515
- });
516
- if (!pushResponse.ok) {
517
- const errorText = await pushResponse.text();
518
- throw new Error(`Backend push-files failed: ${pushResponse.status} ${errorText}`);
257
+ catch (err) {
258
+ console.error(` [AgentSync] Sync failed:`, err.message);
259
+ throw err;
519
260
  }
520
- const pushResult = await pushResponse.json();
521
- // Invalidate caches
522
- this.diskChecksumCache = null;
523
- this.backendChecksumCache = null;
524
- // Mark as synced
525
- if (pushResult.versionId) {
526
- this.markSynced(String(pushResult.versionId));
261
+ finally {
262
+ this.syncing = false;
527
263
  }
528
- console.log(` [AgentSync] Pushed ${Object.keys(allFiles).length} files, deleted ${changes.deleted.length}`);
529
- return {
530
- success: true,
531
- filesPushed: Object.keys(allFiles).length,
532
- filesDeleted: changes.deleted.length,
533
- modifiedFiles: Object.keys(changes.modified),
534
- addedFiles: Object.keys(changes.added),
535
- deletedFiles: changes.deleted,
536
- versionId: pushResult.versionId ? String(pushResult.versionId) : undefined,
537
- };
538
264
  }
539
- // ─── Sync Markers ────────────────────────────────────────
265
+ // ─── Sync Markers (for compatibility with start.ts) ───────
540
266
  markSynced(versionId) {
541
- this.lastSyncTimestamp = { versionId, timestamp: Date.now() };
542
- this.diskDirty = false;
543
- this.diskChecksumCache = null;
544
- this.backendChecksumCache = null;
545
- // Snapshot current disk checksums as baseline (fire-and-forget)
546
- this.snapshotBaseline().catch(err => console.warn(` [AgentSync] Failed to snapshot baseline:`, err.message));
547
- }
548
- async snapshotBaseline() {
549
- const computed = await this.computeDiskChecksums();
550
- this.syncedBaselineChecksums = computed.checksums;
551
- this.diskChecksumCache = { ...computed, computedAt: Date.now() };
552
- console.log(` [AgentSync] Baseline snapshot: ${computed.checksums.size} files`);
267
+ this.lastVersionId = versionId;
268
+ this.lastSyncAt = Date.now();
269
+ this.pendingChangedFiles.clear();
270
+ if (this.debounceTimer) {
271
+ clearTimeout(this.debounceTimer);
272
+ this.debounceTimer = null;
273
+ }
274
+ console.log(` [AgentSync] Marked synced at version ${versionId}`);
553
275
  }
554
276
  /** Read a single file from disk (for diff view) */
555
277
  async readDiskFile(filePath) {
@@ -566,8 +288,7 @@ export class AgentSync {
566
288
  }
567
289
  /**
568
290
  * Pull files from the backend DB and write them to disk.
569
- * This fetches ALL project files and overwrites local versions.
570
- * Files that exist only on disk (not in DB) are left untouched.
291
+ * After writing, clears pending changes (the writes would trigger the watcher).
571
292
  */
572
293
  async pullFromBackend() {
573
294
  const url = `${this.backendUrl}/api/sandbox/files?projectId=${this.projectId}`;
@@ -579,40 +300,37 @@ export class AgentSync {
579
300
  if (!data.files || data.files.length === 0) {
580
301
  return { success: true, filesWritten: 0 };
581
302
  }
582
- // Ensure directories exist
583
303
  const dirsToCreate = new Set();
584
304
  for (const file of data.files) {
585
- const filePath = path.join(this.projectDir, file.path);
586
- dirsToCreate.add(path.dirname(filePath));
305
+ const fp = path.join(this.projectDir, file.path);
306
+ dirsToCreate.add(path.dirname(fp));
587
307
  }
588
308
  for (const dir of dirsToCreate) {
589
309
  await fs.mkdir(dir, { recursive: true }).catch(() => { });
590
310
  }
591
- // Write files in parallel batches
592
311
  const BATCH_SIZE = 50;
593
312
  let written = 0;
594
313
  for (let i = 0; i < data.files.length; i += BATCH_SIZE) {
595
314
  const batch = data.files.slice(i, i + BATCH_SIZE);
596
315
  await Promise.all(batch.map(async (file) => {
597
- const filePath = path.join(this.projectDir, file.path);
598
- await fs.writeFile(filePath, file.content, 'utf-8');
316
+ const fp = path.join(this.projectDir, file.path);
317
+ await fs.writeFile(fp, file.content, 'utf-8');
599
318
  }));
600
319
  written += batch.length;
601
320
  }
602
- // Invalidate caches and re-establish baseline
603
- this.diskChecksumCache = null;
604
- this.backendChecksumCache = null;
605
- const versionId = String(data.versionId);
606
- this.markSynced(versionId);
607
- console.log(` [AgentSync] Pulled ${written} files from backend (version ${versionId})`);
608
- return { success: true, filesWritten: written, versionId };
321
+ this.lastVersionId = String(data.versionId);
322
+ this.lastSyncAt = Date.now();
323
+ this.pendingChangedFiles.clear();
324
+ if (this.debounceTimer) {
325
+ clearTimeout(this.debounceTimer);
326
+ this.debounceTimer = null;
327
+ }
328
+ console.log(` [AgentSync] Pulled ${written} files from backend (version ${data.versionId})`);
329
+ return { success: true, filesWritten: written, versionId: String(data.versionId) };
609
330
  }
610
331
  // ─── Cleanup ──────────────────────────────────────────────
611
332
  destroy() {
612
333
  this.stopFileWatcher();
613
- this.diskChecksumCache = null;
614
- this.backendChecksumCache = null;
615
- this.syncedBaselineChecksums = null;
616
334
  this.pendingChangedFiles.clear();
617
335
  }
618
336
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nstantpage-agent",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "description": "Local development agent for nstantpage.com — run your projects locally, preview in the cloud. Replaces cloud containers for faster builds.",
5
5
  "type": "module",
6
6
  "bin": {