roport 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # Roport CLI
2
+
3
+ The companion server for the **Roport** Roblox plugin. This tool enables two-way syncing between Visual Studio Code and Roblox Studio.
4
+
5
+ ## Installation
6
+
7
+ Install globally using npm:
8
+
9
+ ```bash
10
+ npm install -g roport
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ Navigate to your project directory (where your `.meta.json` files are) and run:
16
+
17
+ ```bash
18
+ roport serve
19
+ ```
20
+
21
+ This will start a local server on port `3456` (default).
22
+
23
+ ## Options
24
+
25
+ - `-p, --port <number>`: Specify a custom port (default: 3456).
26
+
27
+ ## Setup with Roblox
28
+
29
+ 1. Install this CLI tool.
30
+ 2. Install the **Roport** plugin in Roblox Studio.
31
+ 3. Start the server with `roport serve`.
32
+ 4. Connect via the plugin in Roblox Studio.
package/bin/roport.js ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { program } = require('commander');
4
+ const { startServer } = require('../src/server');
5
+
6
+ program
7
+ .name('roport')
8
+ .description('Roport Sync Tool for Roblox')
9
+ .version('1.0.0');
10
+
11
+ program
12
+ .command('serve')
13
+ .description('Start the sync server')
14
+ .option('-p, --port <number>', 'Port to run on', '3456')
15
+ .action((options) => {
16
+ startServer(parseInt(options.port));
17
+ });
18
+
19
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "roport",
3
+ "version": "1.0.0",
4
+ "description": "A sync server for Roblox development. Works with the Roport Roblox Plugin.",
5
+ "main": "src/server.js",
6
+ "bin": {
7
+ "roport": "./bin/roport.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/roport.js serve"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/Rydaguy101/RojoExportPlugin.git"
15
+ },
16
+ "keywords": [
17
+ "roblox",
18
+ "rojo",
19
+ "sync",
20
+ "plugin",
21
+ "development"
22
+ ],
23
+ "author": "Rydaguy101",
24
+ "license": "MIT",
25
+ "files": [
26
+ "bin",
27
+ "src",
28
+ "README.md"
29
+ ],
30
+ "dependencies": {
31
+ "express": "^4.18.2",
32
+ "fs-extra": "^11.1.1",
33
+ "body-parser": "^1.20.2",
34
+ "commander": "^11.0.0",
35
+ "chalk": "^4.1.2"
36
+ }
37
+ }
@@ -0,0 +1,914 @@
1
+ local HttpService = game:GetService("HttpService")
2
+ local RunService = game:GetService("RunService")
3
+ local TweenService = game:GetService("TweenService")
4
+ local ScriptEditorService = game:GetService("ScriptEditorService")
5
+
6
+ -- Prevent running as a normal script
7
+ if not plugin then return end
8
+
9
+ local TOOLBAR_NAME = "Rojo Tools"
10
+ local BUTTON_NAME = "Open Sync Panel"
11
+ local SERVER_URL_PING = "http://127.0.0.1:3456/ping"
12
+ local SERVER_URL_WRITE = "http://127.0.0.1:3456/write"
13
+ local SERVER_URL_BATCH = "http://127.0.0.1:3456/batch"
14
+ local SERVER_URL_DELETE = "http://127.0.0.1:3456/delete"
15
+ local SERVER_URL_POLL = "http://127.0.0.1:3456/poll"
16
+
17
+ -- Colors & Styling
18
+ local COLORS = {
19
+ BG = Color3.fromRGB(37, 37, 37),
20
+ HEADER = Color3.fromRGB(45, 45, 45),
21
+ BUTTON = Color3.fromRGB(55, 55, 55),
22
+ BUTTON_HOVER = Color3.fromRGB(65, 65, 65),
23
+ ACCENT = Color3.fromRGB(0, 122, 204), -- VS Code Blue
24
+ TEXT = Color3.fromRGB(245, 245, 245),
25
+ SUBTEXT = Color3.fromRGB(180, 180, 180),
26
+ SUCCESS = Color3.fromRGB(90, 200, 90),
27
+ ERROR = Color3.fromRGB(220, 80, 80),
28
+ WARNING = Color3.fromRGB(220, 200, 80)
29
+ }
30
+
31
+ -- State
32
+ local state = {
33
+ isConnected = false,
34
+ isAutoSync = false,
35
+ isAutoPull = false,
36
+ trackedInstances = {},
37
+ pathCache = {}, -- [Instance] = "full/path/to/file.ext"
38
+ connections = {} -- [Instance] = {Connection, ...}
39
+ }
40
+
41
+ -- UI Elements
42
+ local toolbar = plugin:CreateToolbar(TOOLBAR_NAME)
43
+ -- Note: You must upload icon.png to Roblox and replace "rbxassetid://0" with the actual Asset ID to see the logo.
44
+ local toggleButton
45
+ local success, err = pcall(function()
46
+ toggleButton = toolbar:CreateButton(BUTTON_NAME, "Open the Rojo Sync Panel", "rbxassetid://0")
47
+ end)
48
+
49
+ if not success then
50
+ warn("Roport: Failed to create toolbar button (it might already exist): " .. tostring(err))
51
+ return
52
+ end
53
+
54
+ local widgetInfo = DockWidgetPluginGuiInfo.new(
55
+ Enum.InitialDockState.Right,
56
+ false, false, 300, 250, 250, 200
57
+ )
58
+
59
+ local widget = plugin:CreateDockWidgetPluginGui("RojoSyncPanel", widgetInfo)
60
+ widget.Title = "Rojo Sync"
61
+ widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
62
+
63
+ -- Main Frame
64
+ local mainFrame = Instance.new("Frame")
65
+ mainFrame.Size = UDim2.new(1, 0, 1, 0)
66
+ mainFrame.BackgroundColor3 = COLORS.BG
67
+ mainFrame.BorderSizePixel = 0
68
+ mainFrame.Parent = widget
69
+
70
+ -- Toast Container (ZIndex 10)
71
+ local toastContainer = Instance.new("Frame")
72
+ toastContainer.Size = UDim2.new(1, -20, 1, -20)
73
+ toastContainer.Position = UDim2.new(0, 10, 0, 10)
74
+ toastContainer.BackgroundTransparency = 1
75
+ toastContainer.ZIndex = 10
76
+ toastContainer.Parent = mainFrame
77
+
78
+ local toastLayout = Instance.new("UIListLayout")
79
+ toastLayout.VerticalAlignment = Enum.VerticalAlignment.Bottom
80
+ toastLayout.HorizontalAlignment = Enum.HorizontalAlignment.Center
81
+ toastLayout.Padding = UDim.new(0, 5)
82
+ toastLayout.Parent = toastContainer
83
+
84
+ -- Modal Container (ZIndex 20)
85
+ local modalContainer = Instance.new("Frame")
86
+ modalContainer.Size = UDim2.new(1, 0, 1, 0)
87
+ modalContainer.BackgroundColor3 = Color3.new(0, 0, 0)
88
+ modalContainer.BackgroundTransparency = 1 -- Starts invisible
89
+ modalContainer.Visible = false
90
+ modalContainer.ZIndex = 20
91
+ modalContainer.Parent = mainFrame
92
+
93
+ -- Content Layout
94
+ local contentFrame = Instance.new("Frame")
95
+ contentFrame.Size = UDim2.new(1, 0, 1, 0)
96
+ contentFrame.BackgroundTransparency = 1
97
+ contentFrame.Parent = mainFrame
98
+
99
+ local listLayout = Instance.new("UIListLayout")
100
+ listLayout.Padding = UDim.new(0, 10)
101
+ listLayout.HorizontalAlignment = Enum.HorizontalAlignment.Center
102
+ listLayout.SortOrder = Enum.SortOrder.LayoutOrder
103
+ listLayout.Parent = contentFrame
104
+
105
+ local padding = Instance.new("UIPadding")
106
+ padding.PaddingTop = UDim.new(0, 15)
107
+ padding.PaddingLeft = UDim.new(0, 15)
108
+ padding.PaddingRight = UDim.new(0, 15)
109
+ padding.Parent = contentFrame
110
+
111
+ -- UI Helpers
112
+ local function createCorner(parent, radius)
113
+ local corner = Instance.new("UICorner")
114
+ corner.CornerRadius = UDim.new(0, radius or 6)
115
+ corner.Parent = parent
116
+ return corner
117
+ end
118
+
119
+ local function showToast(message, type)
120
+ local toast = Instance.new("Frame")
121
+ toast.Size = UDim2.new(1, 0, 0, 0) -- Animate height
122
+ toast.BackgroundTransparency = 1
123
+ toast.ClipsDescendants = true
124
+ toast.Parent = toastContainer
125
+
126
+ local inner = Instance.new("Frame")
127
+ inner.Size = UDim2.new(1, 0, 0, 35)
128
+ inner.BackgroundColor3 = COLORS.HEADER
129
+ inner.BorderSizePixel = 0
130
+ createCorner(inner, 6)
131
+ inner.Parent = toast
132
+
133
+ local bar = Instance.new("Frame")
134
+ bar.Size = UDim2.new(0, 4, 1, 0)
135
+ bar.BackgroundColor3 = type == "error" and COLORS.ERROR or (type == "success" and COLORS.SUCCESS or COLORS.ACCENT)
136
+ createCorner(bar, 6)
137
+ bar.Parent = inner
138
+
139
+ local label = Instance.new("TextLabel")
140
+ label.Size = UDim2.new(1, -15, 1, 0)
141
+ label.Position = UDim2.new(0, 10, 0, 0)
142
+ label.BackgroundTransparency = 1
143
+ label.Text = message
144
+ label.TextColor3 = COLORS.TEXT
145
+ label.Font = Enum.Font.SourceSans
146
+ label.TextSize = 14
147
+ label.TextXAlignment = Enum.TextXAlignment.Left
148
+ label.Parent = inner
149
+
150
+ -- Animation
151
+ local tweenInfo = TweenInfo.new(0.3, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
152
+ TweenService:Create(toast, tweenInfo, {Size = UDim2.new(1, 0, 0, 40)}):Play()
153
+
154
+ task.delay(3, function()
155
+ local out = TweenService:Create(toast, tweenInfo, {Size = UDim2.new(1, 0, 0, 0)})
156
+ out:Play()
157
+ out.Completed:Connect(function() toast:Destroy() end)
158
+ end)
159
+ end
160
+
161
+ local function showModal(title, message, onConfirm)
162
+ modalContainer.Visible = true
163
+ TweenService:Create(modalContainer, TweenInfo.new(0.3), {BackgroundTransparency = 0.5}):Play()
164
+
165
+ -- Clear previous
166
+ for _, c in ipairs(modalContainer:GetChildren()) do c:Destroy() end
167
+
168
+ local box = Instance.new("Frame")
169
+ box.Size = UDim2.new(0.8, 0, 0, 140)
170
+ box.Position = UDim2.new(0.5, 0, 0.5, 0)
171
+ box.AnchorPoint = Vector2.new(0.5, 0.5)
172
+ box.BackgroundColor3 = COLORS.HEADER
173
+ createCorner(box, 8)
174
+ box.Parent = modalContainer
175
+
176
+ -- Scale up animation
177
+ local uiScale = Instance.new("UIScale")
178
+ uiScale.Scale = 0.8
179
+ uiScale.Parent = box
180
+
181
+ TweenService:Create(uiScale, TweenInfo.new(0.3, Enum.EasingStyle.Back), {Scale = 1}):Play()
182
+
183
+ local titleLbl = Instance.new("TextLabel")
184
+ titleLbl.Size = UDim2.new(1, 0, 0, 40)
185
+ titleLbl.BackgroundTransparency = 1
186
+ titleLbl.Text = title
187
+ titleLbl.Font = Enum.Font.SourceSansBold
188
+ titleLbl.TextSize = 18
189
+ titleLbl.TextColor3 = COLORS.TEXT
190
+ titleLbl.Parent = box
191
+
192
+ local msgLbl = Instance.new("TextLabel")
193
+ msgLbl.Size = UDim2.new(1, -20, 0, 50)
194
+ msgLbl.Position = UDim2.new(0, 10, 0, 40)
195
+ msgLbl.BackgroundTransparency = 1
196
+ msgLbl.Text = message
197
+ msgLbl.Font = Enum.Font.SourceSans
198
+ msgLbl.TextSize = 14
199
+ msgLbl.TextColor3 = COLORS.SUBTEXT
200
+ msgLbl.TextWrapped = true
201
+ msgLbl.Parent = box
202
+
203
+ local btnFrame = Instance.new("Frame")
204
+ btnFrame.Size = UDim2.new(1, -20, 0, 35)
205
+ btnFrame.Position = UDim2.new(0, 10, 1, -45)
206
+ btnFrame.BackgroundTransparency = 1
207
+ btnFrame.Parent = box
208
+
209
+ local layout = Instance.new("UIListLayout")
210
+ layout.FillDirection = Enum.FillDirection.Horizontal
211
+ layout.Padding = UDim.new(0, 10)
212
+ layout.HorizontalAlignment = Enum.HorizontalAlignment.Center
213
+ layout.Parent = btnFrame
214
+
215
+ local function makeBtn(text, color, cb)
216
+ local btn = Instance.new("TextButton")
217
+ btn.Size = UDim2.new(0.45, 0, 1, 0)
218
+ btn.BackgroundColor3 = color
219
+ btn.Text = text
220
+ btn.TextColor3 = COLORS.TEXT
221
+ btn.Font = Enum.Font.SourceSansBold
222
+ btn.TextSize = 14
223
+ createCorner(btn, 4)
224
+ btn.Parent = btnFrame
225
+ btn.MouseButton1Click:Connect(cb)
226
+ end
227
+
228
+ makeBtn("Connect", COLORS.ACCENT, function()
229
+ TweenService:Create(modalContainer, TweenInfo.new(0.2), {BackgroundTransparency = 1}):Play()
230
+ modalContainer.Visible = false
231
+ onConfirm()
232
+ end)
233
+
234
+ makeBtn("Cancel", COLORS.BUTTON, function()
235
+ TweenService:Create(modalContainer, TweenInfo.new(0.2), {BackgroundTransparency = 1}):Play()
236
+ modalContainer.Visible = false
237
+ end)
238
+ end
239
+
240
+ -- Components
241
+ local function createButton(text, icon, callback)
242
+ local btn = Instance.new("TextButton")
243
+ btn.Size = UDim2.new(1, 0, 0, 40)
244
+ btn.BackgroundColor3 = COLORS.BUTTON
245
+ btn.Text = ""
246
+ createCorner(btn, 6)
247
+ btn.Parent = contentFrame
248
+
249
+ local lbl = Instance.new("TextLabel")
250
+ lbl.Size = UDim2.new(1, 0, 1, 0)
251
+ lbl.BackgroundTransparency = 1
252
+ lbl.Text = text
253
+ lbl.Font = Enum.Font.SourceSansBold
254
+ lbl.TextSize = 16
255
+ lbl.TextColor3 = COLORS.TEXT
256
+ lbl.Parent = btn
257
+
258
+ btn.MouseButton1Click:Connect(callback)
259
+ return btn
260
+ end
261
+
262
+ -- Status Header
263
+ local statusFrame = Instance.new("Frame")
264
+ statusFrame.Size = UDim2.new(1, 0, 0, 30)
265
+ statusFrame.BackgroundTransparency = 1
266
+ statusFrame.LayoutOrder = 0
267
+ statusFrame.Parent = contentFrame
268
+
269
+ local statusDot = Instance.new("Frame")
270
+ statusDot.Size = UDim2.new(0, 10, 0, 10)
271
+ statusDot.Position = UDim2.new(0, 0, 0.5, -5)
272
+ statusDot.BackgroundColor3 = COLORS.ERROR
273
+ createCorner(statusDot, 10)
274
+ statusDot.Parent = statusFrame
275
+
276
+ local statusText = Instance.new("TextLabel")
277
+ statusText.Size = UDim2.new(1, -20, 1, 0)
278
+ statusText.Position = UDim2.new(0, 20, 0, 0)
279
+ statusText.BackgroundTransparency = 1
280
+ statusText.Text = "Disconnected"
281
+ statusText.Font = Enum.Font.SourceSans
282
+ statusText.TextSize = 14
283
+ statusText.TextColor3 = COLORS.SUBTEXT
284
+ statusText.TextXAlignment = Enum.TextXAlignment.Left
285
+ statusText.Parent = statusFrame
286
+
287
+ -- Helper Functions (Global)
288
+ local function sanitize(name)
289
+ return name:gsub("[\\/:*?\"<>|]", "_")
290
+ end
291
+
292
+ local function getPath(inst)
293
+ local p = {}
294
+ local c = inst
295
+ local s = nil
296
+ while c and c~=game do
297
+ if c==game:GetService("ServerScriptService") then s="src/server" break
298
+ elseif c==game:GetService("ReplicatedStorage") then s="src/shared" break
299
+ elseif c==game:GetService("StarterPlayer").StarterPlayerScripts then s="src/client" break
300
+ elseif c==game:GetService("Workspace") then s="src/workspace" break
301
+ elseif c==game:GetService("StarterGui") then s="src/interface" break
302
+ elseif c==game:GetService("StarterPack") then s="src/tools" break
303
+ elseif c==game:GetService("Lighting") then s="src/lighting" break
304
+ elseif c==game:GetService("ReplicatedFirst") then s="src/first" break
305
+ elseif c==game:GetService("SoundService") then s="src/sounds" break
306
+ end
307
+ table.insert(p,1,(sanitize(c.Name)))
308
+ c=c.Parent
309
+ end
310
+ return s and (s.."/"..table.concat(p,"/"))
311
+ end
312
+
313
+ local function serializeValue(val)
314
+ local t = typeof(val)
315
+ if t == "Vector3" then return {val.X, val.Y, val.Z}
316
+ elseif t == "Vector2" then return {val.X, val.Y}
317
+ elseif t == "Color3" then return {val.R, val.G, val.B}
318
+ elseif t == "CFrame" then return {val:GetComponents()}
319
+ elseif t == "UDim2" then return {val.X.Scale, val.X.Offset, val.Y.Scale, val.Y.Offset}
320
+ elseif t == "UDim" then return {val.Scale, val.Offset}
321
+ elseif t == "Rect" then return {val.Min.X, val.Min.Y, val.Max.X, val.Max.Y}
322
+ elseif t == "NumberRange" then return {val.Min, val.Max}
323
+ elseif t == "EnumItem" then return val.Name
324
+ elseif t == "Instance" then return val.Name -- Reference by name (weak)
325
+ elseif t == "ColorSequence" then
326
+ local kps = {}
327
+ for _, kp in ipairs(val.Keypoints) do table.insert(kps, {kp.Time, {kp.Value.R, kp.Value.G, kp.Value.B}}) end
328
+ return {type="ColorSequence", keypoints=kps}
329
+ elseif t == "NumberSequence" then
330
+ local kps = {}
331
+ for _, kp in ipairs(val.Keypoints) do table.insert(kps, {kp.Time, kp.Value, kp.Envelope}) end
332
+ return {type="NumberSequence", keypoints=kps}
333
+ else return val end
334
+ end
335
+
336
+ local PROPERTY_MAP = {
337
+ Instance = {"Name", "Archivable", "ClassName"},
338
+ BasePart = {"Size", "Position", "Color", "Transparency", "Anchored", "CanCollide", "Material", "Reflectance", "CastShadow", "Locked", "Shape"},
339
+ GuiObject = {"Size", "Position", "AnchorPoint", "BackgroundColor3", "BackgroundTransparency", "BorderColor3", "BorderSizePixel", "Visible", "ZIndex", "LayoutOrder", "ClipsDescendants", "Rotation"},
340
+ TextLabel = {"Text", "TextColor3", "TextSize", "Font", "TextTransparency", "TextXAlignment", "TextYAlignment", "TextScaled", "TextWrapped", "RichText"},
341
+ TextButton = {"Text", "TextColor3", "TextSize", "Font", "TextTransparency", "TextXAlignment", "TextYAlignment", "TextScaled", "TextWrapped", "RichText"},
342
+ TextBox = {"Text", "TextColor3", "TextSize", "Font", "TextTransparency", "TextXAlignment", "TextYAlignment", "TextScaled", "TextWrapped", "PlaceholderText", "ClearTextOnFocus", "MultiLine"},
343
+ ImageLabel = {"Image", "ImageColor3", "ImageTransparency", "ScaleType", "SliceCenter", "TileSize"},
344
+ ImageButton = {"Image", "ImageColor3", "ImageTransparency", "ScaleType", "SliceCenter", "TileSize"},
345
+ ValueBase = {"Value"},
346
+ Sound = {"SoundId", "Volume", "PlaybackSpeed", "Looped", "Playing", "TimePosition"},
347
+ Animation = {"AnimationId"},
348
+ Decal = {"Texture", "Color3", "Transparency", "Face"},
349
+ Texture = {"Texture", "Color3", "Transparency", "Face", "StudsPerTileU", "StudsPerTileV", "OffsetStudsU", "OffsetStudsV"},
350
+ Light = {"Color", "Brightness", "Enabled", "Shadows", "Range"},
351
+ SpotLight = {"Angle", "Face"},
352
+ SurfaceLight = {"Angle", "Face"},
353
+ DataModelMesh = {"Scale", "Offset", "VertexColor"},
354
+ SpecialMesh = {"MeshId", "TextureId", "MeshType"},
355
+ Humanoid = {"Health", "MaxHealth", "WalkSpeed", "JumpPower", "DisplayName", "RigType", "HipHeight"},
356
+ Tool = {"RequiresHandle", "CanBeDropped", "ToolTip", "Enabled", "Grip"},
357
+ Accessory = {"AttachmentPoint"},
358
+ Clothing = {"Color3"},
359
+ Shirt = {"ShirtTemplate"},
360
+ Pants = {"PantsTemplate"},
361
+ ParticleEmitter = {"Color", "LightEmission", "LightInfluence", "Size", "Texture", "Transparency", "ZOffset", "EmissionDirection", "Enabled", "Lifetime", "Rate", "Rotation", "RotSpeed", "Speed", "SpreadAngle"},
362
+ Trail = {"Color", "Enabled", "FaceCamera", "Lifetime", "LightEmission", "LightInfluence", "MaxLength", "MinLength", "Texture", "TextureLength", "Transparency", "WidthScale"},
363
+ Beam = {"Color", "Enabled", "FaceCamera", "LightEmission", "LightInfluence", "Texture", "TextureLength", "TextureMode", "Transparency", "Width0", "Width1", "ZOffset"},
364
+ Fire = {"Color", "Enabled", "Heat", "Size", "SecondaryColor"},
365
+ Smoke = {"Color", "Enabled", "Opacity", "RiseVelocity", "Size"},
366
+ Sparkles = {"SparkleColor", "Enabled"},
367
+ PostEffect = {"Enabled"},
368
+ BlurEffect = {"Size"},
369
+ BloomEffect = {"Intensity", "Size", "Threshold"},
370
+ ColorCorrectionEffect = {"Brightness", "Contrast", "Saturation", "TintColor"},
371
+ SunRaysEffect = {"Intensity", "Spread"},
372
+ Atmosphere = {"Density", "Offset", "Haze", "Color", "Decay", "Glare"},
373
+ Sky = {"SkyboxBk", "SkyboxDn", "SkyboxFt", "SkyboxLf", "SkyboxRt", "SkyboxUp", "SunTextureId", "MoonTextureId", "StarCount"},
374
+ UICorner = {"CornerRadius"},
375
+ UIStroke = {"Color", "Thickness", "Transparency", "ApplyStrokeMode", "LineJoinMode"},
376
+ UIGradient = {"Color", "Offset", "Rotation", "Transparency"},
377
+ UIPadding = {"PaddingBottom", "PaddingLeft", "PaddingRight", "PaddingTop"},
378
+ UIScale = {"Scale"},
379
+ UIListLayout = {"Padding", "FillDirection", "HorizontalAlignment", "VerticalAlignment", "SortOrder", "ItemLineAlignment"},
380
+ UIGridLayout = {"CellPadding", "CellSize", "FillDirection", "HorizontalAlignment", "VerticalAlignment", "SortOrder", "StartCorner"},
381
+ UITableLayout = {"Padding", "FillDirection", "HorizontalAlignment", "VerticalAlignment", "SortOrder"},
382
+ UIPageLayout = {"Padding", "FillDirection", "HorizontalAlignment", "VerticalAlignment", "SortOrder", "Animated", "Circular", "EasingDirection", "EasingStyle", "GamepadInputEnabled", "ScrollWheelInputEnabled", "TouchInputEnabled", "TweenTime"},
383
+ }
384
+
385
+ local function getProperties(inst)
386
+ local props = {
387
+ className = inst.ClassName,
388
+ name = inst.Name,
389
+ properties = {},
390
+ attributes = {},
391
+ tags = game:GetService("CollectionService"):GetTags(inst)
392
+ }
393
+
394
+ -- Serialize Attributes
395
+ for k, v in pairs(inst:GetAttributes()) do
396
+ props.attributes[k] = serializeValue(v)
397
+ end
398
+
399
+ -- Table-driven property serialization
400
+ for className, propList in pairs(PROPERTY_MAP) do
401
+ if inst:IsA(className) then
402
+ for _, propName in ipairs(propList) do
403
+ pcall(function()
404
+ props.properties[propName] = serializeValue(inst[propName])
405
+ end)
406
+ end
407
+ end
408
+ end
409
+
410
+ return props
411
+ end
412
+
413
+ local function getExtension(inst)
414
+ if inst:IsA("Script") then return ".server.luau"
415
+ elseif inst:IsA("LocalScript") then return ".client.luau"
416
+ elseif inst:IsA("ModuleScript") then return ".luau"
417
+ else return "/init.meta.json" end
418
+ end
419
+
420
+ local function getContent(inst)
421
+ if inst:IsA("Script") or inst:IsA("LocalScript") or inst:IsA("ModuleScript") then
422
+ return inst.Source
423
+ else
424
+ return HttpService:JSONEncode(getProperties(inst))
425
+ end
426
+ end
427
+
428
+ local function getInfo(inst)
429
+ return getExtension(inst), getContent(inst)
430
+ end
431
+
432
+ local function syncInstance(inst)
433
+ local path = getPath(inst)
434
+ if not path then return end
435
+
436
+ local ext = getExtension(inst)
437
+ local content = getContent(inst)
438
+ local fullPath = path..ext
439
+
440
+ -- Register for tracking
441
+ registerObject(inst, fullPath)
442
+
443
+ pcall(function()
444
+ HttpService:PostAsync(SERVER_URL_BATCH, HttpService:JSONEncode({
445
+ files = {{filePath = fullPath, content = content}}
446
+ }))
447
+ end)
448
+ end
449
+
450
+ function registerObject(inst, fullPath)
451
+ if state.pathCache[inst] == fullPath then return end
452
+ state.pathCache[inst] = fullPath
453
+
454
+ -- Clear old connections
455
+ if state.connections[inst] then
456
+ for _, c in pairs(state.connections[inst]) do c:Disconnect() end
457
+ end
458
+ state.connections[inst] = {}
459
+
460
+ -- 1. Name Changed (Rename)
461
+ local nameCon = inst:GetPropertyChangedSignal("Name"):Connect(function()
462
+ if not state.isAutoSync then return end
463
+ local oldPath = state.pathCache[inst]
464
+ local newBasePath = getPath(inst)
465
+ if not newBasePath then return end -- Moved to invalid location
466
+
467
+ local newPath = newBasePath .. getExtension(inst)
468
+
469
+ if oldPath and newPath and oldPath ~= newPath then
470
+ -- Delete old
471
+ pcall(function()
472
+ HttpService:PostAsync(SERVER_URL_DELETE, HttpService:JSONEncode({files={oldPath}}))
473
+ end)
474
+ -- Sync new
475
+ syncInstance(inst)
476
+ end
477
+ end)
478
+ local cons = state.connections[inst]
479
+ cons[#cons+1] = nameCon
480
+
481
+ -- 2. Ancestry Changed (Move or Delete)
482
+ local ancestryCon = inst.AncestryChanged:Connect(function(_, parent)
483
+ if not state.isAutoSync then return end
484
+
485
+ if parent == nil then
486
+ -- Deleted
487
+ local oldPath = state.pathCache[inst]
488
+ if oldPath then
489
+ pcall(function()
490
+ HttpService:PostAsync(SERVER_URL_DELETE, HttpService:JSONEncode({files={oldPath}}))
491
+ end)
492
+ state.pathCache[inst] = nil
493
+ if state.connections[inst] then
494
+ for _, c in pairs(state.connections[inst]) do c:Disconnect() end
495
+ state.connections[inst] = nil
496
+ end
497
+ end
498
+ else
499
+ -- Moved
500
+ local oldPath = state.pathCache[inst]
501
+ local newBasePath = getPath(inst)
502
+ if not newBasePath then return end
503
+
504
+ local newPath = newBasePath .. getExtension(inst)
505
+
506
+ if oldPath and newPath and oldPath ~= newPath then
507
+ pcall(function()
508
+ HttpService:PostAsync(SERVER_URL_DELETE, HttpService:JSONEncode({files={oldPath}}))
509
+ end)
510
+ syncInstance(inst)
511
+ end
512
+ end
513
+ end)
514
+ cons[#cons+1] = ancestryCon
515
+ end
516
+
517
+ local function syncAll()
518
+ if not state.isConnected then showToast("Not connected!", "error") return end
519
+ showToast("Starting Batch Sync...", "info")
520
+
521
+ local batch = {}
522
+ local services = {
523
+ game:GetService("Workspace"),
524
+ game:GetService("ServerScriptService"),
525
+ game:GetService("ReplicatedStorage"),
526
+ game:GetService("StarterPlayer").StarterPlayerScripts,
527
+ game:GetService("StarterGui"),
528
+ game:GetService("StarterPack"),
529
+ game:GetService("Lighting"),
530
+ game:GetService("ReplicatedFirst"),
531
+ game:GetService("SoundService")
532
+ }
533
+
534
+ local count = 0
535
+ for _, s in ipairs(services) do
536
+ -- Sync the service itself (folder)
537
+ local servicePath = getPath(s)
538
+ if servicePath then
539
+ -- We don't need content for the root folders usually, but we can ensure they exist
540
+ -- For now, let's just focus on descendants
541
+ end
542
+
543
+ for _, d in ipairs(s:GetDescendants()) do
544
+ -- Skip Terrain and Camera to reduce noise/issues
545
+ if d:IsA("Terrain") or d:IsA("Camera") then continue end
546
+ -- Skip self to prevent recursion
547
+ if d == script or d == plugin then continue end
548
+
549
+ local path = getPath(d)
550
+ if path then
551
+ local ext, content = getInfo(d)
552
+ if ext and content then
553
+ local fullPath = path..ext
554
+ table.insert(batch, {filePath = fullPath, content = content})
555
+ registerObject(d, fullPath) -- Start tracking
556
+ count = count + 1
557
+ print("Roport: Queued " .. d.Name .. " -> " .. fullPath)
558
+ end
559
+ else
560
+ -- Debug why path is nil
561
+ print("Roport: Skipped " .. d.Name .. " (No Path) Parent: " .. (d.Parent and d.Parent.Name or "nil"))
562
+ end
563
+ end
564
+ end
565
+
566
+ print("Roport: Found " .. count .. " items to sync.")
567
+ if count > 0 then
568
+ print("Roport: First item path: " .. batch[1].filePath)
569
+ end
570
+
571
+ -- Send
572
+ task.spawn(function()
573
+ local chunkSize = 50
574
+ for i = 1, #batch, chunkSize do
575
+ local chunk = {}
576
+ for j = i, math.min(i + chunkSize - 1, #batch) do table.insert(chunk, batch[j]) end
577
+ local success, err = pcall(function()
578
+ HttpService:PostAsync(SERVER_URL_BATCH, HttpService:JSONEncode({files=chunk}))
579
+ end)
580
+ if not success then
581
+ warn("Roport: Batch sync failed: " .. tostring(err))
582
+ end
583
+ task.wait(0.1)
584
+ end
585
+ showToast("Sync Complete!", "success")
586
+ end)
587
+ end
588
+
589
+ -- Buttons
590
+ local syncBtn = createButton("Sync All (Push)", "", syncAll)
591
+
592
+ local function pullChanges()
593
+ if not state.isConnected then showToast("Connect first!", "error") return end
594
+ showToast("Checking for updates...", "info")
595
+
596
+ local success, res = pcall(function()
597
+ return HttpService:GetAsync(SERVER_URL_POLL .. "?t=" .. os.time())
598
+ end)
599
+
600
+ if success then
601
+ local data = HttpService:JSONDecode(res)
602
+ if data.changes and #data.changes > 0 then
603
+ print("Roport: Pulling " .. #data.changes .. " files...")
604
+ for _, change in ipairs(data.changes) do
605
+ applyUpdate(change.filePath, change.content)
606
+ end
607
+ showToast("Updated " .. #data.changes .. " files", "success")
608
+ else
609
+ showToast("No changes found", "info")
610
+ end
611
+ else
612
+ warn("Roport: Pull failed: " .. tostring(res))
613
+ showToast("Pull Failed", "error")
614
+ end
615
+ end
616
+
617
+ local pullBtn = createButton("Pull Changes", "", pullChanges)
618
+
619
+ local autoPullBtn = createButton("Auto-Pull: OFF", "", function()
620
+ if not state.isConnected then showToast("Connect first!", "error") return end
621
+ state.isAutoPull = not state.isAutoPull
622
+
623
+ local lbl = autoPullBtn:FindFirstChild("TextLabel")
624
+
625
+ if state.isAutoPull then
626
+ showToast("Auto-Pull Enabled", "success")
627
+ if lbl then lbl.Text = "Auto-Pull: ON" end
628
+ autoPullBtn.BackgroundColor3 = COLORS.ACCENT
629
+ else
630
+ showToast("Auto-Pull Disabled", "info")
631
+ if lbl then lbl.Text = "Auto-Pull: OFF" end
632
+ autoPullBtn.BackgroundColor3 = COLORS.BUTTON
633
+ end
634
+ end)
635
+
636
+ local autoBtn -- Forward declaration
637
+
638
+ autoBtn = createButton("Auto-Sync: OFF", "", function()
639
+ if not state.isConnected then showToast("Connect first!", "error") return end
640
+ state.isAutoSync = not state.isAutoSync
641
+
642
+ local lbl = autoBtn:FindFirstChild("TextLabel")
643
+
644
+ if state.isAutoSync then
645
+ showToast("Auto-Sync Enabled", "success")
646
+ if lbl then lbl.Text = "Auto-Sync: ON" end
647
+ autoBtn.BackgroundColor3 = COLORS.ACCENT
648
+ else
649
+ showToast("Auto-Sync Disabled", "info")
650
+ if lbl then lbl.Text = "Auto-Sync: OFF" end
651
+ autoBtn.BackgroundColor3 = COLORS.BUTTON
652
+ end
653
+ end)
654
+
655
+ -- Auto Sync Logic
656
+ local dirtyScripts = {}
657
+ local lastSyncTime = 0
658
+ local SYNC_INTERVAL = 2 -- Seconds
659
+
660
+ ScriptEditorService.TextDocumentDidChange:Connect(function(document, changes)
661
+ if not state.isAutoSync or not state.isConnected then return end
662
+
663
+ local script = document:GetScript()
664
+ if script then
665
+ dirtyScripts[script] = true
666
+ end
667
+ end)
668
+
669
+ task.spawn(function()
670
+ while true do
671
+ task.wait(0.5)
672
+ if state.isAutoSync and state.isConnected then
673
+ if os.time() - lastSyncTime >= SYNC_INTERVAL then
674
+ local batch = {}
675
+ local count = 0
676
+
677
+ for script, _ in pairs(dirtyScripts) do
678
+ if script.Parent then -- Ensure script still exists
679
+ local path = getPath(script)
680
+ if path then
681
+ local ext, content = getInfo(script)
682
+ if ext and content then
683
+ local fullPath = path..ext
684
+ table.insert(batch, {filePath = fullPath, content = content})
685
+ count = count + 1
686
+ registerObject(script, fullPath) -- Ensure tracking
687
+ end
688
+ end
689
+ end
690
+ end
691
+
692
+ if count > 0 then
693
+ -- Clear dirty list immediately to avoid double sync
694
+ dirtyScripts = {}
695
+
696
+ local success, err = pcall(function()
697
+ HttpService:PostAsync(SERVER_URL_BATCH, HttpService:JSONEncode({files=batch}))
698
+ end)
699
+
700
+ if success then
701
+ lastSyncTime = os.time()
702
+ print("Roport: Auto-synced " .. count .. " scripts")
703
+ else
704
+ warn("Roport: Auto-sync failed: " .. tostring(err))
705
+ end
706
+ end
707
+ end
708
+ end
709
+ end
710
+ end)
711
+
712
+ -- Connection Logic
713
+ local hasPrompted = false
714
+
715
+ local function checkConnection()
716
+ local success, _ = pcall(function() return HttpService:GetAsync(SERVER_URL_PING) end)
717
+
718
+ if success then
719
+ if not state.isConnected then
720
+ -- Server just appeared
721
+ if not hasPrompted then
722
+ print("Roport: Server detected, prompting user...")
723
+ hasPrompted = true
724
+ showModal("Server Detected", "VS Code sync server is running. Connect now?", function()
725
+ state.isConnected = true
726
+ statusDot.BackgroundColor3 = COLORS.SUCCESS
727
+ statusText.Text = "Connected"
728
+ showToast("Connected to VS Code", "success")
729
+
730
+ -- Ask for initial sync
731
+ task.wait(0.5)
732
+ showModal("Initial Sync", "Do you want to sync the workspace to VS Code now?", function()
733
+ syncAll()
734
+ end)
735
+ end)
736
+ end
737
+ end
738
+ else
739
+ state.isConnected = false
740
+ statusDot.BackgroundColor3 = COLORS.ERROR
741
+ statusText.Text = "Disconnected (Server Offline)"
742
+ hasPrompted = false -- Reset so we prompt again if it comes back
743
+ end
744
+ end
745
+
746
+ -- Two-Way Sync Logic
747
+ local function findInstanceByPath(path)
748
+ -- Remove extension
749
+ local cleanPath = path:gsub("%.server%.luau$", ""):gsub("%.client%.luau$", ""):gsub("%.luau$", ""):gsub("/init%.meta%.json$", "")
750
+
751
+ local parts = cleanPath:split("/")
752
+ if parts[1] ~= "src" then return nil end
753
+
754
+ local current = nil
755
+
756
+ -- Map src/xxx to Service
757
+ local serviceMap = {
758
+ workspace = game:GetService("Workspace"),
759
+ server = game:GetService("ServerScriptService"),
760
+ shared = game:GetService("ReplicatedStorage"),
761
+ client = game:GetService("StarterPlayer").StarterPlayerScripts,
762
+ interface = game:GetService("StarterGui"),
763
+ tools = game:GetService("StarterPack"),
764
+ lighting = game:GetService("Lighting"),
765
+ first = game:GetService("ReplicatedFirst"),
766
+ sounds = game:GetService("SoundService")
767
+ }
768
+
769
+ current = serviceMap[parts[2]]
770
+ if not current then return nil end
771
+
772
+ for i = 3, #parts do
773
+ local name = parts[i]
774
+ current = current:FindFirstChild(name)
775
+ if not current then return nil end
776
+ end
777
+
778
+ return current
779
+ end
780
+
781
+ local function createInstanceByPath(path, content)
782
+ local cleanPath = path:gsub("%.server%.luau$", ""):gsub("%.client%.luau$", ""):gsub("%.luau$", ""):gsub("/init%.meta%.json$", "")
783
+ local parts = cleanPath:split("/")
784
+ if parts[1] ~= "src" then return nil end
785
+
786
+ local serviceMap = {
787
+ workspace = game:GetService("Workspace"),
788
+ server = game:GetService("ServerScriptService"),
789
+ shared = game:GetService("ReplicatedStorage"),
790
+ client = game:GetService("StarterPlayer").StarterPlayerScripts,
791
+ interface = game:GetService("StarterGui"),
792
+ tools = game:GetService("StarterPack"),
793
+ lighting = game:GetService("Lighting"),
794
+ first = game:GetService("ReplicatedFirst"),
795
+ sounds = game:GetService("SoundService")
796
+ }
797
+
798
+ local current = serviceMap[parts[2]]
799
+ if not current then return nil end
800
+
801
+ for i = 3, #parts do
802
+ local name = parts[i]
803
+ local nextInst = current:FindFirstChild(name)
804
+
805
+ if not nextInst then
806
+ local isLast = (i == #parts)
807
+ local className = "Folder"
808
+
809
+ if isLast then
810
+ if path:match("%.server%.luau$") then className = "Script"
811
+ elseif path:match("%.client%.luau$") then className = "LocalScript"
812
+ elseif path:match("%.luau$") then className = "ModuleScript"
813
+ elseif path:match("%.json$") then
814
+ -- Try to peek className from content
815
+ local success, data = pcall(function() return HttpService:JSONDecode(content) end)
816
+ if success and data.className then
817
+ className = data.className
818
+ end
819
+ end
820
+ end
821
+
822
+ local success, newInst = pcall(function() return Instance.new(className) end)
823
+ if success and newInst then
824
+ newInst.Name = name
825
+ newInst.Parent = current
826
+ nextInst = newInst
827
+ else
828
+ warn("Roport: Failed to create " .. className .. " for " .. name)
829
+ return nil
830
+ end
831
+ end
832
+ current = nextInst
833
+ end
834
+ return current
835
+ end
836
+
837
+ local function applyUpdate(filePath, content)
838
+ local inst = findInstanceByPath(filePath)
839
+ if not inst then
840
+ inst = createInstanceByPath(filePath, content)
841
+ if not inst then return end
842
+ end
843
+
844
+ if filePath:match("%.json$") then
845
+ local success, data = pcall(function() return HttpService:JSONDecode(content) end)
846
+ if not success then return end
847
+
848
+ -- Update properties
849
+ if data.properties then
850
+ for prop, val in pairs(data.properties) do
851
+ pcall(function()
852
+ local currentVal = inst[prop]
853
+ local targetType = typeof(currentVal)
854
+
855
+ if type(val) == "table" and targetType ~= "table" then
856
+ -- Deserialize complex types
857
+ if targetType == "Color3" then
858
+ inst[prop] = Color3.new(val[1], val[2], val[3])
859
+ elseif targetType == "Vector3" then
860
+ inst[prop] = Vector3.new(val[1], val[2], val[3])
861
+ elseif targetType == "UDim2" then
862
+ inst[prop] = UDim2.new(val[1], val[2], val[3], val[4])
863
+ elseif targetType == "UDim" then
864
+ inst[prop] = UDim.new(val[1], val[2])
865
+ elseif targetType == "Rect" then
866
+ inst[prop] = Rect.new(val[1], val[2], val[3], val[4])
867
+ elseif targetType == "NumberRange" then
868
+ inst[prop] = NumberRange.new(val[1], val[2])
869
+ end
870
+ else
871
+ inst[prop] = val
872
+ end
873
+ end)
874
+ end
875
+ end
876
+ elseif filePath:match("%.luau$") or filePath:match("%.lua$") then
877
+ if inst:IsA("LuaSourceContainer") then
878
+ inst.Source = content
879
+ end
880
+ end
881
+ print("Roport: Updated " .. inst.Name .. " from VS Code")
882
+ end
883
+
884
+ task.spawn(function()
885
+ while true do
886
+ task.wait(1)
887
+ if state.isConnected and state.isAutoPull then
888
+ local success, res = pcall(function()
889
+ return HttpService:GetAsync(SERVER_URL_POLL .. "?t=" .. os.time())
890
+ end)
891
+
892
+ if success then
893
+ local data = HttpService:JSONDecode(res)
894
+ if data.changes and #data.changes > 0 then
895
+ print("Roport: Received " .. #data.changes .. " updates from VS Code")
896
+ for _, change in ipairs(data.changes) do
897
+ applyUpdate(change.filePath, change.content)
898
+ end
899
+ end
900
+ else
901
+ warn("Roport: Poll failed: " .. tostring(res))
902
+ end
903
+ end
904
+ end
905
+ end)
906
+
907
+ task.spawn(function()
908
+ while true do
909
+ checkConnection()
910
+ task.wait(3)
911
+ end
912
+ end)
913
+
914
+ toggleButton.Click:Connect(function() widget.Enabled = not widget.Enabled end)
package/src/server.js ADDED
@@ -0,0 +1,166 @@
1
+ const express = require('express');
2
+ const fs = require('fs-extra');
3
+ const path = require('path');
4
+ const chalk = require('chalk');
5
+
6
+ function startServer(port) {
7
+ const app = express();
8
+
9
+ // Determine project root
10
+ let projectRoot = process.cwd();
11
+ if (fs.existsSync(path.join(projectRoot, 'src'))) {
12
+ // We are in the root
13
+ } else if (fs.existsSync(path.join(projectRoot, '../src'))) {
14
+ // We are in cli/ or similar
15
+ projectRoot = path.resolve(projectRoot, '..');
16
+ }
17
+ console.log(chalk.cyan(`Project Root: ${projectRoot}`));
18
+
19
+ // State for two-way sync
20
+ let changedFiles = new Set();
21
+ let isWriting = false; // Lock to prevent loops (Roblox -> File -> Watcher -> Roblox)
22
+
23
+ // Watch for file changes locally
24
+ try {
25
+ console.log(chalk.gray(`Starting file watcher on ${projectRoot}...`));
26
+ fs.watch(projectRoot, { recursive: true }, (eventType, filename) => {
27
+ // Debug log
28
+ // console.log('Raw event:', eventType, filename);
29
+
30
+ if (filename && !isWriting) {
31
+ // Ignore system folders and the cli folder itself if we are watching root
32
+ if (filename.includes('node_modules') || filename.includes('.git') || filename.startsWith('cli')) return;
33
+
34
+ // Normalize path to forward slashes
35
+ const relativePath = filename.replace(/\\/g, '/');
36
+
37
+ // Debounce/Deduplicate slightly
38
+ if (!changedFiles.has(relativePath)) {
39
+ changedFiles.add(relativePath);
40
+ console.log(chalk.yellow(`File changed: ${relativePath} (${eventType})`));
41
+ }
42
+ }
43
+ });
44
+ } catch (e) {
45
+ console.warn(chalk.red("File watching failed (might not be supported on this OS):"), e);
46
+ }
47
+
48
+ // Increase limit for large file batches
49
+ app.use(express.json({ limit: '50mb' }));
50
+
51
+ app.get('/ping', (req, res) => {
52
+ res.send('pong');
53
+ });
54
+
55
+ // New endpoint for Roblox to poll for changes
56
+ app.get('/poll', async (req, res) => {
57
+ if (changedFiles.size === 0) {
58
+ return res.send({ changes: [] });
59
+ }
60
+
61
+ const changes = [];
62
+ const filesToProcess = Array.from(changedFiles);
63
+ changedFiles.clear(); // Clear immediately so we don't send duplicates
64
+
65
+ for (const filePath of filesToProcess) {
66
+ try {
67
+ const fullPath = path.resolve(projectRoot, filePath);
68
+ // Only send if file still exists (it might have been deleted)
69
+ if (await fs.pathExists(fullPath)) {
70
+ const stat = await fs.stat(fullPath);
71
+ if (stat.isFile()) {
72
+ const content = await fs.readFile(fullPath, 'utf8');
73
+ changes.push({ filePath, content });
74
+ }
75
+ }
76
+ } catch (e) {
77
+ console.error(chalk.red(`Error reading changed file ${filePath}:`), e);
78
+ }
79
+ }
80
+
81
+ if (changes.length > 0) {
82
+ console.log(chalk.magenta(`Sending ${changes.length} updates to Roblox`));
83
+ }
84
+ res.send({ changes });
85
+ });
86
+
87
+ app.post('/batch', async (req, res) => {
88
+ const { files } = req.body;
89
+
90
+ if (!files || !Array.isArray(files)) {
91
+ return res.status(400).send('Invalid body');
92
+ }
93
+
94
+ console.log(chalk.blue(`Received batch of ${files.length} files`));
95
+
96
+ isWriting = true; // Lock watcher
97
+
98
+ try {
99
+ for (const file of files) {
100
+ const { filePath, content } = file;
101
+ // Prevent directory traversal
102
+ const safePath = path.resolve(projectRoot, filePath);
103
+ if (!safePath.startsWith(projectRoot)) {
104
+ console.warn(chalk.yellow(`Skipping unsafe path: ${filePath}`));
105
+ continue;
106
+ }
107
+
108
+ if (filePath.endsWith('.json')) {
109
+ try {
110
+ const json = JSON.parse(content);
111
+ await fs.outputFile(safePath, JSON.stringify(json, null, 2));
112
+ } catch (e) {
113
+ await fs.outputFile(safePath, content);
114
+ }
115
+ } else {
116
+ await fs.outputFile(safePath, content);
117
+ }
118
+ console.log(chalk.green(`Synced: ${filePath}`));
119
+ }
120
+ res.send({ success: true });
121
+ } catch (err) {
122
+ console.error(chalk.red('Error syncing files:'), err);
123
+ res.status(500).send({ error: err.message });
124
+ } finally {
125
+ // Release lock after a short delay to let FS settle
126
+ setTimeout(() => { isWriting = false; }, 500);
127
+ }
128
+ });
129
+
130
+ app.post('/delete', async (req, res) => {
131
+ const { files } = req.body;
132
+
133
+ if (!files || !Array.isArray(files)) {
134
+ return res.status(400).send('Invalid body');
135
+ }
136
+
137
+ console.log(chalk.blue(`Received delete request for ${files.length} files`));
138
+
139
+ try {
140
+ for (const filePath of files) {
141
+ // Prevent directory traversal
142
+ const safePath = path.resolve(projectRoot, filePath);
143
+ if (!safePath.startsWith(projectRoot)) {
144
+ console.warn(chalk.yellow(`Skipping unsafe delete path: ${filePath}`));
145
+ continue;
146
+ }
147
+
148
+ if (await fs.pathExists(safePath)) {
149
+ await fs.remove(safePath);
150
+ console.log(chalk.magenta(`Deleted: ${filePath}`));
151
+ }
152
+ }
153
+ res.send({ success: true });
154
+ } catch (err) {
155
+ console.error(chalk.red('Error deleting files:'), err);
156
+ res.status(500).send({ error: err.message });
157
+ }
158
+ });
159
+
160
+ app.listen(port, () => {
161
+ console.log(chalk.cyan(`Roport server running on http://127.0.0.1:${port}`));
162
+ console.log(chalk.gray('Waiting for requests...'));
163
+ });
164
+ }
165
+
166
+ module.exports = { startServer };