goldsync 0.1.16 → 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 +69 -78
- package/assets/GoldSync.rbxmx +1206 -204
- package/bin/install-companion.mjs +38 -6
- package/media/configuration-assets.png +0 -0
- package/media/file-boundaries.png +0 -0
- package/package.json +2 -2
- package/src/broker.mjs +17 -1
- package/src/companion.mjs +16 -1
- package/src/config.mjs +39 -6
- package/src/git-conflicts.mjs +1 -1
- package/src/restore.mjs +34 -0
- package/src/server.mjs +191 -13
- package/src/store.mjs +246 -5
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { spawn } from "node:child_process";
|
|
4
|
-
import { access, copyFile, cp, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { execFile, spawn } from "node:child_process";
|
|
4
|
+
import { access, copyFile, cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
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,8 +36,41 @@ 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");
|
|
47
|
+
const installedAgent = path.join(runtimeDirectory, "bin", "goldsync-agent.mjs");
|
|
48
|
+
const pidFile = path.join(dataDirectory, "agent.pid");
|
|
49
|
+
let installedPid;
|
|
50
|
+
try {
|
|
51
|
+
installedPid = Number(await readFile(pidFile, "utf8"));
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error.code !== "ENOENT") throw error;
|
|
54
|
+
}
|
|
55
|
+
if (Number.isInteger(installedPid) && installedPid > 0) {
|
|
56
|
+
const stopCommand = `
|
|
57
|
+
$ErrorActionPreference = 'Stop'
|
|
58
|
+
$agentProcess = Get-CimInstance Win32_Process -Filter "ProcessId = $env:GOLDSYNC_AGENT_PID"
|
|
59
|
+
if ($agentProcess) {
|
|
60
|
+
if ((Split-Path -Parent $agentProcess.ExecutablePath) -ine $env:GOLDSYNC_RUNTIME_DIR -or $agentProcess.CommandLine.IndexOf($env:GOLDSYNC_AGENT_SCRIPT, [StringComparison]::OrdinalIgnoreCase) -lt 0) {
|
|
61
|
+
throw 'The recorded GoldSync process does not match the installed agent. No process was stopped.'
|
|
62
|
+
}
|
|
63
|
+
Stop-Process -Id $agentProcess.ProcessId
|
|
64
|
+
}
|
|
65
|
+
`;
|
|
66
|
+
await new Promise((resolve, reject) => {
|
|
67
|
+
execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", stopCommand], {
|
|
68
|
+
windowsHide: true,
|
|
69
|
+
env: { ...process.env, GOLDSYNC_AGENT_PID: String(installedPid), GOLDSYNC_AGENT_SCRIPT: installedAgent, GOLDSYNC_RUNTIME_DIR: runtimeDirectory },
|
|
70
|
+
}, (error) => error ? reject(error) : resolve());
|
|
71
|
+
});
|
|
72
|
+
await rm(pidFile, { force: true });
|
|
73
|
+
}
|
|
41
74
|
await mkdir(path.join(runtimeDirectory, "bin"), { recursive: true });
|
|
42
75
|
await cp(path.join(packageDirectory, "src"), path.join(runtimeDirectory, "src"), {
|
|
43
76
|
recursive: true,
|
|
@@ -69,7 +102,6 @@ try {
|
|
|
69
102
|
}
|
|
70
103
|
|
|
71
104
|
const config = await registerProject(path.join(dataDirectory, "projects.json"), configPath);
|
|
72
|
-
const installedAgent = path.join(runtimeDirectory, "bin", "goldsync-agent.mjs");
|
|
73
105
|
const startupCommand = `"${runtimeNode}" "${installedAgent}"`;
|
|
74
106
|
const vbsString = `"${startupCommand.replaceAll('"', '""')}"`;
|
|
75
107
|
const startupFile = path.join(
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "goldsync",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.34",
|
|
4
4
|
"description": "Automatic native Roblox asset synchronization for Rojo projects.",
|
|
5
5
|
"author": "tay",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"bin/goldsync-agent.mjs",
|
|
14
14
|
"bin/install-companion.mjs",
|
|
15
15
|
"LICENSE.txt",
|
|
16
|
-
"media
|
|
16
|
+
"media",
|
|
17
17
|
"src",
|
|
18
18
|
"README.md"
|
|
19
19
|
],
|
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: 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/config.mjs
CHANGED
|
@@ -76,8 +76,8 @@ export async function loadConfig(configPath) {
|
|
|
76
76
|
const projectRoot = await realpath(path.dirname(absoluteConfigPath));
|
|
77
77
|
const raw = JSON.parse(await readFile(absoluteConfigPath, "utf8"));
|
|
78
78
|
|
|
79
|
-
if (raw.version !== 1) {
|
|
80
|
-
throw new Error("goldsync.project.json must use version 1");
|
|
79
|
+
if (raw.version !== 1 && raw.version !== 2) {
|
|
80
|
+
throw new Error("goldsync.project.json must use version 1 or 2");
|
|
81
81
|
}
|
|
82
82
|
const port = raw.port ?? 34873;
|
|
83
83
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
@@ -90,15 +90,16 @@ export async function loadConfig(configPath) {
|
|
|
90
90
|
|| raw.discoveryRoots.some((root) => !root || root.splitDepth === undefined))) {
|
|
91
91
|
throw new Error("discoveryRoots must contain directory roots with splitDepth");
|
|
92
92
|
}
|
|
93
|
-
const maxPathDepth = raw.maxPathDepth ?? 3;
|
|
94
|
-
if (!Number.isInteger(maxPathDepth) || maxPathDepth < 2 || maxPathDepth > 10) {
|
|
95
|
-
throw new Error(
|
|
93
|
+
const maxPathDepth = raw.maxPathDepth ?? (raw.version === 2 ? 32 : 3);
|
|
94
|
+
if (!Number.isInteger(maxPathDepth) || maxPathDepth < 2 || maxPathDepth > (raw.version === 2 ? 32 : 10)) {
|
|
95
|
+
throw new Error(`maxPathDepth must be an integer between 2 and ${raw.version === 2 ? 32 : 10}`);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
const ids = new Set();
|
|
99
99
|
const studioPaths = new Set();
|
|
100
100
|
const files = new Set();
|
|
101
101
|
const splitRoots = [];
|
|
102
|
+
const treeRoots = [];
|
|
102
103
|
const roots = [...raw.roots, ...(raw.discoveryRoots ?? [])].flatMap((root, index) => {
|
|
103
104
|
const label = index < raw.roots.length ? `roots[${index}]` : `discoveryRoots[${index - raw.roots.length}]`;
|
|
104
105
|
const id = requireString(root.id, `${label}.id`);
|
|
@@ -121,6 +122,22 @@ export async function loadConfig(configPath) {
|
|
|
121
122
|
}
|
|
122
123
|
studioPaths.add(studioPathKey);
|
|
123
124
|
|
|
125
|
+
if (root.mode === "tree") {
|
|
126
|
+
if (raw.version !== 2 || root.file !== undefined || splitDepth !== undefined) {
|
|
127
|
+
throw new Error(`${label}: tree roots require version 2 and cannot specify file or splitDepth`);
|
|
128
|
+
}
|
|
129
|
+
const absoluteDirectory = resolveInsideProject(projectRoot, root.directory, `${label}.directory`);
|
|
130
|
+
if (root.containers !== undefined) throw new Error(`${label}: tree roots split Folders automatically; remove containers`);
|
|
131
|
+
treeRoots.push({
|
|
132
|
+
id, studioPath, absoluteDirectory,
|
|
133
|
+
directory: path.relative(projectRoot, absoluteDirectory).replaceAll(path.sep, "/"),
|
|
134
|
+
});
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
if (root.mode !== undefined) {
|
|
138
|
+
throw new Error(`${label}.mode must be tree when specified`);
|
|
139
|
+
}
|
|
140
|
+
|
|
124
141
|
if (splitDepth !== undefined) {
|
|
125
142
|
const directory = requireString(root.directory, `${label}.directory`);
|
|
126
143
|
const absoluteDirectory = resolveInsideProject(projectRoot, directory, `${label}.directory`);
|
|
@@ -150,6 +167,21 @@ export async function loadConfig(configPath) {
|
|
|
150
167
|
});
|
|
151
168
|
|
|
152
169
|
const configuredRoots = [...roots, ...splitRoots];
|
|
170
|
+
for (const tree of treeRoots) {
|
|
171
|
+
for (const other of [...configuredRoots, ...treeRoots]) {
|
|
172
|
+
if (other === tree) continue;
|
|
173
|
+
const common = Math.min(tree.studioPath.length, other.studioPath.length);
|
|
174
|
+
if (tree.studioPath.slice(0, common).every((segment, index) => segment === other.studioPath[index])) {
|
|
175
|
+
throw new Error(`tree root ${tree.id} overlaps ${other.id}`);
|
|
176
|
+
}
|
|
177
|
+
const otherDirectory = other.absoluteDirectory ?? path.dirname(other.absoluteFile);
|
|
178
|
+
const relative = path.relative(tree.absoluteDirectory, otherDirectory);
|
|
179
|
+
const reverse = path.relative(otherDirectory, tree.absoluteDirectory);
|
|
180
|
+
if ((!relative.startsWith("..") && !path.isAbsolute(relative)) || (!reverse.startsWith("..") && !path.isAbsolute(reverse))) {
|
|
181
|
+
throw new Error(`tree directory ${tree.id} overlaps ${other.id}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
153
185
|
for (const root of roots) {
|
|
154
186
|
if (configuredRoots.some((other) => other !== root
|
|
155
187
|
&& other.studioPath.length > root.studioPath.length
|
|
@@ -166,7 +198,7 @@ export async function loadConfig(configPath) {
|
|
|
166
198
|
}
|
|
167
199
|
|
|
168
200
|
return {
|
|
169
|
-
version:
|
|
201
|
+
version: raw.version,
|
|
170
202
|
name: requireString(raw.name, "name"),
|
|
171
203
|
projectId: requireString(raw.projectId, "projectId"),
|
|
172
204
|
workspaceId: createHash("sha256").update(absoluteConfigPath.toLowerCase()).digest("hex").slice(0, 12),
|
|
@@ -181,5 +213,6 @@ export async function loadConfig(configPath) {
|
|
|
181
213
|
sessionMarkerFile: path.join(projectRoot, ".goldsync", "rojo-session.model.json"),
|
|
182
214
|
roots,
|
|
183
215
|
splitRoots,
|
|
216
|
+
treeRoots,
|
|
184
217
|
};
|
|
185
218
|
}
|
package/src/git-conflicts.mjs
CHANGED
|
@@ -61,7 +61,7 @@ export class GitConflictStore {
|
|
|
61
61
|
return new Map();
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
const output = await runGit(this.repositoryRoot, ["ls-files", "-u", "-z"
|
|
64
|
+
const output = await runGit(this.repositoryRoot, ["ls-files", "-u", "-z"]);
|
|
65
65
|
const byPath = new Map();
|
|
66
66
|
for (const entry of output.toString("utf8").split("\0")) {
|
|
67
67
|
if (!entry) {
|
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,16 +1,25 @@
|
|
|
1
1
|
import { watch } from "node:fs";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
3
|
import { createServer } from "node:http";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { ConflictError } from "./store.mjs";
|
|
6
|
+
import { prepareRestore } from "./restore.mjs";
|
|
5
7
|
|
|
6
8
|
const MAX_SNAPSHOT_BYTES = 128 * 1024 * 1024;
|
|
7
9
|
|
|
8
|
-
function sendJson(response, status, value) {
|
|
10
|
+
function sendJson(response, status, value, request) {
|
|
9
11
|
const body = Buffer.from(JSON.stringify(value));
|
|
12
|
+
const etag = request ? `"${createHash("sha256").update(body).digest("hex")}"` : null;
|
|
13
|
+
if (etag && request.headers["if-none-match"] === etag) {
|
|
14
|
+
response.writeHead(304, { ETag: etag, "Cache-Control": "no-store" });
|
|
15
|
+
response.end();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
10
18
|
response.writeHead(status, {
|
|
11
19
|
"Content-Type": "application/json",
|
|
12
20
|
"Content-Length": body.length,
|
|
13
21
|
"Cache-Control": "no-store",
|
|
22
|
+
...(etag ? { ETag: etag } : {}),
|
|
14
23
|
});
|
|
15
24
|
response.end(body);
|
|
16
25
|
}
|
|
@@ -58,9 +67,50 @@ export class GoldSyncServer {
|
|
|
58
67
|
}
|
|
59
68
|
|
|
60
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;
|
|
61
77
|
this.sessionToken = token;
|
|
62
78
|
}
|
|
63
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
|
+
|
|
64
114
|
async start(options = {}) {
|
|
65
115
|
if (options.listen !== false) {
|
|
66
116
|
await new Promise((resolve, reject) => {
|
|
@@ -79,20 +129,21 @@ export class GoldSyncServer {
|
|
|
79
129
|
for (const splitRoot of this.config.splitRoots ?? []) {
|
|
80
130
|
watchedDirectories.set(splitRoot.absoluteDirectory, true);
|
|
81
131
|
}
|
|
132
|
+
for (const treeRoot of this.config.treeRoots ?? []) {
|
|
133
|
+
watchedDirectories.set(treeRoot.absoluteDirectory, true);
|
|
134
|
+
}
|
|
82
135
|
for (const [directory, recursive] of watchedDirectories) {
|
|
83
|
-
const watcher = watch(directory, { recursive }, (
|
|
136
|
+
const watcher = watch(directory, { recursive }, (event, filename) => {
|
|
137
|
+
if (recursive && (event === "rename" || !filename)) this.store.treeScanNeeded = true;
|
|
84
138
|
if (!filename) {
|
|
85
139
|
return;
|
|
86
140
|
}
|
|
87
141
|
const changedFile = path.join(directory, filename.toString()).toLowerCase();
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
if (!matched && recursive && path.extname(changedFile) === ".rbxm") {
|
|
142
|
+
const root = this.store.rootsByFile.get(changedFile);
|
|
143
|
+
if (root) this.scheduleRefresh(root.id);
|
|
144
|
+
if (!root && recursive && path.extname(changedFile) === ".rbxm") {
|
|
145
|
+
this.store.treeScanNeeded = true;
|
|
146
|
+
if ((this.config.treeRoots ?? []).length > 0) return;
|
|
96
147
|
this.store
|
|
97
148
|
.refreshSplitRoots()
|
|
98
149
|
.then(() => this.gitConflicts?.syncRoots?.(this.store.getRoots()))
|
|
@@ -143,13 +194,91 @@ export class GoldSyncServer {
|
|
|
143
194
|
sendJson(response, 409, { error: "this Studio place is not bound to the current Rojo session" });
|
|
144
195
|
return;
|
|
145
196
|
}
|
|
197
|
+
if (this.config.version === 2 && request.headers["x-goldsync-tree-roots"] !== "4") {
|
|
198
|
+
sendJson(response, 409, { error: "This project requires GoldSync 0.1.29 or newer. Update GoldSync and restart Studio." });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
146
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
|
+
}
|
|
242
|
+
if (this.store.boundaryChange) {
|
|
243
|
+
sendJson(response, 409, { error: "Boundary conversion in progress; retry after it finishes" });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const boundaryMatch = /^\/v1\/roots\/([a-z0-9-]+)\/boundary$/.exec(url.pathname);
|
|
247
|
+
if (request.method === "PUT" && boundaryMatch) {
|
|
248
|
+
if (!this.syncEnabled) { sendJson(response, 409, { error: "Connect Rojo before converting a boundary" }); return; }
|
|
249
|
+
const bytes = await readBody(request);
|
|
250
|
+
if (bytes.length < 4) throw new Error("Invalid boundary packet");
|
|
251
|
+
const headerLength = bytes.readUInt32LE();
|
|
252
|
+
if (headerLength > bytes.length - 4) throw new Error("Invalid boundary header");
|
|
253
|
+
const payload = JSON.parse(bytes.subarray(4, 4 + headerLength));
|
|
254
|
+
if (!Array.isArray(payload.entries) || payload.entries.length > 10000) throw new Error("Invalid boundary entries");
|
|
255
|
+
const conflicts = this.gitConflicts ? await this.gitConflicts.list() : new Map();
|
|
256
|
+
if (Object.keys(payload.expected ?? {}).some((id) => conflicts.has(id))) {
|
|
257
|
+
sendJson(response, 409, { error: "Resolve Git conflicts before converting the boundary" }); return;
|
|
258
|
+
}
|
|
259
|
+
let offset = 4 + headerLength;
|
|
260
|
+
for (const entry of payload.entries) {
|
|
261
|
+
if (!Number.isSafeInteger(entry.size) || entry.size < 0 || offset + entry.size > bytes.length) throw new Error("Invalid snapshot length");
|
|
262
|
+
entry.bytes = bytes.subarray(offset, offset + entry.size);
|
|
263
|
+
offset += entry.size;
|
|
264
|
+
}
|
|
265
|
+
if (offset !== bytes.length) throw new Error("Unexpected boundary packet data");
|
|
266
|
+
try {
|
|
267
|
+
const roots = await this.store.replaceBoundary(boundaryMatch[1], payload.entries, payload.expected);
|
|
268
|
+
this.gitConflicts?.syncRoots?.(this.store.getRoots());
|
|
269
|
+
sendJson(response, 200, { roots });
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (error instanceof ConflictError) { sendJson(response, 409, { error: "Files changed; refresh before converting" }); return; }
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
147
276
|
if (request.method === "GET" && url.pathname === "/v1/config") {
|
|
148
277
|
await this.store.refreshSplitRoots();
|
|
149
278
|
this.gitConflicts?.syncRoots?.(this.store.getRoots());
|
|
150
279
|
const conflicts = this.gitConflicts ? await this.gitConflicts.list() : new Map();
|
|
151
280
|
sendJson(response, 200, {
|
|
152
|
-
version: 1,
|
|
281
|
+
version: this.config.version ?? 1,
|
|
153
282
|
name: this.config.name,
|
|
154
283
|
projectId: this.config.projectId,
|
|
155
284
|
workspaceId: this.config.workspaceId,
|
|
@@ -157,6 +286,8 @@ export class GoldSyncServer {
|
|
|
157
286
|
pollIntervalMs: this.config.pollIntervalMs,
|
|
158
287
|
debounceMs: this.config.debounceMs,
|
|
159
288
|
rojoConnected: this.syncEnabled,
|
|
289
|
+
restore,
|
|
290
|
+
treeRoots: (this.config.treeRoots ?? []).map((root) => ({ id: root.id, studioPath: root.studioPath })),
|
|
160
291
|
splitRoots: (this.config.splitRoots ?? []).map((root) => ({
|
|
161
292
|
id: root.id,
|
|
162
293
|
studioPath: root.studioPath,
|
|
@@ -172,11 +303,58 @@ export class GoldSyncServer {
|
|
|
172
303
|
conflictStages: conflict?.stages ?? null,
|
|
173
304
|
};
|
|
174
305
|
}),
|
|
175
|
-
});
|
|
306
|
+
}, request);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (request.method === "POST" && url.pathname === "/v1/snapshots") {
|
|
311
|
+
if (!this.syncEnabled) {
|
|
312
|
+
sendJson(response, 409, { error: "Snapshot downloads are paused until Rojo is connected" });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const ids = JSON.parse((await readBody(request)).toString("utf8"));
|
|
316
|
+
if (!Array.isArray(ids) || ids.length > 50 || ids.some((id) => typeof id !== "string")) {
|
|
317
|
+
sendJson(response, 400, { error: "Expected at most 50 snapshot IDs" });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const conflicts = this.gitConflicts ? await this.gitConflicts.list() : new Map();
|
|
321
|
+
const chunks = [];
|
|
322
|
+
let size = 0;
|
|
323
|
+
for (const id of ids) {
|
|
324
|
+
const manifest = this.store.getManifest(id);
|
|
325
|
+
if (conflicts.has(id) || !manifest.exists || manifest.size + size > 8 * 1024 * 1024) continue;
|
|
326
|
+
const snapshot = await this.store.get(id);
|
|
327
|
+
if (!snapshot || snapshot.bytes.length + size > 8 * 1024 * 1024) continue;
|
|
328
|
+
const header = Buffer.from(JSON.stringify({ id, hash: snapshot.hash, size: snapshot.bytes.length }));
|
|
329
|
+
if (size + snapshot.bytes.length + header.length + 4 > 8 * 1024 * 1024) continue;
|
|
330
|
+
const length = Buffer.alloc(4);
|
|
331
|
+
length.writeUInt32LE(header.length);
|
|
332
|
+
chunks.push(length, header, snapshot.bytes);
|
|
333
|
+
size += snapshot.bytes.length + header.length + 4;
|
|
334
|
+
}
|
|
335
|
+
const body = Buffer.concat(chunks);
|
|
336
|
+
response.writeHead(200, { "Content-Type": "application/octet-stream", "Content-Length": body.length, "Cache-Control": "no-store" });
|
|
337
|
+
response.end(body);
|
|
176
338
|
return;
|
|
177
339
|
}
|
|
178
340
|
|
|
179
341
|
const splitMatch = /^\/v1\/splits\/([a-z0-9-]+)\/discover$/.exec(url.pathname);
|
|
342
|
+
const treeMatch = /^\/v1\/trees\/([a-z0-9-]+)\/discover$/.exec(url.pathname);
|
|
343
|
+
if (request.method === "POST" && treeMatch) {
|
|
344
|
+
if (!this.syncEnabled) {
|
|
345
|
+
sendJson(response, 409, { error: "Tree discovery is paused until Rojo is connected" });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const payload = JSON.parse((await readBody(request)).toString("utf8"));
|
|
349
|
+
if (!Array.isArray(payload.entries) || payload.entries.length > 10000 || payload.entries.some((entry) => !entry || typeof entry !== "object")) {
|
|
350
|
+
sendJson(response, 400, { error: "entries must be an array with at most 10000 tree entries" });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
await this.store.discoverTreePaths(treeMatch[1], payload.entries);
|
|
354
|
+
this.gitConflicts?.syncRoots?.(this.store.getRoots());
|
|
355
|
+
sendJson(response, 200, { discovered: payload.entries.length });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
180
358
|
if (request.method === "POST" && splitMatch) {
|
|
181
359
|
const payload = JSON.parse((await readBody(request)).toString("utf8"));
|
|
182
360
|
if (!Array.isArray(payload.paths) || payload.paths.length > 10000) {
|
|
@@ -252,7 +430,7 @@ export class GoldSyncServer {
|
|
|
252
430
|
}
|
|
253
431
|
const expectedHash = expectedHashHeader.replace(/^"|"$/g, "");
|
|
254
432
|
try {
|
|
255
|
-
sendJson(response, 200, await this.store.delete(id, expectedHash));
|
|
433
|
+
sendJson(response, 200, await this.store.delete(id, expectedHash, request.headers["x-goldsync-plain-folder"] === "1"));
|
|
256
434
|
} catch (error) {
|
|
257
435
|
if (error instanceof ConflictError) {
|
|
258
436
|
sendJson(response, 409, { error: error.message, current: error.current });
|