goldsync 0.1.16 → 0.1.31

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,7 +1,7 @@
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";
@@ -38,6 +38,33 @@ const configPath = configIndex === -1
38
38
 
39
39
  const dataDirectory = path.join(process.env.LOCALAPPDATA, "GoldSync");
40
40
  const runtimeDirectory = path.join(dataDirectory, "runtime");
41
+ const installedAgent = path.join(runtimeDirectory, "bin", "goldsync-agent.mjs");
42
+ const pidFile = path.join(dataDirectory, "agent.pid");
43
+ let installedPid;
44
+ try {
45
+ installedPid = Number(await readFile(pidFile, "utf8"));
46
+ } catch (error) {
47
+ if (error.code !== "ENOENT") throw error;
48
+ }
49
+ if (Number.isInteger(installedPid) && installedPid > 0) {
50
+ const stopCommand = `
51
+ $ErrorActionPreference = 'Stop'
52
+ $agentProcess = Get-CimInstance Win32_Process -Filter "ProcessId = $env:GOLDSYNC_AGENT_PID"
53
+ if ($agentProcess) {
54
+ if ((Split-Path -Parent $agentProcess.ExecutablePath) -ine $env:GOLDSYNC_RUNTIME_DIR -or $agentProcess.CommandLine.IndexOf($env:GOLDSYNC_AGENT_SCRIPT, [StringComparison]::OrdinalIgnoreCase) -lt 0) {
55
+ throw 'The recorded GoldSync process does not match the installed agent. No process was stopped.'
56
+ }
57
+ Stop-Process -Id $agentProcess.ProcessId
58
+ }
59
+ `;
60
+ await new Promise((resolve, reject) => {
61
+ execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", stopCommand], {
62
+ windowsHide: true,
63
+ env: { ...process.env, GOLDSYNC_AGENT_PID: String(installedPid), GOLDSYNC_AGENT_SCRIPT: installedAgent, GOLDSYNC_RUNTIME_DIR: runtimeDirectory },
64
+ }, (error) => error ? reject(error) : resolve());
65
+ });
66
+ await rm(pidFile, { force: true });
67
+ }
41
68
  await mkdir(path.join(runtimeDirectory, "bin"), { recursive: true });
42
69
  await cp(path.join(packageDirectory, "src"), path.join(runtimeDirectory, "src"), {
43
70
  recursive: true,
@@ -69,7 +96,6 @@ try {
69
96
  }
70
97
 
71
98
  const config = await registerProject(path.join(dataDirectory, "projects.json"), configPath);
72
- const installedAgent = path.join(runtimeDirectory, "bin", "goldsync-agent.mjs");
73
99
  const startupCommand = `"${runtimeNode}" "${installedAgent}"`;
74
100
  const vbsString = `"${startupCommand.replaceAll('"', '""')}"`;
75
101
  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.31",
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/companion.mjs CHANGED
@@ -26,7 +26,7 @@ function request(method, requestPath, body, port = BROKER_PORT) {
26
26
  }
27
27
  : {}),
28
28
  },
29
- timeout: 2000,
29
+ timeout: requestPath === "/v1/workspaces/register" ? 120000 : 2000,
30
30
  },
31
31
  (response) => {
32
32
  const chunks = [];
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) {
package/src/server.mjs CHANGED
@@ -1,16 +1,24 @@
1
1
  import { watch } from "node:fs";
2
+ import { createHash } from "node:crypto";
2
3
  import { createServer } from "node:http";
3
4
  import path from "node:path";
4
5
  import { ConflictError } from "./store.mjs";
5
6
 
6
7
  const MAX_SNAPSHOT_BYTES = 128 * 1024 * 1024;
7
8
 
8
- function sendJson(response, status, value) {
9
+ function sendJson(response, status, value, request) {
9
10
  const body = Buffer.from(JSON.stringify(value));
11
+ const etag = request ? `"${createHash("sha256").update(body).digest("hex")}"` : null;
12
+ if (etag && request.headers["if-none-match"] === etag) {
13
+ response.writeHead(304, { ETag: etag, "Cache-Control": "no-store" });
14
+ response.end();
15
+ return;
16
+ }
10
17
  response.writeHead(status, {
11
18
  "Content-Type": "application/json",
12
19
  "Content-Length": body.length,
13
20
  "Cache-Control": "no-store",
21
+ ...(etag ? { ETag: etag } : {}),
14
22
  });
15
23
  response.end(body);
16
24
  }
@@ -79,20 +87,21 @@ export class GoldSyncServer {
79
87
  for (const splitRoot of this.config.splitRoots ?? []) {
80
88
  watchedDirectories.set(splitRoot.absoluteDirectory, true);
81
89
  }
90
+ for (const treeRoot of this.config.treeRoots ?? []) {
91
+ watchedDirectories.set(treeRoot.absoluteDirectory, true);
92
+ }
82
93
  for (const [directory, recursive] of watchedDirectories) {
83
- const watcher = watch(directory, { recursive }, (_event, filename) => {
94
+ const watcher = watch(directory, { recursive }, (event, filename) => {
95
+ if (recursive && (event === "rename" || !filename)) this.store.treeScanNeeded = true;
84
96
  if (!filename) {
85
97
  return;
86
98
  }
87
99
  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") {
100
+ const root = this.store.rootsByFile.get(changedFile);
101
+ if (root) this.scheduleRefresh(root.id);
102
+ if (!root && recursive && path.extname(changedFile) === ".rbxm") {
103
+ this.store.treeScanNeeded = true;
104
+ if ((this.config.treeRoots ?? []).length > 0) return;
96
105
  this.store
97
106
  .refreshSplitRoots()
98
107
  .then(() => this.gitConflicts?.syncRoots?.(this.store.getRoots()))
@@ -143,13 +152,51 @@ export class GoldSyncServer {
143
152
  sendJson(response, 409, { error: "this Studio place is not bound to the current Rojo session" });
144
153
  return;
145
154
  }
155
+ if (this.config.version === 2 && request.headers["x-goldsync-tree-roots"] !== "4") {
156
+ sendJson(response, 409, { error: "This project requires GoldSync 0.1.29 or newer. Update GoldSync and restart Studio." });
157
+ return;
158
+ }
146
159
  const url = new URL(request.url, `http://${this.config.host}:${this.config.port}`);
160
+ if (this.store.boundaryChange) {
161
+ sendJson(response, 409, { error: "Boundary conversion in progress; retry after it finishes" });
162
+ return;
163
+ }
164
+ const boundaryMatch = /^\/v1\/roots\/([a-z0-9-]+)\/boundary$/.exec(url.pathname);
165
+ if (request.method === "PUT" && boundaryMatch) {
166
+ if (!this.syncEnabled) { sendJson(response, 409, { error: "Connect Rojo before converting a boundary" }); return; }
167
+ const bytes = await readBody(request);
168
+ if (bytes.length < 4) throw new Error("Invalid boundary packet");
169
+ const headerLength = bytes.readUInt32LE();
170
+ if (headerLength > bytes.length - 4) throw new Error("Invalid boundary header");
171
+ const payload = JSON.parse(bytes.subarray(4, 4 + headerLength));
172
+ if (!Array.isArray(payload.entries) || payload.entries.length > 10000) throw new Error("Invalid boundary entries");
173
+ const conflicts = this.gitConflicts ? await this.gitConflicts.list() : new Map();
174
+ if (Object.keys(payload.expected ?? {}).some((id) => conflicts.has(id))) {
175
+ sendJson(response, 409, { error: "Resolve Git conflicts before converting the boundary" }); return;
176
+ }
177
+ let offset = 4 + headerLength;
178
+ for (const entry of payload.entries) {
179
+ if (!Number.isSafeInteger(entry.size) || entry.size < 0 || offset + entry.size > bytes.length) throw new Error("Invalid snapshot length");
180
+ entry.bytes = bytes.subarray(offset, offset + entry.size);
181
+ offset += entry.size;
182
+ }
183
+ if (offset !== bytes.length) throw new Error("Unexpected boundary packet data");
184
+ try {
185
+ const roots = await this.store.replaceBoundary(boundaryMatch[1], payload.entries, payload.expected);
186
+ this.gitConflicts?.syncRoots?.(this.store.getRoots());
187
+ sendJson(response, 200, { roots });
188
+ } catch (error) {
189
+ if (error instanceof ConflictError) { sendJson(response, 409, { error: "Files changed; refresh before converting" }); return; }
190
+ throw error;
191
+ }
192
+ return;
193
+ }
147
194
  if (request.method === "GET" && url.pathname === "/v1/config") {
148
195
  await this.store.refreshSplitRoots();
149
196
  this.gitConflicts?.syncRoots?.(this.store.getRoots());
150
197
  const conflicts = this.gitConflicts ? await this.gitConflicts.list() : new Map();
151
198
  sendJson(response, 200, {
152
- version: 1,
199
+ version: this.config.version ?? 1,
153
200
  name: this.config.name,
154
201
  projectId: this.config.projectId,
155
202
  workspaceId: this.config.workspaceId,
@@ -157,6 +204,7 @@ export class GoldSyncServer {
157
204
  pollIntervalMs: this.config.pollIntervalMs,
158
205
  debounceMs: this.config.debounceMs,
159
206
  rojoConnected: this.syncEnabled,
207
+ treeRoots: (this.config.treeRoots ?? []).map((root) => ({ id: root.id, studioPath: root.studioPath })),
160
208
  splitRoots: (this.config.splitRoots ?? []).map((root) => ({
161
209
  id: root.id,
162
210
  studioPath: root.studioPath,
@@ -172,11 +220,58 @@ export class GoldSyncServer {
172
220
  conflictStages: conflict?.stages ?? null,
173
221
  };
174
222
  }),
175
- });
223
+ }, request);
224
+ return;
225
+ }
226
+
227
+ if (request.method === "POST" && url.pathname === "/v1/snapshots") {
228
+ if (!this.syncEnabled) {
229
+ sendJson(response, 409, { error: "Snapshot downloads are paused until Rojo is connected" });
230
+ return;
231
+ }
232
+ const ids = JSON.parse((await readBody(request)).toString("utf8"));
233
+ if (!Array.isArray(ids) || ids.length > 50 || ids.some((id) => typeof id !== "string")) {
234
+ sendJson(response, 400, { error: "Expected at most 50 snapshot IDs" });
235
+ return;
236
+ }
237
+ const conflicts = this.gitConflicts ? await this.gitConflicts.list() : new Map();
238
+ const chunks = [];
239
+ let size = 0;
240
+ for (const id of ids) {
241
+ const manifest = this.store.getManifest(id);
242
+ if (conflicts.has(id) || !manifest.exists || manifest.size + size > 8 * 1024 * 1024) continue;
243
+ const snapshot = await this.store.get(id);
244
+ if (!snapshot || snapshot.bytes.length + size > 8 * 1024 * 1024) continue;
245
+ const header = Buffer.from(JSON.stringify({ id, hash: snapshot.hash, size: snapshot.bytes.length }));
246
+ if (size + snapshot.bytes.length + header.length + 4 > 8 * 1024 * 1024) continue;
247
+ const length = Buffer.alloc(4);
248
+ length.writeUInt32LE(header.length);
249
+ chunks.push(length, header, snapshot.bytes);
250
+ size += snapshot.bytes.length + header.length + 4;
251
+ }
252
+ const body = Buffer.concat(chunks);
253
+ response.writeHead(200, { "Content-Type": "application/octet-stream", "Content-Length": body.length, "Cache-Control": "no-store" });
254
+ response.end(body);
176
255
  return;
177
256
  }
178
257
 
179
258
  const splitMatch = /^\/v1\/splits\/([a-z0-9-]+)\/discover$/.exec(url.pathname);
259
+ const treeMatch = /^\/v1\/trees\/([a-z0-9-]+)\/discover$/.exec(url.pathname);
260
+ if (request.method === "POST" && treeMatch) {
261
+ if (!this.syncEnabled) {
262
+ sendJson(response, 409, { error: "Tree discovery is paused until Rojo is connected" });
263
+ return;
264
+ }
265
+ const payload = JSON.parse((await readBody(request)).toString("utf8"));
266
+ if (!Array.isArray(payload.entries) || payload.entries.length > 10000 || payload.entries.some((entry) => !entry || typeof entry !== "object")) {
267
+ sendJson(response, 400, { error: "entries must be an array with at most 10000 tree entries" });
268
+ return;
269
+ }
270
+ await this.store.discoverTreePaths(treeMatch[1], payload.entries);
271
+ this.gitConflicts?.syncRoots?.(this.store.getRoots());
272
+ sendJson(response, 200, { discovered: payload.entries.length });
273
+ return;
274
+ }
180
275
  if (request.method === "POST" && splitMatch) {
181
276
  const payload = JSON.parse((await readBody(request)).toString("utf8"));
182
277
  if (!Array.isArray(payload.paths) || payload.paths.length > 10000) {
@@ -252,7 +347,7 @@ export class GoldSyncServer {
252
347
  }
253
348
  const expectedHash = expectedHashHeader.replace(/^"|"$/g, "");
254
349
  try {
255
- sendJson(response, 200, await this.store.delete(id, expectedHash));
350
+ sendJson(response, 200, await this.store.delete(id, expectedHash, request.headers["x-goldsync-plain-folder"] === "1"));
256
351
  } catch (error) {
257
352
  if (error instanceof ConflictError) {
258
353
  sendJson(response, 409, { error: error.message, current: error.current });