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 +20 -0
- package/build.js +77 -0
- package/cli.js +322 -0
- package/converters.js +398 -0
- package/local-sync.js +441 -0
- package/make_rbxmx.js +21 -0
- package/package.json +20 -0
- package/test.js +28 -0
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();
|