syncrbx 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/local-sync.js CHANGED
@@ -1,441 +1,631 @@
1
- const express = require('express');
2
- const chokidar = require('chokidar');
3
- const path = require('path');
4
- const fs = require('fs');
5
- const {
6
- processDirectory, convertFile, instanceToFile,
7
- filePathToInstancePath, stripExtension, ROOT_SERVICES,
8
- buildChecksums, loadProjectConfig,
9
- } = require('./converters');
10
-
11
- const app = express();
12
-
13
- // ---------------------------------------------------------------------------
14
- // Project root & config
15
- // ---------------------------------------------------------------------------
16
- const PROJECT_ROOT = process.cwd();
17
-
18
- const projectConfig = loadProjectConfig(PROJECT_ROOT);
19
- const PORT = projectConfig.port || 34872;
20
-
21
- app.use(express.json({ limit: '50mb' }));
22
-
23
- // ---------------------------------------------------------------------------
24
- // ANSI color helpers
25
- // ---------------------------------------------------------------------------
26
- const c = {
27
- green: (t) => `\x1b[32m${t}\x1b[0m`,
28
- red: (t) => `\x1b[31m${t}\x1b[0m`,
29
- yellow: (t) => `\x1b[33m${t}\x1b[0m`,
30
- cyan: (t) => `\x1b[36m${t}\x1b[0m`,
31
- magenta: (t) => `\x1b[35m${t}\x1b[0m`,
32
- dim: (t) => `\x1b[2m${t}\x1b[0m`,
33
- bold: (t) => `\x1b[1m${t}\x1b[0m`,
34
- };
35
-
36
- function logDisk(msg) { console.log(`${c.cyan('[Disco]')} ${msg}`); }
37
- function logStudio(msg) { console.log(`${c.magenta('[Studio]')} ${msg}`); }
38
- function logServer(msg) { console.log(`${c.green('[SyncRbx]')} ${msg}`); }
39
- function logWarn(msg) { console.log(`${c.yellow('[WARN]')} ${msg}`); }
40
- function logError(msg) { console.log(`${c.red('[ERROR]')} ${msg}`); }
41
- function logConflict(msg){ console.log(`${c.red(c.bold('[CONFLICT]'))} ${msg}`); }
42
-
43
- // ---------------------------------------------------------------------------
44
- // Echo-Loop Prevention
45
- // ---------------------------------------------------------------------------
46
- const ignoreSet = new Set();
47
- const IGNORE_TTL_MS = 3000;
48
-
49
- function markIgnored(filePath) {
50
- const normalized = path.resolve(filePath);
51
- ignoreSet.add(normalized);
52
- setTimeout(() => ignoreSet.delete(normalized), IGNORE_TTL_MS);
53
- }
54
-
55
- function isIgnored(filePath) {
56
- return ignoreSet.has(path.resolve(filePath));
57
- }
58
-
59
- // ---------------------------------------------------------------------------
60
- // Conflict detection
61
- // ---------------------------------------------------------------------------
62
- const lastWriteTimestamps = new Map();
63
-
64
- function getTimestamps(filePath) {
65
- const key = path.resolve(filePath);
66
- if (!lastWriteTimestamps.has(key)) {
67
- lastWriteTimestamps.set(key, { disk: 0, studio: 0 });
68
- }
69
- return lastWriteTimestamps.get(key);
70
- }
71
-
72
- function createConflictBackup(filePath) {
73
- if (!fs.existsSync(filePath)) return;
74
- const backupPath = filePath + '.conflict.bak';
75
- try {
76
- fs.copyFileSync(filePath, backupPath);
77
- logConflict(`Backup guardado: ${path.basename(backupPath)}`);
78
- } catch (e) {
79
- logError(`No se pudo crear backup de conflicto: ${e.message}`);
80
- }
81
- }
82
-
83
- // ---------------------------------------------------------------------------
84
- // Long-polling state
85
- // ---------------------------------------------------------------------------
86
- let pendingChanges = [];
87
- let waitingClients = [];
88
-
89
- function pushChange(change) {
90
- change.timestamp = Date.now();
91
- pendingChanges.push(change);
92
-
93
- while (waitingClients.length > 0) {
94
- const clientRes = waitingClients.shift();
95
- try {
96
- clientRes.json([...pendingChanges]);
97
- } catch { /* client may have disconnected */ }
98
- }
99
- pendingChanges = [];
100
- }
101
-
102
- // ---------------------------------------------------------------------------
103
- // Incremental sync — enrich disk changes with instance data
104
- // ---------------------------------------------------------------------------
105
- function buildChangePayload(type, filePath) {
106
- const instancePath = filePathToInstancePath(filePath, PROJECT_ROOT);
107
-
108
- if (type === 'Removed') {
109
- return { type: 'Removed', instancePath, timestamp: Date.now() };
110
- }
111
-
112
- let data = null;
113
- try {
114
- const stat = fs.statSync(filePath);
115
- if (stat.isDirectory()) {
116
- data = processDirectory(filePath, ROOT_SERVICES.includes(path.basename(filePath)));
117
- } else {
118
- data = convertFile(filePath);
119
- }
120
- } catch (e) {
121
- logWarn(`Could not read ${filePath}: ${e.message}`);
122
- }
123
-
124
- return { type, instancePath, data, timestamp: Date.now() };
125
- }
126
-
127
- // ---------------------------------------------------------------------------
128
- // Chokidar file watcher (Phase 2 - #4: uses project config ignore patterns)
129
- // ---------------------------------------------------------------------------
130
- const ignorePatterns = [
131
- /(^|[\/\\])\../, // dotfiles
132
- /\.conflict\.bak$/, // conflict backups
133
- /node_modules/,
134
- ];
135
-
136
- // Add user-defined ignore patterns from config
137
- if (projectConfig.ignore) {
138
- for (const pattern of projectConfig.ignore) {
139
- if (pattern.startsWith('*.')) {
140
- const ext = pattern.slice(1).replace('.', '\\.');
141
- ignorePatterns.push(new RegExp(`${ext}$`));
142
- }
143
- }
144
- }
145
-
146
- const watcher = chokidar.watch(PROJECT_ROOT, {
147
- ignored: ignorePatterns,
148
- persistent: true,
149
- ignoreInitial: true,
150
- awaitWriteFinish: {
151
- stabilityThreshold: 300,
152
- pollInterval: 100,
153
- },
154
- });
155
-
156
- watcher
157
- .on('add', filePath => {
158
- if (isIgnored(filePath)) return;
159
- logDisk(`Archivo creado: ${path.relative(PROJECT_ROOT, filePath)}`);
160
- getTimestamps(filePath).disk = Date.now();
161
- pushChange(buildChangePayload('Added', filePath));
162
- })
163
- .on('change', filePath => {
164
- if (isIgnored(filePath)) return;
165
- logDisk(`Archivo modificado: ${path.relative(PROJECT_ROOT, filePath)}`);
166
- getTimestamps(filePath).disk = Date.now();
167
- pushChange(buildChangePayload('Changed', filePath));
168
- })
169
- .on('unlink', filePath => {
170
- if (isIgnored(filePath)) return;
171
- logDisk(`Archivo eliminado: ${path.relative(PROJECT_ROOT, filePath)}`);
172
- pushChange(buildChangePayload('Removed', filePath));
173
- lastWriteTimestamps.delete(path.resolve(filePath));
174
- })
175
- .on('addDir', dirPath => {
176
- if (isIgnored(dirPath)) return;
177
- logDisk(`Carpeta creada: ${path.relative(PROJECT_ROOT, dirPath)}`);
178
- pushChange(buildChangePayload('Added', dirPath));
179
- })
180
- .on('unlinkDir', dirPath => {
181
- if (isIgnored(dirPath)) return;
182
- logDisk(`Carpeta eliminada: ${path.relative(PROJECT_ROOT, dirPath)}`);
183
- pushChange(buildChangePayload('Removed', dirPath));
184
- });
185
-
186
- // ---------------------------------------------------------------------------
187
- // API Endpoints
188
- // ---------------------------------------------------------------------------
189
-
190
- app.get('/ping', (req, res) => {
191
- res.json({
192
- status: 'ok',
193
- project: projectConfig.name || path.basename(PROJECT_ROOT),
194
- version: '2.0.2',
195
- });
196
- });
197
-
198
- app.post('/shutdown', (req, res) => {
199
- res.json({ success: true });
200
- logServer('Apagado solicitado por la extensión VS Code.');
201
- setTimeout(() => shutdown('SIGTERM'), 100);
202
- });
203
-
204
- app.get('/tree', (req, res) => {
205
- try {
206
- const tree = [];
207
- if (fs.existsSync(PROJECT_ROOT)) {
208
- const rootFolders = fs.readdirSync(PROJECT_ROOT, { withFileTypes: true });
209
- for (const folder of rootFolders) {
210
- if (folder.isDirectory()) {
211
- const subTree = processDirectory(path.join(PROJECT_ROOT, folder.name), true);
212
- tree.push(subTree);
213
- }
214
- }
215
- }
216
- logServer(`Árbol enviado (${tree.length} servicios)`);
217
- res.json(tree);
218
- } catch (err) {
219
- logError(`Failed to generate tree: ${err.message}`);
220
- res.status(500).json({ error: err.message });
221
- }
222
- });
223
-
224
- app.get('/changes', (req, res) => {
225
- if (pendingChanges.length > 0) {
226
- const changesToSend = [...pendingChanges];
227
- pendingChanges = [];
228
- return res.json(changesToSend);
229
- }
230
-
231
- waitingClients.push(res);
232
-
233
- const timeout = setTimeout(() => {
234
- const index = waitingClients.indexOf(res);
235
- if (index !== -1) {
236
- waitingClients.splice(index, 1);
237
- res.json([]);
238
- }
239
- }, 30000);
240
-
241
- res.on('close', () => {
242
- clearTimeout(timeout);
243
- const index = waitingClients.indexOf(res);
244
- if (index !== -1) {
245
- waitingClients.splice(index, 1);
246
- }
247
- });
248
- });
249
-
250
- // Phase 4 - #7: Checksums endpoint for incremental diff
251
- app.get('/checksums', (req, res) => {
252
- try {
253
- const checksums = buildChecksums(PROJECT_ROOT);
254
- res.json(checksums);
255
- } catch (err) {
256
- logError(`Failed to build checksums: ${err.message}`);
257
- res.status(500).json({ error: err.message });
258
- }
259
- });
260
-
261
- // ---------------------------------------------------------------------------
262
- // Sanitize filename helper
263
- // ---------------------------------------------------------------------------
264
- function sanitizeFileName(name) {
265
- return name.replace(/[<>:"\/\\|?*\x00-\x1f]+/g, '_');
266
- }
267
-
268
- // ---------------------------------------------------------------------------
269
- // POST /studio-change — Studio reports changes to write to disk
270
- // ---------------------------------------------------------------------------
271
- app.post('/studio-change', (req, res) => {
272
- const changes = req.body;
273
-
274
- try {
275
- if (!Array.isArray(changes)) {
276
- return res.status(400).json({ error: 'Expected an array of changes' });
277
- }
278
-
279
- for (const change of changes) {
280
- const pathParts = (change.path || '').split('/').filter(Boolean);
281
- if (pathParts.length < 1) {
282
- logWarn('Received change with empty path, skipping.');
283
- continue;
284
- }
285
-
286
- logStudio(`${change.type} → ${change.path} (${change.className || '?'})`);
287
-
288
- const fileInfo = instanceToFile(change);
289
- const sanitizedParts = pathParts.map(sanitizeFileName);
290
- const ancestorParts = sanitizedParts.slice(0, -1);
291
- const parentDir = path.join(PROJECT_ROOT, ...ancestorParts);
292
-
293
- let targetPath;
294
- if (fileInfo.isDirectory) {
295
- targetPath = path.join(parentDir, sanitizeFileName(fileInfo.fileName));
296
- } else {
297
- targetPath = path.join(parentDir, sanitizeFileName(fileInfo.fileName));
298
- }
299
-
300
- // Conflict detection
301
- if ((change.type === 'Added' || change.type === 'Changed') && !fileInfo.isDirectory) {
302
- const timestamps = getTimestamps(targetPath);
303
- const now = Date.now();
304
- if (timestamps.disk > 0 && (now - timestamps.disk) < 5000 && fs.existsSync(targetPath)) {
305
- logConflict(`${path.basename(targetPath)} editado en ambos lados.`);
306
- createConflictBackup(targetPath);
307
- }
308
- timestamps.studio = now;
309
- }
310
-
311
- // Execute the write
312
- if (change.type === 'Added' || change.type === 'Changed') {
313
- if (!fs.existsSync(parentDir)) {
314
- fs.mkdirSync(parentDir, { recursive: true });
315
- }
316
-
317
- if (fileInfo.isDirectory) {
318
- if (!fs.existsSync(targetPath)) {
319
- fs.mkdirSync(targetPath, { recursive: true });
320
- }
321
- logDisk(`Carpeta creada: ${path.relative(PROJECT_ROOT, targetPath)}`);
322
- } else {
323
- markIgnored(targetPath);
324
- fs.writeFileSync(targetPath, fileInfo.content || '', 'utf8');
325
- logDisk(`Escrito: ${path.relative(PROJECT_ROOT, targetPath)}`);
326
- }
327
- } else if (change.type === 'Removed') {
328
- let pathsToTry = [targetPath];
329
- if (!fs.existsSync(targetPath)) {
330
- const baseName = sanitizeFileName(change.name || pathParts[pathParts.length - 1]);
331
- if (fs.existsSync(parentDir)) {
332
- const siblings = fs.readdirSync(parentDir);
333
- for (const sibling of siblings) {
334
- if (stripExtension(sibling) === baseName) {
335
- pathsToTry.push(path.join(parentDir, sibling));
336
- }
337
- }
338
- }
339
- }
340
-
341
- for (const p of pathsToTry) {
342
- if (fs.existsSync(p)) {
343
- markIgnored(p);
344
- fs.rmSync(p, { recursive: true, force: true });
345
- logDisk(`Eliminado: ${path.relative(PROJECT_ROOT, p)}`);
346
- break;
347
- }
348
- }
349
- } else if (change.type === 'Renamed') {
350
- const oldParts = (change.oldPath || '').split('/').filter(Boolean).map(sanitizeFileName);
351
- if (oldParts.length > 0) {
352
- const oldDir = path.join(PROJECT_ROOT, ...oldParts.slice(0, -1));
353
- const oldName = oldParts[oldParts.length - 1];
354
- if (fs.existsSync(oldDir)) {
355
- const siblings = fs.readdirSync(oldDir);
356
- for (const sibling of siblings) {
357
- if (stripExtension(sibling) === oldName) {
358
- const oldPath = path.join(oldDir, sibling);
359
- markIgnored(oldPath);
360
- markIgnored(targetPath);
361
- try {
362
- fs.renameSync(oldPath, targetPath);
363
- logDisk(`Renombrado: ${sibling} ${fileInfo.fileName}`);
364
- } catch (e) {
365
- logError(`Rename failed: ${e.message}`);
366
- }
367
- break;
368
- }
369
- }
370
- }
371
- }
372
- }
373
- }
374
-
375
- res.json({ success: true });
376
- } catch (e) {
377
- logError(`Processing studio changes: ${e.message}`);
378
- res.status(500).json({ error: e.message });
379
- }
380
- });
381
-
382
- // ---------------------------------------------------------------------------
383
- // Graceful shutdown
384
- // ---------------------------------------------------------------------------
385
- function shutdown(signal) {
386
- logServer(`${signal} recibido. Cerrando SyncRbx...`);
387
- watcher.close().then(() => {
388
- logServer('File watcher cerrado.');
389
- process.exit(0);
390
- });
391
- }
392
-
393
- process.on('SIGINT', () => shutdown('SIGINT'));
394
- process.on('SIGTERM', () => shutdown('SIGTERM'));
395
-
396
- // ---------------------------------------------------------------------------
397
- // Start
398
- // ---------------------------------------------------------------------------
399
- function startServer() {
400
- const defaultFolders = [
401
- ...ROOT_SERVICES,
402
- 'StarterPlayer/StarterPlayerScripts',
403
- 'StarterPlayer/StarterCharacterScripts'
404
- ];
405
- for (const folder of defaultFolders) {
406
- const folderPath = path.join(PROJECT_ROOT, folder);
407
- if (!fs.existsSync(folderPath)) {
408
- try {
409
- fs.mkdirSync(folderPath, { recursive: true });
410
- } catch (e) {
411
- // Ignore permissions/nested errors
412
- }
413
- }
414
- }
415
-
416
- const server = app.listen(PORT, () => {
417
- console.log('');
418
- console.log(c.bold(c.green(' ╔══════════════════════════════════════════╗')));
419
- console.log(c.bold(c.green(' ║ 🌿 SYNCRBX SYNC v2.0 🌿 ║')));
420
- console.log(c.bold(c.green(' ╚══════════════════════════════════════════╝')));
421
- console.log('');
422
- logServer(`Servidor en ${c.bold(`localhost:${PORT}`)}`);
423
- logServer(`Proyecto: ${c.cyan(projectConfig.name)}`);
424
- logServer(`Monitoreando: ${c.cyan(PROJECT_ROOT)}`);
425
- logServer(`Esperando conexión de Roblox Studio...`);
426
- console.log('');
427
- });
428
-
429
- server.on('error', (e) => {
430
- if (e.code === 'EADDRINUSE') {
431
- logError(`El puerto ${PORT} ya está en uso. ¿Hay otro servidor de SyncRbx ejecutándose?`);
432
- logError(`Cierra el otro servidor o cambia el puerto en syncrbx.project.json`);
433
- process.exit(1);
434
- } else {
435
- logError(`Error fatal: ${e.message}`);
436
- process.exit(1);
437
- }
438
- });
439
- }
440
-
441
- module.exports = { startServer };
1
+ const express = require('express');
2
+ const chokidar = require('chokidar');
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const {
6
+ processDirectory, convertFile, instanceToFile,
7
+ filePathToInstancePath, stripExtension, ROOT_SERVICES,
8
+ buildChecksums, loadProjectConfig, classFromFileName, isInitFile, ANY_CLASS,
9
+ saveProjectServices, sanitizeServices, STARTER_PLAYER_CHILDREN,
10
+ } = require('./converters');
11
+
12
+ const app = express();
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Project root & config
16
+ // ---------------------------------------------------------------------------
17
+ const PROJECT_ROOT = process.cwd();
18
+
19
+ const projectConfig = loadProjectConfig(PROJECT_ROOT);
20
+ const PORT = projectConfig.port || 34872;
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Synced services — the user picks them in the Studio plugin the first time a
24
+ // project connects; they are saved in syncrbx.project.json. Nothing outside
25
+ // them is read, written or deleted, in either direction.
26
+ // ---------------------------------------------------------------------------
27
+ function isServiceSynced(serviceName) {
28
+ // Projects that have not chosen yet keep the previous behaviour (every
29
+ // known service), so older plugins still work.
30
+ const services = projectConfig.services || ROOT_SERVICES;
31
+ return services.includes(serviceName);
32
+ }
33
+
34
+ // "Workspace/Map/Door" → is Workspace synced?
35
+ function isInstancePathSynced(instancePath) {
36
+ return isServiceSynced(String(instancePath || '').split('/')[0]);
37
+ }
38
+
39
+ function isDiskPathSynced(filePath) {
40
+ const relative = path.relative(PROJECT_ROOT, filePath);
41
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return false;
42
+ return isServiceSynced(relative.split(path.sep)[0]);
43
+ }
44
+
45
+ // Creates the folder of each synced service that does not exist yet.
46
+ function createServiceFolders(services) {
47
+ const folders = [...services];
48
+ if (services.includes('StarterPlayer')) {
49
+ folders.push(...STARTER_PLAYER_CHILDREN.map(child => `StarterPlayer/${child}`));
50
+ }
51
+ for (const folder of folders) {
52
+ const folderPath = path.join(PROJECT_ROOT, folder);
53
+ if (!fs.existsSync(folderPath)) {
54
+ markIgnored(folderPath);
55
+ fs.mkdirSync(folderPath, { recursive: true });
56
+ logDisk(`Folder created: ${folder}`);
57
+ }
58
+ }
59
+ }
60
+ const HOST = '127.0.0.1';
61
+ const ALLOWED_HOSTS = new Set([`localhost:${PORT}`, `127.0.0.1:${PORT}`, `[::1]:${PORT}`]);
62
+
63
+ // Only Roblox Studio and the VS Code extension talk to this server. Neither
64
+ // sends an Origin header, so a request that carries one comes from a web page
65
+ // and is rejected. The Host check blocks DNS-rebinding attacks.
66
+ app.use((req, res, next) => {
67
+ if (req.headers.origin || !ALLOWED_HOSTS.has(String(req.headers.host || '').toLowerCase())) {
68
+ return res.status(403).json({ error: 'Forbidden' });
69
+ }
70
+ next();
71
+ });
72
+
73
+ app.use(express.json({ limit: '50mb' }));
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // ANSI color helpers
77
+ // ---------------------------------------------------------------------------
78
+ const c = {
79
+ green: (t) => `\x1b[32m${t}\x1b[0m`,
80
+ red: (t) => `\x1b[31m${t}\x1b[0m`,
81
+ yellow: (t) => `\x1b[33m${t}\x1b[0m`,
82
+ cyan: (t) => `\x1b[36m${t}\x1b[0m`,
83
+ magenta: (t) => `\x1b[35m${t}\x1b[0m`,
84
+ dim: (t) => `\x1b[2m${t}\x1b[0m`,
85
+ bold: (t) => `\x1b[1m${t}\x1b[0m`,
86
+ };
87
+
88
+ function logDisk(msg) { console.log(`${c.cyan('[Disk]')} ${msg}`); }
89
+ function logStudio(msg) { console.log(`${c.magenta('[Studio]')} ${msg}`); }
90
+ function logServer(msg) { console.log(`${c.green('[SyncRbx]')} ${msg}`); }
91
+ function logWarn(msg) { console.log(`${c.yellow('[WARN]')} ${msg}`); }
92
+ function logError(msg) { console.log(`${c.red('[ERROR]')} ${msg}`); }
93
+ function logConflict(msg){ console.log(`${c.red(c.bold('[CONFLICT]'))} ${msg}`); }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Echo-Loop Prevention
97
+ // ---------------------------------------------------------------------------
98
+ const ignoreSet = new Set();
99
+ const IGNORE_TTL_MS = 3000;
100
+
101
+ function markIgnored(filePath) {
102
+ const normalized = path.resolve(filePath);
103
+ ignoreSet.add(normalized);
104
+ setTimeout(() => ignoreSet.delete(normalized), IGNORE_TTL_MS);
105
+ }
106
+
107
+ // A path is ignored when it, or a folder above it, was written by SyncRbx.
108
+ function isIgnored(filePath) {
109
+ let current = path.resolve(filePath);
110
+ while (current.startsWith(PROJECT_ROOT)) {
111
+ if (ignoreSet.has(current)) return true;
112
+ const parent = path.dirname(current);
113
+ if (parent === current) break;
114
+ current = parent;
115
+ }
116
+ return false;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Conflict detection
121
+ // ---------------------------------------------------------------------------
122
+ const lastWriteTimestamps = new Map();
123
+
124
+ function getTimestamps(filePath) {
125
+ const key = path.resolve(filePath);
126
+ if (!lastWriteTimestamps.has(key)) {
127
+ lastWriteTimestamps.set(key, { disk: 0, studio: 0 });
128
+ }
129
+ return lastWriteTimestamps.get(key);
130
+ }
131
+
132
+ function createConflictBackup(filePath) {
133
+ if (!fs.existsSync(filePath)) return;
134
+ const backupPath = filePath + '.conflict.bak';
135
+ try {
136
+ fs.copyFileSync(filePath, backupPath);
137
+ logConflict(`Backup saved: ${path.basename(backupPath)}`);
138
+ } catch (e) {
139
+ logError(`Could not create conflict backup: ${e.message}`);
140
+ }
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Trash — nothing SyncRbx deletes or overwrites is lost for good
145
+ // ---------------------------------------------------------------------------
146
+ const TRASH_ROOT = path.join(PROJECT_ROOT, '.syncrbx', 'trash');
147
+
148
+ function trashDestination(filePath) {
149
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
150
+ const relative = path.relative(PROJECT_ROOT, filePath);
151
+ let dest = path.join(TRASH_ROOT, stamp, relative);
152
+ for (let i = 1; fs.existsSync(dest); i++) {
153
+ dest = path.join(TRASH_ROOT, `${stamp}-${i}`, relative);
154
+ }
155
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
156
+ return dest;
157
+ }
158
+
159
+ // Moves a file or folder into .syncrbx/trash instead of deleting it.
160
+ function moveToTrash(filePath) {
161
+ const dest = trashDestination(filePath);
162
+ markIgnored(filePath);
163
+ try {
164
+ fs.renameSync(filePath, dest);
165
+ } catch (e) {
166
+ fs.cpSync(filePath, dest, { recursive: true });
167
+ fs.rmSync(filePath, { recursive: true, force: true });
168
+ }
169
+ logDisk(`Moved to trash: ${path.relative(PROJECT_ROOT, filePath)}`);
170
+ }
171
+
172
+ // Keeps a copy of a file in the trash before it is overwritten.
173
+ function copyToTrash(filePath) {
174
+ fs.copyFileSync(filePath, trashDestination(filePath));
175
+ logDisk(`Previous version saved to trash: ${path.relative(PROJECT_ROOT, filePath)}`);
176
+ }
177
+
178
+ // Whether a directory entry is what Studio would save for className.
179
+ function entryMatchesClass(entryPath, className) {
180
+ let stat;
181
+ try {
182
+ stat = fs.statSync(entryPath);
183
+ } catch {
184
+ return false;
185
+ }
186
+ if (className === 'Folder') return stat.isDirectory();
187
+ if (!stat.isFile()) return false;
188
+ const fileClass = classFromFileName(path.basename(entryPath));
189
+ return fileClass === className || fileClass === ANY_CLASS;
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Long-polling state
194
+ // ---------------------------------------------------------------------------
195
+ let pendingChanges = [];
196
+ let waitingClients = [];
197
+ let flushTimer = null;
198
+ // Changes are grouped for a short moment so bulk operations (git checkout,
199
+ // pull) reach Studio as one batch, where mass-delete protection can see them.
200
+ const FLUSH_DELAY_MS = 150;
201
+
202
+ function flushChanges() {
203
+ flushTimer = null;
204
+ if (pendingChanges.length === 0 || waitingClients.length === 0) return;
205
+ const batch = pendingChanges;
206
+ pendingChanges = [];
207
+ while (waitingClients.length > 0) {
208
+ const clientRes = waitingClients.shift();
209
+ try {
210
+ clientRes.json(batch);
211
+ } catch { /* client may have disconnected */ }
212
+ }
213
+ }
214
+
215
+ function pushChange(change) {
216
+ if (!change) return;
217
+ change.timestamp = Date.now();
218
+ pendingChanges.push(change);
219
+ if (!flushTimer) {
220
+ flushTimer = setTimeout(flushChanges, FLUSH_DELAY_MS);
221
+ }
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Incremental sync enrich disk changes with instance data
226
+ // ---------------------------------------------------------------------------
227
+ function readInstanceData(filePath) {
228
+ try {
229
+ const stat = fs.statSync(filePath);
230
+ if (stat.isDirectory()) {
231
+ return processDirectory(filePath, ROOT_SERVICES.includes(path.basename(filePath)));
232
+ }
233
+ return convertFile(filePath);
234
+ } catch (e) {
235
+ logWarn(`Could not read ${filePath}: ${e.message}`);
236
+ return null;
237
+ }
238
+ }
239
+
240
+ // Returns the change to send to Studio, or null when the file is not synced.
241
+ function buildChangePayload(type, filePath, isDirectory = false) {
242
+ const fileName = path.basename(filePath);
243
+
244
+ if (!isDirectory && isInitFile(fileName)) {
245
+ // init.lua turns its folder into a script. Adding, editing or removing
246
+ // it changes the folder's instance, never deletes it.
247
+ const dirPath = path.dirname(filePath);
248
+ if (!fs.existsSync(dirPath)) return null;
249
+ return {
250
+ type: 'Changed',
251
+ instancePath: filePathToInstancePath(dirPath, PROJECT_ROOT),
252
+ data: readInstanceData(dirPath),
253
+ };
254
+ }
255
+
256
+ const className = isDirectory ? 'Folder' : classFromFileName(fileName);
257
+ if (className === null) return null;
258
+
259
+ const instancePath = filePathToInstancePath(filePath, PROJECT_ROOT);
260
+ if (type === 'Removed') {
261
+ const change = { type: 'Removed', instancePath };
262
+ if (className !== ANY_CLASS) change.className = className;
263
+ return change;
264
+ }
265
+
266
+ return { type, instancePath, data: readInstanceData(filePath) };
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // Chokidar file watcher (Phase 2 - #4: uses project config ignore patterns)
271
+ // ---------------------------------------------------------------------------
272
+ const ignorePatterns = [
273
+ /(^|[\/\\])\../, // dotfiles
274
+ /\.conflict\.bak$/, // conflict backups
275
+ /node_modules/,
276
+ ];
277
+
278
+ // Add user-defined ignore patterns from config
279
+ if (projectConfig.ignore) {
280
+ for (const pattern of projectConfig.ignore) {
281
+ if (pattern.startsWith('*.')) {
282
+ const ext = pattern.slice(1).replace('.', '\\.');
283
+ ignorePatterns.push(new RegExp(`${ext}$`));
284
+ }
285
+ }
286
+ }
287
+
288
+ const watcher = chokidar.watch(PROJECT_ROOT, {
289
+ ignored: ignorePatterns,
290
+ persistent: true,
291
+ ignoreInitial: true,
292
+ awaitWriteFinish: {
293
+ stabilityThreshold: 300,
294
+ pollInterval: 100,
295
+ },
296
+ });
297
+
298
+ watcher
299
+ .on('add', filePath => {
300
+ if (isIgnored(filePath) || !isDiskPathSynced(filePath)) return;
301
+ logDisk(`File created: ${path.relative(PROJECT_ROOT, filePath)}`);
302
+ getTimestamps(filePath).disk = Date.now();
303
+ pushChange(buildChangePayload('Added', filePath));
304
+ })
305
+ .on('change', filePath => {
306
+ if (isIgnored(filePath) || !isDiskPathSynced(filePath)) return;
307
+ logDisk(`File modified: ${path.relative(PROJECT_ROOT, filePath)}`);
308
+ getTimestamps(filePath).disk = Date.now();
309
+ pushChange(buildChangePayload('Changed', filePath));
310
+ })
311
+ .on('unlink', filePath => {
312
+ if (isIgnored(filePath) || !isDiskPathSynced(filePath)) return;
313
+ logDisk(`File deleted: ${path.relative(PROJECT_ROOT, filePath)}`);
314
+ pushChange(buildChangePayload('Removed', filePath));
315
+ lastWriteTimestamps.delete(path.resolve(filePath));
316
+ })
317
+ .on('addDir', dirPath => {
318
+ if (isIgnored(dirPath) || !isDiskPathSynced(dirPath)) return;
319
+ logDisk(`Folder created: ${path.relative(PROJECT_ROOT, dirPath)}`);
320
+ pushChange(buildChangePayload('Added', dirPath, true));
321
+ })
322
+ .on('unlinkDir', dirPath => {
323
+ if (isIgnored(dirPath) || !isDiskPathSynced(dirPath)) return;
324
+ logDisk(`Folder deleted: ${path.relative(PROJECT_ROOT, dirPath)}`);
325
+ pushChange(buildChangePayload('Removed', dirPath, true));
326
+ });
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // API Endpoints
330
+ // ---------------------------------------------------------------------------
331
+
332
+ app.get('/ping', (req, res) => {
333
+ res.json({
334
+ status: 'ok',
335
+ project: projectConfig.name || path.basename(PROJECT_ROOT),
336
+ version: require('./package.json').version,
337
+ // false until the user picks the services to sync in the plugin
338
+ configured: Array.isArray(projectConfig.services),
339
+ services: projectConfig.services,
340
+ availableServices: ROOT_SERVICES,
341
+ });
342
+ });
343
+
344
+ // POST /config saves the services chosen in the plugin
345
+ app.post('/config', (req, res) => {
346
+ const requested = req.body && req.body.services;
347
+ const services = sanitizeServices(requested);
348
+ if (!services || services.length === 0 || services.length !== requested.length) {
349
+ return res.status(400).json({ error: 'services must be a non-empty list of Roblox services' });
350
+ }
351
+ try {
352
+ saveProjectServices(PROJECT_ROOT, services);
353
+ projectConfig.services = services;
354
+ createServiceFolders(services);
355
+ logServer(`Synced services: ${services.join(', ')}`);
356
+ res.json({ success: true, services });
357
+ } catch (e) {
358
+ logError(`Could not save the project config: ${e.message}`);
359
+ res.status(500).json({ error: e.message });
360
+ }
361
+ });
362
+
363
+ app.post('/shutdown', (req, res) => {
364
+ res.json({ success: true });
365
+ logServer('Shutdown requested by VS Code extension.');
366
+ setTimeout(() => shutdown('SIGTERM'), 100);
367
+ });
368
+
369
+ app.get('/tree', (req, res) => {
370
+ try {
371
+ const tree = [];
372
+ if (fs.existsSync(PROJECT_ROOT)) {
373
+ const rootFolders = fs.readdirSync(PROJECT_ROOT, { withFileTypes: true });
374
+ for (const folder of rootFolders) {
375
+ // Only service folders are synced; skips node_modules, .git...
376
+ if (folder.isDirectory() && ROOT_SERVICES.includes(folder.name) && isServiceSynced(folder.name)) {
377
+ const subTree = processDirectory(path.join(PROJECT_ROOT, folder.name), true);
378
+ tree.push(subTree);
379
+ }
380
+ }
381
+ }
382
+ logStudio('Roblox Studio connected');
383
+ logServer(`Tree sent (${tree.length} services)`);
384
+ res.json(tree);
385
+ } catch (err) {
386
+ logError(`Failed to generate tree: ${err.message}`);
387
+ res.status(500).json({ error: err.message });
388
+ }
389
+ });
390
+
391
+ app.get('/changes', (req, res) => {
392
+ // While a flush is scheduled more changes may still join the batch, so the
393
+ // client waits for it instead of taking a partial batch.
394
+ if (pendingChanges.length > 0 && !flushTimer) {
395
+ const changesToSend = [...pendingChanges];
396
+ pendingChanges = [];
397
+ return res.json(changesToSend);
398
+ }
399
+
400
+ waitingClients.push(res);
401
+
402
+ const timeout = setTimeout(() => {
403
+ const index = waitingClients.indexOf(res);
404
+ if (index !== -1) {
405
+ waitingClients.splice(index, 1);
406
+ res.json([]);
407
+ }
408
+ }, 30000);
409
+
410
+ res.on('close', () => {
411
+ clearTimeout(timeout);
412
+ const index = waitingClients.indexOf(res);
413
+ if (index !== -1) {
414
+ waitingClients.splice(index, 1);
415
+ }
416
+ });
417
+ });
418
+
419
+ // Phase 4 - #7: Checksums endpoint for incremental diff
420
+ app.get('/checksums', (req, res) => {
421
+ try {
422
+ const checksums = buildChecksums(PROJECT_ROOT);
423
+ res.json(checksums);
424
+ } catch (err) {
425
+ logError(`Failed to build checksums: ${err.message}`);
426
+ res.status(500).json({ error: err.message });
427
+ }
428
+ });
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // Sanitize filename helper
432
+ // ---------------------------------------------------------------------------
433
+ function sanitizeFileName(name) {
434
+ const sanitized = String(name).replace(/[<>:"\/\\|?*\x00-\x1f]+/g, '_');
435
+ // "." and ".." would escape the project folder once joined into a path.
436
+ return /^\.+$/.test(sanitized) ? sanitized.replace(/\./g, '_') : sanitized;
437
+ }
438
+
439
+ // Resolves a path and guarantees it stays inside PROJECT_ROOT.
440
+ function resolveInsideProject(...parts) {
441
+ const resolved = path.resolve(PROJECT_ROOT, ...parts);
442
+ if (resolved !== PROJECT_ROOT && !resolved.startsWith(PROJECT_ROOT + path.sep)) {
443
+ throw new Error(`Path escapes the project folder: ${parts.join('/')}`);
444
+ }
445
+ return resolved;
446
+ }
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // POST /studio-change — Studio reports changes to write to disk
450
+ // ---------------------------------------------------------------------------
451
+ app.post('/studio-change', (req, res) => {
452
+ const changes = req.body;
453
+
454
+ try {
455
+ if (!Array.isArray(changes)) {
456
+ return res.status(400).json({ error: 'Expected an array of changes' });
457
+ }
458
+
459
+ for (const change of changes) {
460
+ const pathParts = (change.path || '').split('/').filter(Boolean);
461
+ if (pathParts.length < 1) {
462
+ logWarn('Received change with empty path, skipping.');
463
+ continue;
464
+ }
465
+
466
+ // Changes outside the synced services never touch the disk
467
+ if (!isInstancePathSynced(change.path) || (change.oldPath && !isInstancePathSynced(change.oldPath))) {
468
+ continue;
469
+ }
470
+
471
+ logStudio(`${change.type} → ${change.path} (${change.className || '?'})`);
472
+
473
+ const fileInfo = instanceToFile(change);
474
+ const sanitizedParts = pathParts.map(sanitizeFileName);
475
+ const ancestorParts = sanitizedParts.slice(0, -1);
476
+ const parentDir = resolveInsideProject(...ancestorParts);
477
+ const targetPath = resolveInsideProject(...ancestorParts, sanitizeFileName(fileInfo.fileName));
478
+
479
+ // Conflict detection
480
+ if ((change.type === 'Added' || change.type === 'Changed') && !fileInfo.isDirectory) {
481
+ const timestamps = getTimestamps(targetPath);
482
+ const now = Date.now();
483
+ if (timestamps.disk > 0 && (now - timestamps.disk) < 5000 && fs.existsSync(targetPath)) {
484
+ logConflict(`${path.basename(targetPath)} was edited on both sides.`);
485
+ createConflictBackup(targetPath);
486
+ }
487
+ timestamps.studio = now;
488
+ }
489
+
490
+ // Execute the write
491
+ if (change.type === 'Added' || change.type === 'Changed') {
492
+ // Folders created here must not bounce back to Studio as new
493
+ // instances, so every missing level is marked as our own write.
494
+ const missingDirs = [];
495
+ for (let dir = parentDir; !fs.existsSync(dir) && dir !== PROJECT_ROOT; dir = path.dirname(dir)) {
496
+ missingDirs.push(dir);
497
+ }
498
+ missingDirs.forEach(markIgnored);
499
+ if (missingDirs.length > 0) {
500
+ fs.mkdirSync(parentDir, { recursive: true });
501
+ }
502
+
503
+ if (fileInfo.isDirectory) {
504
+ if (!fs.existsSync(targetPath)) {
505
+ markIgnored(targetPath);
506
+ fs.mkdirSync(targetPath, { recursive: true });
507
+ logDisk(`Folder created: ${path.relative(PROJECT_ROOT, targetPath)}`);
508
+ }
509
+ } else {
510
+ const content = fileInfo.content || '';
511
+ const exists = fs.existsSync(targetPath);
512
+ if (exists && fs.readFileSync(targetPath, 'utf8') === content) {
513
+ continue;
514
+ }
515
+ // "Added" over an existing file with other content (Export,
516
+ // a pasted script with the same name) keeps the old version.
517
+ if (exists && change.type === 'Added') {
518
+ copyToTrash(targetPath);
519
+ }
520
+ markIgnored(targetPath);
521
+ fs.writeFileSync(targetPath, content, 'utf8');
522
+ logDisk(`Written: ${path.relative(PROJECT_ROOT, targetPath)}`);
523
+ }
524
+ } else if (change.type === 'Removed') {
525
+ const className = change.className || 'Folder';
526
+ let victim = entryMatchesClass(targetPath, className) ? targetPath : null;
527
+ if (!victim && fs.existsSync(parentDir)) {
528
+ // Same instance saved with another extension (.luau, .model.json)
529
+ const baseName = sanitizeFileName(change.name || pathParts[pathParts.length - 1]);
530
+ const sibling = fs.readdirSync(parentDir).find(entry =>
531
+ stripExtension(entry) === baseName &&
532
+ entryMatchesClass(resolveInsideProject(...ancestorParts, entry), className));
533
+ if (sibling) victim = resolveInsideProject(...ancestorParts, sibling);
534
+ }
535
+ if (victim) {
536
+ moveToTrash(victim);
537
+ }
538
+ } else if (change.type === 'Renamed') {
539
+ const oldParts = (change.oldPath || '').split('/').filter(Boolean).map(sanitizeFileName);
540
+ if (oldParts.length > 0) {
541
+ const oldAncestors = oldParts.slice(0, -1);
542
+ const oldDir = resolveInsideProject(...oldAncestors);
543
+ const oldName = oldParts[oldParts.length - 1];
544
+ const className = change.className || 'Folder';
545
+ const sibling = fs.existsSync(oldDir) && fs.readdirSync(oldDir).find(entry =>
546
+ stripExtension(entry) === oldName &&
547
+ entryMatchesClass(resolveInsideProject(...oldAncestors, entry), className));
548
+ if (sibling) {
549
+ const oldPath = resolveInsideProject(...oldAncestors, sibling);
550
+ // On Windows "script" → "Script" is the same file; don't trash it.
551
+ const sameFile = oldPath.toLowerCase() === targetPath.toLowerCase();
552
+ try {
553
+ if (!sameFile && fs.existsSync(targetPath)) {
554
+ moveToTrash(targetPath);
555
+ }
556
+ if (!fs.existsSync(parentDir)) {
557
+ markIgnored(parentDir);
558
+ fs.mkdirSync(parentDir, { recursive: true });
559
+ }
560
+ markIgnored(oldPath);
561
+ markIgnored(targetPath);
562
+ fs.renameSync(oldPath, targetPath);
563
+ logDisk(`Renamed: ${sibling} → ${fileInfo.fileName}`);
564
+ } catch (e) {
565
+ logError(`Rename failed: ${e.message}`);
566
+ }
567
+ }
568
+ }
569
+ }
570
+ }
571
+
572
+ res.json({ success: true });
573
+ } catch (e) {
574
+ logError(`Processing studio changes: ${e.message}`);
575
+ res.status(500).json({ error: e.message });
576
+ }
577
+ });
578
+
579
+ // ---------------------------------------------------------------------------
580
+ // Graceful shutdown
581
+ // ---------------------------------------------------------------------------
582
+ function shutdown(signal) {
583
+ logServer(`${signal} received. Closing SyncRbx...`);
584
+ watcher.close().then(() => {
585
+ logServer('File watcher closed.');
586
+ process.exit(0);
587
+ });
588
+ }
589
+
590
+ process.on('SIGINT', () => shutdown('SIGINT'));
591
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
592
+
593
+ // ---------------------------------------------------------------------------
594
+ // Start
595
+ // ---------------------------------------------------------------------------
596
+ function startServer() {
597
+ if (projectConfig.services) {
598
+ createServiceFolders(projectConfig.services);
599
+ }
600
+
601
+ const server = app.listen(PORT, HOST, () => {
602
+ console.log('');
603
+ console.log(c.bold(c.green(' ╔══════════════════════════════════════════╗')));
604
+ console.log(c.bold(c.green(' ║ 🌿 SYNCRBX v' + require('./package.json').version + ' 🌿 ║')));
605
+ console.log(c.bold(c.green(' ╚══════════════════════════════════════════╝')));
606
+ console.log('');
607
+ logServer(`Server at ${c.bold(`${HOST}:${PORT}`)}`);
608
+ logServer(`Project: ${c.cyan(projectConfig.name)}`);
609
+ logServer(`Monitoring: ${c.cyan(PROJECT_ROOT)}`);
610
+ if (projectConfig.services) {
611
+ logServer(`Syncing: ${c.cyan(projectConfig.services.join(', '))}`);
612
+ } else {
613
+ logServer(`No services chosen yet: pick them in the Studio plugin when you connect.`);
614
+ }
615
+ logServer(`Waiting for connection from Roblox Studio...`);
616
+ console.log('');
617
+ });
618
+
619
+ server.on('error', (e) => {
620
+ if (e.code === 'EADDRINUSE') {
621
+ logError(`Port ${PORT} is already in use. Is another SyncRbx server running?`);
622
+ logError(`Close the other server or change the port in config.json`);
623
+ process.exit(1);
624
+ } else {
625
+ logError(`Fatal error: ${e.message}`);
626
+ process.exit(1);
627
+ }
628
+ });
629
+ }
630
+
631
+ module.exports = { startServer };