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/README.md +1 -1
- package/cli.js +459 -322
- package/converters.js +60 -11
- package/local-sync.js +631 -441
- package/package.json +26 -20
- package/build.js +0 -77
- package/make_rbxmx.js +0 -21
- package/test.js +0 -28
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
function
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
function
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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 };
|