dominus-cli 2.0.0 → 2.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.
@@ -1,373 +1,530 @@
1
- --[[
2
- Explorer module: serialize DataModel tree, read/write scripts, search, manage instances
3
- ]]
4
-
5
- local ScriptEditorService = game:GetService("ScriptEditorService")
6
-
7
- local Explorer = {}
8
-
9
- local SCRIPT_CLASSES = {
10
- Script = true,
11
- LocalScript = true,
12
- ModuleScript = true,
13
- }
14
-
15
- function Explorer.getPath(instance)
16
- local parts = {}
17
- local current = instance
18
- while current and current ~= game do
19
- table.insert(parts, 1, current.Name)
20
- current = current.Parent
21
- end
22
- return table.concat(parts, ".")
23
- end
24
-
25
- function Explorer.resolveInstance(path)
26
- local parts = string.split(path, ".")
27
- local current = game
28
- for _, part in parts do
29
- local child = current:FindFirstChild(part)
30
- if not child then
31
- return nil
32
- end
33
- current = child
34
- end
35
- return current
36
- end
37
-
38
- function Explorer.getTree(rootPath, maxDepth)
39
- maxDepth = maxDepth or 3
40
-
41
- local root
42
- if rootPath and rootPath ~= "" then
43
- root = Explorer.resolveInstance(rootPath)
44
- if not root then
45
- return {}
46
- end
47
- else
48
- root = game
49
- end
50
-
51
- local function serialize(instance, depth)
52
- if depth > maxDepth then
53
- return nil
54
- end
55
-
56
- local node = {
57
- name = instance.Name,
58
- className = instance.ClassName,
59
- path = Explorer.getPath(instance),
60
- }
61
-
62
- local children = {}
63
- for _, child in instance:GetChildren() do
64
- local childNode = serialize(child, depth + 1)
65
- if childNode then
66
- table.insert(children, childNode)
67
- end
68
- end
69
-
70
- if #children > 0 then
71
- node.children = children
72
- end
73
-
74
- return node
75
- end
76
-
77
- if root == game then
78
- local topLevel = {}
79
- for _, service in game:GetChildren() do
80
- local ok, node = pcall(serialize, service, 1)
81
- if ok and node then
82
- table.insert(topLevel, node)
83
- end
84
- end
85
- return topLevel
86
- else
87
- local node = serialize(root, 0)
88
- return node and { node } or {}
89
- end
90
- end
91
-
92
- function Explorer.getScriptSource(path)
93
- local instance = Explorer.resolveInstance(path)
94
- if not instance then
95
- return { success = false, error = "Instance not found: " .. path }
96
- end
97
-
98
- if not SCRIPT_CLASSES[instance.ClassName] then
99
- return { success = false, error = "Not a script: " .. path }
100
- end
101
-
102
- local ok, source = pcall(function()
103
- return ScriptEditorService:GetEditorSource(instance)
104
- end)
105
-
106
- if not ok then
107
- -- Fallback to .Source property
108
- ok, source = pcall(function()
109
- return instance.Source
110
- end)
111
- end
112
-
113
- if not ok then
114
- return { success = false, error = "Could not read source: " .. tostring(source) }
115
- end
116
-
117
- local lines = select(2, source:gsub("\n", "\n")) + 1
118
-
119
- return {
120
- path = path,
121
- className = instance.ClassName,
122
- source = source,
123
- lineCount = lines,
124
- }
125
- end
126
-
127
- function Explorer.setScriptSource(path, newSource)
128
- local instance = Explorer.resolveInstance(path)
129
- if not instance then
130
- return { success = false, error = "Instance not found: " .. path }
131
- end
132
-
133
- if not SCRIPT_CLASSES[instance.ClassName] then
134
- return { success = false, error = "Not a script: " .. path }
135
- end
136
-
137
- local ok, err = pcall(function()
138
- ScriptEditorService:UpdateSourceAsync(instance, function()
139
- return newSource
140
- end)
141
- end)
142
-
143
- if not ok then
144
- return { success = false, error = "Failed to update source: " .. tostring(err) }
145
- end
146
-
147
- return { success = true }
148
- end
149
-
150
- function Explorer.insertInstance(className, parentPath, name, properties)
151
- local parent = Explorer.resolveInstance(parentPath)
152
- if not parent then
153
- return { success = false, error = "Parent not found: " .. parentPath }
154
- end
155
-
156
- local ok, instance = pcall(function()
157
- local obj = Instance.new(className)
158
- obj.Name = name
159
-
160
- if properties then
161
- for key, value in properties do
162
- pcall(function()
163
- obj[key] = value
164
- end)
165
- end
166
- end
167
-
168
- obj.Parent = parent
169
- return obj
170
- end)
171
-
172
- if not ok then
173
- return { success = false, error = "Failed to create instance: " .. tostring(instance) }
174
- end
175
-
176
- return { success = true, path = Explorer.getPath(instance) }
177
- end
178
-
179
- function Explorer.deleteInstance(path)
180
- local instance = Explorer.resolveInstance(path)
181
- if not instance then
182
- return { success = false, error = "Instance not found: " .. path }
183
- end
184
-
185
- local ok, err = pcall(function()
186
- instance:Destroy()
187
- end)
188
-
189
- if not ok then
190
- return { success = false, error = "Failed to delete: " .. tostring(err) }
191
- end
192
-
193
- return { success = true }
194
- end
195
-
196
- function Explorer.searchScripts(query, maxResults)
197
- maxResults = maxResults or 20
198
- local results = {}
199
- local queryLower = string.lower(query)
200
-
201
- local function searchIn(instance)
202
- if #results >= maxResults then
203
- return
204
- end
205
-
206
- if SCRIPT_CLASSES[instance.ClassName] then
207
- local ok, source = pcall(function()
208
- return ScriptEditorService:GetEditorSource(instance)
209
- end)
210
-
211
- if not ok then
212
- ok, source = pcall(function()
213
- return instance.Source
214
- end)
215
- end
216
-
217
- if ok and source then
218
- local matches = {}
219
- local lineNum = 0
220
- for line in source:gmatch("[^\n]+") do
221
- lineNum = lineNum + 1
222
- if string.find(string.lower(line), queryLower, 1, true) then
223
- table.insert(matches, { line = lineNum, text = line })
224
- if #matches >= 5 then
225
- break
226
- end
227
- end
228
- end
229
-
230
- if #matches > 0 then
231
- table.insert(results, {
232
- path = Explorer.getPath(instance),
233
- className = instance.ClassName,
234
- matches = matches,
235
- })
236
- end
237
- end
238
- end
239
-
240
- for _, child in instance:GetChildren() do
241
- if #results >= maxResults then
242
- return
243
- end
244
- pcall(searchIn, child)
245
- end
246
- end
247
-
248
- pcall(searchIn, game)
249
-
250
- return { results = results }
251
- end
252
-
253
- function Explorer.findReplaceScripts(spec)
254
- local find = spec.find
255
- local replace = spec.replace
256
- local usePattern = spec.usePattern or false
257
- local dryRun = spec.dryRun or false
258
-
259
- if not find or find == "" then
260
- return { success = false, error = "Missing 'find' parameter" }
261
- end
262
-
263
- local root = game
264
- if spec.root and spec.root ~= "" then
265
- root = Explorer.resolveInstance(spec.root)
266
- if not root then
267
- local ok, svc = pcall(function()
268
- return game:GetService(spec.root)
269
- end)
270
- if ok then
271
- root = svc
272
- else
273
- return { success = false, error = "Root not found: " .. spec.root }
274
- end
275
- end
276
- end
277
-
278
- local ChangeHistoryService = game:GetService("ChangeHistoryService")
279
- local recording = nil
280
- if not dryRun then
281
- recording = ChangeHistoryService:TryBeginRecording("Dominus: Find/replace in scripts")
282
- end
283
-
284
- local modified = {}
285
- local totalMatches = 0
286
- local totalFiles = 0
287
-
288
- local function processScript(instance)
289
- if not SCRIPT_CLASSES[instance.ClassName] then
290
- return
291
- end
292
-
293
- local ok, source = pcall(function()
294
- return ScriptEditorService:GetEditorSource(instance)
295
- end)
296
- if not ok then
297
- ok, source = pcall(function()
298
- return instance.Source
299
- end)
300
- end
301
- if not ok or not source then
302
- return
303
- end
304
-
305
- local count = 0
306
- if usePattern then
307
- _, count = source:gsub(find, "")
308
- else
309
- _, count = source:gsub(find, "", nil)
310
- -- plain text: count occurrences with plain find
311
- count = 0
312
- local startPos = 1
313
- while true do
314
- local foundPos = source:find(find, startPos, true)
315
- if not foundPos then
316
- break
317
- end
318
- count = count + 1
319
- startPos = foundPos + #find
320
- end
321
- end
322
-
323
- if count > 0 then
324
- totalMatches = totalMatches + count
325
- totalFiles = totalFiles + 1
326
-
327
- local path = Explorer.getPath(instance)
328
- table.insert(modified, { path = path, matches = count })
329
-
330
- if not dryRun then
331
- local newSource
332
- if usePattern then
333
- newSource = source:gsub(find, replace)
334
- else
335
- newSource =
336
- source:gsub(find:gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1"), replace:gsub("%%", "%%%%"))
337
- end
338
-
339
- pcall(function()
340
- ScriptEditorService:UpdateSourceAsync(instance, function()
341
- return newSource
342
- end)
343
- end)
344
- end
345
- end
346
- end
347
-
348
- local function walk(instance)
349
- processScript(instance)
350
- for _, child in instance:GetChildren() do
351
- pcall(walk, child)
352
- end
353
- end
354
-
355
- pcall(walk, root)
356
-
357
- if recording then
358
- if totalFiles > 0 then
359
- ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
360
- else
361
- ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
362
- end
363
- end
364
-
365
- return {
366
- success = true,
367
- modified = modified,
368
- totalMatches = totalMatches,
369
- totalFiles = totalFiles,
370
- }
371
- end
372
-
373
- return Explorer
1
+ --[[
2
+ Explorer module: serialize DataModel tree, read/write scripts, search, manage instances
3
+ ]]
4
+
5
+ local ScriptEditorService = game:GetService("ScriptEditorService")
6
+ local InstanceRegistry = require(script.Parent.InstanceRegistry)
7
+
8
+ local Explorer = {}
9
+
10
+ local SCRIPT_CLASSES = {
11
+ Script = true,
12
+ LocalScript = true,
13
+ ModuleScript = true,
14
+ }
15
+
16
+ function Explorer.getPath(instance)
17
+ local parts = {}
18
+ local current = instance
19
+ while current and current ~= game do
20
+ table.insert(parts, 1, current.Name)
21
+ current = current.Parent
22
+ end
23
+ return table.concat(parts, ".")
24
+ end
25
+
26
+ function Explorer.getRef(instance)
27
+ return InstanceRegistry.toRef(instance)
28
+ end
29
+
30
+ function Explorer.resolveTarget(target)
31
+ return InstanceRegistry.resolve(target)
32
+ end
33
+
34
+ function Explorer.resolveInstance(path)
35
+ local parts = string.split(path, ".")
36
+ local current = game
37
+ for _, part in parts do
38
+ local child = current:FindFirstChild(part)
39
+ if not child then
40
+ return nil
41
+ end
42
+ current = child
43
+ end
44
+ return current
45
+ end
46
+
47
+ function Explorer.getTree(rootPath, maxDepth)
48
+ maxDepth = maxDepth or 3
49
+
50
+ local root
51
+ if rootPath and rootPath ~= "" then
52
+ root = Explorer.resolveInstance(rootPath)
53
+ if not root then
54
+ return {}
55
+ end
56
+ else
57
+ root = game
58
+ end
59
+
60
+ local function serialize(instance, depth)
61
+ if depth > maxDepth then
62
+ return nil
63
+ end
64
+
65
+ local node = {
66
+ name = instance.Name,
67
+ className = instance.ClassName,
68
+ path = Explorer.getPath(instance),
69
+ }
70
+
71
+ local children = {}
72
+ for _, child in instance:GetChildren() do
73
+ local childNode = serialize(child, depth + 1)
74
+ if childNode then
75
+ table.insert(children, childNode)
76
+ end
77
+ end
78
+
79
+ if #children > 0 then
80
+ node.children = children
81
+ end
82
+
83
+ return node
84
+ end
85
+
86
+ if root == game then
87
+ local topLevel = {}
88
+ for _, service in game:GetChildren() do
89
+ local ok, node = pcall(serialize, service, 1)
90
+ if ok and node then
91
+ table.insert(topLevel, node)
92
+ end
93
+ end
94
+ return topLevel
95
+ else
96
+ local node = serialize(root, 0)
97
+ return node and { node } or {}
98
+ end
99
+ end
100
+
101
+ function Explorer.getScriptSource(path)
102
+ local instance = Explorer.resolveInstance(path)
103
+ if not instance then
104
+ return { success = false, error = "Instance not found: " .. path }
105
+ end
106
+
107
+ if not SCRIPT_CLASSES[instance.ClassName] then
108
+ return { success = false, error = "Not a script: " .. path }
109
+ end
110
+
111
+ local ok, source = pcall(function()
112
+ return ScriptEditorService:GetEditorSource(instance)
113
+ end)
114
+
115
+ if not ok then
116
+ -- Fallback to .Source property
117
+ ok, source = pcall(function()
118
+ return instance.Source
119
+ end)
120
+ end
121
+
122
+ if not ok then
123
+ return { success = false, error = "Could not read source: " .. tostring(source) }
124
+ end
125
+
126
+ local lines = select(2, source:gsub("\n", "\n")) + 1
127
+
128
+ return {
129
+ path = path,
130
+ className = instance.ClassName,
131
+ source = source,
132
+ lineCount = lines,
133
+ }
134
+ end
135
+
136
+ function Explorer.setScriptSource(path, newSource)
137
+ local instance = Explorer.resolveInstance(path)
138
+ if not instance then
139
+ return { success = false, error = "Instance not found: " .. path }
140
+ end
141
+
142
+ if not SCRIPT_CLASSES[instance.ClassName] then
143
+ return { success = false, error = "Not a script: " .. path }
144
+ end
145
+
146
+ local ok, err = pcall(function()
147
+ ScriptEditorService:UpdateSourceAsync(instance, function()
148
+ return newSource
149
+ end)
150
+ end)
151
+
152
+ if not ok then
153
+ return { success = false, error = "Failed to update source: " .. tostring(err) }
154
+ end
155
+
156
+ return { success = true }
157
+ end
158
+
159
+ function Explorer.insertInstance(className, parentPath, name, properties)
160
+ local parent = Explorer.resolveInstance(parentPath)
161
+ if not parent then
162
+ return { success = false, error = "Parent not found: " .. parentPath }
163
+ end
164
+
165
+ local ok, instance = pcall(function()
166
+ local obj = Instance.new(className)
167
+ obj.Name = name
168
+
169
+ if properties then
170
+ for key, value in properties do
171
+ pcall(function()
172
+ obj[key] = value
173
+ end)
174
+ end
175
+ end
176
+
177
+ obj.Parent = parent
178
+ return obj
179
+ end)
180
+
181
+ if not ok then
182
+ return { success = false, error = "Failed to create instance: " .. tostring(instance) }
183
+ end
184
+
185
+ return { success = true, path = Explorer.getPath(instance) }
186
+ end
187
+
188
+ function Explorer.deleteInstance(path)
189
+ local instance = Explorer.resolveInstance(path)
190
+ if not instance then
191
+ return { success = false, error = "Instance not found: " .. path }
192
+ end
193
+
194
+ local ok, err = pcall(function()
195
+ instance:Destroy()
196
+ end)
197
+
198
+ if not ok then
199
+ return { success = false, error = "Failed to delete: " .. tostring(err) }
200
+ end
201
+
202
+ return { success = true }
203
+ end
204
+
205
+ function Explorer.searchScripts(query, maxResults)
206
+ maxResults = maxResults or 20
207
+ local results = {}
208
+ local queryLower = string.lower(query)
209
+
210
+ local function searchIn(instance)
211
+ if #results >= maxResults then
212
+ return
213
+ end
214
+
215
+ if SCRIPT_CLASSES[instance.ClassName] then
216
+ local ok, source = pcall(function()
217
+ return ScriptEditorService:GetEditorSource(instance)
218
+ end)
219
+
220
+ if not ok then
221
+ ok, source = pcall(function()
222
+ return instance.Source
223
+ end)
224
+ end
225
+
226
+ if ok and source then
227
+ local matches = {}
228
+ local lineNum = 0
229
+ for line in source:gmatch("[^\n]+") do
230
+ lineNum = lineNum + 1
231
+ if string.find(string.lower(line), queryLower, 1, true) then
232
+ table.insert(matches, { line = lineNum, text = line })
233
+ if #matches >= 5 then
234
+ break
235
+ end
236
+ end
237
+ end
238
+
239
+ if #matches > 0 then
240
+ table.insert(results, {
241
+ path = Explorer.getPath(instance),
242
+ className = instance.ClassName,
243
+ matches = matches,
244
+ })
245
+ end
246
+ end
247
+ end
248
+
249
+ for _, child in instance:GetChildren() do
250
+ if #results >= maxResults then
251
+ return
252
+ end
253
+ pcall(searchIn, child)
254
+ end
255
+ end
256
+
257
+ pcall(searchIn, game)
258
+
259
+ return { results = results }
260
+ end
261
+
262
+ function Explorer.findReplaceScripts(spec)
263
+ local find = spec.find
264
+ local replace = spec.replace
265
+ local usePattern = spec.usePattern or false
266
+ local dryRun = spec.dryRun or false
267
+
268
+ if not find or find == "" then
269
+ return { success = false, error = "Missing 'find' parameter" }
270
+ end
271
+
272
+ local root = game
273
+ if spec.root and spec.root ~= "" then
274
+ root = Explorer.resolveInstance(spec.root)
275
+ if not root then
276
+ local ok, svc = pcall(function()
277
+ return game:GetService(spec.root)
278
+ end)
279
+ if ok then
280
+ root = svc
281
+ else
282
+ return { success = false, error = "Root not found: " .. spec.root }
283
+ end
284
+ end
285
+ end
286
+
287
+ local ChangeHistoryService = game:GetService("ChangeHistoryService")
288
+ local recording = nil
289
+ if not dryRun then
290
+ recording = ChangeHistoryService:TryBeginRecording("Dominus: Find/replace in scripts")
291
+ end
292
+
293
+ local modified = {}
294
+ local totalMatches = 0
295
+ local totalFiles = 0
296
+
297
+ local function processScript(instance)
298
+ if not SCRIPT_CLASSES[instance.ClassName] then
299
+ return
300
+ end
301
+
302
+ local ok, source = pcall(function()
303
+ return ScriptEditorService:GetEditorSource(instance)
304
+ end)
305
+ if not ok then
306
+ ok, source = pcall(function()
307
+ return instance.Source
308
+ end)
309
+ end
310
+ if not ok or not source then
311
+ return
312
+ end
313
+
314
+ local count = 0
315
+ if usePattern then
316
+ _, count = source:gsub(find, "")
317
+ else
318
+ _, count = source:gsub(find, "", nil)
319
+ -- plain text: count occurrences with plain find
320
+ count = 0
321
+ local startPos = 1
322
+ while true do
323
+ local foundPos = source:find(find, startPos, true)
324
+ if not foundPos then
325
+ break
326
+ end
327
+ count = count + 1
328
+ startPos = foundPos + #find
329
+ end
330
+ end
331
+
332
+ if count > 0 then
333
+ totalMatches = totalMatches + count
334
+ totalFiles = totalFiles + 1
335
+
336
+ local path = Explorer.getPath(instance)
337
+ table.insert(modified, { path = path, matches = count })
338
+
339
+ if not dryRun then
340
+ local newSource
341
+ if usePattern then
342
+ newSource = source:gsub(find, replace)
343
+ else
344
+ newSource =
345
+ source:gsub(find:gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1"), replace:gsub("%%", "%%%%"))
346
+ end
347
+
348
+ pcall(function()
349
+ ScriptEditorService:UpdateSourceAsync(instance, function()
350
+ return newSource
351
+ end)
352
+ end)
353
+ end
354
+ end
355
+ end
356
+
357
+ local function walk(instance)
358
+ processScript(instance)
359
+ for _, child in instance:GetChildren() do
360
+ pcall(walk, child)
361
+ end
362
+ end
363
+
364
+ pcall(walk, root)
365
+
366
+ if recording then
367
+ if totalFiles > 0 then
368
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
369
+ else
370
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
371
+ end
372
+ end
373
+
374
+ return {
375
+ success = true,
376
+ modified = modified,
377
+ totalMatches = totalMatches,
378
+ totalFiles = totalFiles,
379
+ }
380
+ end
381
+
382
+ local function sourceHash(source)
383
+ local hash = 5381
384
+ for index = 1, #source do
385
+ hash = (hash * 33 + string.byte(source, index)) % 4294967296
386
+ end
387
+ return string.format("%d:%08x", #source, hash)
388
+ end
389
+
390
+ function Explorer.getTreeV2(spec)
391
+ local root = game
392
+ if spec.root then
393
+ local resolved, err = InstanceRegistry.resolve(spec.root)
394
+ if not resolved then
395
+ return { success = false, error = err }
396
+ end
397
+ root = resolved
398
+ end
399
+ local maxDepth = math.clamp(spec.maxDepth or 3, 0, 12)
400
+ local maxNodes = math.clamp(spec.maxNodes or 1000, 1, 5000)
401
+ local nodeCount = 0
402
+ local truncated = false
403
+
404
+ local function serialize(instance, depth)
405
+ if depth > maxDepth or nodeCount >= maxNodes then
406
+ truncated = true
407
+ return nil
408
+ end
409
+ nodeCount += 1
410
+ local node = {
411
+ name = instance.Name,
412
+ className = instance.ClassName,
413
+ ref = InstanceRegistry.toRef(instance),
414
+ }
415
+ local children = instance:GetChildren()
416
+ table.sort(children, function(a, b)
417
+ if a.Name == b.Name then
418
+ return a.ClassName < b.ClassName
419
+ end
420
+ return a.Name < b.Name
421
+ end)
422
+ for _, child in children do
423
+ local childNode = serialize(child, depth + 1)
424
+ if childNode then
425
+ node.children = node.children or {}
426
+ table.insert(node.children, childNode)
427
+ end
428
+ if nodeCount >= maxNodes then
429
+ break
430
+ end
431
+ end
432
+ return node
433
+ end
434
+
435
+ if root == game then
436
+ local roots = {}
437
+ local services = game:GetChildren()
438
+ table.sort(services, function(a, b)
439
+ return a.Name < b.Name
440
+ end)
441
+ for _, service in services do
442
+ local node = serialize(service, 0)
443
+ if node then
444
+ table.insert(roots, node)
445
+ end
446
+ if nodeCount >= maxNodes then
447
+ break
448
+ end
449
+ end
450
+ return { success = true, roots = roots, nodeCount = nodeCount, truncated = truncated }
451
+ end
452
+
453
+ return { success = true, roots = { serialize(root, 0) }, nodeCount = nodeCount, truncated = truncated }
454
+ end
455
+
456
+ function Explorer.readScriptV2(spec)
457
+ local instance, err = InstanceRegistry.resolve(spec.target)
458
+ if not instance then
459
+ return { success = false, error = err }
460
+ end
461
+ if not SCRIPT_CLASSES[instance.ClassName] then
462
+ return { success = false, error = "Target is not a LuaSourceContainer" }
463
+ end
464
+ local ok, source = pcall(function()
465
+ return ScriptEditorService:GetEditorSource(instance)
466
+ end)
467
+ if not ok then
468
+ return { success = false, error = "Could not read script source: " .. tostring(source) }
469
+ end
470
+ return {
471
+ success = true,
472
+ target = InstanceRegistry.toRef(instance),
473
+ className = instance.ClassName,
474
+ source = source,
475
+ lineCount = select(2, source:gsub("\n", "\n")) + 1,
476
+ revision = sourceHash(source),
477
+ }
478
+ end
479
+
480
+ function Explorer.updateScriptV2(spec)
481
+ local instance, err = InstanceRegistry.resolve(spec.target)
482
+ if not instance then
483
+ return { success = false, error = err }
484
+ end
485
+ if not SCRIPT_CLASSES[instance.ClassName] then
486
+ return { success = false, error = "Target is not a LuaSourceContainer" }
487
+ end
488
+ if type(spec.source) ~= "string" then
489
+ return { success = false, error = "source must be a string" }
490
+ end
491
+ if #spec.source > 750000 then
492
+ return { success = false, error = "source exceeds 750,000 characters" }
493
+ end
494
+ if type(spec.expectedRevision) ~= "string" then
495
+ return { success = false, error = "expectedRevision is required; call studio_read_script first" }
496
+ end
497
+
498
+ local conflictRevision = nil
499
+ local ok, updateErr = pcall(function()
500
+ ScriptEditorService:UpdateSourceAsync(instance, function(oldSource)
501
+ local currentRevision = sourceHash(oldSource)
502
+ if currentRevision ~= spec.expectedRevision then
503
+ conflictRevision = currentRevision
504
+ return nil
505
+ end
506
+ return spec.source
507
+ end)
508
+ end)
509
+ if conflictRevision then
510
+ return {
511
+ success = false,
512
+ conflict = true,
513
+ error = "Script changed since it was read",
514
+ currentRevision = conflictRevision,
515
+ }
516
+ end
517
+ if not ok then
518
+ return { success = false, error = "Script update failed: " .. tostring(updateErr) }
519
+ end
520
+
521
+ local readback = Explorer.readScriptV2({ target = InstanceRegistry.toRef(instance) })
522
+ return {
523
+ success = true,
524
+ target = readback.target,
525
+ revision = readback.revision,
526
+ lineCount = readback.lineCount,
527
+ }
528
+ end
529
+
530
+ return Explorer