syncrbx 1.0.0

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 ADDED
@@ -0,0 +1,20 @@
1
+ <div align="center">
2
+ <img src="https://raw.githubusercontent.com/DevManyPB/Verde/main/Logo.png" alt="SyncRbx" width="200" />
3
+ </div>
4
+
5
+ # SyncRbx CLI
6
+
7
+ The official Node.js command-line interface for SyncRbx.
8
+ The #1 totally free tool to sync VS Code with Roblox Studio. Instant bidirectional sync, SyncRbx Cloud, and Auto-Folders.
9
+
10
+ ## Links
11
+ - **Website:** [https://www.syncrbx.xyz/](https://www.syncrbx.xyz/)
12
+ - **Documentation:** [https://www.syncrbx.xyz/docs](https://www.syncrbx.xyz/docs)
13
+ - **Community (Discord):** [https://discord.gg/jgM2zNuYsN](https://discord.gg/jgM2zNuYsN)
14
+
15
+ ## Usage
16
+ If you prefer not to use the VS Code extension, you can run the sync server manually using our CLI tool.
17
+
18
+ ```bash
19
+ npx syncrbx serve
20
+ ```
package/build.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * build.js — Compiles the modular OOP plugin (src/) into a single SyncPlugin.lua
3
+ *
4
+ * Usage: node build.js
5
+ * Output: ../plugin/SyncPlugin.lua (ready to paste into Studio)
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ const SRC = path.join(__dirname, '..', 'plugin', 'Workspace', 'VerdePlugin');
12
+ const OUT = path.join(__dirname, '..', 'plugin', 'SyncPlugin.lua');
13
+
14
+ // Read all module files in dependency order
15
+ const modules = {
16
+ 'Utils.Constants': fs.readFileSync(path.join(SRC, 'Utils', 'Constants.lua'), 'utf8'),
17
+ 'Utils.Logger': fs.readFileSync(path.join(SRC, 'Utils', 'Logger.lua'), 'utf8'),
18
+ 'Core.Net': fs.readFileSync(path.join(SRC, 'Core', 'Net.lua'), 'utf8'),
19
+ 'Core.InstanceTracker': fs.readFileSync(path.join(SRC, 'Core', 'InstanceTracker.lua'), 'utf8'),
20
+ 'Core.SyncEngine': fs.readFileSync(path.join(SRC, 'Core', 'SyncEngine.lua'), 'utf8'),
21
+ 'UI.VerdeWidget': fs.readFileSync(path.join(SRC, 'UI', 'VerdeWidget.lua'), 'utf8'),
22
+ };
23
+
24
+ const mainSource = fs.readFileSync(path.join(SRC, 'Main.server.lua'), 'utf8');
25
+
26
+ // Build the bundled output
27
+ let output = `--[[\n Verde Sync Plugin v2.0 — Auto-generated bundle\n DO NOT EDIT — Edit the source files in plugin/src/ instead.\n Built: ${new Date().toISOString()}\n--]]\n\n`;
28
+
29
+ // Create a module loader
30
+ output += `local _modules = {}\nlocal _loaded = {}\n\n`;
31
+ output += `local function _require(name)\n if _loaded[name] then return _loaded[name] end\n local loader = _modules[name]\n if not loader then error("Module not found: " .. name) end\n _loaded[name] = loader()\n return _loaded[name]\nend\n\n`;
32
+
33
+ // Register each module
34
+ for (const [name, source] of Object.entries(modules)) {
35
+ // Replace require() calls with our bundled _require()
36
+ let processed = source;
37
+
38
+ // Replace patterns like: require(script.Parent.Parent.Utils.Constants)
39
+ // with: _require("Utils.Constants")
40
+ processed = processed.replace(
41
+ /require\(script\.Parent\.Parent\.([A-Za-z.]+)\)/g,
42
+ (match, modPath) => `_require("${modPath}")`
43
+ );
44
+ processed = processed.replace(
45
+ /require\(script\.Parent\.([A-Za-z.]+)\)/g,
46
+ (match, modPath) => {
47
+ // Determine the parent module's package
48
+ const parentPkg = name.split('.')[0];
49
+ return `_require("${parentPkg}.${modPath}")`;
50
+ }
51
+ );
52
+
53
+ output += `_modules["${name}"] = function()\n`;
54
+ // Indent the module source
55
+ const lines = processed.split('\n');
56
+ for (const line of lines) {
57
+ // Skip "return X" at the end - we'll handle it
58
+ output += ` ${line}\n`;
59
+ }
60
+ output += `end\n\n`;
61
+ }
62
+
63
+ // Process Main entry point
64
+ let mainProcessed = mainSource;
65
+ mainProcessed = mainProcessed.replace(
66
+ /require\(script\.Parent\.([A-Za-z.]+)\)/g,
67
+ (match, modPath) => `_require("${modPath}")`
68
+ );
69
+
70
+ output += `-- Main Entry Point\n`;
71
+ output += mainProcessed;
72
+ output += '\n';
73
+
74
+ fs.writeFileSync(OUT, output, 'utf8');
75
+ console.log(`✅ Built successfully: ${OUT}`);
76
+ console.log(` Size: ${(fs.statSync(OUT).size / 1024).toFixed(1)} KB`);
77
+ console.log(` Modules: ${Object.keys(modules).length}`);
package/cli.js ADDED
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { Command } = require('commander');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const archiver = require('archiver');
7
+ const AdmZip = require('adm-zip');
8
+ const http = require('http');
9
+ const { exec } = require('child_process');
10
+ const { startServer } = require('./local-sync');
11
+
12
+ const program = new Command();
13
+
14
+ const API_BASE_URL = process.env.SYNCRBX_API_URL || 'http://localhost:3000/api/cli';
15
+ const WEB_BASE_URL = process.env.SYNCRBX_WEB_URL || 'https://www.syncrbx.xyz';
16
+
17
+ const CONFIG_PATH = path.join(process.env.HOME || process.env.USERPROFILE, '.syncrbxconfig.json');
18
+
19
+ // --- Helper Functions ---
20
+
21
+ function forceExit(code) {
22
+ setTimeout(() => {
23
+ process.exit(code);
24
+ }, 50);
25
+ }
26
+
27
+ function getToken() {
28
+ if (fs.existsSync(CONFIG_PATH)) {
29
+ try {
30
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
31
+ return config.access_token;
32
+ } catch (e) {
33
+ return null;
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ function saveToken(token) {
40
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify({ access_token: token }, null, 2));
41
+ }
42
+
43
+ function getProjectConfig() {
44
+ const localSyncRbx = path.join(process.cwd(), '.syncrbx', 'config.json');
45
+ if (fs.existsSync(localSyncRbx)) {
46
+ return JSON.parse(fs.readFileSync(localSyncRbx, 'utf8'));
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function openBrowser(url) {
52
+ const start = (process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open');
53
+ exec(`${start} ${url}`);
54
+ }
55
+
56
+ // --- Commands ---
57
+
58
+ program
59
+ .name('syncrbx')
60
+ .description('SyncRbx - The #1 totally free tool to sync VS Code with Roblox Studio.')
61
+ .version('2.1.0');
62
+
63
+ // Comando: Login
64
+ program
65
+ .command('login')
66
+ .description('Autenticarte con tu cuenta de SyncRbx Sync')
67
+ .option('-t, --token <token>', 'Proporcionar el token directamente')
68
+ .action((options) => {
69
+ if (options.token) {
70
+ saveToken(options.token);
71
+ console.log('✅ Sesión iniciada correctamente con SyncRbx Sync.');
72
+ return;
73
+ }
74
+
75
+ const PORT = 14872;
76
+ const loginUrl = `${WEB_BASE_URL}/cli-login?port=${PORT}`;
77
+
78
+ console.log(`🔗 Abriendo el navegador para autenticarte...`);
79
+ console.log(`Si el navegador no se abre automáticamente, entra aquí: ${loginUrl}`);
80
+
81
+ const server = http.createServer((req, res) => {
82
+ const url = new URL(req.url, `http://localhost:${PORT}`);
83
+ if (url.pathname === '/callback') {
84
+ const token = url.searchParams.get('token');
85
+ if (token) {
86
+ saveToken(token);
87
+ res.writeHead(200, {
88
+ 'Content-Type': 'application/json',
89
+ 'Access-Control-Allow-Origin': '*'
90
+ });
91
+ res.end(JSON.stringify({ success: true }));
92
+ console.log('✅ Sesión iniciada correctamente. Ya puedes volver a la terminal.');
93
+ server.close(() => forceExit(0));
94
+ } else {
95
+ res.writeHead(400, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
96
+ res.end(JSON.stringify({ error: 'Token missing' }));
97
+ console.error('❌ Error: No se recibió ningún token.');
98
+ server.close(() => forceExit(1));
99
+ }
100
+ } else {
101
+ res.writeHead(404);
102
+ res.end();
103
+ }
104
+ });
105
+
106
+ server.listen(PORT, () => {
107
+ openBrowser(loginUrl);
108
+ });
109
+ });
110
+
111
+ // Comando: Serve
112
+ program
113
+ .command('serve')
114
+ .description('Inicia el servidor local de SyncRbx Sync')
115
+ .action(() => {
116
+ startServer();
117
+ });
118
+
119
+ // Comando: Init
120
+ program
121
+ .command('init')
122
+ .description('Inicializa un nuevo repositorio de SyncRbx Sync en la nube')
123
+ .option('--private', 'Hace el repositorio privado')
124
+ .option('--public', 'Hace el repositorio público (por defecto)')
125
+ .action(async (options) => {
126
+ const token = getToken();
127
+ if (!token) {
128
+ console.error('❌ No estás autenticado. Usa "syncrbx login" primero.');
129
+ forceExit(1);
130
+ return;
131
+ }
132
+
133
+ const srcPath = path.join(process.cwd(), 'src');
134
+ const rojoConfig = path.join(process.cwd(), 'default.project.json');
135
+
136
+ if (!fs.existsSync(srcPath) && !fs.existsSync(rojoConfig)) {
137
+ console.log('🚧 No se detectó una estructura de Roblox. Creando scaffolding (src/ y config.json)...');
138
+ fs.mkdirSync(srcPath, { recursive: true });
139
+
140
+ const configJson = {
141
+ name: path.basename(process.cwd()),
142
+ version: "1.0.0",
143
+ engine: "roblox",
144
+ type: "syncrbx-project"
145
+ };
146
+ fs.writeFileSync(path.join(srcPath, 'config.json'), JSON.stringify(configJson, null, 2));
147
+ console.log('✅ Estructura base creada.');
148
+ }
149
+
150
+ const isPrivate = options.private ? true : false;
151
+ const projectName = path.basename(process.cwd());
152
+
153
+ console.log(`🚀 Inicializando proyecto "${projectName}" como ${isPrivate ? 'Privado' : 'Público'}...`);
154
+
155
+ try {
156
+ const res = await fetch(`${API_BASE_URL}/init`, {
157
+ method: 'POST',
158
+ headers: {
159
+ 'Content-Type': 'application/json',
160
+ 'Authorization': token
161
+ },
162
+ body: JSON.stringify({ projectName, isPrivate })
163
+ });
164
+
165
+ const data = await res.json();
166
+
167
+ if (!res.ok) {
168
+ console.error(`❌ Error al inicializar: ${data.error}`);
169
+ forceExit(1);
170
+ return;
171
+ }
172
+
173
+ const syncrbxFolder = path.join(process.cwd(), '.syncrbx');
174
+ if (!fs.existsSync(syncrbxFolder)) fs.mkdirSync(syncrbxFolder);
175
+ fs.writeFileSync(path.join(syncrbxFolder, 'config.json'), JSON.stringify({ project_id: data.id, owner_id: data.owner_id }, null, 2));
176
+
177
+ console.log('✅ Proyecto inicializado con éxito y vinculado a la nube.');
178
+ forceExit(0);
179
+ } catch (err) {
180
+ console.error(`❌ Falló la conexión con la API: ${err.message}`);
181
+ forceExit(1);
182
+ }
183
+ });
184
+
185
+ // Comando: Push
186
+ program
187
+ .command('push')
188
+ .description('Empaqueta tu código y sube un commit a la nube')
189
+ .argument('<message>', 'Mensaje del commit')
190
+ .action(async (message) => {
191
+ const token = getToken();
192
+ if (!token) {
193
+ console.error('❌ No estás autenticado. Usa "syncrbx login" primero.');
194
+ forceExit(1);
195
+ return;
196
+ }
197
+
198
+ const projectConfig = getProjectConfig();
199
+ if (!projectConfig || !projectConfig.project_id) {
200
+ console.error('❌ Este directorio no está vinculado a SyncRbx Cloud. Ejecuta "syncrbx init" primero.');
201
+ forceExit(1);
202
+ return;
203
+ }
204
+
205
+ console.log(`📦 Empaquetando código local...`);
206
+ const zipPath = path.join(process.cwd(), '.syncrbx_build.zip');
207
+ const output = fs.createWriteStream(zipPath);
208
+ const archive = archiver('zip', { zlib: { level: 9 } });
209
+
210
+ output.on('close', async () => {
211
+ const stats = fs.statSync(zipPath);
212
+ const fileSizeInMB = stats.size / (1024 * 1024);
213
+
214
+ if (fileSizeInMB > 10) {
215
+ console.error(`❌ El archivo empaquetado es demasiado grande (${fileSizeInMB.toFixed(2)} MB). Límite de 10 MB excedido.`);
216
+ fs.unlinkSync(zipPath);
217
+ forceExit(1);
218
+ return;
219
+ }
220
+
221
+ console.log(`✅ Archivo comprimido (${stats.size} bytes).`);
222
+ console.log(`☁️ Subiendo a SyncRbx Cloud...`);
223
+
224
+ try {
225
+ const zipBuffer = fs.readFileSync(zipPath);
226
+ const res = await fetch(`${API_BASE_URL}/push`, {
227
+ method: 'POST',
228
+ headers: {
229
+ 'Authorization': token,
230
+ 'Content-Type': 'application/zip',
231
+ 'x-project-id': projectConfig.project_id,
232
+ 'x-commit-message': message
233
+ },
234
+ body: zipBuffer
235
+ });
236
+
237
+ const data = await res.json();
238
+ if (!res.ok) {
239
+ console.error(`❌ Error al subir: ${data.error}`);
240
+ } else {
241
+ console.log(`🎉 Push completado con éxito.`);
242
+ }
243
+ } catch (err) {
244
+ console.error(`❌ Falló la conexión con la API: ${err.message}`);
245
+ }
246
+
247
+ fs.unlinkSync(zipPath);
248
+ process.exit(0);
249
+ });
250
+
251
+ archive.on('error', (err) => {
252
+ throw err;
253
+ });
254
+
255
+ archive.pipe(output);
256
+ archive.glob('**/*.{lua,luau,json,txt,md,toml}', {
257
+ ignore: ['node_modules/**', '.git/**', '.syncrbx/**', '.syncrbx_build.zip']
258
+ });
259
+ archive.finalize();
260
+ });
261
+
262
+ // Comando: Pull
263
+ program
264
+ .command('pull')
265
+ .description('Clona o actualiza un repositorio desde la nube')
266
+ .argument('<project_id>', 'ID del repositorio a clonar')
267
+ .action(async (projectId) => {
268
+ const token = getToken();
269
+ if (!token) {
270
+ console.error('❌ No estás autenticado. Usa "syncrbx login" primero.');
271
+ forceExit(1);
272
+ return;
273
+ }
274
+
275
+ console.log(`🔍 Descargando repositorio con ID: ${projectId}...`);
276
+
277
+ try {
278
+ const res = await fetch(`${API_BASE_URL}/pull/${projectId}`, {
279
+ method: 'GET',
280
+ headers: {
281
+ 'Authorization': token
282
+ }
283
+ });
284
+
285
+ if (!res.ok) {
286
+ const errorData = await res.json().catch(() => ({ error: 'Error desconocido' }));
287
+ console.error(`❌ Error: ${errorData.error}`);
288
+ forceExit(1);
289
+ return;
290
+ }
291
+
292
+ const arrayBuffer = await res.arrayBuffer();
293
+ const buffer = Buffer.from(arrayBuffer);
294
+ const ownerId = res.headers.get('x-owner-id') || 'unknown';
295
+
296
+ const zipPath = path.join(process.cwd(), '.syncrbx_download.zip');
297
+ fs.writeFileSync(zipPath, buffer);
298
+
299
+ console.log(`📂 Extrayendo archivos...`);
300
+ try {
301
+ const zip = new AdmZip(zipPath);
302
+ zip.extractAllTo(process.cwd(), true);
303
+ fs.unlinkSync(zipPath);
304
+ } catch (e) {
305
+ console.error(`❌ Error al extraer el archivo zip: ${e.message}`);
306
+ forceExit(1);
307
+ return;
308
+ }
309
+
310
+ const syncrbxFolder = path.join(process.cwd(), '.syncrbx');
311
+ if (!fs.existsSync(syncrbxFolder)) fs.mkdirSync(syncrbxFolder);
312
+ fs.writeFileSync(path.join(syncrbxFolder, 'config.json'), JSON.stringify({ project_id: projectId, owner_id: ownerId }, null, 2));
313
+
314
+ console.log(`🎉 Pull completado con éxito. El directorio ahora está sincronizado.`);
315
+ forceExit(0);
316
+ } catch (err) {
317
+ console.error(`❌ Falló la conexión con la API: ${err.message}`);
318
+ forceExit(1);
319
+ }
320
+ });
321
+
322
+ program.parse(process.argv);
package/converters.js ADDED
@@ -0,0 +1,398 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const crypto = require('crypto');
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Known Roblox services that map as root containers
7
+ // ---------------------------------------------------------------------------
8
+ const ROOT_SERVICES = [
9
+ 'Workspace', 'Players', 'Lighting', 'MaterialService',
10
+ 'ReplicatedFirst', 'ReplicatedStorage', 'ServerScriptService', 'ServerStorage',
11
+ 'StarterGui', 'StarterPack', 'StarterPlayer',
12
+ 'SoundService', 'Chat', 'TextChatService', 'LocalizationService', 'TestService'
13
+ ];
14
+
15
+ // Sub-services that live inside StarterPlayer (Phase 1 - Bug #2)
16
+ const STARTER_PLAYER_CHILDREN = ['StarterPlayerScripts', 'StarterCharacterScripts'];
17
+
18
+ // Known file extensions (order matters — longest first so we match greedily)
19
+ const KNOWN_EXTENSIONS = [
20
+ '.server.lua', '.server.luau',
21
+ '.client.lua', '.client.luau',
22
+ '.model.json', '.meta.json',
23
+ '.remoteevent', '.remotefunction',
24
+ '.bindableevent', '.bindablefunction',
25
+ '.lua', '.luau',
26
+ '.txt', '.json'
27
+ ];
28
+
29
+ // Phase 2 - #5: More instance types via model.json
30
+ const VALUE_CLASSES = {
31
+ IntValue: 'number',
32
+ NumberValue: 'number',
33
+ BoolValue: 'boolean',
34
+ StringValue: 'string',
35
+ Color3Value: 'color3',
36
+ };
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Helpers
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function stripExtension(filename) {
43
+ const lowerFilename = filename.toLowerCase();
44
+ for (const ext of KNOWN_EXTENSIONS) {
45
+ if (lowerFilename.endsWith(ext.toLowerCase())) {
46
+ return filename.slice(0, -ext.length);
47
+ }
48
+ }
49
+ const lastDot = filename.lastIndexOf('.');
50
+ return lastDot > 0 ? filename.slice(0, lastDot) : filename;
51
+ }
52
+
53
+ function getMetaProperties(metaFilePath) {
54
+ if (fs.existsSync(metaFilePath)) {
55
+ try {
56
+ return JSON.parse(fs.readFileSync(metaFilePath, 'utf8'));
57
+ } catch (e) {
58
+ console.error(`[WARN] Error parsing meta file ${metaFilePath}:`, e.message);
59
+ }
60
+ }
61
+ return {};
62
+ }
63
+
64
+ function determineScriptType(filename) {
65
+ if (filename.endsWith('.server.lua') || filename.endsWith('.server.luau')) return 'Script';
66
+ if (filename.endsWith('.client.lua') || filename.endsWith('.client.luau')) return 'LocalScript';
67
+ if (filename.endsWith('.lua') || filename.endsWith('.luau')) return 'ModuleScript';
68
+ return null;
69
+ }
70
+
71
+ // Phase 4 - #7: File checksum
72
+ function fileChecksum(filePath) {
73
+ try {
74
+ const content = fs.readFileSync(filePath, 'utf8');
75
+ return crypto.createHash('md5').update(content).digest('hex');
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // JSON → Lua table conversion
83
+ // ---------------------------------------------------------------------------
84
+ function jsonToLuaTable(value, indent) {
85
+ indent = indent || 0;
86
+ const pad = '\t'.repeat(indent);
87
+ const padInner = '\t'.repeat(indent + 1);
88
+
89
+ if (value === null || value === undefined) return 'nil';
90
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
91
+ if (typeof value === 'number') return String(value);
92
+ if (typeof value === 'string') {
93
+ const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
94
+ return `"${escaped}"`;
95
+ }
96
+ if (Array.isArray(value)) {
97
+ if (value.length === 0) return '{}';
98
+ const items = value.map(v => `${padInner}${jsonToLuaTable(v, indent + 1)}`);
99
+ return `{\n${items.join(',\n')}\n${pad}}`;
100
+ }
101
+ if (typeof value === 'object') {
102
+ const keys = Object.keys(value);
103
+ if (keys.length === 0) return '{}';
104
+ const entries = keys.map(k => {
105
+ const luaKey = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(k) ? k : `["${k}"]`;
106
+ return `${padInner}${luaKey} = ${jsonToLuaTable(value[k], indent + 1)}`;
107
+ });
108
+ return `{\n${entries.join(',\n')}\n${pad}}`;
109
+ }
110
+ return 'nil';
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // File → Instance conversion
115
+ // ---------------------------------------------------------------------------
116
+ function convertFile(filePath) {
117
+ const filename = path.basename(filePath);
118
+ let content;
119
+ try {
120
+ content = fs.readFileSync(filePath, 'utf8');
121
+ } catch (e) {
122
+ console.error(`[WARN] Could not read file ${filePath}:`, e.message);
123
+ return null;
124
+ }
125
+
126
+ const name = stripExtension(filename);
127
+
128
+ let instance = {
129
+ Name: name,
130
+ ClassName: 'Unknown',
131
+ Properties: {},
132
+ Children: []
133
+ };
134
+
135
+ if (filename.endsWith('.model.json')) {
136
+ try {
137
+ const parsed = JSON.parse(content);
138
+ instance.ClassName = parsed.ClassName || 'Folder';
139
+ instance.Properties = parsed.Properties || {};
140
+ if (parsed.Name) instance.Name = parsed.Name;
141
+ if (parsed.Children && Array.isArray(parsed.Children)) {
142
+ instance.Children = parsed.Children;
143
+ }
144
+ } catch (e) {
145
+ console.error(`[WARN] Error parsing model file ${filePath}:`, e.message);
146
+ }
147
+ } else if (filename.toLowerCase().endsWith('.remoteevent')) {
148
+ instance.ClassName = 'RemoteEvent';
149
+ } else if (filename.toLowerCase().endsWith('.remotefunction')) {
150
+ instance.ClassName = 'RemoteFunction';
151
+ } else if (filename.toLowerCase().endsWith('.bindableevent')) {
152
+ instance.ClassName = 'BindableEvent';
153
+ } else if (filename.toLowerCase().endsWith('.bindablefunction')) {
154
+ instance.ClassName = 'BindableFunction';
155
+ } else if (filename.endsWith('.txt')) {
156
+ instance.ClassName = 'StringValue';
157
+ instance.Properties.Value = content;
158
+ } else if (filename.endsWith('.json') && !filename.endsWith('.meta.json')) {
159
+ try {
160
+ const parsed = JSON.parse(content);
161
+ instance.ClassName = 'ModuleScript';
162
+ instance.Properties.Source = `return ${jsonToLuaTable(parsed)}`;
163
+ } catch (e) {
164
+ console.error(`[WARN] Error parsing JSON data file ${filePath}:`, e.message);
165
+ instance.ClassName = 'ModuleScript';
166
+ instance.Properties.Source = `-- ERROR: Could not parse JSON\nreturn nil`;
167
+ }
168
+ } else {
169
+ const scriptType = determineScriptType(filename);
170
+ if (scriptType) {
171
+ instance.ClassName = scriptType;
172
+ instance.Properties.Source = content;
173
+ }
174
+ }
175
+
176
+ return instance;
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Directory → Instance tree (recursive)
181
+ // ---------------------------------------------------------------------------
182
+ function processDirectory(dirPath, isRoot = false) {
183
+ const dirName = path.basename(dirPath);
184
+
185
+ let className = 'Folder';
186
+ if (isRoot && ROOT_SERVICES.includes(dirName)) {
187
+ className = dirName;
188
+ } else if (STARTER_PLAYER_CHILDREN.includes(dirName)) {
189
+ // Phase 1 - Bug #2: Identify these as their proper class
190
+ className = dirName;
191
+ }
192
+
193
+ let instance = {
194
+ Name: dirName,
195
+ ClassName: className,
196
+ Properties: {},
197
+ Children: []
198
+ };
199
+
200
+ let entries;
201
+ try {
202
+ entries = fs.readdirSync(dirPath, { withFileTypes: true });
203
+ } catch (e) {
204
+ console.error(`[WARN] Could not read directory ${dirPath}:`, e.message);
205
+ return instance;
206
+ }
207
+
208
+ // Handle init.lua / init.server.lua / init.client.lua
209
+ const initFile = entries.find(e =>
210
+ e.isFile() && e.name.startsWith('init.') &&
211
+ (e.name.endsWith('.lua') || e.name.endsWith('.luau'))
212
+ );
213
+ if (initFile) {
214
+ const scriptType = determineScriptType(initFile.name);
215
+ if (scriptType && instance.ClassName === 'Folder') {
216
+ instance.ClassName = scriptType;
217
+ try {
218
+ instance.Properties.Source = fs.readFileSync(path.join(dirPath, initFile.name), 'utf8');
219
+ } catch (e) {
220
+ console.error(`[WARN] Could not read init file:`, e.message);
221
+ }
222
+ }
223
+ }
224
+
225
+ // Handle meta.json inside the folder
226
+ const metaInside = entries.find(e => e.isFile() && e.name === 'meta.json');
227
+ if (metaInside) {
228
+ const metaProps = getMetaProperties(path.join(dirPath, 'meta.json'));
229
+ instance.Properties = { ...instance.Properties, ...metaProps };
230
+ }
231
+
232
+ // Process children
233
+ for (const entry of entries) {
234
+ if (entry.name === 'meta.json' || entry.name.endsWith('.meta.json') || entry.name.startsWith('init.')) {
235
+ continue;
236
+ }
237
+
238
+ const entryPath = path.join(dirPath, entry.name);
239
+
240
+ if (entry.isDirectory()) {
241
+ const childInstance = processDirectory(entryPath, false);
242
+ const metaFileOutside = entries.find(e => e.isFile() && e.name === `${entry.name}.meta.json`);
243
+ if (metaFileOutside) {
244
+ const metaProps = getMetaProperties(path.join(dirPath, metaFileOutside.name));
245
+ childInstance.Properties = { ...childInstance.Properties, ...metaProps };
246
+ }
247
+ instance.Children.push(childInstance);
248
+ } else if (entry.isFile()) {
249
+ const childInstance = convertFile(entryPath);
250
+ if (childInstance && childInstance.ClassName !== 'Unknown') {
251
+ const metaFileName = `${entry.name}.meta.json`;
252
+ const metaFile = entries.find(e => e.isFile() && e.name === metaFileName);
253
+ if (metaFile) {
254
+ const metaProps = getMetaProperties(path.join(dirPath, metaFileName));
255
+ childInstance.Properties = { ...childInstance.Properties, ...metaProps };
256
+ }
257
+ instance.Children.push(childInstance);
258
+ }
259
+ }
260
+ }
261
+
262
+ return instance;
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Phase 4 - #7: Build checksums map for the entire project
267
+ // ---------------------------------------------------------------------------
268
+ function buildChecksums(dirPath, prefix = '') {
269
+ const checksums = {};
270
+ let entries;
271
+ try {
272
+ entries = fs.readdirSync(dirPath, { withFileTypes: true });
273
+ } catch {
274
+ return checksums;
275
+ }
276
+
277
+ for (const entry of entries) {
278
+ if (entry.name.endsWith('.conflict.bak') || entry.name.endsWith('.meta.json')) continue;
279
+ const entryPath = path.join(dirPath, entry.name);
280
+ const instancePath = prefix ? `${prefix}/${stripExtension(entry.name)}` : stripExtension(entry.name);
281
+
282
+ if (entry.isDirectory()) {
283
+ Object.assign(checksums, buildChecksums(entryPath, prefix ? `${prefix}/${entry.name}` : entry.name));
284
+ } else if (entry.isFile()) {
285
+ const checksum = fileChecksum(entryPath);
286
+ if (checksum) {
287
+ checksums[instancePath] = checksum;
288
+ }
289
+ }
290
+ }
291
+ return checksums;
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Instance → File (inverse conversion for Studio → Disk)
296
+ // ---------------------------------------------------------------------------
297
+ function instanceToFile(instanceData) {
298
+ const name = instanceData.name || instanceData.Name || 'Untitled';
299
+ const className = instanceData.className || instanceData.ClassName || 'Folder';
300
+ const source = instanceData.source || (instanceData.Properties && instanceData.Properties.Source) || '';
301
+ const value = instanceData.value || (instanceData.Properties && instanceData.Properties.Value) || '';
302
+
303
+ if (className === 'Folder') {
304
+ return { fileName: name, content: null, isDirectory: true };
305
+ }
306
+ if (className === 'Script') {
307
+ return { fileName: `${name}.server.lua`, content: source, isDirectory: false };
308
+ }
309
+ if (className === 'LocalScript') {
310
+ return { fileName: `${name}.client.lua`, content: source, isDirectory: false };
311
+ }
312
+ if (className === 'ModuleScript') {
313
+ return { fileName: `${name}.lua`, content: source, isDirectory: false };
314
+ }
315
+ if (className === 'StringValue') {
316
+ return { fileName: `${name}.txt`, content: value, isDirectory: false };
317
+ }
318
+ if (className === 'RemoteEvent') {
319
+ return { fileName: `${name}.remoteevent`, content: '', isDirectory: false };
320
+ }
321
+ if (className === 'RemoteFunction') {
322
+ return { fileName: `${name}.remotefunction`, content: '', isDirectory: false };
323
+ }
324
+ if (className === 'BindableEvent') {
325
+ return { fileName: `${name}.bindableevent`, content: '', isDirectory: false };
326
+ }
327
+ if (className === 'BindableFunction') {
328
+ return { fileName: `${name}.bindablefunction`, content: '', isDirectory: false };
329
+ }
330
+
331
+ // Phase 2 - #5: Value types and other instances → .model.json
332
+ if (VALUE_CLASSES[className] !== undefined) {
333
+ const modelData = { ClassName: className, Properties: { Value: value } };
334
+ return { fileName: `${name}.model.json`, content: JSON.stringify(modelData, null, 2), isDirectory: false };
335
+ }
336
+
337
+ // Generic instance → .model.json
338
+ const modelData = { ClassName: className, Properties: {} };
339
+ if (instanceData.Properties) {
340
+ modelData.Properties = { ...instanceData.Properties };
341
+ }
342
+ return { fileName: `${name}.model.json`, content: JSON.stringify(modelData, null, 2), isDirectory: false };
343
+ }
344
+
345
+ function filePathToInstancePath(filePath, projectRoot) {
346
+ let relative = path.relative(projectRoot, filePath).replace(/\\/g, '/');
347
+ const parts = relative.split('/');
348
+ if (parts.length > 0) {
349
+ const last = parts[parts.length - 1];
350
+ if (last.startsWith('init.') && (last.endsWith('.lua') || last.endsWith('.luau'))) {
351
+ parts.pop();
352
+ } else {
353
+ parts[parts.length - 1] = stripExtension(last);
354
+ }
355
+ }
356
+ return parts.join('/');
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // Phase 2 - #4: Project config loader
361
+ // ---------------------------------------------------------------------------
362
+ function loadProjectConfig(projectRoot) {
363
+ const configPath = path.join(projectRoot, 'syncrbx.project.json');
364
+ const defaults = {
365
+ name: path.basename(projectRoot),
366
+ port: 34872,
367
+ ignore: ['*.conflict.bak', 'node_modules', '.git', '.vscode'],
368
+ tree: null, // null = auto-detect from folder names
369
+ };
370
+
371
+ if (fs.existsSync(configPath)) {
372
+ try {
373
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
374
+ return { ...defaults, ...config };
375
+ } catch (e) {
376
+ console.error(`[WARN] Error parsing syncrbx.project.json: ${e.message}`);
377
+ }
378
+ }
379
+ return defaults;
380
+ }
381
+
382
+ module.exports = {
383
+ ROOT_SERVICES,
384
+ STARTER_PLAYER_CHILDREN,
385
+ KNOWN_EXTENSIONS,
386
+ VALUE_CLASSES,
387
+ stripExtension,
388
+ determineScriptType,
389
+ jsonToLuaTable,
390
+ convertFile,
391
+ processDirectory,
392
+ buildChecksums,
393
+ instanceToFile,
394
+ filePathToInstancePath,
395
+ getMetaProperties,
396
+ loadProjectConfig,
397
+ fileChecksum,
398
+ };
package/local-sync.js ADDED
@@ -0,0 +1,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,
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 };
package/make_rbxmx.js ADDED
@@ -0,0 +1,21 @@
1
+ const fs = require('fs');
2
+ const src = fs.readFileSync('../plugin/SyncPlugin.lua', 'utf8');
3
+ const xml = `<roblox xmlns:xmime="http://www.w3.org/2005/05/xmlmime" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://www.roblox.com/roblox.xsd" version="4">
4
+ <Meta name="ExplicitAutoJoints">true</Meta>
5
+ <External>null</External>
6
+ <External>nil</External>
7
+ <Item class="Script" referent="RBX1">
8
+ <Properties>
9
+ <BinaryString name="AttributesSerialize"></BinaryString>
10
+ <bool name="Disabled">false</bool>
11
+ <Content name="LinkedSource"><null></null></Content>
12
+ <string name="Name">SyncRbxSyncPlugin</string>
13
+ <string name="ScriptGuid">{11111111-1111-1111-1111-111111111111}</string>
14
+ <ProtectedString name="Source"><![CDATA[
15
+ ${src}
16
+ ]]></ProtectedString>
17
+ </Properties>
18
+ </Item>
19
+ </roblox>`;
20
+ fs.writeFileSync('../plugin/SyncRbxSync.rbxmx', xml);
21
+ console.log('Successfully generated SyncRbxSync.rbxmx');
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "syncrbx",
3
+ "version": "1.0.0",
4
+ "description": "The #1 totally free tool to sync VS Code with Roblox Studio. Instant bidirectional sync, SyncRbx Cloud, and Auto-Folders.",
5
+ "main": "local-sync.js",
6
+ "bin": {
7
+ "syncrbx": "cli.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node cli.js serve",
11
+ "test": "node test.js"
12
+ },
13
+ "dependencies": {
14
+ "adm-zip": "^0.6.0",
15
+ "archiver": "^7.0.1",
16
+ "chokidar": "^3.6.0",
17
+ "commander": "^15.0.0",
18
+ "express": "^4.22.2"
19
+ }
20
+ }
package/test.js ADDED
@@ -0,0 +1,28 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { processDirectory } = require('./converters');
4
+
5
+ const TEST_DIR = path.join(__dirname, '..', 'test_project');
6
+
7
+ function buildProjectTree() {
8
+ console.log(`Starting Phase 1 Test on ${TEST_DIR}...`);
9
+
10
+ if (!fs.existsSync(TEST_DIR)) {
11
+ console.error("Test directory not found!");
12
+ return;
13
+ }
14
+
15
+ const projectTree = [];
16
+ const rootFolders = fs.readdirSync(TEST_DIR, { withFileTypes: true });
17
+
18
+ for (const folder of rootFolders) {
19
+ if (folder.isDirectory()) {
20
+ const tree = processDirectory(path.join(TEST_DIR, folder.name), true);
21
+ projectTree.push(tree);
22
+ }
23
+ }
24
+
25
+ console.log(JSON.stringify(projectTree, null, 2));
26
+ }
27
+
28
+ buildProjectTree();