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.
@@ -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("Run 'goldsync install' from a repository containing goldsync.project.json.");
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.16",
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/icon.png",
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("maxPathDepth must be an integer between 2 and 10");
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: 1,
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
  }
@@ -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", "--", ...this.gitPaths.values()]);
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) {
@@ -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 }, (_event, filename) => {
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
- let matched = false;
89
- for (const root of this.store.getRoots()) {
90
- if (root.absoluteFile.toLowerCase() === changedFile) {
91
- matched = true;
92
- this.scheduleRefresh(root.id);
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 });