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 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();