nstantpage-agent 0.8.5 → 0.8.7

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.
@@ -3,14 +3,12 @@
3
3
  *
4
4
  * CORE PRINCIPLE: Disk is ALWAYS the source of truth. ONE-WAY only.
5
5
  *
6
- * When a file changes on disk → push it to the backend DB. No checksums,
7
- * no comparisons, no conflict resolution. Whatever is on disk wins.
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
8
10
  *
9
- * Flow:
10
- * 1. File watcher detects changes (debounced 1s)
11
- * 2. Accumulate changed file paths in pendingChangedFiles
12
- * 3. Fire onSyncDirty → frontend receives notification
13
- * 4. Frontend calls pushToBackend → agent reads pending files from disk → pushes to DB
11
+ * No checksums, no comparisons, no diffs. Disk wins. Always.
14
12
  */
15
13
  export type SyncDirection = 'in-sync' | 'backend-ahead' | 'disk-ahead' | 'diverged';
16
14
  export interface SyncStatusResult {
@@ -49,9 +47,9 @@ export declare class AgentSync {
49
47
  private fileWatcher;
50
48
  private debounceTimer;
51
49
  private pendingChangedFiles;
52
- private pushing;
50
+ private syncing;
53
51
  private lastVersionId;
54
- private lastPushAt;
52
+ private lastSyncAt;
55
53
  constructor(options: AgentSyncOptions);
56
54
  startFileWatcher(): void;
57
55
  stopFileWatcher(): void;
package/dist/agentSync.js CHANGED
@@ -3,14 +3,12 @@
3
3
  *
4
4
  * CORE PRINCIPLE: Disk is ALWAYS the source of truth. ONE-WAY only.
5
5
  *
6
- * When a file changes on disk → push it to the backend DB. No checksums,
7
- * no comparisons, no conflict resolution. Whatever is on disk wins.
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
8
10
  *
9
- * Flow:
10
- * 1. File watcher detects changes (debounced 1s)
11
- * 2. Accumulate changed file paths in pendingChangedFiles
12
- * 3. Fire onSyncDirty → frontend receives notification
13
- * 4. Frontend calls pushToBackend → agent reads pending files from disk → pushes to DB
11
+ * No checksums, no comparisons, no diffs. Disk wins. Always.
14
12
  */
15
13
  import path from 'path';
16
14
  import fs from 'fs/promises';
@@ -23,13 +21,20 @@ const SKIP_DIRS = new Set([
23
21
  ]);
24
22
  const SKIP_FILES = new Set([
25
23
  '.DS_Store', 'Thumbs.db', '.diagramgpt-meta.json',
26
- 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock',
27
- '.npmrc', '.gitignore', 'README.md',
28
24
  ]);
29
25
  const SKIP_EXTENSIONS = new Set([
30
- '.map', '.log', '.tmp', '.swp', '.swo', '.svg', '.png', '.jpg', '.jpeg',
31
- '.gif', '.ico', '.woff', '.woff2', '.ttf', '.eot',
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',
32
35
  ]);
36
+ /** Max file size to sync (1MB) */
37
+ const MAX_FILE_SIZE = 1_048_576;
33
38
  function shouldSkip(name, isDir) {
34
39
  if (isDir)
35
40
  return SKIP_DIRS.has(name) || name.startsWith('.');
@@ -37,7 +42,7 @@ function shouldSkip(name, isDir) {
37
42
  return true;
38
43
  if (name.startsWith('.'))
39
44
  return true;
40
- const ext = path.extname(name);
45
+ const ext = path.extname(name).toLowerCase();
41
46
  return SKIP_EXTENSIONS.has(ext);
42
47
  }
43
48
  /** Check if a relative path should be skipped (checks every path component) */
@@ -49,6 +54,42 @@ function shouldSkipPath(relPath) {
49
54
  }
50
55
  return shouldSkip(parts[parts.length - 1], false);
51
56
  }
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 });
63
+ }
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;
92
+ }
52
93
  export class AgentSync {
53
94
  projectDir;
54
95
  projectId;
@@ -58,9 +99,9 @@ export class AgentSync {
58
99
  fileWatcher = null;
59
100
  debounceTimer = null;
60
101
  pendingChangedFiles = new Set();
61
- pushing = false;
102
+ syncing = false;
62
103
  lastVersionId = null;
63
- lastPushAt = null;
104
+ lastSyncAt = null;
64
105
  constructor(options) {
65
106
  this.projectDir = options.projectDir;
66
107
  this.projectId = options.projectId;
@@ -86,7 +127,6 @@ export class AgentSync {
86
127
  const count = this.pendingChangedFiles.size;
87
128
  const preview = [...this.pendingChangedFiles].slice(0, 5).join(', ');
88
129
  console.log(` [AgentSync] Change detected: ${count} file(s) — ${preview}${count > 5 ? '...' : ''}`);
89
- // Notify frontend so it triggers auto-push
90
130
  if (this.onSyncDirty) {
91
131
  this.onSyncDirty(this.projectId);
92
132
  }
@@ -112,10 +152,10 @@ export class AgentSync {
112
152
  this.debounceTimer = null;
113
153
  }
114
154
  }
115
- // ─── Sync Status (simplified — just pending file count) ───
155
+ // ─── Sync Status ─────────────────────────────────────────
116
156
  async getSyncStatus() {
117
157
  const pending = this.pendingChangedFiles.size;
118
- const isDirty = pending > 0 || this.pushing;
158
+ const isDirty = pending > 0 && !this.syncing;
119
159
  return {
120
160
  inSync: !isDirty,
121
161
  direction: isDirty ? 'disk-ahead' : 'in-sync',
@@ -127,7 +167,7 @@ export class AgentSync {
127
167
  diskOnlyFiles: [],
128
168
  backendOnlyFiles: [],
129
169
  lastSyncedVersionId: this.lastVersionId,
130
- lastSyncedAt: this.lastPushAt,
170
+ lastSyncedAt: this.lastSyncAt,
131
171
  diskDirty: isDirty,
132
172
  computedAt: Date.now(),
133
173
  };
@@ -139,114 +179,93 @@ export class AgentSync {
139
179
  status: 'modified',
140
180
  }));
141
181
  }
142
- // ─── Push to Backend ──────────────────────────────────────
182
+ // ─── Push to Backend (Full Sync) ──────────────────────────
143
183
  //
144
- // Reads the pending changed files from disk and pushes them to the DB.
145
- // No checksums, no comparison just read and push.
146
- // If a file no longer exists on disk, it's marked as deleted.
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
147
188
  async pushToBackend() {
148
- const filesToProcess = [...this.pendingChangedFiles];
149
- if (filesToProcess.length === 0) {
189
+ if (this.pendingChangedFiles.size === 0) {
150
190
  return {
151
- success: true,
152
- filesPushed: 0,
153
- filesDeleted: 0,
154
- modifiedFiles: [],
155
- addedFiles: [],
156
- deletedFiles: [],
191
+ success: true, filesPushed: 0, filesDeleted: 0,
192
+ modifiedFiles: [], addedFiles: [], deletedFiles: [],
157
193
  message: 'Already in sync — no changes to push',
158
194
  };
159
195
  }
160
- if (this.pushing) {
196
+ if (this.syncing) {
161
197
  return {
162
- success: true,
163
- filesPushed: 0,
164
- filesDeleted: 0,
165
- modifiedFiles: [],
166
- addedFiles: [],
167
- deletedFiles: [],
168
- message: 'Push already in progress',
198
+ success: true, filesPushed: 0, filesDeleted: 0,
199
+ modifiedFiles: [], addedFiles: [], deletedFiles: [],
200
+ message: 'Sync already in progress',
169
201
  };
170
202
  }
171
- this.pushing = true;
203
+ this.syncing = true;
172
204
  try {
173
- const files = {};
174
- const deleted = [];
175
- for (const relPath of filesToProcess) {
176
- const fullPath = path.join(this.projectDir, relPath);
177
- try {
178
- const content = await fs.readFile(fullPath, 'utf-8');
179
- files[relPath] = content;
180
- }
181
- catch {
182
- // File doesn't exist or is binary/unreadable
183
- if (!existsSync(fullPath)) {
184
- deleted.push(relPath);
185
- }
186
- }
187
- }
188
- const totalChanges = Object.keys(files).length + deleted.length;
189
- if (totalChanges === 0) {
190
- // All pending files were binary/unreadable — clear them
191
- for (const f of filesToProcess)
192
- this.pendingChangedFiles.delete(f);
193
- return {
194
- success: true,
195
- filesPushed: 0,
196
- filesDeleted: 0,
197
- modifiedFiles: [],
198
- addedFiles: [],
199
- deletedFiles: [],
200
- message: 'No pushable changes',
201
- };
202
- }
203
- const pushUrl = `${this.backendUrl}/api/sandbox/push-files`;
204
- const response = await fetch(pushUrl, {
205
+ // Step 1: Create new version + clear all FullFiles
206
+ const syncRes = await fetch(`${this.backendUrl}/api/projects/${this.projectId}/sync`, {
205
207
  method: 'POST',
206
208
  headers: { 'Content-Type': 'application/json' },
207
- body: JSON.stringify({
208
- projectId: parseInt(this.projectId, 10),
209
- files,
210
- deletedFiles: deleted,
211
- }),
212
209
  });
213
- if (!response.ok) {
214
- const errorText = await response.text();
215
- throw new Error(`Backend push-files failed: ${response.status} ${errorText}`);
210
+ if (!syncRes.ok) {
211
+ const text = await syncRes.text().catch(() => '');
212
+ throw new Error(`Sync init failed (${syncRes.status}): ${text}`);
213
+ }
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;
226
+ }
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}`);
238
+ }
239
+ totalPushed += batch.length;
216
240
  }
217
- const pushResult = await response.json();
218
- // Success — remove pushed files from pending
219
- for (const f of filesToProcess)
220
- this.pendingChangedFiles.delete(f);
221
- if (pushResult.versionId)
222
- this.lastVersionId = String(pushResult.versionId);
223
- this.lastPushAt = Date.now();
224
- const fileNames = Object.keys(files);
225
- console.log(` [AgentSync] Pushed ${fileNames.length} files, deleted ${deleted.length}`);
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})`);
226
247
  return {
227
248
  success: true,
228
- filesPushed: fileNames.length,
229
- filesDeleted: deleted.length,
249
+ filesPushed: totalPushed,
250
+ filesDeleted: 0,
230
251
  modifiedFiles: fileNames,
231
252
  addedFiles: [],
232
- deletedFiles: deleted,
233
- versionId: pushResult.versionId ? String(pushResult.versionId) : undefined,
253
+ deletedFiles: [],
254
+ versionId: String(syncData.versionId),
234
255
  };
235
256
  }
236
257
  catch (err) {
237
- // Push failed — keep files in pending for retry
238
- console.error(` [AgentSync] Push failed:`, err.message);
258
+ console.error(` [AgentSync] Sync failed:`, err.message);
239
259
  throw err;
240
260
  }
241
261
  finally {
242
- this.pushing = false;
262
+ this.syncing = false;
243
263
  }
244
264
  }
245
265
  // ─── Sync Markers (for compatibility with start.ts) ───────
246
266
  markSynced(versionId) {
247
267
  this.lastVersionId = versionId;
248
- this.lastPushAt = Date.now();
249
- // Clear any pending changes — we just synced, don't re-push fetched files
268
+ this.lastSyncAt = Date.now();
250
269
  this.pendingChangedFiles.clear();
251
270
  if (this.debounceTimer) {
252
271
  clearTimeout(this.debounceTimer);
@@ -281,7 +300,6 @@ export class AgentSync {
281
300
  if (!data.files || data.files.length === 0) {
282
301
  return { success: true, filesWritten: 0 };
283
302
  }
284
- // Ensure directories exist
285
303
  const dirsToCreate = new Set();
286
304
  for (const file of data.files) {
287
305
  const fp = path.join(this.projectDir, file.path);
@@ -290,7 +308,6 @@ export class AgentSync {
290
308
  for (const dir of dirsToCreate) {
291
309
  await fs.mkdir(dir, { recursive: true }).catch(() => { });
292
310
  }
293
- // Write files in parallel batches
294
311
  const BATCH_SIZE = 50;
295
312
  let written = 0;
296
313
  for (let i = 0; i < data.files.length; i += BATCH_SIZE) {
@@ -301,9 +318,8 @@ export class AgentSync {
301
318
  }));
302
319
  written += batch.length;
303
320
  }
304
- // Clear pending changes from the writes and mark synced
305
321
  this.lastVersionId = String(data.versionId);
306
- this.lastPushAt = Date.now();
322
+ this.lastSyncAt = Date.now();
307
323
  this.pendingChangedFiles.clear();
308
324
  if (this.debounceTimer) {
309
325
  clearTimeout(this.debounceTimer);
@@ -268,13 +268,16 @@ export async function startCommand(directory, options) {
268
268
  console.log(chalk.gray(` API server: port ${apiPort}`));
269
269
  console.log(chalk.gray(` Gateway: ${options.gateway}`));
270
270
  console.log(chalk.gray(` Backend: ${backendUrl}\n`));
271
- // 1. Fetch project files from the backend
271
+ // Determine if this is a user-managed local directory (--dir flag or explicit path)
272
+ // For local projects: disk is truth, NEVER overwrite disk from DB
273
+ const isLocalDir = !!(options.dir) || directory !== '.';
274
+ // 1. Fetch or sync project files
272
275
  const installer = new PackageInstaller({ projectDir });
273
276
  let fetchedVersionId;
274
- try {
275
- const { fileCount, isNew, versionId } = await fetchProjectFiles(backendUrl, projectId, projectDir, token);
276
- fetchedVersionId = versionId;
277
- // 2. Install dependencies if needed (verifies actual packages, not just folder)
277
+ if (isLocalDir) {
278
+ // Local project SKIP DB→disk fetch. Disk is truth.
279
+ console.log(chalk.gray(` Local project — using files from disk (not fetching from DB)`));
280
+ // Install dependencies if needed
278
281
  if (!installer.areDependenciesInstalled()) {
279
282
  if (fs.existsSync(path.join(projectDir, 'package.json'))) {
280
283
  console.log(chalk.gray(' Installing dependencies...'));
@@ -292,13 +295,37 @@ export async function startCommand(directory, options) {
292
295
  console.log(chalk.gray(` Dependencies already installed`));
293
296
  }
294
297
  }
295
- catch (err) {
296
- console.log(chalk.yellow(` ⚠ Could not fetch project files: ${err.message}`));
297
- console.log(chalk.gray(' Continuing with existing local files (if any)...'));
298
- if (!fs.existsSync(path.join(projectDir, 'package.json'))) {
299
- console.log(chalk.red(`\n✗ No package.json found and cannot fetch files from backend.`));
300
- console.log(chalk.gray(' Check your project ID and authentication.'));
301
- process.exit(1);
298
+ else {
299
+ // Cloud/managed project fetch files from DB to disk
300
+ try {
301
+ const { fileCount, isNew, versionId } = await fetchProjectFiles(backendUrl, projectId, projectDir, token);
302
+ fetchedVersionId = versionId;
303
+ // 2. Install dependencies if needed (verifies actual packages, not just folder)
304
+ if (!installer.areDependenciesInstalled()) {
305
+ if (fs.existsSync(path.join(projectDir, 'package.json'))) {
306
+ console.log(chalk.gray(' Installing dependencies...'));
307
+ try {
308
+ await installer.ensureDependencies();
309
+ console.log(chalk.green(` ✓ Dependencies installed`));
310
+ }
311
+ catch (err) {
312
+ console.log(chalk.yellow(` ⚠ Install failed: ${err.message?.slice(0, 200)}`));
313
+ console.log(chalk.gray(` Will retry when dev server starts`));
314
+ }
315
+ }
316
+ }
317
+ else {
318
+ console.log(chalk.gray(` Dependencies already installed`));
319
+ }
320
+ }
321
+ catch (err) {
322
+ console.log(chalk.yellow(` ⚠ Could not fetch project files: ${err.message}`));
323
+ console.log(chalk.gray(' Continuing with existing local files (if any)...'));
324
+ if (!fs.existsSync(path.join(projectDir, 'package.json'))) {
325
+ console.log(chalk.red(`\n✗ No package.json found and cannot fetch files from backend.`));
326
+ console.log(chalk.gray(' Check your project ID and authentication.'));
327
+ process.exit(1);
328
+ }
302
329
  }
303
330
  }
304
331
  // Save .nstantpage.json for future runs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nstantpage-agent",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
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": {