syncrbx 1.0.0 → 1.1.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/cli.js CHANGED
@@ -1,322 +1,459 @@
1
- #!/usr/bin/env node
2
-
3
- const { Command } = require('commander');
4
- const fs = require('fs');
5
- const path = require('path');
6
- const archiver = require('archiver');
7
- const AdmZip = require('adm-zip');
8
- const http = require('http');
9
- const { exec } = require('child_process');
10
- const { startServer } = require('./local-sync');
11
-
12
- const program = new Command();
13
-
14
- const API_BASE_URL = process.env.SYNCRBX_API_URL || 'http://localhost:3000/api/cli';
15
- const WEB_BASE_URL = process.env.SYNCRBX_WEB_URL || 'https://www.syncrbx.xyz';
16
-
17
- const CONFIG_PATH = path.join(process.env.HOME || process.env.USERPROFILE, '.syncrbxconfig.json');
18
-
19
- // --- Helper Functions ---
20
-
21
- function forceExit(code) {
22
- setTimeout(() => {
23
- process.exit(code);
24
- }, 50);
25
- }
26
-
27
- function getToken() {
28
- if (fs.existsSync(CONFIG_PATH)) {
29
- try {
30
- const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
31
- return config.access_token;
32
- } catch (e) {
33
- return null;
34
- }
35
- }
36
- return null;
37
- }
38
-
39
- function saveToken(token) {
40
- fs.writeFileSync(CONFIG_PATH, JSON.stringify({ access_token: token }, null, 2));
41
- }
42
-
43
- function getProjectConfig() {
44
- const localSyncRbx = path.join(process.cwd(), '.syncrbx', 'config.json');
45
- if (fs.existsSync(localSyncRbx)) {
46
- return JSON.parse(fs.readFileSync(localSyncRbx, 'utf8'));
47
- }
48
- return null;
49
- }
50
-
51
- function openBrowser(url) {
52
- const start = (process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open');
53
- exec(`${start} ${url}`);
54
- }
55
-
56
- // --- Commands ---
57
-
58
- program
59
- .name('syncrbx')
60
- .description('SyncRbx - The #1 totally free tool to sync VS Code with Roblox Studio.')
61
- .version('2.1.0');
62
-
63
- // Comando: Login
64
- program
65
- .command('login')
66
- .description('Autenticarte con tu cuenta de SyncRbx Sync')
67
- .option('-t, --token <token>', 'Proporcionar el token directamente')
68
- .action((options) => {
69
- if (options.token) {
70
- saveToken(options.token);
71
- console.log('✅ Sesión iniciada correctamente con SyncRbx Sync.');
72
- return;
73
- }
74
-
75
- const PORT = 14872;
76
- const loginUrl = `${WEB_BASE_URL}/cli-login?port=${PORT}`;
77
-
78
- console.log(`🔗 Abriendo el navegador para autenticarte...`);
79
- console.log(`Si el navegador no se abre automáticamente, entra aquí: ${loginUrl}`);
80
-
81
- const server = http.createServer((req, res) => {
82
- const url = new URL(req.url, `http://localhost:${PORT}`);
83
- if (url.pathname === '/callback') {
84
- const token = url.searchParams.get('token');
85
- if (token) {
86
- saveToken(token);
87
- res.writeHead(200, {
88
- 'Content-Type': 'application/json',
89
- 'Access-Control-Allow-Origin': '*'
90
- });
91
- res.end(JSON.stringify({ success: true }));
92
- console.log('✅ Sesión iniciada correctamente. Ya puedes volver a la terminal.');
93
- server.close(() => forceExit(0));
94
- } else {
95
- res.writeHead(400, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
96
- res.end(JSON.stringify({ error: 'Token missing' }));
97
- console.error('❌ Error: No se recibió ningún token.');
98
- server.close(() => forceExit(1));
99
- }
100
- } else {
101
- res.writeHead(404);
102
- res.end();
103
- }
104
- });
105
-
106
- server.listen(PORT, () => {
107
- openBrowser(loginUrl);
108
- });
109
- });
110
-
111
- // Comando: Serve
112
- program
113
- .command('serve')
114
- .description('Inicia el servidor local de SyncRbx Sync')
115
- .action(() => {
116
- startServer();
117
- });
118
-
119
- // Comando: Init
120
- program
121
- .command('init')
122
- .description('Inicializa un nuevo repositorio de SyncRbx Sync en la nube')
123
- .option('--private', 'Hace el repositorio privado')
124
- .option('--public', 'Hace el repositorio público (por defecto)')
125
- .action(async (options) => {
126
- const token = getToken();
127
- if (!token) {
128
- console.error('❌ No estás autenticado. Usa "syncrbx login" primero.');
129
- forceExit(1);
130
- return;
131
- }
132
-
133
- const srcPath = path.join(process.cwd(), 'src');
134
- const rojoConfig = path.join(process.cwd(), 'default.project.json');
135
-
136
- if (!fs.existsSync(srcPath) && !fs.existsSync(rojoConfig)) {
137
- console.log('🚧 No se detectó una estructura de Roblox. Creando scaffolding (src/ y config.json)...');
138
- fs.mkdirSync(srcPath, { recursive: true });
139
-
140
- const configJson = {
141
- name: path.basename(process.cwd()),
142
- version: "1.0.0",
143
- engine: "roblox",
144
- type: "syncrbx-project"
145
- };
146
- fs.writeFileSync(path.join(srcPath, 'config.json'), JSON.stringify(configJson, null, 2));
147
- console.log('✅ Estructura base creada.');
148
- }
149
-
150
- const isPrivate = options.private ? true : false;
151
- const projectName = path.basename(process.cwd());
152
-
153
- console.log(`🚀 Inicializando proyecto "${projectName}" como ${isPrivate ? 'Privado' : 'Público'}...`);
154
-
155
- try {
156
- const res = await fetch(`${API_BASE_URL}/init`, {
157
- method: 'POST',
158
- headers: {
159
- 'Content-Type': 'application/json',
160
- 'Authorization': token
161
- },
162
- body: JSON.stringify({ projectName, isPrivate })
163
- });
164
-
165
- const data = await res.json();
166
-
167
- if (!res.ok) {
168
- console.error(`❌ Error al inicializar: ${data.error}`);
169
- forceExit(1);
170
- return;
171
- }
172
-
173
- const syncrbxFolder = path.join(process.cwd(), '.syncrbx');
174
- if (!fs.existsSync(syncrbxFolder)) fs.mkdirSync(syncrbxFolder);
175
- fs.writeFileSync(path.join(syncrbxFolder, 'config.json'), JSON.stringify({ project_id: data.id, owner_id: data.owner_id }, null, 2));
176
-
177
- console.log('✅ Proyecto inicializado con éxito y vinculado a la nube.');
178
- forceExit(0);
179
- } catch (err) {
180
- console.error(`❌ Falló la conexión con la API: ${err.message}`);
181
- forceExit(1);
182
- }
183
- });
184
-
185
- // Comando: Push
186
- program
187
- .command('push')
188
- .description('Empaqueta tu código y sube un commit a la nube')
189
- .argument('<message>', 'Mensaje del commit')
190
- .action(async (message) => {
191
- const token = getToken();
192
- if (!token) {
193
- console.error('❌ No estás autenticado. Usa "syncrbx login" primero.');
194
- forceExit(1);
195
- return;
196
- }
197
-
198
- const projectConfig = getProjectConfig();
199
- if (!projectConfig || !projectConfig.project_id) {
200
- console.error('❌ Este directorio no está vinculado a SyncRbx Cloud. Ejecuta "syncrbx init" primero.');
201
- forceExit(1);
202
- return;
203
- }
204
-
205
- console.log(`📦 Empaquetando código local...`);
206
- const zipPath = path.join(process.cwd(), '.syncrbx_build.zip');
207
- const output = fs.createWriteStream(zipPath);
208
- const archive = archiver('zip', { zlib: { level: 9 } });
209
-
210
- output.on('close', async () => {
211
- const stats = fs.statSync(zipPath);
212
- const fileSizeInMB = stats.size / (1024 * 1024);
213
-
214
- if (fileSizeInMB > 10) {
215
- console.error(`❌ El archivo empaquetado es demasiado grande (${fileSizeInMB.toFixed(2)} MB). Límite de 10 MB excedido.`);
216
- fs.unlinkSync(zipPath);
217
- forceExit(1);
218
- return;
219
- }
220
-
221
- console.log(`✅ Archivo comprimido (${stats.size} bytes).`);
222
- console.log(`☁️ Subiendo a SyncRbx Cloud...`);
223
-
224
- try {
225
- const zipBuffer = fs.readFileSync(zipPath);
226
- const res = await fetch(`${API_BASE_URL}/push`, {
227
- method: 'POST',
228
- headers: {
229
- 'Authorization': token,
230
- 'Content-Type': 'application/zip',
231
- 'x-project-id': projectConfig.project_id,
232
- 'x-commit-message': message
233
- },
234
- body: zipBuffer
235
- });
236
-
237
- const data = await res.json();
238
- if (!res.ok) {
239
- console.error(`❌ Error al subir: ${data.error}`);
240
- } else {
241
- console.log(`🎉 Push completado con éxito.`);
242
- }
243
- } catch (err) {
244
- console.error(`❌ Falló la conexión con la API: ${err.message}`);
245
- }
246
-
247
- fs.unlinkSync(zipPath);
248
- process.exit(0);
249
- });
250
-
251
- archive.on('error', (err) => {
252
- throw err;
253
- });
254
-
255
- archive.pipe(output);
256
- archive.glob('**/*.{lua,luau,json,txt,md,toml}', {
257
- ignore: ['node_modules/**', '.git/**', '.syncrbx/**', '.syncrbx_build.zip']
258
- });
259
- archive.finalize();
260
- });
261
-
262
- // Comando: Pull
263
- program
264
- .command('pull')
265
- .description('Clona o actualiza un repositorio desde la nube')
266
- .argument('<project_id>', 'ID del repositorio a clonar')
267
- .action(async (projectId) => {
268
- const token = getToken();
269
- if (!token) {
270
- console.error('❌ No estás autenticado. Usa "syncrbx login" primero.');
271
- forceExit(1);
272
- return;
273
- }
274
-
275
- console.log(`🔍 Descargando repositorio con ID: ${projectId}...`);
276
-
277
- try {
278
- const res = await fetch(`${API_BASE_URL}/pull/${projectId}`, {
279
- method: 'GET',
280
- headers: {
281
- 'Authorization': token
282
- }
283
- });
284
-
285
- if (!res.ok) {
286
- const errorData = await res.json().catch(() => ({ error: 'Error desconocido' }));
287
- console.error(`❌ Error: ${errorData.error}`);
288
- forceExit(1);
289
- return;
290
- }
291
-
292
- const arrayBuffer = await res.arrayBuffer();
293
- const buffer = Buffer.from(arrayBuffer);
294
- const ownerId = res.headers.get('x-owner-id') || 'unknown';
295
-
296
- const zipPath = path.join(process.cwd(), '.syncrbx_download.zip');
297
- fs.writeFileSync(zipPath, buffer);
298
-
299
- console.log(`📂 Extrayendo archivos...`);
300
- try {
301
- const zip = new AdmZip(zipPath);
302
- zip.extractAllTo(process.cwd(), true);
303
- fs.unlinkSync(zipPath);
304
- } catch (e) {
305
- console.error(`❌ Error al extraer el archivo zip: ${e.message}`);
306
- forceExit(1);
307
- return;
308
- }
309
-
310
- const syncrbxFolder = path.join(process.cwd(), '.syncrbx');
311
- if (!fs.existsSync(syncrbxFolder)) fs.mkdirSync(syncrbxFolder);
312
- fs.writeFileSync(path.join(syncrbxFolder, 'config.json'), JSON.stringify({ project_id: projectId, owner_id: ownerId }, null, 2));
313
-
314
- console.log(`🎉 Pull completado con éxito. El directorio ahora está sincronizado.`);
315
- forceExit(0);
316
- } catch (err) {
317
- console.error(`❌ Falló la conexión con la API: ${err.message}`);
318
- forceExit(1);
319
- }
320
- });
321
-
322
- program.parse(process.argv);
1
+ #!/usr/bin/env node
2
+
3
+ const { Command } = require('commander');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+ const archiver = require('archiver');
8
+ const AdmZip = require('adm-zip');
9
+ const http = require('http');
10
+ const { spawn } = require('child_process');
11
+
12
+ const program = new Command();
13
+
14
+ const API_BASE_URL = process.env.SYNCRBX_API_URL || 'https://syncrbxbot.onrender.com/api/cli';
15
+ const WEB_BASE_URL = process.env.SYNCRBX_WEB_URL || 'https://www.syncrbx.xyz';
16
+
17
+ const CONFIG_PATH = path.join(process.env.HOME || process.env.USERPROFILE, '.syncrbxconfig.json');
18
+
19
+ // Refresh the access token when it expires in less than this many seconds.
20
+ const TOKEN_REFRESH_MARGIN_S = 60;
21
+
22
+ // --- Helper Functions ---
23
+
24
+ function forceExit(code) {
25
+ setTimeout(() => {
26
+ process.exit(code);
27
+ }, 50);
28
+ }
29
+
30
+ function readCredentials() {
31
+ if (!fs.existsSync(CONFIG_PATH)) return null;
32
+ try {
33
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
34
+ } catch (e) {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function saveCredentials({ access_token, refresh_token }) {
40
+ const data = { access_token };
41
+ if (refresh_token) data.refresh_token = refresh_token;
42
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2), { mode: 0o600 });
43
+ }
44
+
45
+ function getTokenExpiry(token) {
46
+ try {
47
+ const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));
48
+ return typeof payload.exp === 'number' ? payload.exp : null;
49
+ } catch (e) {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ // Returns a usable access token, refreshing it through the API when it is
55
+ // about to expire. Returns null when the user has to log in again.
56
+ async function getToken() {
57
+ const credentials = readCredentials();
58
+ if (!credentials || !credentials.access_token) return null;
59
+
60
+ const exp = getTokenExpiry(credentials.access_token);
61
+ const expiresSoon = exp !== null && exp - Date.now() / 1000 < TOKEN_REFRESH_MARGIN_S;
62
+ if (!expiresSoon) return credentials.access_token;
63
+
64
+ if (!credentials.refresh_token) {
65
+ console.error('❌ Your session has expired. Run "syncrbx login" again.');
66
+ return null;
67
+ }
68
+
69
+ try {
70
+ const res = await fetch(`${API_BASE_URL}/refresh`, {
71
+ method: 'POST',
72
+ headers: { 'Content-Type': 'application/json' },
73
+ body: JSON.stringify({ refresh_token: credentials.refresh_token }),
74
+ });
75
+ const data = await res.json().catch(() => ({}));
76
+ if (!res.ok || !data.access_token) {
77
+ console.error('❌ Your session has expired. Run "syncrbx login" again.');
78
+ return null;
79
+ }
80
+ saveCredentials(data);
81
+ return data.access_token;
82
+ } catch (err) {
83
+ console.error(`❌ Could not refresh your session: ${err.message}`);
84
+ return null;
85
+ }
86
+ }
87
+
88
+ // Trades the browser's access token for a session that belongs to the CLI, so
89
+ // both can refresh independently. Returns null if the API does not support it.
90
+ async function createCliSession(webAccessToken) {
91
+ try {
92
+ const res = await fetch(`${API_BASE_URL}/session`, {
93
+ method: 'POST',
94
+ headers: { 'Authorization': webAccessToken },
95
+ });
96
+ const data = await res.json().catch(() => ({}));
97
+ return res.ok && data.access_token ? data : null;
98
+ } catch (err) {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ function getProjectConfig() {
104
+ const localSyncRbx = path.join(process.cwd(), '.syncrbx', 'config.json');
105
+ if (fs.existsSync(localSyncRbx)) {
106
+ return JSON.parse(fs.readFileSync(localSyncRbx, 'utf8'));
107
+ }
108
+ return null;
109
+ }
110
+
111
+ function openBrowser(url) {
112
+ // Spawn without a shell so the "&" in the query string is not treated as a
113
+ // command separator.
114
+ const [command, args] = process.platform === 'darwin' ? ['open', [url]]
115
+ : process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
116
+ : ['xdg-open', [url]];
117
+ try {
118
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
119
+ child.on('error', () => { /* the URL is also printed to the terminal */ });
120
+ child.unref();
121
+ } catch (e) {
122
+ // the URL is also printed to the terminal
123
+ }
124
+ }
125
+
126
+ function getAllowedWebOrigins() {
127
+ const origin = new URL(WEB_BASE_URL).origin;
128
+ const origins = new Set([origin]);
129
+ const url = new URL(origin);
130
+ url.hostname = url.hostname.startsWith('www.') ? url.hostname.slice(4) : `www.${url.hostname}`;
131
+ origins.add(url.origin);
132
+ return origins;
133
+ }
134
+
135
+ // --- Commands ---
136
+
137
+ program
138
+ .name('syncrbx')
139
+ .description('SyncRbx - A free tool to sync VS Code with Roblox Studio.')
140
+ .version(require('./package.json').version);
141
+
142
+ // Command: Login
143
+ program
144
+ .command('login')
145
+ .description('Authenticate with your SyncRbx account')
146
+ .option('-t, --token <token>', 'Provide the token directly')
147
+ .action((options) => {
148
+ if (options.token) {
149
+ saveCredentials({ access_token: options.token });
150
+ console.log('✅ Successfully logged in to SyncRbx.');
151
+ return;
152
+ }
153
+
154
+ const PORT = 14872;
155
+ // Random value that the web page must echo back, so other pages cannot
156
+ // push their own token into the CLI while it is waiting.
157
+ const state = crypto.randomBytes(24).toString('hex');
158
+ const loginUrl = `${WEB_BASE_URL}/cli-login?port=${PORT}&state=${state}`;
159
+ const allowedOrigins = getAllowedWebOrigins();
160
+
161
+ console.log(`🔗 Opening the browser to authenticate...`);
162
+ console.log(`If the browser does not open automatically, click here: ${loginUrl}`);
163
+
164
+ const server = http.createServer((req, res) => {
165
+ const url = new URL(req.url, `http://localhost:${PORT}`);
166
+ const headers = { 'Content-Type': 'application/json' };
167
+ if (req.headers.origin && allowedOrigins.has(req.headers.origin)) {
168
+ headers['Access-Control-Allow-Origin'] = req.headers.origin;
169
+ headers['Vary'] = 'Origin';
170
+ }
171
+
172
+ if (url.pathname !== '/callback') {
173
+ res.writeHead(404);
174
+ res.end();
175
+ return;
176
+ }
177
+
178
+ const receivedState = url.searchParams.get('state') || '';
179
+ const stateMatches = receivedState.length === state.length
180
+ && crypto.timingSafeEqual(Buffer.from(receivedState), Buffer.from(state));
181
+ if (!stateMatches) {
182
+ res.writeHead(403, headers);
183
+ res.end(JSON.stringify({ error: 'Invalid state' }));
184
+ return;
185
+ }
186
+
187
+ const token = url.searchParams.get('token');
188
+ if (!token) {
189
+ res.writeHead(400, headers);
190
+ res.end(JSON.stringify({ error: 'Token missing' }));
191
+ console.error('❌ Error: No token received.');
192
+ server.close(() => forceExit(1));
193
+ return;
194
+ }
195
+
196
+ res.writeHead(200, headers);
197
+ res.end(JSON.stringify({ success: true }));
198
+ server.close();
199
+
200
+ createCliSession(token).then(session => {
201
+ saveCredentials(session || { access_token: token });
202
+ if (!session) {
203
+ console.log('⚠️ This login will expire in about an hour. Run "syncrbx login" again when it does.');
204
+ }
205
+ console.log('✅ Successfully logged in. You can now return to the terminal.');
206
+ forceExit(0);
207
+ });
208
+ });
209
+
210
+ server.listen(PORT, '127.0.0.1', () => {
211
+ openBrowser(loginUrl);
212
+ });
213
+ });
214
+
215
+ // Command: Serve
216
+ program
217
+ .command('serve')
218
+ .description('Start the local SyncRbx server')
219
+ .action(() => {
220
+ // Loaded lazily: requiring local-sync starts the file watcher.
221
+ require('./local-sync').startServer();
222
+ });
223
+
224
+ // Command: Init
225
+ program
226
+ .command('init')
227
+ .description('Initialize a new SyncRbx repository in the cloud')
228
+ .option('--private', 'Make the repository private')
229
+ .option('--public', 'Make the repository public (default)')
230
+ .action(async (options) => {
231
+ const token = await getToken();
232
+ if (!token) {
233
+ console.error('❌ You are not authenticated. Use "syncrbx login" first.');
234
+ forceExit(1);
235
+ return;
236
+ }
237
+
238
+ const srcPath = path.join(process.cwd(), 'src');
239
+ const rojoConfig = path.join(process.cwd(), 'default.project.json');
240
+
241
+ if (!fs.existsSync(srcPath) && !fs.existsSync(rojoConfig)) {
242
+ console.log('🚧 Roblox structure not detected. Creating scaffolding (src/ and config.json)...');
243
+ fs.mkdirSync(srcPath, { recursive: true });
244
+
245
+ const configJson = {
246
+ name: path.basename(process.cwd()),
247
+ version: "1.0.0",
248
+ engine: "roblox",
249
+ type: "syncrbx-project"
250
+ };
251
+ fs.writeFileSync(path.join(srcPath, 'config.json'), JSON.stringify(configJson, null, 2));
252
+ console.log('✅ Base structure created.');
253
+ }
254
+
255
+ const isPrivate = options.private ? true : false;
256
+ const projectName = path.basename(process.cwd());
257
+
258
+ console.log(`🚀 Initializing project "${projectName}" as ${isPrivate ? 'private' : 'public'}...`);
259
+
260
+ try {
261
+ const res = await fetch(`${API_BASE_URL}/init`, {
262
+ method: 'POST',
263
+ headers: {
264
+ 'Content-Type': 'application/json',
265
+ 'Authorization': token
266
+ },
267
+ body: JSON.stringify({ projectName, isPrivate })
268
+ });
269
+
270
+ const data = await res.json();
271
+
272
+ if (!res.ok) {
273
+ console.error(`❌ Error initializing: ${data.error}`);
274
+ forceExit(1);
275
+ return;
276
+ }
277
+
278
+ const syncrbxFolder = path.join(process.cwd(), '.syncrbx');
279
+ if (!fs.existsSync(syncrbxFolder)) fs.mkdirSync(syncrbxFolder);
280
+ fs.writeFileSync(path.join(syncrbxFolder, 'config.json'), JSON.stringify({ project_id: data.id, owner_id: data.owner_id }, null, 2));
281
+
282
+ console.log('✅ Project initialized successfully and linked to the cloud.');
283
+ forceExit(0);
284
+ } catch (err) {
285
+ console.error(`❌ API connection failed: ${err.message}`);
286
+ forceExit(1);
287
+ }
288
+ });
289
+
290
+ // Command: Push
291
+ program
292
+ .command('push')
293
+ .description('Package your code and push a commit to the cloud')
294
+ .argument('<message>', 'Commit message')
295
+ .action(async (message) => {
296
+ const token = await getToken();
297
+ if (!token) {
298
+ console.error('❌ You are not authenticated. Use "syncrbx login" first.');
299
+ forceExit(1);
300
+ return;
301
+ }
302
+
303
+ const projectConfig = getProjectConfig();
304
+ if (!projectConfig || !projectConfig.project_id) {
305
+ console.error('❌ This directory is not linked to SyncRbx Cloud. Run "syncrbx init" first.');
306
+ forceExit(1);
307
+ return;
308
+ }
309
+
310
+ console.log(`📦 Packaging local code...`);
311
+ const zipPath = path.join(process.cwd(), '.syncrbx_build.zip');
312
+ const output = fs.createWriteStream(zipPath);
313
+ const archive = archiver('zip', { zlib: { level: 9 } });
314
+
315
+ output.on('close', async () => {
316
+ const stats = fs.statSync(zipPath);
317
+ const fileSizeInMB = stats.size / (1024 * 1024);
318
+
319
+ if (fileSizeInMB > 10) {
320
+ console.error(`❌ The packaged file is too large (${fileSizeInMB.toFixed(2)} MB). 10 MB limit exceeded.`);
321
+ fs.unlinkSync(zipPath);
322
+ forceExit(1);
323
+ return;
324
+ }
325
+
326
+ console.log(`✅ Compressed file (${stats.size} bytes).`);
327
+ console.log(`☁️ Pushing to SyncRbx Cloud...`);
328
+
329
+ let exitCode = 1;
330
+ try {
331
+ const zipBuffer = fs.readFileSync(zipPath);
332
+ const res = await fetch(`${API_BASE_URL}/push`, {
333
+ method: 'POST',
334
+ headers: {
335
+ 'Authorization': token,
336
+ 'Content-Type': 'application/zip',
337
+ 'x-project-id': projectConfig.project_id,
338
+ // HTTP headers only carry Latin-1, so accents and emoji
339
+ // are percent-encoded.
340
+ 'x-commit-message': encodeURIComponent(message),
341
+ 'x-commit-message-encoding': 'uri'
342
+ },
343
+ body: zipBuffer
344
+ });
345
+
346
+ const data = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
347
+ if (!res.ok) {
348
+ console.error(`❌ Error pushing: ${data.error}`);
349
+ } else {
350
+ console.log(`🎉 Push successfully completed.`);
351
+ exitCode = 0;
352
+ }
353
+ } catch (err) {
354
+ console.error(`❌ API connection failed: ${err.message}`);
355
+ }
356
+
357
+ fs.unlinkSync(zipPath);
358
+ process.exit(exitCode);
359
+ });
360
+
361
+ archive.on('error', (err) => {
362
+ console.error(`❌ Error packaging files: ${err.message}`);
363
+ try { fs.unlinkSync(zipPath); } catch (e) { /* already gone */ }
364
+ forceExit(1);
365
+ });
366
+
367
+ archive.pipe(output);
368
+ archive.glob('**/*.{lua,luau,json,txt,md,toml}', {
369
+ ignore: ['node_modules/**', '.git/**', '.syncrbx/**', '.syncrbx_build.zip']
370
+ });
371
+ archive.finalize();
372
+ });
373
+
374
+ // Command: Pull
375
+ program
376
+ .command('pull')
377
+ .description('Clone or update a repository from the cloud')
378
+ .argument('<project_id>', 'ID of the repository to clone')
379
+ .option('-f, --force', 'Overwrite local files that differ from the cloud version')
380
+ .action(async (projectId, options) => {
381
+ const token = await getToken();
382
+ if (!token) {
383
+ console.error('❌ You are not authenticated. Use "syncrbx login" first.');
384
+ forceExit(1);
385
+ return;
386
+ }
387
+
388
+ console.log(`🔍 Downloading repository with ID: ${projectId}...`);
389
+
390
+ try {
391
+ const res = await fetch(`${API_BASE_URL}/pull/${encodeURIComponent(projectId)}`, {
392
+ method: 'GET',
393
+ headers: {
394
+ 'Authorization': token
395
+ }
396
+ });
397
+
398
+ if (!res.ok) {
399
+ const errorData = await res.json().catch(() => ({ error: 'Unknown error' }));
400
+ console.error(`❌ Error: ${errorData.error}`);
401
+ forceExit(1);
402
+ return;
403
+ }
404
+
405
+ const arrayBuffer = await res.arrayBuffer();
406
+ const buffer = Buffer.from(arrayBuffer);
407
+ const ownerId = res.headers.get('x-owner-id') || 'unknown';
408
+
409
+ let zip;
410
+ try {
411
+ zip = new AdmZip(buffer);
412
+ } catch (e) {
413
+ console.error(`❌ Error reading zip file: ${e.message}`);
414
+ forceExit(1);
415
+ return;
416
+ }
417
+
418
+ if (!options.force) {
419
+ const overwritten = zip.getEntries()
420
+ .filter(entry => !entry.isDirectory)
421
+ .filter(entry => {
422
+ const localPath = path.join(process.cwd(), entry.entryName);
423
+ if (!fs.existsSync(localPath)) return false;
424
+ return !fs.statSync(localPath).isFile() || !fs.readFileSync(localPath).equals(entry.getData());
425
+ })
426
+ .map(entry => entry.entryName);
427
+
428
+ if (overwritten.length > 0) {
429
+ console.error(`❌ Pull would overwrite ${overwritten.length} local file(s) with different content:`);
430
+ for (const name of overwritten.slice(0, 10)) console.error(` - ${name}`);
431
+ if (overwritten.length > 10) console.error(` ...and ${overwritten.length - 10} more`);
432
+ console.error('Run "syncrbx pull <project_id> --force" to overwrite them.');
433
+ forceExit(1);
434
+ return;
435
+ }
436
+ }
437
+
438
+ console.log(`📂 Extracting files...`);
439
+ try {
440
+ zip.extractAllTo(process.cwd(), true);
441
+ } catch (e) {
442
+ console.error(`❌ Error extracting zip file: ${e.message}`);
443
+ forceExit(1);
444
+ return;
445
+ }
446
+
447
+ const syncrbxFolder = path.join(process.cwd(), '.syncrbx');
448
+ if (!fs.existsSync(syncrbxFolder)) fs.mkdirSync(syncrbxFolder);
449
+ fs.writeFileSync(path.join(syncrbxFolder, 'config.json'), JSON.stringify({ project_id: projectId, owner_id: ownerId }, null, 2));
450
+
451
+ console.log(`🎉 Pull completed successfully. The directory is now synchronized.`);
452
+ forceExit(0);
453
+ } catch (err) {
454
+ console.error(`❌ API connection failed: ${err.message}`);
455
+ forceExit(1);
456
+ }
457
+ });
458
+
459
+ program.parse(process.argv);