goldsync 0.1.11
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/LICENSE.txt +5 -0
- package/README.md +129 -0
- package/assets/GoldSync.rbxmx +2516 -0
- package/bin/goldsync-agent.mjs +54 -0
- package/bin/install-companion.mjs +96 -0
- package/package.json +42 -0
- package/src/broker.mjs +306 -0
- package/src/companion.mjs +220 -0
- package/src/config.mjs +165 -0
- package/src/git-conflicts.mjs +127 -0
- package/src/rojo-session.mjs +165 -0
- package/src/server.mjs +308 -0
- package/src/session-marker.mjs +27 -0
- package/src/store.mjs +337 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { GoldSyncBroker } from "./broker.mjs";
|
|
6
|
+
import { loadConfig } from "./config.mjs";
|
|
7
|
+
|
|
8
|
+
export const BROKER_PORT = 34873;
|
|
9
|
+
export const HEARTBEAT_MS = 5000;
|
|
10
|
+
|
|
11
|
+
function request(method, requestPath, body, port = BROKER_PORT) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const bytes = body === undefined ? undefined : Buffer.from(JSON.stringify(body));
|
|
14
|
+
const brokerRequest = http.request(
|
|
15
|
+
{
|
|
16
|
+
host: "127.0.0.1",
|
|
17
|
+
port,
|
|
18
|
+
path: requestPath,
|
|
19
|
+
method,
|
|
20
|
+
headers: {
|
|
21
|
+
"X-GoldSync-Client": "vscode-extension",
|
|
22
|
+
...(bytes
|
|
23
|
+
? {
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
"Content-Length": bytes.length,
|
|
26
|
+
}
|
|
27
|
+
: {}),
|
|
28
|
+
},
|
|
29
|
+
timeout: 2000,
|
|
30
|
+
},
|
|
31
|
+
(response) => {
|
|
32
|
+
const chunks = [];
|
|
33
|
+
response.on("data", (chunk) => chunks.push(chunk));
|
|
34
|
+
response.on("end", () => {
|
|
35
|
+
const responseText = Buffer.concat(chunks).toString("utf8");
|
|
36
|
+
let value = {};
|
|
37
|
+
if (responseText) {
|
|
38
|
+
try {
|
|
39
|
+
value = JSON.parse(responseText);
|
|
40
|
+
} catch {
|
|
41
|
+
reject(new Error(`GoldSync returned invalid JSON (HTTP ${response.statusCode})`));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
46
|
+
reject(new Error(value.error ?? `GoldSync request failed (HTTP ${response.statusCode})`));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
resolve(value);
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
brokerRequest.once("timeout", () => brokerRequest.destroy(new Error("GoldSync broker timed out")));
|
|
54
|
+
brokerRequest.once("error", reject);
|
|
55
|
+
if (bytes) {
|
|
56
|
+
brokerRequest.write(bytes);
|
|
57
|
+
}
|
|
58
|
+
brokerRequest.end();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function fileExists(file) {
|
|
63
|
+
try {
|
|
64
|
+
await access(file);
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function findProjectConfig(startDirectory) {
|
|
72
|
+
const direct = path.join(path.resolve(startDirectory), "goldsync.project.json");
|
|
73
|
+
if (await fileExists(direct)) {
|
|
74
|
+
return direct;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { readdir } = await import("node:fs/promises");
|
|
78
|
+
const matches = [];
|
|
79
|
+
for (const entry of await readdir(path.resolve(startDirectory), { withFileTypes: true })) {
|
|
80
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const candidate = path.join(startDirectory, entry.name, "goldsync.project.json");
|
|
84
|
+
if (await fileExists(candidate)) {
|
|
85
|
+
matches.push(path.resolve(candidate));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (matches.length === 1) {
|
|
89
|
+
return matches[0];
|
|
90
|
+
}
|
|
91
|
+
throw new Error(
|
|
92
|
+
matches.length === 0
|
|
93
|
+
? "No goldsync.project.json was found here or one folder below."
|
|
94
|
+
: "More than one GoldSync project was found. Run the installer from the intended game folder.",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function readRegistry(registryFile) {
|
|
99
|
+
try {
|
|
100
|
+
const value = JSON.parse(await readFile(registryFile, "utf8"));
|
|
101
|
+
return {
|
|
102
|
+
version: 1,
|
|
103
|
+
clientId: typeof value.clientId === "string" ? value.clientId : randomUUID(),
|
|
104
|
+
projects: Array.isArray(value.projects) ? value.projects : [],
|
|
105
|
+
};
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error.code !== "ENOENT") {
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
return { version: 1, clientId: randomUUID(), projects: [] };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function registerProject(registryFile, configPath) {
|
|
115
|
+
const absoluteConfigPath = path.resolve(configPath);
|
|
116
|
+
const config = await loadConfig(absoluteConfigPath);
|
|
117
|
+
const registry = await readRegistry(registryFile);
|
|
118
|
+
registry.projects = registry.projects.filter(
|
|
119
|
+
(project) =>
|
|
120
|
+
project.projectId !== config.projectId &&
|
|
121
|
+
(typeof project.configPath !== "string" || project.configPath.toLowerCase() !== absoluteConfigPath.toLowerCase()),
|
|
122
|
+
);
|
|
123
|
+
registry.projects.push({
|
|
124
|
+
projectId: config.projectId,
|
|
125
|
+
name: config.name,
|
|
126
|
+
configPath: absoluteConfigPath,
|
|
127
|
+
});
|
|
128
|
+
await mkdir(path.dirname(registryFile), { recursive: true });
|
|
129
|
+
await writeFile(registryFile, `${JSON.stringify(registry, null, 2)}\n`);
|
|
130
|
+
return config;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export class CompanionAgent {
|
|
134
|
+
constructor(options) {
|
|
135
|
+
this.registryFile = options.registryFile;
|
|
136
|
+
this.port = options.port ?? BROKER_PORT;
|
|
137
|
+
this.heartbeatMs = options.heartbeatMs ?? HEARTBEAT_MS;
|
|
138
|
+
this.log = options.log ?? (() => {});
|
|
139
|
+
this.refreshing = false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async brokerHealthy() {
|
|
143
|
+
try {
|
|
144
|
+
const health = await request("GET", "/v1/health", undefined, this.port);
|
|
145
|
+
return health.broker === true;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async ensureBroker() {
|
|
152
|
+
if (await this.brokerHealthy()) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (this.ownedBroker) {
|
|
156
|
+
await this.ownedBroker.stop();
|
|
157
|
+
this.ownedBroker = undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const broker = new GoldSyncBroker({ port: this.port });
|
|
161
|
+
try {
|
|
162
|
+
await broker.start();
|
|
163
|
+
this.ownedBroker = broker;
|
|
164
|
+
this.log(`Started broker on 127.0.0.1:${this.port}`);
|
|
165
|
+
return;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
await broker.stop();
|
|
168
|
+
if (error.code !== "EADDRINUSE") {
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
174
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
175
|
+
if (await this.brokerHealthy()) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
throw new Error(`port ${this.port} is occupied by another application`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async refresh() {
|
|
183
|
+
if (this.refreshing) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
this.refreshing = true;
|
|
187
|
+
try {
|
|
188
|
+
await this.ensureBroker();
|
|
189
|
+
const registry = await readRegistry(this.registryFile);
|
|
190
|
+
for (const project of registry.projects) {
|
|
191
|
+
if (!(await fileExists(project.configPath))) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
await request(
|
|
195
|
+
"POST",
|
|
196
|
+
"/v1/workspaces/register",
|
|
197
|
+
{ configPath: project.configPath, clientId: registry.clientId },
|
|
198
|
+
this.port,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
} finally {
|
|
202
|
+
this.refreshing = false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async start() {
|
|
207
|
+
await this.refresh();
|
|
208
|
+
this.timer = setInterval(() => {
|
|
209
|
+
this.refresh().catch((error) => this.log(error.stack ?? error.message));
|
|
210
|
+
}, this.heartbeatMs);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async stop() {
|
|
214
|
+
clearInterval(this.timer);
|
|
215
|
+
if (this.ownedBroker) {
|
|
216
|
+
await this.ownedBroker.stop();
|
|
217
|
+
this.ownedBroker = undefined;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
6
|
+
|
|
7
|
+
function requireString(value, label) {
|
|
8
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
9
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resolveInsideProject(projectRoot, file, label) {
|
|
15
|
+
requireString(file, label);
|
|
16
|
+
const resolved = path.resolve(projectRoot, file);
|
|
17
|
+
const relative = path.relative(projectRoot, resolved);
|
|
18
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
19
|
+
throw new Error(`${label} must stay inside the project root`);
|
|
20
|
+
}
|
|
21
|
+
return resolved;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveProjectFile(projectRoot, file, label) {
|
|
25
|
+
requireString(file, label);
|
|
26
|
+
if (path.extname(file).toLowerCase() !== ".rbxm") {
|
|
27
|
+
throw new Error(`${label} must point to an .rbxm file`);
|
|
28
|
+
}
|
|
29
|
+
return resolveInsideProject(projectRoot, file, label);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeStudioPath(root, label, maxPathDepth, extraDepth = 0) {
|
|
33
|
+
if (!Array.isArray(root.studioPath) || root.studioPath.length < 2) {
|
|
34
|
+
throw new Error(`${label}.studioPath must contain a service and child path`);
|
|
35
|
+
}
|
|
36
|
+
const studioPath = root.studioPath.map((segment, segmentIndex) =>
|
|
37
|
+
requireString(segment, `${label}.studioPath[${segmentIndex}]`),
|
|
38
|
+
);
|
|
39
|
+
if (studioPath.length + extraDepth > maxPathDepth) {
|
|
40
|
+
throw new Error(`${label}.studioPath exceeds maxPathDepth (${maxPathDepth})`);
|
|
41
|
+
}
|
|
42
|
+
return studioPath;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function normalizeRojo(raw, projectRoot) {
|
|
46
|
+
if (raw !== undefined && (!raw || typeof raw !== "object" || Array.isArray(raw))) {
|
|
47
|
+
throw new Error("rojo must be an object when specified");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const overrides = raw ?? {};
|
|
51
|
+
let rojoProject = null;
|
|
52
|
+
if (
|
|
53
|
+
raw === undefined ||
|
|
54
|
+
overrides.projectFile !== undefined ||
|
|
55
|
+
overrides.port === undefined ||
|
|
56
|
+
overrides.projectName === undefined
|
|
57
|
+
) {
|
|
58
|
+
const projectFile = overrides.projectFile ?? "default.project.json";
|
|
59
|
+
const absoluteProjectFile = resolveInsideProject(projectRoot, projectFile, "rojo.projectFile");
|
|
60
|
+
rojoProject = JSON.parse(await readFile(absoluteProjectFile, "utf8"));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const port = overrides.port ?? rojoProject?.servePort ?? 34872;
|
|
64
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
65
|
+
throw new Error("rojo.port must be an integer between 1 and 65535");
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
host: "127.0.0.1",
|
|
69
|
+
port,
|
|
70
|
+
projectName: requireString(overrides.projectName ?? rojoProject?.name, "rojo.projectName"),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function loadConfig(configPath) {
|
|
75
|
+
const absoluteConfigPath = path.resolve(configPath);
|
|
76
|
+
const projectRoot = await realpath(path.dirname(absoluteConfigPath));
|
|
77
|
+
const raw = JSON.parse(await readFile(absoluteConfigPath, "utf8"));
|
|
78
|
+
|
|
79
|
+
if (raw.version !== 1) {
|
|
80
|
+
throw new Error("goldsync.project.json must use version 1");
|
|
81
|
+
}
|
|
82
|
+
const port = raw.port ?? 34873;
|
|
83
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
84
|
+
throw new Error("port must be an integer between 1 and 65535");
|
|
85
|
+
}
|
|
86
|
+
if (!Array.isArray(raw.roots) || raw.roots.length === 0) {
|
|
87
|
+
throw new Error("roots must contain at least one sync root");
|
|
88
|
+
}
|
|
89
|
+
const maxPathDepth = raw.maxPathDepth ?? 3;
|
|
90
|
+
if (!Number.isInteger(maxPathDepth) || maxPathDepth < 2 || maxPathDepth > 10) {
|
|
91
|
+
throw new Error("maxPathDepth must be an integer between 2 and 10");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ids = new Set();
|
|
95
|
+
const studioPaths = new Set();
|
|
96
|
+
const files = new Set();
|
|
97
|
+
const splitRoots = [];
|
|
98
|
+
const roots = raw.roots.flatMap((root, index) => {
|
|
99
|
+
const label = `roots[${index}]`;
|
|
100
|
+
const id = requireString(root.id, `${label}.id`);
|
|
101
|
+
if (!ID_PATTERN.test(id)) {
|
|
102
|
+
throw new Error(`${label}.id must contain lowercase letters, numbers, or hyphens`);
|
|
103
|
+
}
|
|
104
|
+
if (ids.has(id)) {
|
|
105
|
+
throw new Error(`duplicate root id: ${id}`);
|
|
106
|
+
}
|
|
107
|
+
ids.add(id);
|
|
108
|
+
|
|
109
|
+
const splitDepth = root.splitDepth;
|
|
110
|
+
if (splitDepth !== undefined && (!Number.isInteger(splitDepth) || splitDepth < 1 || splitDepth > 3)) {
|
|
111
|
+
throw new Error(`${label}.splitDepth must be an integer between 1 and 3`);
|
|
112
|
+
}
|
|
113
|
+
const studioPath = normalizeStudioPath(root, label, maxPathDepth, splitDepth ?? 0);
|
|
114
|
+
const studioPathKey = JSON.stringify(studioPath);
|
|
115
|
+
if (studioPaths.has(studioPathKey)) {
|
|
116
|
+
throw new Error(`duplicate Studio path: ${studioPath.join(".")}`);
|
|
117
|
+
}
|
|
118
|
+
studioPaths.add(studioPathKey);
|
|
119
|
+
|
|
120
|
+
if (splitDepth !== undefined) {
|
|
121
|
+
const directory = requireString(root.directory, `${label}.directory`);
|
|
122
|
+
const absoluteDirectory = resolveInsideProject(projectRoot, directory, `${label}.directory`);
|
|
123
|
+
splitRoots.push({
|
|
124
|
+
id,
|
|
125
|
+
studioPath,
|
|
126
|
+
directory: path.relative(projectRoot, absoluteDirectory).replaceAll(path.sep, "/"),
|
|
127
|
+
absoluteDirectory,
|
|
128
|
+
splitDepth,
|
|
129
|
+
});
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const absoluteFile = resolveProjectFile(projectRoot, root.file, `${label}.file`);
|
|
134
|
+
const fileKey = absoluteFile.toLowerCase();
|
|
135
|
+
if (files.has(fileKey)) {
|
|
136
|
+
throw new Error(`duplicate output file: ${root.file}`);
|
|
137
|
+
}
|
|
138
|
+
files.add(fileKey);
|
|
139
|
+
|
|
140
|
+
return [{
|
|
141
|
+
id,
|
|
142
|
+
studioPath,
|
|
143
|
+
file: path.relative(projectRoot, absoluteFile).replaceAll(path.sep, "/"),
|
|
144
|
+
absoluteFile,
|
|
145
|
+
}];
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
version: 1,
|
|
150
|
+
name: requireString(raw.name, "name"),
|
|
151
|
+
projectId: requireString(raw.projectId, "projectId"),
|
|
152
|
+
workspaceId: createHash("sha256").update(absoluteConfigPath.toLowerCase()).digest("hex").slice(0, 12),
|
|
153
|
+
rojo: await normalizeRojo(raw.rojo, projectRoot),
|
|
154
|
+
host: "127.0.0.1",
|
|
155
|
+
port,
|
|
156
|
+
pollIntervalMs: Number.isInteger(raw.pollIntervalMs) ? Math.max(raw.pollIntervalMs, 250) : 750,
|
|
157
|
+
debounceMs: Number.isInteger(raw.debounceMs) ? Math.max(raw.debounceMs, 100) : 600,
|
|
158
|
+
maxPathDepth,
|
|
159
|
+
projectRoot,
|
|
160
|
+
stateDirectory: path.join(projectRoot, ".goldsync"),
|
|
161
|
+
sessionMarkerFile: path.join(projectRoot, ".goldsync", "rojo-session.model.json"),
|
|
162
|
+
roots,
|
|
163
|
+
splitRoots,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const MAX_GIT_OUTPUT_BYTES = 160 * 1024 * 1024;
|
|
7
|
+
const STAGES = { base: 1, ours: 2, theirs: 3 };
|
|
8
|
+
|
|
9
|
+
function runGit(directory, args) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
execFile(
|
|
12
|
+
"git",
|
|
13
|
+
["-C", directory, ...args],
|
|
14
|
+
{ encoding: null, maxBuffer: MAX_GIT_OUTPUT_BYTES, windowsHide: true },
|
|
15
|
+
(error, stdout, stderr) => {
|
|
16
|
+
if (error) {
|
|
17
|
+
error.message = Buffer.from(stderr).toString("utf8").trim() || error.message;
|
|
18
|
+
reject(error);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
resolve(Buffer.from(stdout));
|
|
22
|
+
},
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class GitConflictStore {
|
|
28
|
+
constructor(config) {
|
|
29
|
+
this.config = config;
|
|
30
|
+
this.roots = new Map(config.roots.map((root) => [root.id, root]));
|
|
31
|
+
this.repositoryRoot = null;
|
|
32
|
+
this.gitPaths = new Map();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async initialize() {
|
|
36
|
+
try {
|
|
37
|
+
this.repositoryRoot = await realpath(
|
|
38
|
+
(await runGit(this.config.projectRoot, ["rev-parse", "--show-toplevel"])).toString("utf8").trim(),
|
|
39
|
+
);
|
|
40
|
+
} catch {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.syncRoots(this.config.roots);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
syncRoots(roots) {
|
|
48
|
+
if (!this.repositoryRoot) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const root of roots) {
|
|
52
|
+
const relative = path.relative(this.repositoryRoot, root.absoluteFile);
|
|
53
|
+
if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
|
|
54
|
+
this.gitPaths.set(root.id, relative.replaceAll(path.sep, "/"));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async list() {
|
|
60
|
+
if (!this.repositoryRoot || this.gitPaths.size === 0) {
|
|
61
|
+
return new Map();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const output = await runGit(this.repositoryRoot, ["ls-files", "-u", "-z", "--", ...this.gitPaths.values()]);
|
|
65
|
+
const byPath = new Map();
|
|
66
|
+
for (const entry of output.toString("utf8").split("\0")) {
|
|
67
|
+
if (!entry) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const match = /^(\d+) ([0-9a-f]+) ([123])\t(.+)$/.exec(entry);
|
|
71
|
+
if (!match) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const [, mode, objectId, stage, file] = match;
|
|
75
|
+
const stages = byPath.get(file) ?? [];
|
|
76
|
+
stages.push({ mode, objectId, stage: Number(stage) });
|
|
77
|
+
byPath.set(file, stages);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const conflicts = new Map();
|
|
81
|
+
for (const [id, file] of this.gitPaths) {
|
|
82
|
+
const stages = byPath.get(file);
|
|
83
|
+
if (!stages) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
stages.sort((left, right) => left.stage - right.stage);
|
|
87
|
+
conflicts.set(id, {
|
|
88
|
+
id,
|
|
89
|
+
file,
|
|
90
|
+
token: createHash("sha256").update(JSON.stringify(stages)).digest("hex"),
|
|
91
|
+
stages: {
|
|
92
|
+
base: stages.some((stage) => stage.stage === STAGES.base),
|
|
93
|
+
ours: stages.some((stage) => stage.stage === STAGES.ours),
|
|
94
|
+
theirs: stages.some((stage) => stage.stage === STAGES.theirs),
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return conflicts;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async readStage(id, name) {
|
|
102
|
+
const stage = STAGES[name];
|
|
103
|
+
if (!stage) {
|
|
104
|
+
throw new Error(`unknown conflict stage: ${name}`);
|
|
105
|
+
}
|
|
106
|
+
const conflict = (await this.list()).get(id);
|
|
107
|
+
if (!conflict || !conflict.stages[name]) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
return runGit(this.repositoryRoot, ["show", `:${stage}:${conflict.file}`]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async resolve(id, bytes, token, store) {
|
|
114
|
+
const conflict = (await this.list()).get(id);
|
|
115
|
+
if (!conflict) {
|
|
116
|
+
throw new Error("Git conflict no longer exists");
|
|
117
|
+
}
|
|
118
|
+
if (conflict.token !== token) {
|
|
119
|
+
throw new Error("Git conflict changed while the merge workspace was open");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const manifest = store.getManifest(id);
|
|
123
|
+
await store.put(id, bytes, manifest.exists ? manifest.hash : "*");
|
|
124
|
+
await runGit(this.repositoryRoot, ["add", "--", conflict.file]);
|
|
125
|
+
return store.getManifest(id);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { request } from "node:http";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
|
|
5
|
+
const PROJECT_NAME_KEY = Buffer.from("projectName");
|
|
6
|
+
const SESSION_ID_KEY = Buffer.from("sessionId");
|
|
7
|
+
const executeFile = promisify(execFile);
|
|
8
|
+
const DISCOVERY_CACHE_MS = 2000;
|
|
9
|
+
|
|
10
|
+
let discoveredPorts = [];
|
|
11
|
+
let discoveryExpiresAt = 0;
|
|
12
|
+
let discoveryPromise;
|
|
13
|
+
|
|
14
|
+
function readString(buffer, offset) {
|
|
15
|
+
const prefix = buffer[offset];
|
|
16
|
+
if (prefix >= 0xa0 && prefix <= 0xbf) {
|
|
17
|
+
const length = prefix & 0x1f;
|
|
18
|
+
return buffer.subarray(offset + 1, offset + 1 + length).toString("utf8");
|
|
19
|
+
}
|
|
20
|
+
if (prefix === 0xd9) {
|
|
21
|
+
const length = buffer[offset + 1];
|
|
22
|
+
return buffer.subarray(offset + 2, offset + 2 + length).toString("utf8");
|
|
23
|
+
}
|
|
24
|
+
if (prefix === 0xda) {
|
|
25
|
+
const length = buffer.readUInt16BE(offset + 1);
|
|
26
|
+
return buffer.subarray(offset + 3, offset + 3 + length).toString("utf8");
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readField(buffer, key) {
|
|
32
|
+
const keyOffset = buffer.indexOf(key);
|
|
33
|
+
if (keyOffset === -1) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return readString(buffer, keyOffset + key.length);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function readRojoSessionInfo(buffer) {
|
|
40
|
+
const projectName = readField(buffer, PROJECT_NAME_KEY);
|
|
41
|
+
const sessionId = readField(buffer, SESSION_ID_KEY);
|
|
42
|
+
return projectName && sessionId ? { projectName, sessionId } : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readRojoProjectName(buffer) {
|
|
46
|
+
return readField(buffer, PROJECT_NAME_KEY);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseRojoServePorts(commandLines) {
|
|
50
|
+
const ports = new Set();
|
|
51
|
+
for (const line of commandLines.split(/\r?\n/)) {
|
|
52
|
+
if (!/rojo(?:\.exe)?(?:"|\s)/i.test(line) || !/\bserve\b/i.test(line)) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
for (const match of line.matchAll(/(?:^|\s)--port(?:=|\s+)["']?(\d{1,5})/gi)) {
|
|
56
|
+
const port = Number(match[1]);
|
|
57
|
+
if (port >= 1 && port <= 65535) {
|
|
58
|
+
ports.add(port);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return [...ports];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readRojoProcessCommands() {
|
|
66
|
+
if (process.platform === "win32") {
|
|
67
|
+
const { stdout } = await executeFile(
|
|
68
|
+
"powershell.exe",
|
|
69
|
+
[
|
|
70
|
+
"-NoProfile",
|
|
71
|
+
"-NonInteractive",
|
|
72
|
+
"-Command",
|
|
73
|
+
"Get-CimInstance Win32_Process -Filter \"Name = 'rojo.exe'\" | Select-Object -ExpandProperty CommandLine",
|
|
74
|
+
],
|
|
75
|
+
{ timeout: 2000, windowsHide: true },
|
|
76
|
+
);
|
|
77
|
+
return stdout;
|
|
78
|
+
}
|
|
79
|
+
const { stdout } = await executeFile("ps", ["-ax", "-o", "command="], { timeout: 2000 });
|
|
80
|
+
return stdout;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function discoverRojoServePorts() {
|
|
84
|
+
if (Date.now() < discoveryExpiresAt) {
|
|
85
|
+
return discoveredPorts;
|
|
86
|
+
}
|
|
87
|
+
if (!discoveryPromise) {
|
|
88
|
+
discoveryPromise = readRojoProcessCommands()
|
|
89
|
+
.then(parseRojoServePorts)
|
|
90
|
+
.catch(() => [])
|
|
91
|
+
.then((ports) => {
|
|
92
|
+
discoveredPorts = ports;
|
|
93
|
+
discoveryExpiresAt = Date.now() + DISCOVERY_CACHE_MS;
|
|
94
|
+
return ports;
|
|
95
|
+
})
|
|
96
|
+
.finally(() => {
|
|
97
|
+
discoveryPromise = undefined;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return discoveryPromise;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function readRojoResponse(rojo) {
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
const rojoRequest = request(
|
|
106
|
+
{ host: rojo.host, port: rojo.port, path: "/api/rojo", method: "GET" },
|
|
107
|
+
(response) => {
|
|
108
|
+
const chunks = [];
|
|
109
|
+
response.on("data", (chunk) => chunks.push(chunk));
|
|
110
|
+
response.on("end", () => {
|
|
111
|
+
resolve(response.statusCode >= 200 && response.statusCode < 300 ? Buffer.concat(chunks) : null);
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
rojoRequest.setTimeout(1000, () => rojoRequest.destroy(new Error("Rojo session request timed out")));
|
|
116
|
+
rojoRequest.once("error", reject);
|
|
117
|
+
rojoRequest.end();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function getRojoProjectName(rojo, fetchImplementation) {
|
|
122
|
+
if (fetchImplementation) {
|
|
123
|
+
const response = await fetchImplementation(`http://${rojo.host}:${rojo.port}/api/rojo`);
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
return readRojoProjectName(Buffer.from(await response.arrayBuffer()));
|
|
128
|
+
}
|
|
129
|
+
const response = await readRojoResponse(rojo);
|
|
130
|
+
return response ? readRojoProjectName(response) : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function getRojoSessionInfo(rojo, fetchImplementation) {
|
|
134
|
+
if (fetchImplementation) {
|
|
135
|
+
const response = await fetchImplementation(`http://${rojo.host}:${rojo.port}/api/rojo`);
|
|
136
|
+
if (!response.ok) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
return readRojoSessionInfo(Buffer.from(await response.arrayBuffer()));
|
|
140
|
+
}
|
|
141
|
+
const response = await readRojoResponse(rojo);
|
|
142
|
+
return response ? readRojoSessionInfo(response) : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function findRojoSession(rojo, options = {}) {
|
|
146
|
+
const dynamicPorts = options.ports ?? (await discoverRojoServePorts());
|
|
147
|
+
const ports = [...new Set([...dynamicPorts, rojo.port])];
|
|
148
|
+
const matches = (
|
|
149
|
+
await Promise.all(
|
|
150
|
+
ports.map(async (port) => {
|
|
151
|
+
try {
|
|
152
|
+
const session = await getRojoSessionInfo({ ...rojo, port }, options.fetchImplementation);
|
|
153
|
+
return session?.projectName === rojo.projectName ? { port, sessionId: session.sessionId } : null;
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}),
|
|
158
|
+
)
|
|
159
|
+
).filter((port) => port !== null);
|
|
160
|
+
|
|
161
|
+
if (matches.length > 1) {
|
|
162
|
+
return { ambiguous: true, ports: matches.map((match) => match.port) };
|
|
163
|
+
}
|
|
164
|
+
return matches.length === 1 ? { ambiguous: false, ...matches[0] } : null;
|
|
165
|
+
}
|