@el4cteo/rbx-studio-mcp 0.8.4 → 0.8.6

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.
Files changed (65) hide show
  1. package/README.md +20 -3
  2. package/dist/bridge/rpc.js +1 -1
  3. package/dist/bridge/rpc.js.map +1 -1
  4. package/dist/index.js +7 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/apidump.js +75 -0
  7. package/dist/lib/apidump.js.map +1 -1
  8. package/dist/lib/errors.js +5 -4
  9. package/dist/lib/errors.js.map +1 -1
  10. package/dist/lib/format.js +92 -25
  11. package/dist/lib/format.js.map +1 -1
  12. package/dist/lib/notices.js +29 -0
  13. package/dist/lib/notices.js.map +1 -0
  14. package/dist/lib/protocol.js.map +1 -1
  15. package/dist/lib/sync.js +1141 -0
  16. package/dist/lib/sync.js.map +1 -0
  17. package/dist/lib/syncplan.js +338 -0
  18. package/dist/lib/syncplan.js.map +1 -0
  19. package/dist/lib/tool.js +9 -2
  20. package/dist/lib/tool.js.map +1 -1
  21. package/dist/tools/discover.js +6 -1
  22. package/dist/tools/discover.js.map +1 -1
  23. package/dist/tools/exec.js +5 -0
  24. package/dist/tools/exec.js.map +1 -1
  25. package/dist/tools/instances.js +2 -2
  26. package/dist/tools/instances.js.map +1 -1
  27. package/dist/tools/perf.js +28 -3
  28. package/dist/tools/perf.js.map +1 -1
  29. package/dist/tools/screenshot.js +7 -3
  30. package/dist/tools/screenshot.js.map +1 -1
  31. package/dist/tools/scripts.js +110 -28
  32. package/dist/tools/scripts.js.map +1 -1
  33. package/dist/tools/sync.js +169 -0
  34. package/dist/tools/sync.js.map +1 -0
  35. package/package.json +4 -4
  36. package/plugin/src/Config.luau +1 -1
  37. package/plugin/src/Dispatch.luau +131 -90
  38. package/plugin/src/ExecRuntime.luau +190 -168
  39. package/plugin/src/LogBuffer.luau +38 -9
  40. package/plugin/src/Paths.luau +42 -0
  41. package/plugin/src/Phrase.luau +41 -0
  42. package/plugin/src/ScriptEdit.luau +94 -8
  43. package/plugin/src/Serialize.luau +16 -1
  44. package/plugin/src/Transport.luau +5 -2
  45. package/plugin/src/Undo.luau +74 -10
  46. package/plugin/src/handlers/Capture.luau +818 -809
  47. package/plugin/src/handlers/Debug.luau +19 -16
  48. package/plugin/src/handlers/Discover.luau +31 -1
  49. package/plugin/src/handlers/Perf.luau +100 -21
  50. package/plugin/src/handlers/Scripts.luau +274 -90
  51. package/plugin/src/handlers/Sync.luau +968 -0
  52. package/plugin/src/init.server.luau +33 -2
  53. package/scripts/build.mjs +14 -0
  54. package/scripts/sync-fake.mjs +191 -0
  55. package/scripts/test-live-sync-scale.mjs +150 -0
  56. package/scripts/test-live-sync.mjs +232 -0
  57. package/scripts/test-live-tools.mjs +6 -1
  58. package/scripts/test-plugin.mjs +16 -0
  59. package/scripts/test-results.mjs +41 -0
  60. package/scripts/test-sync-more.mjs +228 -0
  61. package/scripts/test-sync.mjs +245 -0
  62. package/dist/tools/spatial.js +0 -135
  63. package/dist/tools/spatial.js.map +0 -1
  64. package/dist/tools/upload.js +0 -294
  65. package/dist/tools/upload.js.map +0 -1
@@ -0,0 +1,968 @@
1
+ --!strict
2
+ --[[
3
+ The Studio half of `sync`: scripts mirrored to files on disk, and instance
4
+ trees exported to build files and rebuilt from them.
5
+
6
+ Everything that decides WHAT to sync lives in the server, which can see the
7
+ disk. This side answers four questions and carries out one kind of order:
8
+
9
+ scan which scripts exist, where, and at what revision
10
+ read their source
11
+ changes what changed since the last time anyone asked (for `watch`)
12
+ export an instance tree as the same spec `create` takes
13
+
14
+ apply write, create, delete and move scripts, each item checked against
15
+ the revision the server last saw, so nothing the user changed in
16
+ the meantime is overwritten
17
+ build replace an instance tree from a spec, keeping its scripts
18
+
19
+ Revisions are `ScriptEdit.fingerprint`, the same `rev` script_read hands out,
20
+ so a sync and an agent editing through the script tools agree on what
21
+ "unchanged" means.
22
+ ]]
23
+
24
+ local ScriptEditorService = game:GetService("ScriptEditorService")
25
+ local HttpService = game:GetService("HttpService")
26
+ local CollectionService = game:GetService("CollectionService")
27
+
28
+ local Dispatch = require(script.Parent.Parent.Dispatch)
29
+ local Paths = require(script.Parent.Parent.Paths)
30
+ local Scope = require(script.Parent.Parent.Scope)
31
+ local ScriptEdit = require(script.Parent.Parent.ScriptEdit)
32
+ local Serialize = require(script.Parent.Parent.Serialize)
33
+ local Undo = require(script.Parent.Parent.Undo)
34
+ local Instances = require(script.Parent.Instances)
35
+
36
+ local Sync = {}
37
+
38
+ -- Past this, a place is better synced a service at a time: every scan reads
39
+ -- and fingerprints every script's buffer on Studio's main thread.
40
+ local MAX_SCRIPTS = 5_000
41
+
42
+ -- Where scripts are looked for by default. `Players` is left out on purpose:
43
+ -- in edit it is empty, and in a playtest its scripts are copies the engine
44
+ -- made from StarterPlayer, which would sync back as duplicates.
45
+ local DEFAULT_ROOTS = {
46
+ "Workspace",
47
+ "ReplicatedFirst",
48
+ "ReplicatedStorage",
49
+ "ServerScriptService",
50
+ "ServerStorage",
51
+ "StarterGui",
52
+ "StarterPack",
53
+ "StarterPlayer",
54
+ "SoundService",
55
+ "Lighting",
56
+ "Teams",
57
+ "Chat",
58
+ "TextChatService",
59
+ "TestService",
60
+ }
61
+
62
+ -- The panel's own `copy` output. A log, not code, and it is rewritten on every
63
+ -- `copy`, so syncing it would only ever produce noise on disk.
64
+ local IGNORED_NAMES = {
65
+ ["rbx-studio log"] = true,
66
+ }
67
+
68
+ -- Same limit as `script_create`: past it `.Source` refuses and only the editor
69
+ -- path takes the text.
70
+ local SOURCE_PROPERTY_LIMIT = 200_000
71
+
72
+ export type Logger = (level: string, message: string, detail: string?) -> ()
73
+ local log: Logger = function() end
74
+
75
+ --[[
76
+ Hands in the panel's logger. A handler requiring the console would pull the
77
+ whole widget into every test harness that loads this file.
78
+ ]]
79
+ function Sync.setup(logger: Logger)
80
+ log = logger
81
+ end
82
+
83
+ local function isScript(instance: Instance): boolean
84
+ return instance:IsA("LuaSourceContainer")
85
+ end
86
+
87
+ local function rootsOf(params: { [string]: any }): { Instance }
88
+ local wanted = params.roots
89
+ local roots: { Instance } = {}
90
+ if typeof(wanted) == "table" and #wanted > 0 then
91
+ for _, path in wanted do
92
+ table.insert(roots, Paths.resolve(tostring(path)))
93
+ end
94
+ return roots
95
+ end
96
+ for _, name in DEFAULT_ROOTS do
97
+ local service = game:FindFirstChild(name)
98
+ if service ~= nil then
99
+ table.insert(roots, service)
100
+ end
101
+ end
102
+ return roots
103
+ end
104
+
105
+ --[[
106
+ Whether Studio's own Script Sync already binds this script to a file.
107
+
108
+ Two syncs writing the same script is a race neither can see, so such scripts
109
+ are reported and left alone. Silent when the service is unavailable.
110
+ ]]
111
+ local function fileSynced(target: Instance): boolean
112
+ local service = game:FindService("InstanceFileSyncService")
113
+ if service == nil then
114
+ return false
115
+ end
116
+ local ok, status = pcall(function()
117
+ return (service :: any):GetStatus(target)
118
+ end)
119
+ if not ok or status == nil then
120
+ return false
121
+ end
122
+ local name = tostring(status)
123
+ return not (string.find(name, "NotSynced") or string.find(name, "Unknown") or name == "nil")
124
+ end
125
+
126
+
127
+ -- Scripts under the roots, in a fixed order (see `stableOrder` in Discover).
128
+ local function collect(roots: { Instance }): { LuaSourceContainer }
129
+ local found: { LuaSourceContainer } = {}
130
+ local seen: { [Instance]: boolean } = {}
131
+ local function consider(instance: Instance)
132
+ if seen[instance] or not isScript(instance) or IGNORED_NAMES[instance.Name] then
133
+ return
134
+ end
135
+ if Scope.isNoisy(instance) then
136
+ return
137
+ end
138
+ seen[instance] = true
139
+ table.insert(found, instance :: LuaSourceContainer)
140
+ end
141
+ for _, root in roots do
142
+ consider(root)
143
+ for _, descendant in root:GetDescendants() do
144
+ consider(descendant)
145
+ end
146
+ end
147
+ if #found > MAX_SCRIPTS then
148
+ Dispatch.fail(
149
+ "TOO_BROAD",
150
+ string.format("%d scripts, over the %d a sync handles at once.", #found, MAX_SCRIPTS),
151
+ "Pass `roots` to sync a few services or folders at a time."
152
+ )
153
+ end
154
+ local keys: { [Instance]: string } = {}
155
+ for _, target in found do
156
+ local ok, id = pcall(function()
157
+ return target:GetDebugId()
158
+ end)
159
+ keys[target] = target:GetFullName() .. "\0" .. (if ok then id else "")
160
+ end
161
+ table.sort(found, function(a: Instance, b: Instance): boolean
162
+ return keys[a] < keys[b]
163
+ end)
164
+ return found
165
+ end
166
+
167
+ --[[
168
+ Every script under the roots: where it is, what it is, and -- unless the
169
+ caller only wants the shape -- its revision.
170
+
171
+ The server lays files out by the chain of instances above each script, and
172
+ needs to know which links are scripts themselves (a script with scripts
173
+ inside becomes a folder with an `init` file). That chain is shared by every
174
+ script in a folder, so it comes back once, as `nodes`: each instance on any
175
+ chain, with its parent's position in the same list (0 for the DataModel)
176
+ and its sibling ordinal when its name is not unique. Scripts point into it.
177
+ ]]
178
+ function Sync.scan(params: { [string]: any }): { [string]: any }
179
+ local withRevisions = params.revisions ~= false
180
+ local memo: Paths.NameIndex = {}
181
+ local nodes: { { [string]: any } } = {}
182
+ local indexOf: { [Instance]: number } = {}
183
+
184
+ local function nodeOf(instance: Instance): number
185
+ if instance == game then
186
+ return 0
187
+ end
188
+ local known = indexOf[instance]
189
+ if known ~= nil then
190
+ return known
191
+ end
192
+ local parent = nodeOf(instance.Parent :: Instance)
193
+ table.insert(nodes, {
194
+ path = Paths.of(instance, memo),
195
+ name = instance.Name,
196
+ className = instance.ClassName,
197
+ parent = parent,
198
+ ordinal = Paths.ordinal(instance, memo),
199
+ })
200
+ indexOf[instance] = #nodes
201
+ return #nodes
202
+ end
203
+
204
+ local items: { { [string]: any } } = {}
205
+ for _, target in collect(rootsOf(params)) do
206
+ table.insert(items, {
207
+ node = nodeOf(target),
208
+ revision = if withRevisions then ScriptEdit.revisionOf(target) else nil,
209
+ fileSynced = if fileSynced(target) then true else nil,
210
+ })
211
+ end
212
+ local roots: { string } = {}
213
+ for _, root in rootsOf(params) do
214
+ table.insert(roots, Paths.of(root, memo))
215
+ end
216
+ return { nodes = nodes, items = items, roots = roots }
217
+ end
218
+
219
+ --[[
220
+ Every synced script's path, one string. `watch` compares it every few
221
+ seconds to notice what no event reports -- a folder renamed moves every
222
+ script inside it -- without reading or fingerprinting a single script.
223
+ ]]
224
+ function Sync.shape(params: { [string]: any }): { [string]: any }
225
+ local memo: Paths.NameIndex = {}
226
+ local paths: { string } = {}
227
+ for _, target in collect(rootsOf(params)) do
228
+ table.insert(paths, Paths.of(target, memo))
229
+ end
230
+ return { shape = table.concat(paths, "\n") }
231
+ end
232
+
233
+ -- Declared ahead of `revisions`, which needs it.
234
+ local scriptAt: (path: string) -> LuaSourceContainer
235
+
236
+ --[[
237
+ Revisions only, for `watch` to tell a real Studio edit from the echo of a
238
+ write it made itself -- without shipping any source back.
239
+ ]]
240
+ function Sync.revisions(params: { [string]: any }): { [string]: any }
241
+ local items: { { [string]: any } } = {}
242
+ for _, path in (if typeof(params.paths) == "table" then params.paths else {}) :: { any } do
243
+ local ok, revision = pcall(function()
244
+ return ScriptEdit.revisionOf(scriptAt(tostring(path)))
245
+ end)
246
+ table.insert(items, if ok then { path = path, revision = revision } else { path = path, missing = true })
247
+ end
248
+ return { items = items }
249
+ end
250
+
251
+ -- Resolves a path and insists it is a script, or explains why not.
252
+ function scriptAt(path: string): LuaSourceContainer
253
+ local instance = Paths.resolve(path)
254
+ if not isScript(instance) then
255
+ Dispatch.fail("NOT_A_SCRIPT", string.format("%s is a %s, not a script.", path, instance.ClassName))
256
+ end
257
+ return instance :: LuaSourceContainer
258
+ end
259
+
260
+ local function reasonOf(err: any): (string, string)
261
+ if typeof(err) == "table" then
262
+ return tostring(err.code or "FAILED"), tostring(err.message)
263
+ end
264
+ return "FAILED", tostring(err)
265
+ end
266
+
267
+ -- Source and revision of each path. Per path, so one missing script does not
268
+ -- sink a read of three hundred.
269
+ function Sync.read(params: { [string]: any }): { [string]: any }
270
+ local paths = params.paths
271
+ if typeof(paths) ~= "table" then
272
+ Dispatch.fail("BAD_PARAMS", "sync.read requires `paths`.")
273
+ end
274
+ local items: { { [string]: any } } = {}
275
+ for _, path in paths do
276
+ local ok, result = pcall(function()
277
+ local target = scriptAt(tostring(path))
278
+ local source = ScriptEdit.read(target)
279
+ return { path = path, source = source, revision = ScriptEdit.revisionOf(target, source), className = target.ClassName }
280
+ end)
281
+ if ok then
282
+ table.insert(items, result)
283
+ else
284
+ local code, message = reasonOf(result)
285
+ table.insert(items, { path = path, code = code, message = message })
286
+ end
287
+ end
288
+ return { items = items }
289
+ end
290
+
291
+ --[[
292
+ Walks a chain of names from the DataModel, creating what is missing.
293
+
294
+ By name rather than through `Paths.resolve`: the chain comes from a folder
295
+ on disk, which knows names and nothing about sibling indexes. Missing links
296
+ become the class the chain asks for -- a Folder, unless the file says the
297
+ link is a script -- and a missing SERVICE is refused, since that is a
298
+ misspelled top-level folder, not something to invent.
299
+ ]]
300
+ local function ensureChain(start: any, chain: { { [string]: any } }, create: boolean): Instance?
301
+ local current: Instance = if typeof(start) == "string" and start ~= "" then Paths.resolve(start) else game
302
+ for index, link in chain do
303
+ local name = tostring(link.name)
304
+ local child = current:FindFirstChild(name)
305
+ if child == nil then
306
+ if current == game then
307
+ local ok, service = pcall(function()
308
+ return game:GetService(name)
309
+ end)
310
+ if ok and service ~= nil then
311
+ child = service
312
+ else
313
+ Dispatch.fail(
314
+ "UNKNOWN_SERVICE",
315
+ string.format('"%s" is not a service.', name),
316
+ "Top-level folders in the sync folder are service names, e.g. ServerScriptService."
317
+ )
318
+ end
319
+ elseif not create then
320
+ return nil
321
+ else
322
+ local className = if typeof(link.className) == "string" then link.className else "Folder"
323
+ local made = Instance.new(className)
324
+ made.Name = name
325
+ made.Parent = current
326
+ child = made
327
+ end
328
+ end
329
+ current = child :: Instance
330
+ end
331
+ return current
332
+ end
333
+
334
+ -- Refuses unless `target` is still at the revision the server last saw.
335
+ local function assertRevision(target: LuaSourceContainer, revision: any)
336
+ if typeof(revision) ~= "string" then
337
+ return
338
+ end
339
+ local live = ScriptEdit.revisionOf(target)
340
+ if live ~= revision then
341
+ Dispatch.fail("STALE_SCRIPT", string.format("changed in Studio since the last sync (now %s)", live))
342
+ end
343
+ end
344
+
345
+ --[[
346
+ Carries out one batch of the server's decisions.
347
+
348
+ Structural changes -- creates, deletes, moves -- share one undo step, "MCP
349
+ sync", so a sync that put files where they should not be is one Ctrl+Z.
350
+ Source writes go through the script editor after that, with its own
351
+ per-script undo, exactly like `script_edit`.
352
+
353
+ Every item is checked against the revision the server based its decision
354
+ on, and fails alone: a sync is many independent files, and one the user
355
+ touched a second ago must not hold the other forty back. Each item reports
356
+ what happened, and the new revision where there is one.
357
+ ]]
358
+ function Sync.apply(params: { [string]: any }): { [string]: any }
359
+ local writes = if typeof(params.writes) == "table" then params.writes else {}
360
+ local creates = if typeof(params.creates) == "table" then params.creates else {}
361
+ local deletes = if typeof(params.deletes) == "table" then params.deletes else {}
362
+ local moves = if typeof(params.moves) == "table" then params.moves else {}
363
+
364
+ local results = { writes = {}, creates = {}, deletes = {}, moves = {} }
365
+ type Late = { target: LuaSourceContainer, source: string, result: { [string]: any } }
366
+ local late: { Late } = {}
367
+
368
+ local structural = #creates + #deletes + #moves > 0
369
+ local recorded = false
370
+ if structural then
371
+ local _, didRecord = Undo.recordPartial("StudioMCP.Sync", "MCP sync", function()
372
+ for _, item in creates do
373
+ local ok, err = pcall(function()
374
+ local parent = ensureChain(item.parentPath, item.parents or {}, true) :: Instance
375
+ local name = tostring(item.name)
376
+ local existing = parent:FindFirstChild(name)
377
+ if existing ~= nil and isScript(existing) then
378
+ Dispatch.fail("EXISTS", string.format("%s already has a script named %s", parent:GetFullName(), name))
379
+ end
380
+ local made = Instance.new(item.className) :: LuaSourceContainer
381
+ made.Name = name
382
+ local source = if typeof(item.source) == "string" then item.source else ""
383
+ local result: { [string]: any } = { ok = true }
384
+ if #source < SOURCE_PROPERTY_LIMIT then
385
+ (made :: ScriptEdit.SourceContainer).Source = source
386
+ else
387
+ table.insert(late, { target = made, source = source, result = result })
388
+ end
389
+ made.Parent = parent
390
+ result.path = Paths.of(made)
391
+ result.revision = ScriptEdit.revisionOf(made, if #source < SOURCE_PROPERTY_LIMIT then source else "")
392
+ return result
393
+ end)
394
+ if ok then
395
+ table.insert(results.creates, err)
396
+ else
397
+ local code, message = reasonOf(err)
398
+ table.insert(results.creates, { ok = false, code = code, message = message })
399
+ end
400
+ end
401
+
402
+ for _, item in deletes do
403
+ local ok, err = pcall(function()
404
+ local target = scriptAt(tostring(item.path))
405
+ assertRevision(target, item.revision)
406
+ target:Destroy()
407
+ end)
408
+ if ok then
409
+ table.insert(results.deletes, { ok = true, path = item.path })
410
+ else
411
+ local code, message = reasonOf(err)
412
+ table.insert(results.deletes, { ok = false, path = item.path, code = code, message = message })
413
+ end
414
+ end
415
+
416
+ for _, item in moves do
417
+ local ok, err = pcall(function()
418
+ local target = scriptAt(tostring(item.path))
419
+ assertRevision(target, item.revision)
420
+ local parent = ensureChain(item.parentPath, item.parents or {}, true) :: Instance
421
+ target.Name = tostring(item.name)
422
+ target.Parent = parent
423
+ return Paths.of(target)
424
+ end)
425
+ if ok then
426
+ table.insert(results.moves, { ok = true, from = item.path, path = err })
427
+ else
428
+ local code, message = reasonOf(err)
429
+ table.insert(results.moves, { ok = false, from = item.path, code = code, message = message })
430
+ end
431
+ end
432
+ return true
433
+ end)
434
+ recorded = didRecord
435
+ end
436
+
437
+ -- Sources too long for `.Source`, written now that the scripts exist.
438
+ for _, entry in late do
439
+ local ok, err = pcall(function()
440
+ Undo.exclusive(function()
441
+ ScriptEdit.write(entry.target, function(current)
442
+ if current ~= "" then
443
+ Dispatch.fail("STALE_SCRIPT", "edited before its source could be written; that text was kept")
444
+ end
445
+ return entry.source
446
+ end)
447
+ end)
448
+ end)
449
+ if ok then
450
+ entry.result.revision = ScriptEdit.revisionOf(entry.target)
451
+ else
452
+ local code, message = reasonOf(err)
453
+ entry.result.ok = false
454
+ entry.result.code = code
455
+ entry.result.message = "created, but the source was not written: " .. message
456
+ end
457
+ end
458
+
459
+ if #writes > 0 then
460
+ Undo.exclusive(function()
461
+ for _, item in writes do
462
+ local ok, err = pcall(function()
463
+ local target = scriptAt(tostring(item.path))
464
+ local source = tostring(item.source)
465
+ -- Checked inside the editor's callback: the text the write
466
+ -- replaces is the text the check saw.
467
+ ScriptEdit.write(target, function(current)
468
+ if typeof(item.revision) == "string" and ScriptEdit.revisionOf(target, current) ~= item.revision then
469
+ Dispatch.fail("STALE_SCRIPT", "changed in Studio since the last sync; that text was kept")
470
+ end
471
+ return source
472
+ end)
473
+ return ScriptEdit.revisionOf(target)
474
+ end)
475
+ if ok then
476
+ table.insert(results.writes, { ok = true, path = item.path, revision = err })
477
+ else
478
+ local code, message = reasonOf(err)
479
+ table.insert(results.writes, { ok = false, path = item.path, code = code, message = message })
480
+ end
481
+ end
482
+ return true
483
+ end)
484
+ end
485
+
486
+ local counts = {}
487
+ for _, kind in { "writes", "creates", "deletes", "moves" } do
488
+ local done = 0
489
+ for _, result in (results :: any)[kind] do
490
+ if result.ok then
491
+ done += 1
492
+ end
493
+ end
494
+ if done > 0 then
495
+ table.insert(counts, string.format("%d %s", done, kind))
496
+ end
497
+ end
498
+ if #counts > 0 then
499
+ log("ok", "sync from files: " .. table.concat(counts, ", "), if recorded then "one undo step" else nil)
500
+ end
501
+
502
+ return {
503
+ writes = results.writes,
504
+ creates = results.creates,
505
+ deletes = results.deletes,
506
+ moves = results.moves,
507
+ undoStep = if recorded then "MCP sync" else nil,
508
+ }
509
+ end
510
+
511
+ -- Change tracking, for `watch` ----------------------------------------------
512
+
513
+ --[[
514
+ What changed since the server last asked, so `watch` does not fingerprint
515
+ every script in the place twice a second.
516
+
517
+ Edits in the script editor arrive through `TextDocumentDidChange`, and every
518
+ other write to `Source` through the property signal. Scripts appearing,
519
+ disappearing or being renamed mark the whole shape as changed, and the
520
+ server rescans. Renaming a FOLDER changes script paths with no event on the
521
+ scripts at all, which is why the server also rescans the shape on a slow
522
+ timer rather than trusting this alone.
523
+
524
+ Only runs while someone is asking: tracking starts on the first `changes`
525
+ call and stops by itself once nobody has asked for a minute, so a watch
526
+ that ended with its process leaves nothing connected behind.
527
+ ]]
528
+ type Tracker = {
529
+ token: string,
530
+ dirty: { [Instance]: boolean },
531
+ structural: boolean,
532
+ connections: { RBXScriptConnection },
533
+ watched: { [Instance]: boolean },
534
+ lastPolled: number,
535
+ }
536
+ local tracker: Tracker? = nil
537
+ local IDLE_STOP = 60
538
+
539
+ local function stopTracking()
540
+ local current = tracker
541
+ if current == nil then
542
+ return
543
+ end
544
+ for _, connection in current.connections do
545
+ connection:Disconnect()
546
+ end
547
+ tracker = nil
548
+ end
549
+
550
+ local function watchScript(current: Tracker, instance: Instance)
551
+ if current.watched[instance] or not isScript(instance) then
552
+ return
553
+ end
554
+ current.watched[instance] = true
555
+ table.insert(current.connections, instance:GetPropertyChangedSignal("Source"):Connect(function()
556
+ current.dirty[instance] = true
557
+ end))
558
+ table.insert(current.connections, instance:GetPropertyChangedSignal("Name"):Connect(function()
559
+ current.structural = true
560
+ end))
561
+ end
562
+
563
+ local function startTracking(): Tracker
564
+ local current: Tracker = {
565
+ token = HttpService:GenerateGUID(false),
566
+ dirty = setmetatable({}, { __mode = "k" }) :: any,
567
+ structural = false,
568
+ connections = {},
569
+ watched = setmetatable({}, { __mode = "k" }) :: any,
570
+ lastPolled = os.clock(),
571
+ }
572
+ for _, root in rootsOf({}) do
573
+ for _, descendant in root:GetDescendants() do
574
+ watchScript(current, descendant)
575
+ end
576
+ end
577
+ table.insert(current.connections, game.DescendantAdded:Connect(function(instance)
578
+ if isScript(instance) then
579
+ current.structural = true
580
+ watchScript(current, instance)
581
+ end
582
+ end))
583
+ table.insert(current.connections, game.DescendantRemoving:Connect(function(instance)
584
+ if isScript(instance) then
585
+ current.structural = true
586
+ end
587
+ end))
588
+ pcall(function()
589
+ table.insert(current.connections, ScriptEditorService.TextDocumentDidChange:Connect(function(document)
590
+ local ok, target = pcall(function()
591
+ return document:GetScript()
592
+ end)
593
+ if ok and target ~= nil then
594
+ current.dirty[target] = true
595
+ end
596
+ end))
597
+ end)
598
+ tracker = current
599
+
600
+ task.spawn(function()
601
+ while tracker == current do
602
+ task.wait(IDLE_STOP / 2)
603
+ if tracker == current and os.clock() - current.lastPolled > IDLE_STOP then
604
+ stopTracking()
605
+ end
606
+ end
607
+ end)
608
+ return current
609
+ end
610
+
611
+ --[[
612
+ Returns what changed since the previous call, and forgets it.
613
+
614
+ `token` names the tracking session. A token the server does not recognise
615
+ -- the first call, or the plugin reloaded in between and lost everything it
616
+ had noted -- comes back with `reset`, meaning "assume anything changed".
617
+ ]]
618
+ function Sync.changes(params: { [string]: any }): { [string]: any }
619
+ local current = tracker
620
+ if current == nil or params.token ~= current.token then
621
+ current = current or startTracking()
622
+ current.lastPolled = os.clock()
623
+ current.dirty = setmetatable({}, { __mode = "k" }) :: any
624
+ current.structural = false
625
+ return { token = current.token, reset = true, dirty = {}, structural = true }
626
+ end
627
+ current.lastPolled = os.clock()
628
+
629
+ local dirty: { string } = {}
630
+ local memo: Paths.NameIndex = {}
631
+ for instance in current.dirty do
632
+ if instance.Parent ~= nil and not Scope.isNoisy(instance) then
633
+ table.insert(dirty, Paths.of(instance, memo))
634
+ end
635
+ end
636
+ local structural = current.structural
637
+ current.dirty = setmetatable({}, { __mode = "k" }) :: any
638
+ current.structural = false
639
+ return { token = current.token, reset = false, dirty = dirty, structural = structural }
640
+ end
641
+
642
+ function Sync.stop(_params: { [string]: any }): { [string]: any }
643
+ stopTracking()
644
+ return { stopped = true }
645
+ end
646
+
647
+ -- A line in the panel for work the server did that Studio cannot see, like
648
+ -- files written to disk. Quiet: logging the call as well would say it twice.
649
+ function Sync.log(params: { [string]: any }): { [string]: any }
650
+ local lines = params.lines
651
+ if typeof(lines) == "table" then
652
+ for _, line in lines do
653
+ if typeof(line) == "table" and typeof(line.message) == "string" then
654
+ log(
655
+ if typeof(line.level) == "string" then line.level else "dim",
656
+ line.message,
657
+ if typeof(line.detail) == "string" then line.detail else nil
658
+ )
659
+ end
660
+ end
661
+ end
662
+ return { logged = true }
663
+ end
664
+
665
+ -- Build files ---------------------------------------------------------------
666
+
667
+ -- Attribute types `create` can write back; the list its schema accepts.
668
+ local ATTRIBUTE_TYPES: { [string]: boolean } = {
669
+ BrickColor = true,
670
+ CFrame = true,
671
+ Color3 = true,
672
+ ColorSequence = true,
673
+ Font = true,
674
+ NumberRange = true,
675
+ NumberSequence = true,
676
+ Rect = true,
677
+ UDim = true,
678
+ UDim2 = true,
679
+ Vector2 = true,
680
+ Vector3 = true,
681
+ }
682
+
683
+ -- Classes in a subtree other than scripts, so the server can say which of their
684
+ -- properties are worth exporting.
685
+ function Sync.classes(params: { [string]: any }): { [string]: any }
686
+ local root = Paths.resolve(tostring(params.path))
687
+ if isScript(root) then
688
+ Dispatch.fail("IS_A_SCRIPT", "Scripts sync as files; export the instance tree that holds them.")
689
+ end
690
+ local seen: { [string]: boolean } = { [root.ClassName] = true }
691
+ for _, descendant in root:GetDescendants() do
692
+ if not isScript(descendant) then
693
+ seen[descendant.ClassName] = true
694
+ end
695
+ end
696
+ local classes: { string } = {}
697
+ for name in seen do
698
+ table.insert(classes, name)
699
+ end
700
+ table.sort(classes)
701
+ -- The chain above the root, so the server can place the build file beside
702
+ -- the synced scripts of the same tree.
703
+ local chain: { { [string]: any } } = {}
704
+ local link: Instance? = root
705
+ while link ~= nil and link ~= game do
706
+ table.insert(chain, 1, {
707
+ name = (link :: Instance).Name,
708
+ className = (link :: Instance).ClassName,
709
+ path = Paths.of(link :: Instance),
710
+ ordinal = Paths.ordinal(link :: Instance),
711
+ })
712
+ link = (link :: Instance).Parent
713
+ end
714
+ return { path = Paths.of(root), classes = classes, chain = chain }
715
+ end
716
+
717
+ --[[
718
+ An instance tree as the spec `create` takes: class, name, and every
719
+ property that differs from a fresh instance of the class.
720
+
721
+ Differs, because a build file is read and edited by people and agents:
722
+ three hundred default values per frame would bury the eight that make it
723
+ that frame. Each exported value is also parsed back before it is kept, so
724
+ a build file never contains something the build could not apply --
725
+ anything that does not survive is listed in `skipped` instead.
726
+
727
+ Scripts are not exported. They live in the sync folder as files, and a
728
+ rebuild carries the existing ones over (see `build`).
729
+ ]]
730
+ function Sync.export(params: { [string]: any }): { [string]: any }
731
+ local root = Paths.resolve(tostring(params.path))
732
+ local propertyLists = if typeof(params.properties) == "table" then params.properties else {}
733
+ local defaults: { [string]: Instance | false } = {}
734
+ local skipped: { string } = {}
735
+ local scripts = 0
736
+ local count = 0
737
+
738
+ local function defaultOf(className: string): Instance?
739
+ local cached = defaults[className]
740
+ if cached == nil then
741
+ local ok, made = pcall(Instance.new, className)
742
+ cached = if ok then made else false
743
+ defaults[className] = cached
744
+ end
745
+ return if cached then cached :: Instance else nil
746
+ end
747
+
748
+ local function describe(instance: Instance, depth: number): { [string]: any }?
749
+ if isScript(instance) then
750
+ scripts += 1
751
+ return nil
752
+ end
753
+ count += 1
754
+ local spec: { [string]: any } = { className = instance.ClassName, name = instance.Name }
755
+ local default = defaultOf(instance.ClassName)
756
+ local properties: { [string]: any } = {}
757
+ local any = false
758
+ for _, entry in (propertyLists[instance.ClassName] or {}) :: { any } do
759
+ local name = tostring(entry.name)
760
+ local okRead, value = pcall(function()
761
+ return (instance :: any)[name]
762
+ end)
763
+ if not okRead then
764
+ continue
765
+ end
766
+ if default ~= nil then
767
+ local okDefault, fallback = pcall(function()
768
+ return (default :: any)[name]
769
+ end)
770
+ if okDefault and fallback == value then
771
+ continue
772
+ end
773
+ end
774
+ local text = Serialize.value(value)
775
+ if typeof(text) == "table" or text == nil then
776
+ table.insert(skipped, string.format("%s.%s", instance:GetFullName(), name))
777
+ continue
778
+ end
779
+ local parsed = Serialize.parse(text, tostring(entry.type))
780
+ if not parsed then
781
+ table.insert(skipped, string.format("%s.%s", instance:GetFullName(), name))
782
+ continue
783
+ end
784
+ properties[name] = text
785
+ any = true
786
+ end
787
+ if any then
788
+ spec.properties = properties
789
+ end
790
+
791
+ local attributes = instance:GetAttributes()
792
+ if next(attributes) ~= nil then
793
+ local exported: { [string]: any } = {}
794
+ local anyAttribute = false
795
+ for name, value in attributes do
796
+ local kind = typeof(value)
797
+ if kind == "string" or kind == "number" or kind == "boolean" then
798
+ exported[name] = value
799
+ anyAttribute = true
800
+ elseif ATTRIBUTE_TYPES[kind] then
801
+ exported[name] = { type = kind, value = Serialize.value(value) }
802
+ anyAttribute = true
803
+ else
804
+ -- `create` cannot type it, and one unreadable value would
805
+ -- stop the whole file from building.
806
+ table.insert(skipped, string.format("%s@%s (%s)", instance:GetFullName(), name, kind))
807
+ end
808
+ end
809
+ if anyAttribute then
810
+ spec.attributes = exported
811
+ end
812
+ end
813
+
814
+ local tags = CollectionService:GetTags(instance)
815
+ if #tags > 0 then
816
+ table.sort(tags)
817
+ spec.tags = { add = tags }
818
+ end
819
+
820
+ local children: { { [string]: any } } = {}
821
+ for _, child in instance:GetChildren() do
822
+ local described = describe(child, depth + 1)
823
+ if described ~= nil then
824
+ table.insert(children, described)
825
+ end
826
+ end
827
+ if #children > 0 then
828
+ -- By name, then class: a stable file, so an export of an unchanged
829
+ -- tree is byte-identical and shows up as nothing changed.
830
+ table.sort(children, function(a, b)
831
+ if a.name ~= b.name then
832
+ return a.name < b.name
833
+ end
834
+ return a.className < b.className
835
+ end)
836
+ spec.children = children
837
+ end
838
+ return spec
839
+ end
840
+
841
+ local spec = describe(root, 0)
842
+ for _, default in defaults do
843
+ if default then
844
+ (default :: Instance):Destroy()
845
+ end
846
+ end
847
+ if spec == nil then
848
+ Dispatch.fail("IS_A_SCRIPT", "Scripts sync as files; export the instance tree that holds them.")
849
+ end
850
+ return { path = Paths.of(root), spec = spec, instances = count, scripts = scripts, skipped = skipped }
851
+ end
852
+
853
+ --[[
854
+ Replaces an instance tree with a freshly built one, as one undo step.
855
+
856
+ The old tree is renamed out of the way, the new one created in its place,
857
+ and then the scripts are carried across: a build file holds no scripts
858
+ (they sync as files), so rebuilding a ScreenGui must not delete the
859
+ LocalScript that drives it. Each script goes back under the same chain of
860
+ names it sat under, or under the new root when that chain no longer exists
861
+ -- which is reported, since the script may now be looking in the wrong place.
862
+ ]]
863
+ function Sync.build(params: { [string]: any }): { [string]: any }
864
+ local spec = params.spec
865
+ if typeof(spec) ~= "table" then
866
+ Dispatch.fail("BAD_PARAMS", "sync.build requires a `spec`.")
867
+ end
868
+ local parent = Paths.resolve(tostring(params.parent))
869
+ local name = tostring(spec.name or spec.className)
870
+
871
+ local carried: { { [string]: any } } = {}
872
+ local builtPath: string? = nil
873
+ local _, recorded = Undo.record("StudioMCP.Build", "MCP build", function()
874
+ -- The tree this file built last time, when its name changed in the file:
875
+ -- renaming it there must replace that tree, not add a second one.
876
+ local old: Instance? = nil
877
+ if typeof(params.replaces) == "string" then
878
+ local found, previous = pcall(Paths.resolve, params.replaces)
879
+ if found and previous.Parent == parent then
880
+ old = previous
881
+ end
882
+ end
883
+ old = old or parent:FindFirstChild(name)
884
+ if old ~= nil and isScript(old) then
885
+ Dispatch.fail("IS_A_SCRIPT", string.format("%s is a script; build files hold instance trees.", old:GetFullName()))
886
+ end
887
+ if old ~= nil then
888
+ old.Name = "__mcp_build_old"
889
+ end
890
+
891
+ local top = table.clone(spec) :: any
892
+ top.parent = Paths.of(parent)
893
+ local created = Instances.create({ instances = { top } })
894
+ local fresh = parent:FindFirstChild(name)
895
+ if fresh == nil then
896
+ Dispatch.fail("BUILD_FAILED", "The new tree was not created.")
897
+ end
898
+ builtPath = (created.items[1] or {}).path
899
+
900
+ if old ~= nil then
901
+ local previous = old :: Instance
902
+ for _, item in previous:GetDescendants() do
903
+ local descendant = item :: Instance
904
+ if not isScript(descendant) then
905
+ continue
906
+ end
907
+ -- Only the outermost scripts: a script's own children move with it.
908
+ local outer = true
909
+ local above = descendant.Parent
910
+ while above ~= nil and above ~= previous do
911
+ if isScript(above) then
912
+ outer = false
913
+ break
914
+ end
915
+ above = above.Parent
916
+ end
917
+ if not outer then
918
+ continue
919
+ end
920
+
921
+ local chain: { string } = {}
922
+ local link = descendant.Parent
923
+ while link ~= nil and link ~= previous do
924
+ table.insert(chain, 1, link.Name)
925
+ link = link.Parent
926
+ end
927
+ local destination: Instance = fresh :: Instance
928
+ local exact = true
929
+ for _, step in chain do
930
+ local next = destination:FindFirstChild(step)
931
+ if next == nil then
932
+ exact = false
933
+ break
934
+ end
935
+ destination = next
936
+ end
937
+ if not exact then
938
+ destination = fresh :: Instance
939
+ end
940
+ descendant.Parent = destination
941
+ table.insert(carried, { path = Paths.of(descendant), exact = exact })
942
+ end
943
+ previous:Destroy()
944
+ end
945
+ return true
946
+ end)
947
+
948
+ log("ok", string.format("built %s from a build file", builtPath or name), if recorded then "one undo step" else nil)
949
+ return { path = builtPath, carried = carried, undoStep = if recorded then "MCP build" else nil }
950
+ end
951
+
952
+ function Sync.register()
953
+ Dispatch.registerAll("sync", {
954
+ scan = Sync.scan,
955
+ shape = Sync.shape,
956
+ revisions = Sync.revisions,
957
+ read = Sync.read,
958
+ apply = Sync.apply,
959
+ changes = Sync.changes,
960
+ stop = Sync.stop,
961
+ log = Sync.log,
962
+ classes = Sync.classes,
963
+ export = Sync.export,
964
+ build = Sync.build,
965
+ })
966
+ end
967
+
968
+ return Sync