verde-sync 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/build.js +77 -0
- package/cli.js +283 -0
- package/converters.js +375 -0
- package/local-sync.js +411 -0
- package/package.json +20 -0
- package/test.js +28 -0
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', 'src');
|
|
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,283 @@
|
|
|
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 { createClient } = require('@supabase/supabase-js');
|
|
8
|
+
const { startServer } = require('./local-sync');
|
|
9
|
+
|
|
10
|
+
const program = new Command();
|
|
11
|
+
|
|
12
|
+
// Configuración de Supabase (Deben ser las de producción de Verde Sync)
|
|
13
|
+
const SUPABASE_URL = process.env.SUPABASE_URL || 'https://kbwtiamvxsakgkdiqowh.supabase.co';
|
|
14
|
+
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || 'sb_publishable_JJsCW2748RsShs44NSZndQ_Rk2u5-hc';
|
|
15
|
+
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
|
16
|
+
|
|
17
|
+
const CONFIG_PATH = path.join(process.env.HOME || process.env.USERPROFILE, '.verdeconfig.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 getAuthClient(token) {
|
|
44
|
+
return createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
|
45
|
+
global: {
|
|
46
|
+
headers: {
|
|
47
|
+
Authorization: `Bearer ${token}`
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getProjectConfig() {
|
|
54
|
+
const localVerde = path.join(process.cwd(), '.verde', 'config.json');
|
|
55
|
+
if (fs.existsSync(localVerde)) {
|
|
56
|
+
return JSON.parse(fs.readFileSync(localVerde, 'utf8'));
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- Commands ---
|
|
62
|
+
|
|
63
|
+
program
|
|
64
|
+
.name('verde')
|
|
65
|
+
.description('Verde Sync CLI - Bidirectional Sync and Version Control for Roblox')
|
|
66
|
+
.version('2.0.0');
|
|
67
|
+
|
|
68
|
+
// Comando: Login
|
|
69
|
+
program
|
|
70
|
+
.command('login')
|
|
71
|
+
.description('Autenticarte con tu cuenta de Verde Sync')
|
|
72
|
+
.option('-t, --token <token>', 'Proporcionar el token directamente')
|
|
73
|
+
.action(async (options) => {
|
|
74
|
+
if (options.token) {
|
|
75
|
+
saveToken(options.token);
|
|
76
|
+
console.log('✅ Sesión iniciada correctamente con Verde Sync.');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log('🔗 Para autenticarte, ve a https://verdesync.xyz/settings y copia tu Token CLI.');
|
|
81
|
+
|
|
82
|
+
const readline = require('readline').createInterface({
|
|
83
|
+
input: process.stdin,
|
|
84
|
+
output: process.stdout
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
readline.question('Pegue su Token CLI aquí: ', (token) => {
|
|
88
|
+
if (token && token.trim().length > 0) {
|
|
89
|
+
saveToken(token.trim());
|
|
90
|
+
console.log('✅ Sesión iniciada correctamente con Verde Sync.');
|
|
91
|
+
} else {
|
|
92
|
+
console.log('❌ Error: No se proporcionó ningún token.');
|
|
93
|
+
}
|
|
94
|
+
readline.close();
|
|
95
|
+
forceExit(0);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Comando: Serve (Sincronización local)
|
|
100
|
+
program
|
|
101
|
+
.command('serve')
|
|
102
|
+
.description('Inicia el servidor local de Verde Sync')
|
|
103
|
+
.action(() => {
|
|
104
|
+
startServer();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Comando: Init
|
|
108
|
+
program
|
|
109
|
+
.command('init')
|
|
110
|
+
.description('Inicializa un nuevo repositorio de Verde Sync en la nube')
|
|
111
|
+
.option('--private', 'Hace el repositorio privado')
|
|
112
|
+
.option('--public', 'Hace el repositorio público (por defecto)')
|
|
113
|
+
.action(async (options) => {
|
|
114
|
+
const token = getToken();
|
|
115
|
+
if (!token) {
|
|
116
|
+
console.error('❌ No estás autenticado. Usa "verde login" primero.');
|
|
117
|
+
forceExit(1);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const authClient = getAuthClient(token);
|
|
122
|
+
const { data: { user }, error: userError } = await authClient.auth.getUser(token);
|
|
123
|
+
|
|
124
|
+
if (userError || !user) {
|
|
125
|
+
console.error('❌ Token inválido o expirado. Vuelve a hacer "verde login".');
|
|
126
|
+
forceExit(1);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const srcPath = path.join(process.cwd(), 'src');
|
|
131
|
+
const rojoConfig = path.join(process.cwd(), 'default.project.json');
|
|
132
|
+
|
|
133
|
+
// Validación y Scaffolding
|
|
134
|
+
if (!fs.existsSync(srcPath) && !fs.existsSync(rojoConfig)) {
|
|
135
|
+
console.log('🚧 No se detectó una estructura de Roblox. Creando scaffolding (src/ y config.json)...');
|
|
136
|
+
fs.mkdirSync(srcPath, { recursive: true });
|
|
137
|
+
|
|
138
|
+
const configJson = {
|
|
139
|
+
name: path.basename(process.cwd()),
|
|
140
|
+
version: "1.0.0",
|
|
141
|
+
engine: "roblox",
|
|
142
|
+
type: "verde-sync-project"
|
|
143
|
+
};
|
|
144
|
+
fs.writeFileSync(path.join(srcPath, 'config.json'), JSON.stringify(configJson, null, 2));
|
|
145
|
+
console.log('✅ Estructura base creada.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const isPrivate = options.private ? true : false;
|
|
149
|
+
const projectName = path.basename(process.cwd());
|
|
150
|
+
|
|
151
|
+
console.log(`🚀 Inicializando proyecto "${projectName}" como ${isPrivate ? 'Privado' : 'Público'}...`);
|
|
152
|
+
|
|
153
|
+
// Real DB Insert
|
|
154
|
+
const { data, error } = await authClient
|
|
155
|
+
.from('verde_projects')
|
|
156
|
+
.insert([
|
|
157
|
+
{ owner_id: user.id, name: projectName, is_private: isPrivate, description: 'Proyecto sincronizado vía Verde CLI' }
|
|
158
|
+
])
|
|
159
|
+
.select()
|
|
160
|
+
.single();
|
|
161
|
+
|
|
162
|
+
if (error) {
|
|
163
|
+
if (error.code === '23505') {
|
|
164
|
+
console.error('❌ Error: Ya tienes un proyecto con este mismo nombre.');
|
|
165
|
+
} else {
|
|
166
|
+
console.error(`❌ Error en la base de datos: ${error.message}`);
|
|
167
|
+
}
|
|
168
|
+
forceExit(1);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Guardar vínculo local
|
|
173
|
+
const verdeFolder = path.join(process.cwd(), '.verde');
|
|
174
|
+
if (!fs.existsSync(verdeFolder)) fs.mkdirSync(verdeFolder);
|
|
175
|
+
|
|
176
|
+
fs.writeFileSync(path.join(verdeFolder, 'config.json'), JSON.stringify({ project_id: data.id, owner_id: user.id }, null, 2));
|
|
177
|
+
|
|
178
|
+
console.log('✅ Proyecto inicializado con éxito y vinculado a la nube.');
|
|
179
|
+
forceExit(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Comando: Push
|
|
183
|
+
program
|
|
184
|
+
.command('push')
|
|
185
|
+
.description('Empaqueta tu código y sube un commit a la nube')
|
|
186
|
+
.argument('<message>', 'Mensaje del commit')
|
|
187
|
+
.action(async (message) => {
|
|
188
|
+
const token = getToken();
|
|
189
|
+
if (!token) {
|
|
190
|
+
console.error('❌ No estás autenticado. Usa "verde login" primero.');
|
|
191
|
+
forceExit(1);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const projectConfig = getProjectConfig();
|
|
196
|
+
if (!projectConfig || !projectConfig.project_id) {
|
|
197
|
+
console.error('❌ Este directorio no está vinculado a Verde Cloud. Ejecuta "verde init" primero.');
|
|
198
|
+
forceExit(1);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const authClient = getAuthClient(token);
|
|
203
|
+
const { data: { user }, error: userError } = await authClient.auth.getUser(token);
|
|
204
|
+
if (userError || !user) {
|
|
205
|
+
console.error('❌ Token inválido o expirado. Vuelve a hacer "verde login".');
|
|
206
|
+
forceExit(1);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
console.log(`📦 Empaquetando código local...`);
|
|
211
|
+
const zipPath = path.join(process.cwd(), '.verde_build.zip');
|
|
212
|
+
const output = fs.createWriteStream(zipPath);
|
|
213
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
214
|
+
|
|
215
|
+
output.on('close', async () => {
|
|
216
|
+
const stats = fs.statSync(zipPath);
|
|
217
|
+
const fileSizeInMB = stats.size / (1024 * 1024);
|
|
218
|
+
|
|
219
|
+
if (fileSizeInMB > 10) {
|
|
220
|
+
console.error(`❌ El archivo empaquetado es demasiado grande (${fileSizeInMB.toFixed(2)} MB). Límite de 10 MB excedido.`);
|
|
221
|
+
fs.unlinkSync(zipPath);
|
|
222
|
+
forceExit(1);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
console.log(`✅ Archivo comprimido (${stats.size} bytes).`);
|
|
227
|
+
console.log(`☁️ Subiendo a Supabase Storage...`);
|
|
228
|
+
|
|
229
|
+
const timestamp = Date.now();
|
|
230
|
+
const storagePath = `${user.id}/${projectConfig.project_id}/${timestamp}.zip`;
|
|
231
|
+
|
|
232
|
+
const zipBuffer = fs.readFileSync(zipPath);
|
|
233
|
+
|
|
234
|
+
const { error: uploadError } = await authClient.storage
|
|
235
|
+
.from('verde-repositories')
|
|
236
|
+
.upload(storagePath, zipBuffer, {
|
|
237
|
+
contentType: 'application/zip',
|
|
238
|
+
upsert: true
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
if (uploadError) {
|
|
242
|
+
console.error(`❌ Error al subir al Storage: ${uploadError.message}`);
|
|
243
|
+
fs.unlinkSync(zipPath);
|
|
244
|
+
forceExit(1);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
console.log(`📝 Registrando commit: "${message}"`);
|
|
249
|
+
|
|
250
|
+
const { error: dbError } = await authClient
|
|
251
|
+
.from('verde_commits')
|
|
252
|
+
.insert([
|
|
253
|
+
{
|
|
254
|
+
project_id: projectConfig.project_id,
|
|
255
|
+
author_id: user.id,
|
|
256
|
+
message: message,
|
|
257
|
+
storage_path: storagePath
|
|
258
|
+
}
|
|
259
|
+
]);
|
|
260
|
+
|
|
261
|
+
if (dbError) {
|
|
262
|
+
console.error(`❌ Error al registrar commit: ${dbError.message}`);
|
|
263
|
+
} else {
|
|
264
|
+
console.log(`🎉 Push completado con éxito.`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
fs.unlinkSync(zipPath); // Limpiar
|
|
268
|
+
process.exit(0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
archive.on('error', (err) => {
|
|
272
|
+
throw err;
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
archive.pipe(output);
|
|
276
|
+
// Solo subir archivos seguros y evitar node_modules y multimedia
|
|
277
|
+
archive.glob('**/*.{lua,luau,json,txt,md,toml}', {
|
|
278
|
+
ignore: ['node_modules/**', '.git/**', '.verde/**', '.verde_build.zip']
|
|
279
|
+
});
|
|
280
|
+
archive.finalize();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
program.parse(process.argv);
|
package/converters.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
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
|
+
'.lua', '.luau',
|
|
24
|
+
'.txt', '.json'
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// Phase 2 - #5: More instance types via model.json
|
|
28
|
+
const VALUE_CLASSES = {
|
|
29
|
+
IntValue: 'number',
|
|
30
|
+
NumberValue: 'number',
|
|
31
|
+
BoolValue: 'boolean',
|
|
32
|
+
StringValue: 'string',
|
|
33
|
+
Color3Value: 'color3',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Helpers
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
function stripExtension(filename) {
|
|
41
|
+
for (const ext of KNOWN_EXTENSIONS) {
|
|
42
|
+
if (filename.endsWith(ext)) {
|
|
43
|
+
return filename.slice(0, -ext.length);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const lastDot = filename.lastIndexOf('.');
|
|
47
|
+
return lastDot > 0 ? filename.slice(0, lastDot) : filename;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getMetaProperties(metaFilePath) {
|
|
51
|
+
if (fs.existsSync(metaFilePath)) {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(fs.readFileSync(metaFilePath, 'utf8'));
|
|
54
|
+
} catch (e) {
|
|
55
|
+
console.error(`[WARN] Error parsing meta file ${metaFilePath}:`, e.message);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function determineScriptType(filename) {
|
|
62
|
+
if (filename.endsWith('.server.lua') || filename.endsWith('.server.luau')) return 'Script';
|
|
63
|
+
if (filename.endsWith('.client.lua') || filename.endsWith('.client.luau')) return 'LocalScript';
|
|
64
|
+
if (filename.endsWith('.lua') || filename.endsWith('.luau')) return 'ModuleScript';
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Phase 4 - #7: File checksum
|
|
69
|
+
function fileChecksum(filePath) {
|
|
70
|
+
try {
|
|
71
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
72
|
+
return crypto.createHash('md5').update(content).digest('hex');
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// JSON → Lua table conversion
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
function jsonToLuaTable(value, indent) {
|
|
82
|
+
indent = indent || 0;
|
|
83
|
+
const pad = '\t'.repeat(indent);
|
|
84
|
+
const padInner = '\t'.repeat(indent + 1);
|
|
85
|
+
|
|
86
|
+
if (value === null || value === undefined) return 'nil';
|
|
87
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
88
|
+
if (typeof value === 'number') return String(value);
|
|
89
|
+
if (typeof value === 'string') {
|
|
90
|
+
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
|
91
|
+
return `"${escaped}"`;
|
|
92
|
+
}
|
|
93
|
+
if (Array.isArray(value)) {
|
|
94
|
+
if (value.length === 0) return '{}';
|
|
95
|
+
const items = value.map(v => `${padInner}${jsonToLuaTable(v, indent + 1)}`);
|
|
96
|
+
return `{\n${items.join(',\n')}\n${pad}}`;
|
|
97
|
+
}
|
|
98
|
+
if (typeof value === 'object') {
|
|
99
|
+
const keys = Object.keys(value);
|
|
100
|
+
if (keys.length === 0) return '{}';
|
|
101
|
+
const entries = keys.map(k => {
|
|
102
|
+
const luaKey = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(k) ? k : `["${k}"]`;
|
|
103
|
+
return `${padInner}${luaKey} = ${jsonToLuaTable(value[k], indent + 1)}`;
|
|
104
|
+
});
|
|
105
|
+
return `{\n${entries.join(',\n')}\n${pad}}`;
|
|
106
|
+
}
|
|
107
|
+
return 'nil';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// File → Instance conversion
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
function convertFile(filePath) {
|
|
114
|
+
const filename = path.basename(filePath);
|
|
115
|
+
let content;
|
|
116
|
+
try {
|
|
117
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
118
|
+
} catch (e) {
|
|
119
|
+
console.error(`[WARN] Could not read file ${filePath}:`, e.message);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const name = stripExtension(filename);
|
|
124
|
+
|
|
125
|
+
let instance = {
|
|
126
|
+
Name: name,
|
|
127
|
+
ClassName: 'Unknown',
|
|
128
|
+
Properties: {},
|
|
129
|
+
Children: []
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (filename.endsWith('.model.json')) {
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(content);
|
|
135
|
+
instance.ClassName = parsed.ClassName || 'Folder';
|
|
136
|
+
instance.Properties = parsed.Properties || {};
|
|
137
|
+
if (parsed.Name) instance.Name = parsed.Name;
|
|
138
|
+
if (parsed.Children && Array.isArray(parsed.Children)) {
|
|
139
|
+
instance.Children = parsed.Children;
|
|
140
|
+
}
|
|
141
|
+
} catch (e) {
|
|
142
|
+
console.error(`[WARN] Error parsing model file ${filePath}:`, e.message);
|
|
143
|
+
}
|
|
144
|
+
} else if (filename.endsWith('.txt')) {
|
|
145
|
+
instance.ClassName = 'StringValue';
|
|
146
|
+
instance.Properties.Value = content;
|
|
147
|
+
} else if (filename.endsWith('.json') && !filename.endsWith('.meta.json')) {
|
|
148
|
+
try {
|
|
149
|
+
const parsed = JSON.parse(content);
|
|
150
|
+
instance.ClassName = 'ModuleScript';
|
|
151
|
+
instance.Properties.Source = `return ${jsonToLuaTable(parsed)}`;
|
|
152
|
+
} catch (e) {
|
|
153
|
+
console.error(`[WARN] Error parsing JSON data file ${filePath}:`, e.message);
|
|
154
|
+
instance.ClassName = 'ModuleScript';
|
|
155
|
+
instance.Properties.Source = `-- ERROR: Could not parse JSON\nreturn nil`;
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
const scriptType = determineScriptType(filename);
|
|
159
|
+
if (scriptType) {
|
|
160
|
+
instance.ClassName = scriptType;
|
|
161
|
+
instance.Properties.Source = content;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return instance;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Directory → Instance tree (recursive)
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
function processDirectory(dirPath, isRoot = false) {
|
|
172
|
+
const dirName = path.basename(dirPath);
|
|
173
|
+
|
|
174
|
+
let className = 'Folder';
|
|
175
|
+
if (isRoot && ROOT_SERVICES.includes(dirName)) {
|
|
176
|
+
className = dirName;
|
|
177
|
+
} else if (STARTER_PLAYER_CHILDREN.includes(dirName)) {
|
|
178
|
+
// Phase 1 - Bug #2: Identify these as their proper class
|
|
179
|
+
className = dirName;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let instance = {
|
|
183
|
+
Name: dirName,
|
|
184
|
+
ClassName: className,
|
|
185
|
+
Properties: {},
|
|
186
|
+
Children: []
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
let entries;
|
|
190
|
+
try {
|
|
191
|
+
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
192
|
+
} catch (e) {
|
|
193
|
+
console.error(`[WARN] Could not read directory ${dirPath}:`, e.message);
|
|
194
|
+
return instance;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Handle init.lua / init.server.lua / init.client.lua
|
|
198
|
+
const initFile = entries.find(e =>
|
|
199
|
+
e.isFile() && e.name.startsWith('init.') &&
|
|
200
|
+
(e.name.endsWith('.lua') || e.name.endsWith('.luau'))
|
|
201
|
+
);
|
|
202
|
+
if (initFile) {
|
|
203
|
+
const scriptType = determineScriptType(initFile.name);
|
|
204
|
+
if (scriptType && instance.ClassName === 'Folder') {
|
|
205
|
+
instance.ClassName = scriptType;
|
|
206
|
+
try {
|
|
207
|
+
instance.Properties.Source = fs.readFileSync(path.join(dirPath, initFile.name), 'utf8');
|
|
208
|
+
} catch (e) {
|
|
209
|
+
console.error(`[WARN] Could not read init file:`, e.message);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Handle meta.json inside the folder
|
|
215
|
+
const metaInside = entries.find(e => e.isFile() && e.name === 'meta.json');
|
|
216
|
+
if (metaInside) {
|
|
217
|
+
const metaProps = getMetaProperties(path.join(dirPath, 'meta.json'));
|
|
218
|
+
instance.Properties = { ...instance.Properties, ...metaProps };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Process children
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
if (entry.name === 'meta.json' || entry.name.endsWith('.meta.json') || entry.name.startsWith('init.')) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const entryPath = path.join(dirPath, entry.name);
|
|
228
|
+
|
|
229
|
+
if (entry.isDirectory()) {
|
|
230
|
+
const childInstance = processDirectory(entryPath, false);
|
|
231
|
+
const metaFileOutside = entries.find(e => e.isFile() && e.name === `${entry.name}.meta.json`);
|
|
232
|
+
if (metaFileOutside) {
|
|
233
|
+
const metaProps = getMetaProperties(path.join(dirPath, metaFileOutside.name));
|
|
234
|
+
childInstance.Properties = { ...childInstance.Properties, ...metaProps };
|
|
235
|
+
}
|
|
236
|
+
instance.Children.push(childInstance);
|
|
237
|
+
} else if (entry.isFile()) {
|
|
238
|
+
const childInstance = convertFile(entryPath);
|
|
239
|
+
if (childInstance && childInstance.ClassName !== 'Unknown') {
|
|
240
|
+
const metaFileName = `${entry.name}.meta.json`;
|
|
241
|
+
const metaFile = entries.find(e => e.isFile() && e.name === metaFileName);
|
|
242
|
+
if (metaFile) {
|
|
243
|
+
const metaProps = getMetaProperties(path.join(dirPath, metaFileName));
|
|
244
|
+
childInstance.Properties = { ...childInstance.Properties, ...metaProps };
|
|
245
|
+
}
|
|
246
|
+
instance.Children.push(childInstance);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return instance;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Phase 4 - #7: Build checksums map for the entire project
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
function buildChecksums(dirPath, prefix = '') {
|
|
258
|
+
const checksums = {};
|
|
259
|
+
let entries;
|
|
260
|
+
try {
|
|
261
|
+
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
262
|
+
} catch {
|
|
263
|
+
return checksums;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
for (const entry of entries) {
|
|
267
|
+
if (entry.name.endsWith('.conflict.bak') || entry.name.endsWith('.meta.json')) continue;
|
|
268
|
+
const entryPath = path.join(dirPath, entry.name);
|
|
269
|
+
const instancePath = prefix ? `${prefix}/${stripExtension(entry.name)}` : stripExtension(entry.name);
|
|
270
|
+
|
|
271
|
+
if (entry.isDirectory()) {
|
|
272
|
+
Object.assign(checksums, buildChecksums(entryPath, prefix ? `${prefix}/${entry.name}` : entry.name));
|
|
273
|
+
} else if (entry.isFile()) {
|
|
274
|
+
const checksum = fileChecksum(entryPath);
|
|
275
|
+
if (checksum) {
|
|
276
|
+
checksums[instancePath] = checksum;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return checksums;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// Instance → File (inverse conversion for Studio → Disk)
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
function instanceToFile(instanceData) {
|
|
287
|
+
const name = instanceData.name || instanceData.Name || 'Untitled';
|
|
288
|
+
const className = instanceData.className || instanceData.ClassName || 'Folder';
|
|
289
|
+
const source = instanceData.source || (instanceData.Properties && instanceData.Properties.Source) || '';
|
|
290
|
+
const value = instanceData.value || (instanceData.Properties && instanceData.Properties.Value) || '';
|
|
291
|
+
|
|
292
|
+
if (className === 'Folder') {
|
|
293
|
+
return { fileName: name, content: null, isDirectory: true };
|
|
294
|
+
}
|
|
295
|
+
if (className === 'Script') {
|
|
296
|
+
return { fileName: `${name}.server.lua`, content: source, isDirectory: false };
|
|
297
|
+
}
|
|
298
|
+
if (className === 'LocalScript') {
|
|
299
|
+
return { fileName: `${name}.client.lua`, content: source, isDirectory: false };
|
|
300
|
+
}
|
|
301
|
+
if (className === 'ModuleScript') {
|
|
302
|
+
return { fileName: `${name}.lua`, content: source, isDirectory: false };
|
|
303
|
+
}
|
|
304
|
+
if (className === 'StringValue') {
|
|
305
|
+
return { fileName: `${name}.txt`, content: value, isDirectory: false };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Phase 2 - #5: Value types and other instances → .model.json
|
|
309
|
+
if (VALUE_CLASSES[className] !== undefined) {
|
|
310
|
+
const modelData = { ClassName: className, Properties: { Value: value } };
|
|
311
|
+
return { fileName: `${name}.model.json`, content: JSON.stringify(modelData, null, 2), isDirectory: false };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Generic instance → .model.json
|
|
315
|
+
const modelData = { ClassName: className, Properties: {} };
|
|
316
|
+
if (instanceData.Properties) {
|
|
317
|
+
modelData.Properties = { ...instanceData.Properties };
|
|
318
|
+
}
|
|
319
|
+
return { fileName: `${name}.model.json`, content: JSON.stringify(modelData, null, 2), isDirectory: false };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function filePathToInstancePath(filePath, projectRoot) {
|
|
323
|
+
let relative = path.relative(projectRoot, filePath).replace(/\\/g, '/');
|
|
324
|
+
const parts = relative.split('/');
|
|
325
|
+
if (parts.length > 0) {
|
|
326
|
+
const last = parts[parts.length - 1];
|
|
327
|
+
if (last.startsWith('init.') && (last.endsWith('.lua') || last.endsWith('.luau'))) {
|
|
328
|
+
parts.pop();
|
|
329
|
+
} else {
|
|
330
|
+
parts[parts.length - 1] = stripExtension(last);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return parts.join('/');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// Phase 2 - #4: Project config loader
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
function loadProjectConfig(projectRoot) {
|
|
340
|
+
const configPath = path.join(projectRoot, 'verde.project.json');
|
|
341
|
+
const defaults = {
|
|
342
|
+
name: path.basename(projectRoot),
|
|
343
|
+
port: 34872,
|
|
344
|
+
ignore: ['*.conflict.bak', 'node_modules', '.git', '.vscode'],
|
|
345
|
+
tree: null, // null = auto-detect from folder names
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
if (fs.existsSync(configPath)) {
|
|
349
|
+
try {
|
|
350
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
351
|
+
return { ...defaults, ...config };
|
|
352
|
+
} catch (e) {
|
|
353
|
+
console.error(`[WARN] Error parsing verde.project.json: ${e.message}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return defaults;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
module.exports = {
|
|
360
|
+
ROOT_SERVICES,
|
|
361
|
+
STARTER_PLAYER_CHILDREN,
|
|
362
|
+
KNOWN_EXTENSIONS,
|
|
363
|
+
VALUE_CLASSES,
|
|
364
|
+
stripExtension,
|
|
365
|
+
determineScriptType,
|
|
366
|
+
jsonToLuaTable,
|
|
367
|
+
convertFile,
|
|
368
|
+
processDirectory,
|
|
369
|
+
buildChecksums,
|
|
370
|
+
instanceToFile,
|
|
371
|
+
filePathToInstancePath,
|
|
372
|
+
getMetaProperties,
|
|
373
|
+
loadProjectConfig,
|
|
374
|
+
fileChecksum,
|
|
375
|
+
};
|
package/local-sync.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
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 (Phase 2 - #4)
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
const targetDirArg = process.argv[2]
|
|
17
|
+
? path.resolve(process.argv[2])
|
|
18
|
+
: path.join(__dirname, '..', 'test_project');
|
|
19
|
+
const PROJECT_ROOT = targetDirArg;
|
|
20
|
+
|
|
21
|
+
const projectConfig = loadProjectConfig(PROJECT_ROOT);
|
|
22
|
+
const PORT = projectConfig.port || 34872;
|
|
23
|
+
|
|
24
|
+
app.use(express.json({ limit: '50mb' }));
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// ANSI color helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
const c = {
|
|
30
|
+
green: (t) => `\x1b[32m${t}\x1b[0m`,
|
|
31
|
+
red: (t) => `\x1b[31m${t}\x1b[0m`,
|
|
32
|
+
yellow: (t) => `\x1b[33m${t}\x1b[0m`,
|
|
33
|
+
cyan: (t) => `\x1b[36m${t}\x1b[0m`,
|
|
34
|
+
magenta: (t) => `\x1b[35m${t}\x1b[0m`,
|
|
35
|
+
dim: (t) => `\x1b[2m${t}\x1b[0m`,
|
|
36
|
+
bold: (t) => `\x1b[1m${t}\x1b[0m`,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function logDisk(msg) { console.log(`${c.cyan('[Disco]')} ${msg}`); }
|
|
40
|
+
function logStudio(msg) { console.log(`${c.magenta('[Studio]')} ${msg}`); }
|
|
41
|
+
function logServer(msg) { console.log(`${c.green('[Verde]')} ${msg}`); }
|
|
42
|
+
function logWarn(msg) { console.log(`${c.yellow('[WARN]')} ${msg}`); }
|
|
43
|
+
function logError(msg) { console.log(`${c.red('[ERROR]')} ${msg}`); }
|
|
44
|
+
function logConflict(msg){ console.log(`${c.red(c.bold('[CONFLICT]'))} ${msg}`); }
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Echo-Loop Prevention
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
const ignoreSet = new Set();
|
|
50
|
+
const IGNORE_TTL_MS = 3000;
|
|
51
|
+
|
|
52
|
+
function markIgnored(filePath) {
|
|
53
|
+
const normalized = path.resolve(filePath);
|
|
54
|
+
ignoreSet.add(normalized);
|
|
55
|
+
setTimeout(() => ignoreSet.delete(normalized), IGNORE_TTL_MS);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isIgnored(filePath) {
|
|
59
|
+
return ignoreSet.has(path.resolve(filePath));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Conflict detection
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
const lastWriteTimestamps = new Map();
|
|
66
|
+
|
|
67
|
+
function getTimestamps(filePath) {
|
|
68
|
+
const key = path.resolve(filePath);
|
|
69
|
+
if (!lastWriteTimestamps.has(key)) {
|
|
70
|
+
lastWriteTimestamps.set(key, { disk: 0, studio: 0 });
|
|
71
|
+
}
|
|
72
|
+
return lastWriteTimestamps.get(key);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function createConflictBackup(filePath) {
|
|
76
|
+
if (!fs.existsSync(filePath)) return;
|
|
77
|
+
const backupPath = filePath + '.conflict.bak';
|
|
78
|
+
try {
|
|
79
|
+
fs.copyFileSync(filePath, backupPath);
|
|
80
|
+
logConflict(`Backup guardado: ${path.basename(backupPath)}`);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
logError(`No se pudo crear backup de conflicto: ${e.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Long-polling state
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
let pendingChanges = [];
|
|
90
|
+
let waitingClients = [];
|
|
91
|
+
|
|
92
|
+
function pushChange(change) {
|
|
93
|
+
change.timestamp = Date.now();
|
|
94
|
+
pendingChanges.push(change);
|
|
95
|
+
|
|
96
|
+
while (waitingClients.length > 0) {
|
|
97
|
+
const clientRes = waitingClients.shift();
|
|
98
|
+
try {
|
|
99
|
+
clientRes.json([...pendingChanges]);
|
|
100
|
+
} catch { /* client may have disconnected */ }
|
|
101
|
+
}
|
|
102
|
+
pendingChanges = [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Incremental sync — enrich disk changes with instance data
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
function buildChangePayload(type, filePath) {
|
|
109
|
+
const instancePath = filePathToInstancePath(filePath, PROJECT_ROOT);
|
|
110
|
+
|
|
111
|
+
if (type === 'Removed') {
|
|
112
|
+
return { type: 'Removed', instancePath, timestamp: Date.now() };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let data = null;
|
|
116
|
+
try {
|
|
117
|
+
const stat = fs.statSync(filePath);
|
|
118
|
+
if (stat.isDirectory()) {
|
|
119
|
+
data = processDirectory(filePath, ROOT_SERVICES.includes(path.basename(filePath)));
|
|
120
|
+
} else {
|
|
121
|
+
data = convertFile(filePath);
|
|
122
|
+
}
|
|
123
|
+
} catch (e) {
|
|
124
|
+
logWarn(`Could not read ${filePath}: ${e.message}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { type, instancePath, data, timestamp: Date.now() };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Chokidar file watcher (Phase 2 - #4: uses project config ignore patterns)
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
const ignorePatterns = [
|
|
134
|
+
/(^|[\/\\])\../, // dotfiles
|
|
135
|
+
/\.conflict\.bak$/, // conflict backups
|
|
136
|
+
/node_modules/,
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
// Add user-defined ignore patterns from config
|
|
140
|
+
if (projectConfig.ignore) {
|
|
141
|
+
for (const pattern of projectConfig.ignore) {
|
|
142
|
+
if (pattern.startsWith('*.')) {
|
|
143
|
+
const ext = pattern.slice(1).replace('.', '\\.');
|
|
144
|
+
ignorePatterns.push(new RegExp(`${ext}$`));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const watcher = chokidar.watch(PROJECT_ROOT, {
|
|
150
|
+
ignored: ignorePatterns,
|
|
151
|
+
persistent: true,
|
|
152
|
+
ignoreInitial: true,
|
|
153
|
+
awaitWriteFinish: {
|
|
154
|
+
stabilityThreshold: 300,
|
|
155
|
+
pollInterval: 100,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
watcher
|
|
160
|
+
.on('add', filePath => {
|
|
161
|
+
if (isIgnored(filePath)) return;
|
|
162
|
+
logDisk(`Archivo creado: ${path.relative(PROJECT_ROOT, filePath)}`);
|
|
163
|
+
getTimestamps(filePath).disk = Date.now();
|
|
164
|
+
pushChange(buildChangePayload('Added', filePath));
|
|
165
|
+
})
|
|
166
|
+
.on('change', filePath => {
|
|
167
|
+
if (isIgnored(filePath)) return;
|
|
168
|
+
logDisk(`Archivo modificado: ${path.relative(PROJECT_ROOT, filePath)}`);
|
|
169
|
+
getTimestamps(filePath).disk = Date.now();
|
|
170
|
+
pushChange(buildChangePayload('Changed', filePath));
|
|
171
|
+
})
|
|
172
|
+
.on('unlink', filePath => {
|
|
173
|
+
if (isIgnored(filePath)) return;
|
|
174
|
+
logDisk(`Archivo eliminado: ${path.relative(PROJECT_ROOT, filePath)}`);
|
|
175
|
+
pushChange(buildChangePayload('Removed', filePath));
|
|
176
|
+
lastWriteTimestamps.delete(path.resolve(filePath));
|
|
177
|
+
})
|
|
178
|
+
.on('addDir', dirPath => {
|
|
179
|
+
if (isIgnored(dirPath)) return;
|
|
180
|
+
logDisk(`Carpeta creada: ${path.relative(PROJECT_ROOT, dirPath)}`);
|
|
181
|
+
pushChange(buildChangePayload('Added', dirPath));
|
|
182
|
+
})
|
|
183
|
+
.on('unlinkDir', dirPath => {
|
|
184
|
+
if (isIgnored(dirPath)) return;
|
|
185
|
+
logDisk(`Carpeta eliminada: ${path.relative(PROJECT_ROOT, dirPath)}`);
|
|
186
|
+
pushChange(buildChangePayload('Removed', dirPath));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// API Endpoints
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
app.get('/ping', (req, res) => {
|
|
194
|
+
res.json({
|
|
195
|
+
status: 'ok',
|
|
196
|
+
project: projectConfig.name || path.basename(PROJECT_ROOT),
|
|
197
|
+
version: '2.0.0',
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
app.get('/tree', (req, res) => {
|
|
202
|
+
try {
|
|
203
|
+
const tree = [];
|
|
204
|
+
if (fs.existsSync(PROJECT_ROOT)) {
|
|
205
|
+
const rootFolders = fs.readdirSync(PROJECT_ROOT, { withFileTypes: true });
|
|
206
|
+
for (const folder of rootFolders) {
|
|
207
|
+
if (folder.isDirectory()) {
|
|
208
|
+
const subTree = processDirectory(path.join(PROJECT_ROOT, folder.name), true);
|
|
209
|
+
tree.push(subTree);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
logServer(`Árbol enviado (${tree.length} servicios)`);
|
|
214
|
+
res.json(tree);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
logError(`Failed to generate tree: ${err.message}`);
|
|
217
|
+
res.status(500).json({ error: err.message });
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
app.get('/changes', (req, res) => {
|
|
222
|
+
if (pendingChanges.length > 0) {
|
|
223
|
+
const changesToSend = [...pendingChanges];
|
|
224
|
+
pendingChanges = [];
|
|
225
|
+
return res.json(changesToSend);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
waitingClients.push(res);
|
|
229
|
+
|
|
230
|
+
const timeout = setTimeout(() => {
|
|
231
|
+
const index = waitingClients.indexOf(res);
|
|
232
|
+
if (index !== -1) {
|
|
233
|
+
waitingClients.splice(index, 1);
|
|
234
|
+
res.json([]);
|
|
235
|
+
}
|
|
236
|
+
}, 30000);
|
|
237
|
+
|
|
238
|
+
res.on('close', () => {
|
|
239
|
+
clearTimeout(timeout);
|
|
240
|
+
const index = waitingClients.indexOf(res);
|
|
241
|
+
if (index !== -1) {
|
|
242
|
+
waitingClients.splice(index, 1);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Phase 4 - #7: Checksums endpoint for incremental diff
|
|
248
|
+
app.get('/checksums', (req, res) => {
|
|
249
|
+
try {
|
|
250
|
+
const checksums = buildChecksums(PROJECT_ROOT);
|
|
251
|
+
res.json(checksums);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
logError(`Failed to build checksums: ${err.message}`);
|
|
254
|
+
res.status(500).json({ error: err.message });
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Sanitize filename helper
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
function sanitizeFileName(name) {
|
|
262
|
+
return name.replace(/[<>:"\/\\|?*\x00-\x1f]+/g, '_');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// POST /studio-change — Studio reports changes to write to disk
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
app.post('/studio-change', (req, res) => {
|
|
269
|
+
const changes = req.body;
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
if (!Array.isArray(changes)) {
|
|
273
|
+
return res.status(400).json({ error: 'Expected an array of changes' });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
for (const change of changes) {
|
|
277
|
+
const pathParts = (change.path || '').split('/').filter(Boolean);
|
|
278
|
+
if (pathParts.length < 1) {
|
|
279
|
+
logWarn('Received change with empty path, skipping.');
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
logStudio(`${change.type} → ${change.path} (${change.className || '?'})`);
|
|
284
|
+
|
|
285
|
+
const fileInfo = instanceToFile(change);
|
|
286
|
+
const sanitizedParts = pathParts.map(sanitizeFileName);
|
|
287
|
+
const ancestorParts = sanitizedParts.slice(0, -1);
|
|
288
|
+
const parentDir = path.join(PROJECT_ROOT, ...ancestorParts);
|
|
289
|
+
|
|
290
|
+
let targetPath;
|
|
291
|
+
if (fileInfo.isDirectory) {
|
|
292
|
+
targetPath = path.join(parentDir, sanitizeFileName(fileInfo.fileName));
|
|
293
|
+
} else {
|
|
294
|
+
targetPath = path.join(parentDir, sanitizeFileName(fileInfo.fileName));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Conflict detection
|
|
298
|
+
if ((change.type === 'Added' || change.type === 'Changed') && !fileInfo.isDirectory) {
|
|
299
|
+
const timestamps = getTimestamps(targetPath);
|
|
300
|
+
const now = Date.now();
|
|
301
|
+
if (timestamps.disk > 0 && (now - timestamps.disk) < 5000 && fs.existsSync(targetPath)) {
|
|
302
|
+
logConflict(`${path.basename(targetPath)} editado en ambos lados.`);
|
|
303
|
+
createConflictBackup(targetPath);
|
|
304
|
+
}
|
|
305
|
+
timestamps.studio = now;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Execute the write
|
|
309
|
+
if (change.type === 'Added' || change.type === 'Changed') {
|
|
310
|
+
if (!fs.existsSync(parentDir)) {
|
|
311
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (fileInfo.isDirectory) {
|
|
315
|
+
if (!fs.existsSync(targetPath)) {
|
|
316
|
+
fs.mkdirSync(targetPath, { recursive: true });
|
|
317
|
+
}
|
|
318
|
+
logDisk(`Carpeta creada: ${path.relative(PROJECT_ROOT, targetPath)}`);
|
|
319
|
+
} else {
|
|
320
|
+
markIgnored(targetPath);
|
|
321
|
+
fs.writeFileSync(targetPath, fileInfo.content || '', 'utf8');
|
|
322
|
+
logDisk(`Escrito: ${path.relative(PROJECT_ROOT, targetPath)}`);
|
|
323
|
+
}
|
|
324
|
+
} else if (change.type === 'Removed') {
|
|
325
|
+
let pathsToTry = [targetPath];
|
|
326
|
+
if (!fs.existsSync(targetPath)) {
|
|
327
|
+
const baseName = sanitizeFileName(change.name || pathParts[pathParts.length - 1]);
|
|
328
|
+
if (fs.existsSync(parentDir)) {
|
|
329
|
+
const siblings = fs.readdirSync(parentDir);
|
|
330
|
+
for (const sibling of siblings) {
|
|
331
|
+
if (stripExtension(sibling) === baseName) {
|
|
332
|
+
pathsToTry.push(path.join(parentDir, sibling));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const p of pathsToTry) {
|
|
339
|
+
if (fs.existsSync(p)) {
|
|
340
|
+
markIgnored(p);
|
|
341
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
342
|
+
logDisk(`Eliminado: ${path.relative(PROJECT_ROOT, p)}`);
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
} else if (change.type === 'Renamed') {
|
|
347
|
+
const oldParts = (change.oldPath || '').split('/').filter(Boolean).map(sanitizeFileName);
|
|
348
|
+
if (oldParts.length > 0) {
|
|
349
|
+
const oldDir = path.join(PROJECT_ROOT, ...oldParts.slice(0, -1));
|
|
350
|
+
const oldName = oldParts[oldParts.length - 1];
|
|
351
|
+
if (fs.existsSync(oldDir)) {
|
|
352
|
+
const siblings = fs.readdirSync(oldDir);
|
|
353
|
+
for (const sibling of siblings) {
|
|
354
|
+
if (stripExtension(sibling) === oldName) {
|
|
355
|
+
const oldPath = path.join(oldDir, sibling);
|
|
356
|
+
markIgnored(oldPath);
|
|
357
|
+
markIgnored(targetPath);
|
|
358
|
+
try {
|
|
359
|
+
fs.renameSync(oldPath, targetPath);
|
|
360
|
+
logDisk(`Renombrado: ${sibling} → ${fileInfo.fileName}`);
|
|
361
|
+
} catch (e) {
|
|
362
|
+
logError(`Rename failed: ${e.message}`);
|
|
363
|
+
}
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
res.json({ success: true });
|
|
373
|
+
} catch (e) {
|
|
374
|
+
logError(`Processing studio changes: ${e.message}`);
|
|
375
|
+
res.status(500).json({ error: e.message });
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// Graceful shutdown
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
function shutdown(signal) {
|
|
383
|
+
logServer(`${signal} recibido. Cerrando Verde...`);
|
|
384
|
+
watcher.close().then(() => {
|
|
385
|
+
logServer('File watcher cerrado.');
|
|
386
|
+
process.exit(0);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
391
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// Start
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
function startServer() {
|
|
397
|
+
app.listen(PORT, () => {
|
|
398
|
+
console.log('');
|
|
399
|
+
console.log(c.bold(c.green(' ╔══════════════════════════════════════════╗')));
|
|
400
|
+
console.log(c.bold(c.green(' ║ 🌿 VERDE SYNC v2.0 🌿 ║')));
|
|
401
|
+
console.log(c.bold(c.green(' ╚══════════════════════════════════════════╝')));
|
|
402
|
+
console.log('');
|
|
403
|
+
logServer(`Servidor en ${c.bold(`localhost:${PORT}`)}`);
|
|
404
|
+
logServer(`Proyecto: ${c.cyan(projectConfig.name)}`);
|
|
405
|
+
logServer(`Monitoreando: ${c.cyan(PROJECT_ROOT)}`);
|
|
406
|
+
logServer(`Esperando conexión de Roblox Studio...`);
|
|
407
|
+
console.log('');
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
module.exports = { startServer };
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "verde-sync",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Bidirectional sync tool between PC and Roblox Studio",
|
|
5
|
+
"main": "local-sync.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"verde": "cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node cli.js serve",
|
|
11
|
+
"test": "node test.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@supabase/supabase-js": "^2.110.2",
|
|
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();
|