goldsync 0.1.31 → 0.1.34
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 +10 -0
- package/assets/GoldSync.rbxmx +202 -12
- package/bin/install-companion.mjs +9 -3
- package/package.json +1 -1
- package/src/broker.mjs +17 -1
- package/src/companion.mjs +16 -1
- package/src/restore.mjs +34 -0
- package/src/server.mjs +84 -1
- package/src/store.mjs +17 -2
package/README.md
CHANGED
|
@@ -82,6 +82,16 @@ When changing a Folder to another class, Push the changed parent. GoldSync verif
|
|
|
82
82
|
|
|
83
83
|
## Daily use
|
|
84
84
|
|
|
85
|
+
To make local files authoritative and replace the configured Studio roots completely, run:
|
|
86
|
+
|
|
87
|
+
```powershell
|
|
88
|
+
npx --yes goldsync@latest restore-studio --yes
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Update GoldSync, restart Studio, and connect Rojo first. This command discards unsaved Studio changes inside the configured roots, removes extra instances, and resets their sync state. Other Studio roots and local files are unchanged. It validates every snapshot before replacing the roots and reports completion in the terminal. It requires version 2 tree roots.
|
|
92
|
+
|
|
93
|
+
The command uses the files currently on disk. It does not fetch Git or remove old local `.rbxm` files. Restore the local assets from your intended Git revision first if that is the copy you want in Studio.
|
|
94
|
+
|
|
85
95
|
Matching assets synchronize automatically. When an entry needs attention, choose the copy you want to keep:
|
|
86
96
|
|
|
87
97
|
| Control | Direction |
|
package/assets/GoldSync.rbxmx
CHANGED
|
@@ -20,15 +20,17 @@ local SESSION_MARKER_NAME = "__GoldSyncSession"
|
|
|
20
20
|
local CLIENT_HEADERS = {
|
|
21
21
|
["X-GoldSync-Client"] = "studio-plugin",
|
|
22
22
|
["X-GoldSync-Tree-Roots"] = "4",
|
|
23
|
+
["X-GoldSync-Restore"] = "1",
|
|
23
24
|
}
|
|
24
25
|
local SETTINGS_PREFIX = "GoldSync:v2:"
|
|
25
26
|
local UI_SETTINGS_PREFIX = "GoldSync:ui:v1:"
|
|
26
|
-
local VERSION = "0.1.
|
|
27
|
+
local VERSION = "0.1.34"
|
|
27
28
|
local startupProfile = { settings = 0, watchers = 0, reconcile = 0, downloads = 0 }
|
|
28
29
|
local savedHashes = {}
|
|
29
30
|
local savedHashesKey
|
|
30
31
|
local hashesDirty = false
|
|
31
32
|
local TreeRoots = require(script.Parent.TreeRoots)
|
|
33
|
+
local StudioRestore = require(script.Parent.StudioRestore)
|
|
32
34
|
local REQUEST_INTERVAL_SECONDS = 0.15
|
|
33
35
|
local REQUEST_RETRY_DELAYS = { 1, 2, 4 }
|
|
34
36
|
|
|
@@ -393,6 +395,7 @@ rootsLayout.Parent = rootsContainer
|
|
|
393
395
|
|
|
394
396
|
local running = true
|
|
395
397
|
local bulkRunning = false
|
|
398
|
+
local refreshing = false
|
|
396
399
|
local requestLocked = false
|
|
397
400
|
local requestWaiters = {}
|
|
398
401
|
local lastRequestAt = 0
|
|
@@ -420,27 +423,41 @@ local resolveGitConflict
|
|
|
420
423
|
local applyTreeFilter
|
|
421
424
|
local resetInstanceTrees
|
|
422
425
|
|
|
426
|
+
local function pullStates(affected)
|
|
427
|
+
table.sort(affected, function(left, right)
|
|
428
|
+
local leftDepth, rightDepth = #left.manifest.studioPath, #right.manifest.studioPath
|
|
429
|
+
if leftDepth ~= rightDepth then return leftDepth < rightDepth end
|
|
430
|
+
return left.manifest.id < right.manifest.id
|
|
431
|
+
end)
|
|
432
|
+
for _, state in affected do
|
|
433
|
+
if states[state.manifest.id] == state and not state.manifest.gitConflict then
|
|
434
|
+
pull(state)
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
423
439
|
for index, button in attentionBulkButtons do
|
|
424
440
|
button.Activated:Connect(function()
|
|
425
441
|
if bulkRunning or not serverConfig or not serverConfig.rojoConnected then
|
|
426
442
|
return
|
|
427
443
|
end
|
|
444
|
+
bulkRunning = true
|
|
445
|
+
while refreshing do task.wait() end
|
|
428
446
|
local affected = {}
|
|
429
447
|
for _, state in states do
|
|
430
448
|
if state.statusKind == "issue" and not state.manifest.gitConflict then
|
|
431
449
|
table.insert(affected, state)
|
|
432
450
|
end
|
|
433
451
|
end
|
|
434
|
-
bulkRunning = true
|
|
435
452
|
refreshStatusSummary()
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
453
|
+
if index == 2 then
|
|
454
|
+
pullStates(affected)
|
|
455
|
+
else
|
|
456
|
+
for _, state in affected do
|
|
457
|
+
if states[state.manifest.id] ~= state or state.manifest.gitConflict then
|
|
458
|
+
continue
|
|
459
|
+
end
|
|
441
460
|
upload(state, true)
|
|
442
|
-
else
|
|
443
|
-
pull(state)
|
|
444
461
|
end
|
|
445
462
|
end
|
|
446
463
|
bulkRunning = false
|
|
@@ -1324,7 +1341,7 @@ deleteSnapshot = function(state, explicit)
|
|
|
1324
1341
|
end
|
|
1325
1342
|
|
|
1326
1343
|
upload = function(state, overwrite)
|
|
1327
|
-
if state.coveredByBoundary then return end
|
|
1344
|
+
if state.coveredByBoundary or state.suppress then return end
|
|
1328
1345
|
if state.uploading or state.pulling or state.deleting then
|
|
1329
1346
|
return
|
|
1330
1347
|
end
|
|
@@ -1422,6 +1439,7 @@ upload = function(state, overwrite)
|
|
|
1422
1439
|
setStatus(state, "Conversion failed: " .. tostring(packet), Color3.fromRGB(245, 106, 106))
|
|
1423
1440
|
return
|
|
1424
1441
|
end
|
|
1442
|
+
if state.suppress then state.uploading = false return end
|
|
1425
1443
|
local response, failure = request("PUT", "/v1/roots/" .. state.manifest.id .. "/boundary", packet, { ["Content-Type"] = "application/octet-stream" })
|
|
1426
1444
|
state.uploading = false
|
|
1427
1445
|
if not response or not response.Success then
|
|
@@ -1474,6 +1492,7 @@ upload = function(state, overwrite)
|
|
|
1474
1492
|
end
|
|
1475
1493
|
|
|
1476
1494
|
local expectedHash = "*"
|
|
1495
|
+
if state.suppress then state.uploading = false return end
|
|
1477
1496
|
if overwrite then
|
|
1478
1497
|
expectedHash = state.manifest.exists and state.manifest.hash or "*"
|
|
1479
1498
|
elseif state.lastHash then
|
|
@@ -2092,6 +2111,7 @@ local function createGroup(path, order, instanceIds, attention)
|
|
|
2092
2111
|
return
|
|
2093
2112
|
end
|
|
2094
2113
|
bulkRunning = true
|
|
2114
|
+
while refreshing do task.wait() end
|
|
2095
2115
|
for _, state in states do
|
|
2096
2116
|
if group.attention and state.statusKind ~= "issue" then continue end
|
|
2097
2117
|
if pathStartsWith(group.path, state.manifest.studioPath, group.instanceIds, state.manifest.instanceIds) and not state.manifest.gitConflict then
|
|
@@ -2105,12 +2125,15 @@ local function createGroup(path, order, instanceIds, attention)
|
|
|
2105
2125
|
return
|
|
2106
2126
|
end
|
|
2107
2127
|
bulkRunning = true
|
|
2128
|
+
while refreshing do task.wait() end
|
|
2129
|
+
local affected = {}
|
|
2108
2130
|
for _, state in states do
|
|
2109
2131
|
if group.attention and state.statusKind ~= "issue" then continue end
|
|
2110
2132
|
if pathStartsWith(group.path, state.manifest.studioPath, group.instanceIds, state.manifest.instanceIds) and not state.manifest.gitConflict then
|
|
2111
|
-
|
|
2133
|
+
table.insert(affected, state)
|
|
2112
2134
|
end
|
|
2113
2135
|
end
|
|
2136
|
+
pullStates(affected)
|
|
2114
2137
|
bulkRunning = false
|
|
2115
2138
|
end)
|
|
2116
2139
|
return group
|
|
@@ -3084,7 +3107,7 @@ local function manifestChanged(previous, current)
|
|
|
3084
3107
|
or TreeRoots.pathKey(previous.studioPath, previous.instanceIds) ~= TreeRoots.pathKey(current.studioPath, current.instanceIds)
|
|
3085
3108
|
end
|
|
3086
3109
|
|
|
3087
|
-
|
|
3110
|
+
local function refreshConfigNow()
|
|
3088
3111
|
local refreshStarted = os.clock()
|
|
3089
3112
|
local response, requestError = request("GET", "/v1/config", nil, if configETag then { ["If-None-Match"] = configETag } else nil)
|
|
3090
3113
|
if not response or (not response.Success and response.StatusCode ~= 304) then
|
|
@@ -3130,6 +3153,56 @@ refreshConfig = function()
|
|
|
3130
3153
|
table.clear(groups)
|
|
3131
3154
|
end
|
|
3132
3155
|
serverConfig = decoded
|
|
3156
|
+
if decoded.restore and decoded.restore.status == "pending" then
|
|
3157
|
+
bulkRunning = true
|
|
3158
|
+
for _, state in states do
|
|
3159
|
+
state.generation += 1
|
|
3160
|
+
state.suppress = true
|
|
3161
|
+
end
|
|
3162
|
+
local prepared
|
|
3163
|
+
local success, failure = pcall(function()
|
|
3164
|
+
local base = "/v1/restore/" .. decoded.restore.id
|
|
3165
|
+
local response, failure = request("POST", base .. "/claim")
|
|
3166
|
+
assert(response and response.Success, failure or (response and responseError(response)) or "Restore download failed")
|
|
3167
|
+
connectionStatus.Text = "Restoring Studio from local files..."
|
|
3168
|
+
prepared = StudioRestore.prepare(response.Body)
|
|
3169
|
+
local authorized = request("POST", base .. "/commit")
|
|
3170
|
+
assert(authorized and authorized.Success, "Restore expired or Rojo disconnected; Studio was not replaced")
|
|
3171
|
+
for _, state in states do disconnectRoot(state) end
|
|
3172
|
+
disconnectSplitRoots()
|
|
3173
|
+
ChangeHistoryService:SetWaypoint("GoldSync before authoritative restore")
|
|
3174
|
+
StudioRestore.commit(prepared)
|
|
3175
|
+
ChangeHistoryService:SetWaypoint("GoldSync restore from local files")
|
|
3176
|
+
for _, state in states do
|
|
3177
|
+
destroyConflictWorkspace(state)
|
|
3178
|
+
destroyRootRow(state)
|
|
3179
|
+
end
|
|
3180
|
+
table.clear(states)
|
|
3181
|
+
table.clear(treeDiscovery)
|
|
3182
|
+
table.clear(savedHashes)
|
|
3183
|
+
for _, manifest in prepared.manifests do savedHashes[manifest.id] = manifest.hash end
|
|
3184
|
+
hashesDirty = true
|
|
3185
|
+
saveHashes()
|
|
3186
|
+
end)
|
|
3187
|
+
if not success and prepared then prepared.staging:Destroy() end
|
|
3188
|
+
local acknowledged = request("POST", "/v1/restore/" .. decoded.restore.id .. "/result", HttpService:JSONEncode({ success = success, error = if success then nil else tostring(failure) }), { ["Content-Type"] = "application/json" })
|
|
3189
|
+
bulkRunning = false
|
|
3190
|
+
configETag = nil
|
|
3191
|
+
if not success then
|
|
3192
|
+
for _, state in states do
|
|
3193
|
+
state.suppress = false
|
|
3194
|
+
state.root = nil
|
|
3195
|
+
state.observedRoot = nil
|
|
3196
|
+
end
|
|
3197
|
+
connectionStatus.Text = "Restore failed: " .. tostring(failure)
|
|
3198
|
+
elseif not acknowledged or not acknowledged.Success then
|
|
3199
|
+
connectionStatus.Text = "Studio restored, but the service did not acknowledge completion."
|
|
3200
|
+
else
|
|
3201
|
+
connectionStatus.Text = "Restored Studio from local files"
|
|
3202
|
+
end
|
|
3203
|
+
return success
|
|
3204
|
+
end
|
|
3205
|
+
if decoded.restore and (decoded.restore.status == "preparing" or decoded.restore.status == "running") then return true end
|
|
3133
3206
|
if decoded.rojoConnected then
|
|
3134
3207
|
connectionStatus.Text = "Connected · " .. decoded.name
|
|
3135
3208
|
connectionStatus.TextColor3 = Color3.fromRGB(102, 214, 139)
|
|
@@ -3197,6 +3270,16 @@ refreshConfig = function()
|
|
|
3197
3270
|
disconnectSplitRoot(id)
|
|
3198
3271
|
end
|
|
3199
3272
|
|
|
3273
|
+
local presentRoots = {}
|
|
3274
|
+
for _, manifest in decoded.roots do
|
|
3275
|
+
if manifest.treeRootId and not manifest.container and not manifest.exists and not manifest.gitConflict
|
|
3276
|
+
and not resolveRoot(manifest.studioPath, manifest.instanceIds) then
|
|
3277
|
+
continue
|
|
3278
|
+
end
|
|
3279
|
+
table.insert(presentRoots, manifest)
|
|
3280
|
+
end
|
|
3281
|
+
decoded.roots = presentRoots
|
|
3282
|
+
|
|
3200
3283
|
local boundaryPaths = {}
|
|
3201
3284
|
for _, manifest in decoded.roots do
|
|
3202
3285
|
local discovery = treeDiscovery[manifest.treeRootId]
|
|
@@ -3298,6 +3381,19 @@ refreshConfig = function()
|
|
|
3298
3381
|
return true
|
|
3299
3382
|
end
|
|
3300
3383
|
|
|
3384
|
+
refreshConfig = function()
|
|
3385
|
+
if bulkRunning or refreshing then return false end
|
|
3386
|
+
refreshing = true
|
|
3387
|
+
local success, result = pcall(refreshConfigNow)
|
|
3388
|
+
refreshing = false
|
|
3389
|
+
if not success then
|
|
3390
|
+
batchingStatusUpdates = false
|
|
3391
|
+
warn("GoldSync refresh failed: " .. tostring(result))
|
|
3392
|
+
return false
|
|
3393
|
+
end
|
|
3394
|
+
return result
|
|
3395
|
+
end
|
|
3396
|
+
|
|
3301
3397
|
toolbarButton.Click:Connect(function()
|
|
3302
3398
|
widget.Enabled = not widget.Enabled
|
|
3303
3399
|
if widget.Enabled then
|
|
@@ -3328,6 +3424,100 @@ end)
|
|
|
3328
3424
|
</Properties>
|
|
3329
3425
|
</Item>
|
|
3330
3426
|
<Item class="ModuleScript" referent="2">
|
|
3427
|
+
<Properties>
|
|
3428
|
+
<string name="Name">StudioRestore</string>
|
|
3429
|
+
<string name="Source"><![CDATA[local HttpService = game:GetService("HttpService")
|
|
3430
|
+
local SerializationService = game:GetService("SerializationService")
|
|
3431
|
+
local TreeRoots = require(script.Parent.TreeRoots)
|
|
3432
|
+
|
|
3433
|
+
local StudioRestore = {}
|
|
3434
|
+
|
|
3435
|
+
function StudioRestore.prepare(body)
|
|
3436
|
+
local staging = Instance.new("Folder")
|
|
3437
|
+
local success, result = pcall(function()
|
|
3438
|
+
local bytes = buffer.fromstring(body)
|
|
3439
|
+
local headerLength = buffer.readu32(bytes, 0)
|
|
3440
|
+
local packet = HttpService:JSONDecode(buffer.readstring(bytes, 4, headerLength))
|
|
3441
|
+
local offset = 4 + headerLength
|
|
3442
|
+
local instances, roots = {}, {}
|
|
3443
|
+
local trees = {}
|
|
3444
|
+
for _, tree in packet.trees do
|
|
3445
|
+
trees[tree.id] = TreeRoots.pathKey(tree.studioPath)
|
|
3446
|
+
end
|
|
3447
|
+
for _, manifest in packet.roots do
|
|
3448
|
+
local key = TreeRoots.pathKey(manifest.studioPath, manifest.instanceIds)
|
|
3449
|
+
assert(not instances[key], "Duplicate restore path")
|
|
3450
|
+
local isRoot = trees[manifest.treeRootId] == key
|
|
3451
|
+
local instance
|
|
3452
|
+
if manifest.exists then
|
|
3453
|
+
local restored = SerializationService:DeserializeInstancesAsync(buffer.fromstring(buffer.readstring(bytes, offset, manifest.size)))
|
|
3454
|
+
for _, candidate in restored do candidate.Parent = staging end
|
|
3455
|
+
assert(#restored == 1, "Snapshot must contain one root: " .. manifest.file)
|
|
3456
|
+
instance = restored[1]
|
|
3457
|
+
else
|
|
3458
|
+
assert(manifest.container and manifest.size == 0, "Missing asset snapshot: " .. manifest.file)
|
|
3459
|
+
instance = Instance.new("Folder")
|
|
3460
|
+
instance.Name = manifest.studioPath[#manifest.studioPath]
|
|
3461
|
+
local id = manifest.instanceIds[#manifest.studioPath]
|
|
3462
|
+
if id ~= "" then instance:SetAttribute("GoldSyncId", id) end
|
|
3463
|
+
instance.Parent = staging
|
|
3464
|
+
end
|
|
3465
|
+
offset += manifest.size
|
|
3466
|
+
assert(instance.Name == manifest.studioPath[#manifest.studioPath], "Snapshot name does not match " .. manifest.file)
|
|
3467
|
+
assert((instance:GetAttribute("GoldSyncId") or "") == manifest.instanceIds[#manifest.studioPath], "Snapshot identity does not match " .. manifest.file)
|
|
3468
|
+
assert(TreeRoots.isContainer(instance, isRoot) == manifest.container, "Snapshot class does not match " .. manifest.file)
|
|
3469
|
+
assert(not manifest.container or #instance:GetChildren() == 0, "Container snapshot contains children: " .. manifest.file)
|
|
3470
|
+
if isRoot then
|
|
3471
|
+
table.insert(roots, { instance = instance, path = manifest.studioPath })
|
|
3472
|
+
else
|
|
3473
|
+
local parentKey = TreeRoots.pathKey(manifest.studioPath, manifest.instanceIds, #manifest.studioPath - 1)
|
|
3474
|
+
assert(instances[parentKey], "Restore parent is missing: " .. manifest.file)
|
|
3475
|
+
instance.Parent = instances[parentKey]
|
|
3476
|
+
end
|
|
3477
|
+
instances[key] = instance
|
|
3478
|
+
end
|
|
3479
|
+
assert(offset == buffer.len(bytes), "Unexpected restore packet data")
|
|
3480
|
+
assert(#roots == #packet.trees, "Restore is missing a configured root")
|
|
3481
|
+
return { staging = staging, roots = roots, manifests = packet.roots }
|
|
3482
|
+
end)
|
|
3483
|
+
if not success then
|
|
3484
|
+
staging:Destroy()
|
|
3485
|
+
error(result)
|
|
3486
|
+
end
|
|
3487
|
+
return result
|
|
3488
|
+
end
|
|
3489
|
+
|
|
3490
|
+
function StudioRestore.commit(prepared)
|
|
3491
|
+
local previous = {}
|
|
3492
|
+
for _, root in prepared.roots do
|
|
3493
|
+
local parent = game
|
|
3494
|
+
for depth = 1, #root.path - 1 do
|
|
3495
|
+
parent = parent:FindFirstChild(root.path[depth])
|
|
3496
|
+
assert(parent, "Configured Studio parent is missing: " .. table.concat(root.path, "."))
|
|
3497
|
+
end
|
|
3498
|
+
root.parent = parent
|
|
3499
|
+
for _, child in parent:GetChildren() do
|
|
3500
|
+
if child.Name == root.instance.Name then table.insert(previous, { instance = child, parent = parent }) end
|
|
3501
|
+
end
|
|
3502
|
+
end
|
|
3503
|
+
local success, failure = pcall(function()
|
|
3504
|
+
for _, root in previous do root.instance.Parent = nil end
|
|
3505
|
+
for _, root in prepared.roots do root.instance.Parent = root.parent end
|
|
3506
|
+
end)
|
|
3507
|
+
if not success then
|
|
3508
|
+
for _, root in prepared.roots do root.instance.Parent = prepared.staging end
|
|
3509
|
+
for _, root in previous do root.instance.Parent = root.parent end
|
|
3510
|
+
error(failure)
|
|
3511
|
+
end
|
|
3512
|
+
for _, root in previous do root.instance:Destroy() end
|
|
3513
|
+
prepared.staging:Destroy()
|
|
3514
|
+
end
|
|
3515
|
+
|
|
3516
|
+
return StudioRestore
|
|
3517
|
+
]]></string>
|
|
3518
|
+
</Properties>
|
|
3519
|
+
</Item>
|
|
3520
|
+
<Item class="ModuleScript" referent="3">
|
|
3331
3521
|
<Properties>
|
|
3332
3522
|
<string name="Name">TreeRoots</string>
|
|
3333
3523
|
<string name="Source"><![CDATA[local TreeRoots = {}
|
|
@@ -5,11 +5,11 @@ import { access, copyFile, cp, mkdir, readFile, rm, writeFile } from "node:fs/pr
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import process from "node:process";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { findProjectConfig, registerProject } from "../src/companion.mjs";
|
|
8
|
+
import { findProjectConfig, registerProject, restoreStudio } from "../src/companion.mjs";
|
|
9
9
|
|
|
10
10
|
const command = process.argv[2];
|
|
11
11
|
if (command === "--help" || command === "-h") {
|
|
12
|
-
console.log("
|
|
12
|
+
console.log("goldsync install\ngoldsync restore-studio --yes\nRun from your project folder. restore-studio replaces configured Studio roots with local files and discards their unsaved Studio changes.");
|
|
13
13
|
process.exit(0);
|
|
14
14
|
}
|
|
15
15
|
if (command === "--version" || command === "-v") {
|
|
@@ -17,7 +17,7 @@ if (command === "--version" || command === "-v") {
|
|
|
17
17
|
console.log(packageJson.version);
|
|
18
18
|
process.exit(0);
|
|
19
19
|
}
|
|
20
|
-
if (command && command !== "install" && !command.startsWith("--")) {
|
|
20
|
+
if (command && command !== "install" && command !== "restore-studio" && !command.startsWith("--")) {
|
|
21
21
|
throw new Error(`Unknown GoldSync command: ${command}`);
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -36,6 +36,12 @@ const configPath = configIndex === -1
|
|
|
36
36
|
? await findProjectConfig(projectDirectory)
|
|
37
37
|
: path.resolve(process.argv[configIndex + 1]);
|
|
38
38
|
|
|
39
|
+
if (command === "restore-studio") {
|
|
40
|
+
if (!process.argv.includes("--yes")) throw new Error("This replaces configured Studio roots and discards their unsaved changes. Run restore-studio --yes to proceed.");
|
|
41
|
+
await restoreStudio(configPath);
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
const dataDirectory = path.join(process.env.LOCALAPPDATA, "GoldSync");
|
|
40
46
|
const runtimeDirectory = path.join(dataDirectory, "runtime");
|
|
41
47
|
const installedAgent = path.join(runtimeDirectory, "bin", "goldsync-agent.mjs");
|
package/package.json
CHANGED
package/src/broker.mjs
CHANGED
|
@@ -237,7 +237,23 @@ export class GoldSyncBroker {
|
|
|
237
237
|
|
|
238
238
|
async handle(request, response) {
|
|
239
239
|
const url = new URL(request.url, `http://${this.host}:${this.port}`);
|
|
240
|
-
const managementRequest = MANAGEMENT_CLIENTS.has(request.headers["x-goldsync-client"]);
|
|
240
|
+
const managementRequest = request.headers.origin === undefined && MANAGEMENT_CLIENTS.has(request.headers["x-goldsync-client"]);
|
|
241
|
+
if (managementRequest && url.pathname === "/v1/workspaces/restore-studio") {
|
|
242
|
+
const body = request.method === "POST" ? await readJson(request) : null;
|
|
243
|
+
const runtime = this.workspaces.get(body?.workspaceId ?? url.searchParams.get("workspaceId"));
|
|
244
|
+
if (!runtime) { sendJson(response, 404, { error: "GoldSync project is not registered" }); return; }
|
|
245
|
+
if (request.method === "POST") {
|
|
246
|
+
if (body.confirm !== true) { sendJson(response, 400, { error: "restore-studio requires --yes" }); return; }
|
|
247
|
+
sendJson(response, 200, await runtime.server.beginRestore());
|
|
248
|
+
} else if (request.method === "GET") {
|
|
249
|
+
const status = runtime.server.restoreStatus();
|
|
250
|
+
if (!status || status.id !== url.searchParams.get("id")) { sendJson(response, 404, { error: "Restore operation no longer exists" }); return; }
|
|
251
|
+
sendJson(response, 200, status);
|
|
252
|
+
} else {
|
|
253
|
+
sendJson(response, 405, { error: "Method not allowed" });
|
|
254
|
+
}
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
241
257
|
if (managementRequest && request.method === "GET" && url.pathname === "/v1/health") {
|
|
242
258
|
sendJson(response, 200, {
|
|
243
259
|
version: 1,
|
package/src/companion.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import http from "node:http";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { GoldSyncBroker } from "./broker.mjs";
|
|
6
6
|
import { loadConfig } from "./config.mjs";
|
|
7
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
8
|
|
|
8
9
|
export const BROKER_PORT = 34873;
|
|
9
10
|
export const HEARTBEAT_MS = 5000;
|
|
@@ -26,7 +27,7 @@ function request(method, requestPath, body, port = BROKER_PORT) {
|
|
|
26
27
|
}
|
|
27
28
|
: {}),
|
|
28
29
|
},
|
|
29
|
-
timeout: requestPath === "/v1/workspaces/register" ? 120000 : 2000,
|
|
30
|
+
timeout: requestPath === "/v1/workspaces/register" || (method === "POST" && requestPath === "/v1/workspaces/restore-studio") ? 120000 : 2000,
|
|
30
31
|
},
|
|
31
32
|
(response) => {
|
|
32
33
|
const chunks = [];
|
|
@@ -59,6 +60,20 @@ function request(method, requestPath, body, port = BROKER_PORT) {
|
|
|
59
60
|
});
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
export async function restoreStudio(configPath) {
|
|
64
|
+
const workspace = await request("POST", "/v1/workspaces/register", { configPath, clientId: randomUUID() });
|
|
65
|
+
let job = await request("POST", "/v1/workspaces/restore-studio", { workspaceId: workspace.workspaceId, confirm: true });
|
|
66
|
+
console.log("Replacing configured Studio roots with local assets. Unsaved Studio changes in those roots will be removed.");
|
|
67
|
+
const deadline = Date.now() + 330000;
|
|
68
|
+
while (["pending", "running", "preparing"].includes(job.status)) {
|
|
69
|
+
if (Date.now() > deadline) throw new Error("Restore did not finish. Check Studio before retrying.");
|
|
70
|
+
await delay(500);
|
|
71
|
+
job = await request("GET", `/v1/workspaces/restore-studio?workspaceId=${encodeURIComponent(workspace.workspaceId)}&id=${encodeURIComponent(job.id)}`);
|
|
72
|
+
}
|
|
73
|
+
if (job.status !== "completed") throw new Error(job.error ?? "Studio restore failed");
|
|
74
|
+
console.log("Studio now matches the local asset files. Stale instances and previous sync state were removed.");
|
|
75
|
+
}
|
|
76
|
+
|
|
62
77
|
async function fileExists(file) {
|
|
63
78
|
try {
|
|
64
79
|
await access(file);
|
package/src/restore.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import { SnapshotStore } from "./store.mjs";
|
|
3
|
+
import { GitConflictStore } from "./git-conflicts.mjs";
|
|
4
|
+
|
|
5
|
+
export async function prepareRestore(config) {
|
|
6
|
+
if (config.version !== 2 || !config.treeRoots?.length || config.roots.length || config.splitRoots?.length) {
|
|
7
|
+
throw new Error("restore-studio requires a project containing only version 2 tree roots");
|
|
8
|
+
}
|
|
9
|
+
for (const tree of config.treeRoots) {
|
|
10
|
+
if (!(await stat(tree.absoluteDirectory)).isDirectory()) throw new Error(`Missing asset directory: ${tree.directory}`);
|
|
11
|
+
}
|
|
12
|
+
const store = new SnapshotStore(config);
|
|
13
|
+
await store.initialize();
|
|
14
|
+
const conflicts = new GitConflictStore(config);
|
|
15
|
+
await conflicts.initialize();
|
|
16
|
+
conflicts.syncRoots(store.getRoots());
|
|
17
|
+
if ((await conflicts.list()).size) throw new Error("Resolve asset Git conflicts before restoring Studio");
|
|
18
|
+
const roots = store.getManifests();
|
|
19
|
+
roots.sort((left, right) => left.studioPath.length - right.studioPath.length || left.id.localeCompare(right.id));
|
|
20
|
+
const chunks = [];
|
|
21
|
+
let size = 0;
|
|
22
|
+
for (const root of roots) {
|
|
23
|
+
const snapshot = await store.get(root.id);
|
|
24
|
+
if (root.exists && (!snapshot || snapshot.hash !== root.hash)) throw new Error(`File changed during restore preparation: ${root.file}`);
|
|
25
|
+
root.size = snapshot?.bytes.length ?? 0;
|
|
26
|
+
if (snapshot) chunks.push(snapshot.bytes);
|
|
27
|
+
size += root.size;
|
|
28
|
+
if (size > 128 * 1024 * 1024) throw new Error("Restore exceeds the 128 MiB limit");
|
|
29
|
+
}
|
|
30
|
+
const header = Buffer.from(JSON.stringify({ roots, trees: config.treeRoots.map(({ id, studioPath }) => ({ id, studioPath })) }));
|
|
31
|
+
const length = Buffer.alloc(4);
|
|
32
|
+
length.writeUInt32LE(header.length);
|
|
33
|
+
return { store, packet: Buffer.concat([length, header, ...chunks]) };
|
|
34
|
+
}
|
package/src/server.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { watch } from "node:fs";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { ConflictError } from "./store.mjs";
|
|
6
|
+
import { prepareRestore } from "./restore.mjs";
|
|
6
7
|
|
|
7
8
|
const MAX_SNAPSHOT_BYTES = 128 * 1024 * 1024;
|
|
8
9
|
|
|
@@ -66,9 +67,50 @@ export class GoldSyncServer {
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
setSessionToken(token) {
|
|
70
|
+
if (token !== this.sessionToken && this.restoreJob) {
|
|
71
|
+
this.restoreJob.status = "failed";
|
|
72
|
+
this.restoreJob.error = "Rojo session changed; run restore-studio again";
|
|
73
|
+
this.restoreJob.packet = null;
|
|
74
|
+
this.restoreJob.store = null;
|
|
75
|
+
}
|
|
76
|
+
if (token !== this.sessionToken) this.lastRestoreClient = null;
|
|
69
77
|
this.sessionToken = token;
|
|
70
78
|
}
|
|
71
79
|
|
|
80
|
+
restoreStatus() {
|
|
81
|
+
const job = this.restoreJob;
|
|
82
|
+
if (!job) return null;
|
|
83
|
+
if ((job.status === "pending" && Date.now() - job.createdAt > 15000)
|
|
84
|
+
|| (job.status === "running" && Date.now() - job.createdAt > 300000)) {
|
|
85
|
+
job.status = "failed";
|
|
86
|
+
job.error = "Studio restore timed out. Check Studio, update the plugin and reconnect before retrying.";
|
|
87
|
+
job.packet = null;
|
|
88
|
+
job.store = null;
|
|
89
|
+
}
|
|
90
|
+
return { id: job.id, status: job.status, error: job.error };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async beginRestore() {
|
|
94
|
+
if (!this.syncEnabled || !this.sessionToken) throw new Error("Connect Studio through Rojo before restoring");
|
|
95
|
+
if (!this.lastRestoreClient || Date.now() - this.lastRestoreClient > 10000) throw new Error("Open Studio with GoldSync 0.1.34 or newer and connect Rojo first");
|
|
96
|
+
if (this.store.boundaryChange || ["preparing", "pending", "running"].includes(this.restoreStatus()?.status)) throw new Error("A sync operation is already running");
|
|
97
|
+
const job = { id: randomUUID(), status: "preparing", createdAt: Date.now() };
|
|
98
|
+
this.restoreJob = job;
|
|
99
|
+
try {
|
|
100
|
+
Object.assign(job, await prepareRestore(this.config));
|
|
101
|
+
if (job.status !== "preparing") throw new Error("Rojo session changed while preparing restore");
|
|
102
|
+
job.status = "pending";
|
|
103
|
+
job.createdAt = Date.now();
|
|
104
|
+
} catch (error) {
|
|
105
|
+
job.status = "failed";
|
|
106
|
+
job.error = error.message;
|
|
107
|
+
job.packet = null;
|
|
108
|
+
job.store = null;
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
return this.restoreStatus();
|
|
112
|
+
}
|
|
113
|
+
|
|
72
114
|
async start(options = {}) {
|
|
73
115
|
if (options.listen !== false) {
|
|
74
116
|
await new Promise((resolve, reject) => {
|
|
@@ -157,6 +199,46 @@ export class GoldSyncServer {
|
|
|
157
199
|
return;
|
|
158
200
|
}
|
|
159
201
|
const url = new URL(request.url, `http://${this.config.host}:${this.config.port}`);
|
|
202
|
+
if (request.headers["x-goldsync-restore"] === "1") this.lastRestoreClient = Date.now();
|
|
203
|
+
const restoreMatch = /^\/v1\/restore\/([a-f0-9-]+)\/(claim|commit|result)$/.exec(url.pathname);
|
|
204
|
+
if (request.method === "POST" && restoreMatch) {
|
|
205
|
+
const job = this.restoreJob;
|
|
206
|
+
const status = this.restoreStatus();
|
|
207
|
+
if (job?.id === restoreMatch[1] && restoreMatch[2] === "result" && ["completed", "failed"].includes(status.status)) {
|
|
208
|
+
sendJson(response, 200, status); return;
|
|
209
|
+
}
|
|
210
|
+
if (!this.syncEnabled || !job || job.id !== restoreMatch[1] || !["pending", "running"].includes(status.status)) {
|
|
211
|
+
sendJson(response, 409, { error: "Restore is no longer active" }); return;
|
|
212
|
+
}
|
|
213
|
+
if (restoreMatch[2] === "claim") {
|
|
214
|
+
if (job.status !== "pending") { sendJson(response, 409, { error: "Restore was already claimed" }); return; }
|
|
215
|
+
job.status = "running";
|
|
216
|
+
job.createdAt = Date.now();
|
|
217
|
+
response.writeHead(200, { "Content-Type": "application/octet-stream", "Content-Length": job.packet.length });
|
|
218
|
+
response.end(job.packet);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (job.status !== "running") { sendJson(response, 409, { error: "Claim the restore first" }); return; }
|
|
222
|
+
if (restoreMatch[2] === "result") {
|
|
223
|
+
const result = JSON.parse((await readBody(request)).toString("utf8"));
|
|
224
|
+
if (typeof result.success !== "boolean") { sendJson(response, 400, { error: "Expected restore result" }); return; }
|
|
225
|
+
job.status = result.success ? "completed" : "failed";
|
|
226
|
+
job.error = result.success ? undefined : String(result.error ?? "Studio restore failed").slice(0, 2000);
|
|
227
|
+
if (result.success) {
|
|
228
|
+
this.store = job.store;
|
|
229
|
+
this.store.treeScanNeeded = true;
|
|
230
|
+
this.gitConflicts?.syncRoots(this.store.getRoots());
|
|
231
|
+
}
|
|
232
|
+
job.packet = null;
|
|
233
|
+
job.store = null;
|
|
234
|
+
}
|
|
235
|
+
sendJson(response, 200, this.restoreStatus());
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const restore = this.restoreStatus();
|
|
239
|
+
if (["preparing", "pending", "running"].includes(restore?.status) && url.pathname !== "/v1/config") {
|
|
240
|
+
sendJson(response, 409, { error: "Authoritative Studio restore in progress" }); return;
|
|
241
|
+
}
|
|
160
242
|
if (this.store.boundaryChange) {
|
|
161
243
|
sendJson(response, 409, { error: "Boundary conversion in progress; retry after it finishes" });
|
|
162
244
|
return;
|
|
@@ -204,6 +286,7 @@ export class GoldSyncServer {
|
|
|
204
286
|
pollIntervalMs: this.config.pollIntervalMs,
|
|
205
287
|
debounceMs: this.config.debounceMs,
|
|
206
288
|
rojoConnected: this.syncEnabled,
|
|
289
|
+
restore,
|
|
207
290
|
treeRoots: (this.config.treeRoots ?? []).map((root) => ({ id: root.id, studioPath: root.studioPath })),
|
|
208
291
|
splitRoots: (this.config.splitRoots ?? []).map((root) => ({
|
|
209
292
|
id: root.id,
|
package/src/store.mjs
CHANGED
|
@@ -363,20 +363,35 @@ export class SnapshotStore {
|
|
|
363
363
|
const entries = [];
|
|
364
364
|
const visit = async (directory, relativePath, instanceIds) => {
|
|
365
365
|
if (tree.studioPath.length + relativePath.length > this.config.maxPathDepth) throw new Error(`tree root ${tree.id} exceeds maxPathDepth`);
|
|
366
|
-
|
|
366
|
+
const start = entries.length;
|
|
367
|
+
let metadata = false;
|
|
367
368
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
368
369
|
if (entry.isSymbolicLink()) throw new Error(`tree roots cannot contain symbolic links: ${entry.name}`);
|
|
369
370
|
if (entry.isDirectory()) {
|
|
370
371
|
const segment = decodeTreeSegment(entry.name);
|
|
371
372
|
await visit(path.join(directory, entry.name), [...relativePath, segment.name], [...instanceIds, segment.id]);
|
|
372
|
-
} else if (entry.isFile() && entry.name
|
|
373
|
+
} else if (entry.isFile() && entry.name === "_root.rbxm") {
|
|
374
|
+
metadata = true;
|
|
375
|
+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith(".rbxm")) {
|
|
373
376
|
const segment = decodeTreeSegment(entry.name.slice(0, -5));
|
|
374
377
|
entries.push({ path: [...relativePath, segment.name], instanceIds: [...instanceIds, segment.id], container: false });
|
|
375
378
|
}
|
|
376
379
|
}
|
|
380
|
+
if (relativePath.length === 0 || metadata || entries.length > start) {
|
|
381
|
+
entries.push({ path: relativePath, instanceIds, container: true });
|
|
382
|
+
}
|
|
377
383
|
};
|
|
378
384
|
await visit(tree.absoluteDirectory, [], []);
|
|
385
|
+
const containers = new Set(entries.filter(entry => entry.container)
|
|
386
|
+
.map(entry => this.makeTreeRoot(tree, entry.path, true, entry.instanceIds).id));
|
|
387
|
+
for (const [id, root] of this.roots) {
|
|
388
|
+
if (root.treeRootId !== tree.id || !root.diskContainer || containers.has(id)) continue;
|
|
389
|
+
this.roots.delete(id);
|
|
390
|
+
this.rootsByFile.delete(root.absoluteFile.toLowerCase());
|
|
391
|
+
this.manifestRoots = null;
|
|
392
|
+
}
|
|
379
393
|
await this.discoverTreePaths(tree.id, entries);
|
|
394
|
+
for (const id of containers) this.roots.get(id).diskContainer = true;
|
|
380
395
|
}
|
|
381
396
|
}
|
|
382
397
|
|