@el4cteo/rbx-studio-mcp 0.8.4 → 0.8.6
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 +20 -3
- package/dist/bridge/rpc.js +1 -1
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/apidump.js +75 -0
- package/dist/lib/apidump.js.map +1 -1
- package/dist/lib/errors.js +5 -4
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/format.js +92 -25
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/notices.js +29 -0
- package/dist/lib/notices.js.map +1 -0
- package/dist/lib/protocol.js.map +1 -1
- package/dist/lib/sync.js +1141 -0
- package/dist/lib/sync.js.map +1 -0
- package/dist/lib/syncplan.js +338 -0
- package/dist/lib/syncplan.js.map +1 -0
- package/dist/lib/tool.js +9 -2
- package/dist/lib/tool.js.map +1 -1
- package/dist/tools/discover.js +6 -1
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +5 -0
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/instances.js +2 -2
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/perf.js +28 -3
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/screenshot.js +7 -3
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/scripts.js +110 -28
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/sync.js +169 -0
- package/dist/tools/sync.js.map +1 -0
- package/package.json +4 -4
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Dispatch.luau +131 -90
- package/plugin/src/ExecRuntime.luau +190 -168
- package/plugin/src/LogBuffer.luau +38 -9
- package/plugin/src/Paths.luau +42 -0
- package/plugin/src/Phrase.luau +41 -0
- package/plugin/src/ScriptEdit.luau +94 -8
- package/plugin/src/Serialize.luau +16 -1
- package/plugin/src/Transport.luau +5 -2
- package/plugin/src/Undo.luau +74 -10
- package/plugin/src/handlers/Capture.luau +818 -809
- package/plugin/src/handlers/Debug.luau +19 -16
- package/plugin/src/handlers/Discover.luau +31 -1
- package/plugin/src/handlers/Perf.luau +100 -21
- package/plugin/src/handlers/Scripts.luau +274 -90
- package/plugin/src/handlers/Sync.luau +968 -0
- package/plugin/src/init.server.luau +33 -2
- package/scripts/build.mjs +14 -0
- package/scripts/sync-fake.mjs +191 -0
- package/scripts/test-live-sync-scale.mjs +150 -0
- package/scripts/test-live-sync.mjs +232 -0
- package/scripts/test-live-tools.mjs +6 -1
- package/scripts/test-plugin.mjs +16 -0
- package/scripts/test-results.mjs +41 -0
- package/scripts/test-sync-more.mjs +228 -0
- package/scripts/test-sync.mjs +245 -0
- package/dist/tools/spatial.js +0 -135
- package/dist/tools/spatial.js.map +0 -1
- package/dist/tools/upload.js +0 -294
- package/dist/tools/upload.js.map +0 -1
package/dist/lib/sync.js
ADDED
|
@@ -0,0 +1,1141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sync`: scripts mirrored between Studio and a folder on disk.
|
|
3
|
+
*
|
|
4
|
+
* The point is working the way an agent works best. Files on disk can be read
|
|
5
|
+
* a window at a time, searched with real tools, edited in place and diffed,
|
|
6
|
+
* all without a round trip to Studio per step; Studio is still where the game
|
|
7
|
+
* runs and where the result is checked. This module moves text between the two
|
|
8
|
+
* safely. `syncplan.ts` decides what should move; this does the moving.
|
|
9
|
+
*
|
|
10
|
+
* Safety is the design constraint throughout:
|
|
11
|
+
*
|
|
12
|
+
* - Every write into Studio is conditional on the revision the last sync saw,
|
|
13
|
+
* checked inside the editor's own write callback. A script the user touched
|
|
14
|
+
* since is never overwritten; it becomes a conflict.
|
|
15
|
+
* - Every write to disk is checked against the file's last hash the same way.
|
|
16
|
+
* - Nothing is deleted on the first sync, deletions on disk go to a trash
|
|
17
|
+
* folder rather than away, deletions in Studio are one Ctrl+Z, and a run
|
|
18
|
+
* that would delete most of what it manages stops and asks.
|
|
19
|
+
* - A folder remembers which place it belongs to, so pointing the wrong place
|
|
20
|
+
* at it is refused instead of "syncing" one game over another.
|
|
21
|
+
*/
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import { watch as watchFolder } from "node:fs";
|
|
24
|
+
import { mkdir, readFile, readdir, rename, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { parseCreateTree, typeCreateSpec } from "../tools/instances.js";
|
|
27
|
+
import { buildableProperties } from "./apidump.js";
|
|
28
|
+
import { ToolError } from "./errors.js";
|
|
29
|
+
import { pushNotice } from "./notices.js";
|
|
30
|
+
import { classFromFile, decodeName, emptyManifest, encodeName, layout, normalise, placementOf, plan, } from "./syncplan.js";
|
|
31
|
+
const STATE_DIR = ".rbx-sync";
|
|
32
|
+
const MANIFEST = "manifest.json";
|
|
33
|
+
const BUILD_SUFFIX = ".build.json";
|
|
34
|
+
/** Past this many deletions, and past half of what is tracked, a run stops and asks. */
|
|
35
|
+
const MASS_DELETE = 10;
|
|
36
|
+
/** Studio sources per `sync.read` / writes per `sync.apply`, by size. */
|
|
37
|
+
const BATCH_BYTES = 1_500_000;
|
|
38
|
+
const BATCH_PATHS = 200;
|
|
39
|
+
export const hashOf = (text) => createHash("sha256").update(normalise(text), "utf8").digest("hex");
|
|
40
|
+
const posix = (relative) => relative.split(path.sep).join("/");
|
|
41
|
+
const absolute = (dir, relative) => path.join(dir, ...relative.split("/"));
|
|
42
|
+
// Target ---------------------------------------------------------------------
|
|
43
|
+
/**
|
|
44
|
+
* The Studio session a sync runs against: named, or the active one, or the
|
|
45
|
+
* only one. Always an edit session -- a playtest's DataModel is thrown away on
|
|
46
|
+
* Stop, so syncing into it would look like it worked and then vanish.
|
|
47
|
+
*/
|
|
48
|
+
export async function targetSession(bridge, studioId) {
|
|
49
|
+
const { list, activeId } = await bridge.sessions();
|
|
50
|
+
const session = studioId !== undefined
|
|
51
|
+
? list.find((entry) => entry.studioId === studioId)
|
|
52
|
+
: activeId !== null
|
|
53
|
+
? list.find((entry) => entry.studioId === activeId)
|
|
54
|
+
: list.filter((entry) => !entry.context?.startsWith("playtest")).length === 1
|
|
55
|
+
? list.find((entry) => !entry.context?.startsWith("playtest"))
|
|
56
|
+
: undefined;
|
|
57
|
+
if (session === undefined) {
|
|
58
|
+
if (list.length === 0)
|
|
59
|
+
throw new ToolError("NO_STUDIO", "No Studio is connected.", "Open Studio with the plugin enabled.");
|
|
60
|
+
throw new ToolError("AMBIGUOUS_STUDIO", "Several Studio windows are connected and none is chosen.", "Pass `studioId` (see list_studios) or call set_active_studio.");
|
|
61
|
+
}
|
|
62
|
+
if (session.context?.startsWith("playtest")) {
|
|
63
|
+
throw new ToolError("WRONG_CONTEXT", "That is a running playtest; anything synced into it is discarded on Stop.", "Pass the edit session's studioId (see list_studios).");
|
|
64
|
+
}
|
|
65
|
+
return session;
|
|
66
|
+
}
|
|
67
|
+
// Disk -----------------------------------------------------------------------
|
|
68
|
+
/** Hashes by path, reused while a file's size and mtime have not moved. */
|
|
69
|
+
const hashCache = new Map();
|
|
70
|
+
/**
|
|
71
|
+
* Every script and build file under `dir`. Dot-folders (`.git`, the sync's
|
|
72
|
+
* own `.rbx-sync`) and `node_modules` are skipped; so are symlinks, which could
|
|
73
|
+
* lead a sync to write outside the folder it was given.
|
|
74
|
+
*/
|
|
75
|
+
async function scanDisk(dir) {
|
|
76
|
+
const state = { scripts: new Map(), builds: new Map() };
|
|
77
|
+
const walk = async (folder) => {
|
|
78
|
+
let entries;
|
|
79
|
+
try {
|
|
80
|
+
entries = await readdir(folder, { withFileTypes: true });
|
|
81
|
+
}
|
|
82
|
+
catch (cause) {
|
|
83
|
+
if (cause.code === "ENOENT")
|
|
84
|
+
return;
|
|
85
|
+
throw cause;
|
|
86
|
+
}
|
|
87
|
+
await Promise.all(entries.map(async (entry) => {
|
|
88
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.isSymbolicLink())
|
|
89
|
+
return;
|
|
90
|
+
const full = path.join(folder, entry.name);
|
|
91
|
+
if (entry.isDirectory())
|
|
92
|
+
return walk(full);
|
|
93
|
+
if (!entry.isFile())
|
|
94
|
+
return;
|
|
95
|
+
const isBuild = entry.name.endsWith(BUILD_SUFFIX);
|
|
96
|
+
if (!isBuild && classFromFile(entry.name) === null)
|
|
97
|
+
return;
|
|
98
|
+
const info = await stat(full);
|
|
99
|
+
const cached = hashCache.get(full);
|
|
100
|
+
let hash = cached?.hash;
|
|
101
|
+
if (!cached || cached.size !== info.size || cached.mtimeMs !== info.mtimeMs) {
|
|
102
|
+
hash = hashOf(await readFile(full, "utf8"));
|
|
103
|
+
hashCache.set(full, { size: info.size, mtimeMs: info.mtimeMs, hash });
|
|
104
|
+
}
|
|
105
|
+
(isBuild ? state.builds : state.scripts).set(posix(path.relative(dir, full)), hash);
|
|
106
|
+
}));
|
|
107
|
+
};
|
|
108
|
+
await walk(dir);
|
|
109
|
+
return state;
|
|
110
|
+
}
|
|
111
|
+
async function readText(dir, relative) {
|
|
112
|
+
return normalise(await readFile(absolute(dir, relative), "utf8"));
|
|
113
|
+
}
|
|
114
|
+
/** Writes through a staging file, so a crash never leaves half a script. */
|
|
115
|
+
async function writeText(dir, relative, text) {
|
|
116
|
+
const target = absolute(dir, relative);
|
|
117
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
118
|
+
const staging = `${target}.${process.pid}.tmp`;
|
|
119
|
+
await writeFile(staging, text, "utf8");
|
|
120
|
+
await rename(staging, target);
|
|
121
|
+
hashCache.delete(target);
|
|
122
|
+
}
|
|
123
|
+
/** Moves a file into `.rbx-sync/trash/<run>/`, keeping its relative path. */
|
|
124
|
+
async function trash(dir, relative, run) {
|
|
125
|
+
const from = absolute(dir, relative);
|
|
126
|
+
const to = path.join(dir, STATE_DIR, "trash", run, ...relative.split("/"));
|
|
127
|
+
await mkdir(path.dirname(to), { recursive: true });
|
|
128
|
+
await rename(from, to);
|
|
129
|
+
hashCache.delete(from);
|
|
130
|
+
}
|
|
131
|
+
async function unlinkQuiet(target) {
|
|
132
|
+
await unlink(target).catch(() => undefined);
|
|
133
|
+
}
|
|
134
|
+
/** Removes folders a move or delete left empty, up to (never including) `dir`. */
|
|
135
|
+
async function pruneEmpty(dir, relatives) {
|
|
136
|
+
const folders = new Set();
|
|
137
|
+
for (const relative of relatives) {
|
|
138
|
+
let folder = path.dirname(absolute(dir, relative));
|
|
139
|
+
while (folder.startsWith(dir) && folder !== dir) {
|
|
140
|
+
folders.add(folder);
|
|
141
|
+
folder = path.dirname(folder);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const folder of [...folders].sort((a, b) => b.length - a.length)) {
|
|
145
|
+
try {
|
|
146
|
+
if ((await readdir(folder)).length === 0)
|
|
147
|
+
await rmdir(folder);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// Gone already, or not empty after all: either way nothing to prune.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async function loadManifest(dir) {
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(await readFile(path.join(dir, STATE_DIR, MANIFEST), "utf8"));
|
|
157
|
+
if (parsed.version !== 1 || typeof parsed.files !== "object")
|
|
158
|
+
return null;
|
|
159
|
+
parsed.builds ??= {};
|
|
160
|
+
return parsed;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function saveManifest(dir, manifest) {
|
|
167
|
+
const folder = path.join(dir, STATE_DIR);
|
|
168
|
+
await mkdir(folder, { recursive: true });
|
|
169
|
+
// The manifest records this machine's view of one Studio session; committed,
|
|
170
|
+
// it would be wrong on every other machine. Kept out of git by default.
|
|
171
|
+
await writeFile(path.join(folder, ".gitignore"), "*\n", "utf8");
|
|
172
|
+
const target = path.join(folder, MANIFEST);
|
|
173
|
+
await writeFile(`${target}.tmp`, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
174
|
+
await rename(`${target}.tmp`, target);
|
|
175
|
+
}
|
|
176
|
+
async function readStudio(bridge, studioId, paths, quiet) {
|
|
177
|
+
const out = new Map();
|
|
178
|
+
for (let start = 0; start < paths.length; start += BATCH_PATHS) {
|
|
179
|
+
const { items } = await bridge.call("sync.read", { paths: paths.slice(start, start + BATCH_PATHS), quiet }, { studioId, timeoutMs: 60_000 });
|
|
180
|
+
for (const item of items)
|
|
181
|
+
out.set(item.path, item);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
// The run ---------------------------------------------------------------------
|
|
186
|
+
/** One run at a time per folder, so `watch` and a manual call never interleave. */
|
|
187
|
+
const locks = new Map();
|
|
188
|
+
function exclusive(dir, body) {
|
|
189
|
+
const previous = locks.get(dir) ?? Promise.resolve();
|
|
190
|
+
const next = previous.catch(() => undefined).then(body);
|
|
191
|
+
locks.set(dir, next);
|
|
192
|
+
return next;
|
|
193
|
+
}
|
|
194
|
+
export function resolveDir(dir) {
|
|
195
|
+
return path.resolve(process.cwd(), dir ?? "studio");
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* One sync: scan both sides, plan, read what the plan needs, and apply it --
|
|
199
|
+
* or, with `dryRun`, only say what would happen.
|
|
200
|
+
*/
|
|
201
|
+
export function runSync(bridge, options) {
|
|
202
|
+
return exclusive(options.dir, () => syncOnce(bridge, options));
|
|
203
|
+
}
|
|
204
|
+
async function syncOnce(bridge, options) {
|
|
205
|
+
const { dir, direction, prefer } = options;
|
|
206
|
+
const session = await targetSession(bridge, options.studioId);
|
|
207
|
+
const studioId = session.studioId;
|
|
208
|
+
const stored = await loadManifest(dir);
|
|
209
|
+
const manifest = stored ?? emptyManifest();
|
|
210
|
+
assertPlace(options, manifest, session);
|
|
211
|
+
const roots = options.roots ?? manifest.roots;
|
|
212
|
+
const quiet = options.quiet;
|
|
213
|
+
const scan = await bridge.call("sync.scan", { roots, revisions: true, quiet }, { studioId, timeoutMs: 120_000 });
|
|
214
|
+
const studio = layout(scan);
|
|
215
|
+
const disk = await scanDisk(dir);
|
|
216
|
+
const inScope = (studioPath) => scan.roots.some((root) => studioPath === root || studioPath.startsWith(`${root}.`));
|
|
217
|
+
// Files outside the roots are left alone, the same as scripts are: with
|
|
218
|
+
// roots [ServerScriptService], a ReplicatedStorage/ file is not new work.
|
|
219
|
+
const rootDirs = scan.roots.map((root) => studio.dirOf.get(root) ?? root.split(".").map((name) => encodeName(name, "dir")).join("/"));
|
|
220
|
+
const underRoots = (file) => rootDirs.some((root) => file.startsWith(`${root}/`));
|
|
221
|
+
for (const table of [disk.scripts, disk.builds]) {
|
|
222
|
+
for (const file of [...table.keys()])
|
|
223
|
+
if (!underRoots(file))
|
|
224
|
+
table.delete(file);
|
|
225
|
+
}
|
|
226
|
+
const { actions, held } = plan({ manifest, studio, disk: disk.scripts, inScope, direction, prefer });
|
|
227
|
+
const report = {
|
|
228
|
+
dir,
|
|
229
|
+
place: session.placeName,
|
|
230
|
+
dryRun: options.dryRun === true,
|
|
231
|
+
lines: [],
|
|
232
|
+
counts: {},
|
|
233
|
+
conflicts: [],
|
|
234
|
+
held: held.map(describe),
|
|
235
|
+
failures: [],
|
|
236
|
+
fileSynced: studio.fileSynced,
|
|
237
|
+
builds: [],
|
|
238
|
+
};
|
|
239
|
+
// A run that would delete most of what it tracks is far more likely to be a
|
|
240
|
+
// wrong `dir` or `roots` than a real intent. Stop and say so.
|
|
241
|
+
const deletions = actions.filter((action) => action.kind === "delete-disk" || action.kind === "delete-studio").length;
|
|
242
|
+
const tracked = Object.keys(manifest.files).length;
|
|
243
|
+
if (deletions > MASS_DELETE && deletions * 2 > tracked && !options.confirmDeletes && !options.dryRun) {
|
|
244
|
+
throw new ToolError("MASS_DELETE", `This sync would delete ${deletions} of the ${tracked} scripts it tracks.`, "Check `dir` and `roots` with op=\"status\". If the deletions are intended, pass `confirmDeletes: true`.");
|
|
245
|
+
}
|
|
246
|
+
// Content ----------------------------------------------------------------
|
|
247
|
+
const moveSource = new Map();
|
|
248
|
+
for (const action of [...actions, ...held]) {
|
|
249
|
+
if (action.kind === "move-disk")
|
|
250
|
+
moveSource.set(action.file, action.fromFile);
|
|
251
|
+
}
|
|
252
|
+
const diskText = new Map();
|
|
253
|
+
const readDisk = async (file) => {
|
|
254
|
+
if (!diskText.has(file))
|
|
255
|
+
diskText.set(file, await readText(dir, moveSource.get(file) ?? file));
|
|
256
|
+
return diskText.get(file);
|
|
257
|
+
};
|
|
258
|
+
const needsStudio = actions
|
|
259
|
+
.filter((action) => ["pull", "create-disk", "merge", "adopt"].includes(action.kind))
|
|
260
|
+
.map((action) => action.path);
|
|
261
|
+
const studioText = await readStudio(bridge, studioId, [...new Set(needsStudio)], quiet);
|
|
262
|
+
const remembered = manifest.conflicts ?? {};
|
|
263
|
+
const conflictsNow = {};
|
|
264
|
+
// For pulls settled here: the file text the decision saw, which the write
|
|
265
|
+
// may replace. Anything else on disk by then is a newer edit.
|
|
266
|
+
const diskSeen = new Map();
|
|
267
|
+
// Merges and adoptions settle into ordinary actions once both texts are known.
|
|
268
|
+
const settled = [];
|
|
269
|
+
const recorded = [];
|
|
270
|
+
for (const action of actions) {
|
|
271
|
+
if (action.kind !== "merge" && action.kind !== "adopt") {
|
|
272
|
+
settled.push(action);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const read = studioText.get(action.path);
|
|
276
|
+
if (read?.source === undefined) {
|
|
277
|
+
report.failures.push({ file: action.file, reason: read?.message ?? "could not be read from Studio" });
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const onDisk = await readDisk(action.file);
|
|
281
|
+
const inStudio = normalise(read.source);
|
|
282
|
+
// A conflict reported before: whichever side moved since is the answer.
|
|
283
|
+
const memory = remembered[action.file];
|
|
284
|
+
const studioMoved = memory !== undefined && read.revision !== memory.studio;
|
|
285
|
+
const diskMoved = memory !== undefined && hashOf(onDisk) !== memory.disk;
|
|
286
|
+
if (onDisk === inStudio) {
|
|
287
|
+
recorded.push({ file: action.file, path: action.path, revision: read.revision, className: action.className, text: onDisk });
|
|
288
|
+
}
|
|
289
|
+
else if (memory && diskMoved && !studioMoved && direction !== "pull") {
|
|
290
|
+
settled.push({ kind: "push", file: action.file, path: action.path, revision: read.revision, className: action.className });
|
|
291
|
+
}
|
|
292
|
+
else if (memory && studioMoved && !diskMoved && direction !== "push") {
|
|
293
|
+
diskSeen.set(action.file, hashOf(onDisk));
|
|
294
|
+
settled.push({ kind: "pull", file: action.file, path: action.path, revision: read.revision, className: action.className });
|
|
295
|
+
}
|
|
296
|
+
else if (prefer === "studio" && direction !== "push") {
|
|
297
|
+
diskSeen.set(action.file, hashOf(onDisk));
|
|
298
|
+
settled.push({ kind: "pull", file: action.file, path: action.path, revision: read.revision, className: action.className });
|
|
299
|
+
}
|
|
300
|
+
else if (prefer === "disk" && direction !== "pull") {
|
|
301
|
+
settled.push({ kind: "push", file: action.file, path: action.path, revision: read.revision, className: action.className });
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
conflictsNow[action.file] = { studio: read.revision, disk: hashOf(onDisk), text: inStudio };
|
|
305
|
+
settled.push({
|
|
306
|
+
kind: "conflict",
|
|
307
|
+
file: action.file,
|
|
308
|
+
path: action.path,
|
|
309
|
+
reason: (action.kind === "adopt"
|
|
310
|
+
? "exists in both places with different text, and sync has no record of either"
|
|
311
|
+
: "changed in both Studio and on disk since the last sync") +
|
|
312
|
+
`. Studio's version: ${STATE_DIR}/conflicts/${action.file}. Edit the file to the merged text (or fix it in Studio) and sync again.`,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
for (const action of settled) {
|
|
317
|
+
if (action.kind === "conflict")
|
|
318
|
+
report.conflicts.push({ file: action.file, path: action.path, reason: action.reason });
|
|
319
|
+
}
|
|
320
|
+
const work = settled.filter((action) => action.kind !== "conflict");
|
|
321
|
+
if (options.dryRun) {
|
|
322
|
+
for (const action of work)
|
|
323
|
+
tally(report, action);
|
|
324
|
+
if (options.builds !== false)
|
|
325
|
+
await syncBuilds(bridge, studioId, manifest, disk, studio, report, options);
|
|
326
|
+
return report;
|
|
327
|
+
}
|
|
328
|
+
const run = new Date().toISOString().replace(/[:.]/g, "-");
|
|
329
|
+
const touched = [];
|
|
330
|
+
// 1. Files renamed on disk to follow Studio, first: later steps read them there.
|
|
331
|
+
for (const action of work) {
|
|
332
|
+
if (action.kind !== "move-disk")
|
|
333
|
+
continue;
|
|
334
|
+
try {
|
|
335
|
+
// Checked on disk, not against the plan: on Windows and macOS `Util.luau`
|
|
336
|
+
// is also `UTIL.luau`, which the plan may be about to trash.
|
|
337
|
+
if (await stat(absolute(dir, action.file)).then(() => true, () => false)) {
|
|
338
|
+
report.failures.push({ file: action.file, reason: `a file is already there, so ${action.fromFile} was not moved; sync again once it is gone` });
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
await mkdir(path.dirname(absolute(dir, action.file)), { recursive: true });
|
|
342
|
+
await rename(absolute(dir, action.fromFile), absolute(dir, action.file));
|
|
343
|
+
hashCache.delete(absolute(dir, action.fromFile));
|
|
344
|
+
moveSource.delete(action.file);
|
|
345
|
+
delete manifest.files[action.fromFile];
|
|
346
|
+
manifest.files[action.file] = { path: action.path, className: action.className, revision: action.revision, hash: action.hash };
|
|
347
|
+
touched.push(action.fromFile);
|
|
348
|
+
tally(report, action);
|
|
349
|
+
}
|
|
350
|
+
catch (cause) {
|
|
351
|
+
report.failures.push({ file: action.file, reason: `could not move from ${action.fromFile}: ${String(cause)}` });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// 2. Studio: structure and text in batches, each item checked against its revision.
|
|
355
|
+
const placements = new Map();
|
|
356
|
+
const initClassOf = (folder) => {
|
|
357
|
+
for (const [file] of disk.scripts) {
|
|
358
|
+
if (!file.startsWith(`${folder}/`) || file.slice(folder.length + 1).includes("/"))
|
|
359
|
+
continue;
|
|
360
|
+
const parsed = classFromFile(file.slice(folder.length + 1));
|
|
361
|
+
if (parsed?.init)
|
|
362
|
+
return parsed.className;
|
|
363
|
+
}
|
|
364
|
+
return undefined;
|
|
365
|
+
};
|
|
366
|
+
const placement = (file) => {
|
|
367
|
+
if (!placements.has(file))
|
|
368
|
+
placements.set(file, placementOf(file, studio, initClassOf));
|
|
369
|
+
return placements.get(file);
|
|
370
|
+
};
|
|
371
|
+
const creates = [];
|
|
372
|
+
const moves = [];
|
|
373
|
+
const deletes = [];
|
|
374
|
+
const writes = [];
|
|
375
|
+
for (const action of work) {
|
|
376
|
+
if (action.kind === "create-studio" || action.kind === "move-studio") {
|
|
377
|
+
const where = placement(action.file);
|
|
378
|
+
if ("error" in where) {
|
|
379
|
+
report.failures.push({ file: action.file, reason: where.error });
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const target = { parentPath: where.parentPath, parents: where.parents, name: where.name };
|
|
383
|
+
if (action.kind === "create-studio") {
|
|
384
|
+
const text = await readDisk(action.file);
|
|
385
|
+
creates.push({ action, item: { ...target, className: where.className, source: text }, text });
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
moves.push({ action, item: { ...target, path: action.path, revision: action.revision } });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
else if (action.kind === "delete-studio") {
|
|
392
|
+
deletes.push({ action, item: { path: action.path, revision: action.revision } });
|
|
393
|
+
}
|
|
394
|
+
else if (action.kind === "push") {
|
|
395
|
+
const text = await readDisk(action.file);
|
|
396
|
+
writes.push({ action, item: { path: action.path, source: text, revision: action.revision }, text });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// Outer scripts before the ones inside them, so a new folder's init script
|
|
400
|
+
// exists before its children look for their parent.
|
|
401
|
+
creates.sort((a, b) => depthOf(a.action.file) - depthOf(b.action.file) || initFirst(a.action.file, b.action.file));
|
|
402
|
+
if (creates.length + moves.length + deletes.length > 0) {
|
|
403
|
+
const response = await bridge.call("sync.apply", { creates: creates.map((entry) => entry.item), moves: moves.map((entry) => entry.item), deletes: deletes.map((entry) => entry.item), quiet }, { studioId, timeoutMs: 120_000 });
|
|
404
|
+
report.undoStep = response.undoStep;
|
|
405
|
+
response.creates.forEach((result, index) => {
|
|
406
|
+
const { action, text } = creates[index];
|
|
407
|
+
if (!result.ok || !result.path)
|
|
408
|
+
return report.failures.push({ file: action.file, reason: `${result.code}: ${result.message}` });
|
|
409
|
+
const where = placement(action.file);
|
|
410
|
+
manifest.files[action.file] = { path: result.path, className: where.className, revision: result.revision ?? "", hash: hashOf(text) };
|
|
411
|
+
tally(report, action);
|
|
412
|
+
});
|
|
413
|
+
response.moves.forEach((result, index) => {
|
|
414
|
+
const { action } = moves[index];
|
|
415
|
+
if (!result.ok || !result.path)
|
|
416
|
+
return report.failures.push({ file: action.file, reason: `${result.code}: ${result.message}` });
|
|
417
|
+
delete manifest.files[action.fromFile];
|
|
418
|
+
manifest.files[action.file] = { path: result.path, className: action.className, revision: action.revision, hash: action.hash };
|
|
419
|
+
tally(report, action);
|
|
420
|
+
});
|
|
421
|
+
response.deletes.forEach((result, index) => {
|
|
422
|
+
const { action } = deletes[index];
|
|
423
|
+
if (!result.ok)
|
|
424
|
+
return report.failures.push({ file: action.file, reason: `${result.code}: ${result.message}` });
|
|
425
|
+
delete manifest.files[action.file];
|
|
426
|
+
tally(report, action);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
for (const batch of batches(writes, (entry) => entry.text.length)) {
|
|
430
|
+
const response = await bridge.call("sync.apply", { writes: batch.map((entry) => entry.item), quiet }, { studioId, timeoutMs: 120_000 });
|
|
431
|
+
response.writes.forEach((result, index) => {
|
|
432
|
+
const { action, text } = batch[index];
|
|
433
|
+
if (!result.ok) {
|
|
434
|
+
const conflict = result.code === "STALE_SCRIPT";
|
|
435
|
+
if (conflict)
|
|
436
|
+
report.conflicts.push({ file: action.file, path: action.path, reason: "changed in Studio while syncing" });
|
|
437
|
+
else
|
|
438
|
+
report.failures.push({ file: action.file, reason: `${result.code}: ${result.message}` });
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
manifest.files[action.file] = { path: action.path, className: action.className, revision: result.revision ?? "", hash: hashOf(text) };
|
|
442
|
+
tally(report, action);
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
// 3. Disk: Studio's text into files, checked against the hash the plan saw.
|
|
446
|
+
for (const action of work) {
|
|
447
|
+
if (action.kind !== "pull" && action.kind !== "create-disk")
|
|
448
|
+
continue;
|
|
449
|
+
const read = studioText.get(action.path);
|
|
450
|
+
if (read?.source === undefined || read.revision === undefined) {
|
|
451
|
+
report.failures.push({ file: action.file, reason: read?.message ?? "could not be read from Studio" });
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
// Re-read now, not from the scan: an editor may have saved in between.
|
|
455
|
+
const expected = diskSeen.get(action.file) ?? (action.kind === "pull" ? manifest.files[action.file]?.hash : undefined);
|
|
456
|
+
const current = await readText(dir, action.file).then(hashOf, () => undefined);
|
|
457
|
+
const overwrites = action.kind === "create-disk" ? current !== undefined : current !== undefined && current !== expected;
|
|
458
|
+
if (overwrites && prefer !== "studio") {
|
|
459
|
+
report.conflicts.push({ file: action.file, path: action.path, reason: "changed on disk while syncing" });
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
await writeText(dir, action.file, read.source);
|
|
464
|
+
manifest.files[action.file] = { path: action.path, className: read.className ?? action.className, revision: read.revision, hash: hashOf(read.source) };
|
|
465
|
+
tally(report, action);
|
|
466
|
+
}
|
|
467
|
+
catch (cause) {
|
|
468
|
+
report.failures.push({ file: action.file, reason: String(cause) });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
for (const entry of recorded) {
|
|
472
|
+
manifest.files[entry.file] = { path: entry.path, className: entry.className, revision: entry.revision, hash: hashOf(entry.text) };
|
|
473
|
+
}
|
|
474
|
+
for (const action of work) {
|
|
475
|
+
if (action.kind === "delete-disk") {
|
|
476
|
+
try {
|
|
477
|
+
await trash(dir, action.file, run);
|
|
478
|
+
delete manifest.files[action.file];
|
|
479
|
+
touched.push(action.file);
|
|
480
|
+
tally(report, action);
|
|
481
|
+
}
|
|
482
|
+
catch (cause) {
|
|
483
|
+
report.failures.push({ file: action.file, reason: String(cause) });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
else if (action.kind === "forget") {
|
|
487
|
+
delete manifest.files[action.file];
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
await pruneEmpty(dir, touched);
|
|
491
|
+
// 4. Build files, both ways.
|
|
492
|
+
if (options.builds !== false) {
|
|
493
|
+
await syncBuilds(bridge, studioId, manifest, disk, studio, report, options);
|
|
494
|
+
}
|
|
495
|
+
// 5. Conflicts: remember this run's, with Studio's side beside them to merge
|
|
496
|
+
// from; forget the ones that were settled.
|
|
497
|
+
const newConflicts = Object.keys(conflictsNow).filter((file) => remembered[file] === undefined);
|
|
498
|
+
const nextMemory = {};
|
|
499
|
+
for (const [file, conflict] of Object.entries(conflictsNow)) {
|
|
500
|
+
nextMemory[file] = { studio: conflict.studio, disk: conflict.disk };
|
|
501
|
+
await writeText(path.join(dir, STATE_DIR), `conflicts/${file}`, conflict.text).catch(() => undefined);
|
|
502
|
+
}
|
|
503
|
+
for (const [file, memory] of Object.entries(report.buildConflicts ?? {})) {
|
|
504
|
+
nextMemory[file] = { studio: memory.studio, disk: memory.disk };
|
|
505
|
+
if (remembered[file] === undefined)
|
|
506
|
+
newConflicts.push(file);
|
|
507
|
+
await writeText(path.join(dir, STATE_DIR), `conflicts/${file}`, memory.text).catch(() => undefined);
|
|
508
|
+
}
|
|
509
|
+
// Build files outside this run keep their memory.
|
|
510
|
+
if (options.builds === false) {
|
|
511
|
+
for (const [file, memory] of Object.entries(remembered)) {
|
|
512
|
+
if (file.endsWith(BUILD_SUFFIX))
|
|
513
|
+
nextMemory[file] = memory;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
for (const file of Object.keys(remembered)) {
|
|
517
|
+
if (nextMemory[file] === undefined) {
|
|
518
|
+
await unlinkQuiet(path.join(dir, STATE_DIR, "conflicts", ...file.split("/")));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
manifest.conflicts = Object.keys(nextMemory).length > 0 ? nextMemory : undefined;
|
|
522
|
+
manifest.placeId = session.placeId;
|
|
523
|
+
manifest.placeName = session.placeName;
|
|
524
|
+
manifest.roots = options.roots ?? manifest.roots;
|
|
525
|
+
await saveManifest(dir, manifest);
|
|
526
|
+
const diskSide = ["pull", "create-disk", "delete-disk", "move-disk"]
|
|
527
|
+
.map((kind) => [kind, report.counts[kind] ?? 0])
|
|
528
|
+
.filter(([, count]) => count > 0);
|
|
529
|
+
if (diskSide.length > 0 || newConflicts.length > 0) {
|
|
530
|
+
const lines = [];
|
|
531
|
+
if (diskSide.length > 0) {
|
|
532
|
+
lines.push({ level: "ok", message: `sync to files: ${diskSide.map(([kind, count]) => `${count} ${LABELS[kind]}`).join(", ")}`, detail: path.basename(dir) });
|
|
533
|
+
}
|
|
534
|
+
// Once per conflict, not once per run: a watch would repeat it every second.
|
|
535
|
+
for (const conflict of report.conflicts.filter((entry) => newConflicts.includes(entry.file)).slice(0, 5)) {
|
|
536
|
+
lines.push({ level: "warn", message: `sync conflict: ${conflict.file}`, detail: "changed on both sides; see .rbx-sync/conflicts" });
|
|
537
|
+
}
|
|
538
|
+
if (lines.length > 0)
|
|
539
|
+
await bridge.call("sync.log", { lines }, { studioId, timeoutMs: 5_000 }).catch(() => undefined);
|
|
540
|
+
}
|
|
541
|
+
report.newConflicts = newConflicts;
|
|
542
|
+
return report;
|
|
543
|
+
}
|
|
544
|
+
const LABELS = {
|
|
545
|
+
push: "written to Studio",
|
|
546
|
+
pull: "written to disk",
|
|
547
|
+
"create-studio": "created in Studio",
|
|
548
|
+
"create-disk": "created on disk",
|
|
549
|
+
"delete-studio": "deleted in Studio",
|
|
550
|
+
"delete-disk": "moved to .rbx-sync/trash",
|
|
551
|
+
"move-studio": "moved in Studio",
|
|
552
|
+
"move-disk": "moved on disk",
|
|
553
|
+
export: "build files updated from Studio",
|
|
554
|
+
};
|
|
555
|
+
function describe(action) {
|
|
556
|
+
switch (action.kind) {
|
|
557
|
+
case "move-disk":
|
|
558
|
+
case "move-studio":
|
|
559
|
+
return `${action.kind.padEnd(13)} ${action.fromFile} -> ${action.file}`;
|
|
560
|
+
case "conflict":
|
|
561
|
+
return `conflict ${action.file}: ${action.reason}`;
|
|
562
|
+
default:
|
|
563
|
+
return `${action.kind.padEnd(13)} ${action.file}`;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
function tally(report, action) {
|
|
567
|
+
report.counts[action.kind] = (report.counts[action.kind] ?? 0) + 1;
|
|
568
|
+
report.lines.push(describe(action));
|
|
569
|
+
}
|
|
570
|
+
const depthOf = (file) => file.split("/").length;
|
|
571
|
+
const initFirst = (a, b) => Number(!classFromFile(a.split("/").pop())?.init) - Number(!classFromFile(b.split("/").pop())?.init);
|
|
572
|
+
function* batches(items, size) {
|
|
573
|
+
let batch = [];
|
|
574
|
+
let bytes = 0;
|
|
575
|
+
for (const item of items) {
|
|
576
|
+
const cost = size(item);
|
|
577
|
+
if (batch.length > 0 && (bytes + cost > BATCH_BYTES || batch.length >= BATCH_PATHS)) {
|
|
578
|
+
yield batch;
|
|
579
|
+
batch = [];
|
|
580
|
+
bytes = 0;
|
|
581
|
+
}
|
|
582
|
+
batch.push(item);
|
|
583
|
+
bytes += cost;
|
|
584
|
+
}
|
|
585
|
+
if (batch.length > 0)
|
|
586
|
+
yield batch;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Keys in a fixed order, so exporting an unchanged tree writes the same bytes
|
|
590
|
+
* and a diff shows only what really changed.
|
|
591
|
+
*/
|
|
592
|
+
function canonical(value) {
|
|
593
|
+
if (Array.isArray(value))
|
|
594
|
+
return value.map(canonical);
|
|
595
|
+
if (value === null || typeof value !== "object")
|
|
596
|
+
return value;
|
|
597
|
+
const order = ["className", "name", "properties", "attributes", "tags", "children"];
|
|
598
|
+
const entries = Object.entries(value).sort(([a], [b]) => {
|
|
599
|
+
const ai = order.indexOf(a);
|
|
600
|
+
const bi = order.indexOf(b);
|
|
601
|
+
if (ai !== -1 || bi !== -1)
|
|
602
|
+
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
|
603
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
604
|
+
});
|
|
605
|
+
return Object.fromEntries(entries.map(([key, item]) => [key, canonical(item)]));
|
|
606
|
+
}
|
|
607
|
+
/** Property lists per class, from the API dump; they do not change within a run. */
|
|
608
|
+
const propertyLists = new Map();
|
|
609
|
+
/**
|
|
610
|
+
* A tree as build-file text, exactly as `export` would write it -- or null when
|
|
611
|
+
* nothing is at that path any more. The same function feeds `export` and the
|
|
612
|
+
* change check, so "Studio changed" never means "the exporter changed".
|
|
613
|
+
*/
|
|
614
|
+
async function exportTree(bridge, studioId, target, quiet) {
|
|
615
|
+
let listed;
|
|
616
|
+
try {
|
|
617
|
+
listed = await bridge.call("sync.classes", { path: target, quiet }, { studioId, timeoutMs: 30_000 });
|
|
618
|
+
}
|
|
619
|
+
catch (cause) {
|
|
620
|
+
if (cause instanceof ToolError && cause.code === "NOT_FOUND")
|
|
621
|
+
return null;
|
|
622
|
+
throw cause;
|
|
623
|
+
}
|
|
624
|
+
const properties = {};
|
|
625
|
+
for (const className of listed.classes) {
|
|
626
|
+
if (!propertyLists.has(className)) {
|
|
627
|
+
propertyLists.set(className, (await buildableProperties(className)).map((info) => ({ name: info.name, type: info.valueType })));
|
|
628
|
+
}
|
|
629
|
+
properties[className] = propertyLists.get(className);
|
|
630
|
+
}
|
|
631
|
+
const exported = await bridge.call("sync.export", { path: listed.path, properties, quiet }, { studioId, timeoutMs: 120_000 });
|
|
632
|
+
return {
|
|
633
|
+
path: exported.path,
|
|
634
|
+
text: `${JSON.stringify(canonical(exported.spec), null, 2)}\n`,
|
|
635
|
+
chain: listed.chain,
|
|
636
|
+
instances: exported.instances,
|
|
637
|
+
scripts: exported.scripts,
|
|
638
|
+
skipped: exported.skipped,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
/** The studio path a build file's folder stands for, or null. */
|
|
642
|
+
function parentOfBuild(file, studio) {
|
|
643
|
+
const folder = file.split("/").slice(0, -1).join("/");
|
|
644
|
+
if (folder === "")
|
|
645
|
+
return null;
|
|
646
|
+
const known = studio.pathOfDir.get(folder);
|
|
647
|
+
if (known !== undefined)
|
|
648
|
+
return known;
|
|
649
|
+
// A bare service folder with no scripts in it yet.
|
|
650
|
+
return folder.includes("/") ? null : decodeName(folder);
|
|
651
|
+
}
|
|
652
|
+
/** Replaces the tree at `parent` from a build file; returns the new tree's export. */
|
|
653
|
+
async function buildFrom(bridge, studioId, dir, file, parent, quiet, replaces) {
|
|
654
|
+
let raw;
|
|
655
|
+
try {
|
|
656
|
+
raw = JSON.parse(await readText(dir, file));
|
|
657
|
+
}
|
|
658
|
+
catch (cause) {
|
|
659
|
+
throw new ToolError("BAD_BUILD_FILE", `${file} is not valid JSON: ${cause.message}`, "Fix the file and sync again.");
|
|
660
|
+
}
|
|
661
|
+
const [spec] = parseCreateTree([raw]);
|
|
662
|
+
const typed = await typeCreateSpec(spec, file);
|
|
663
|
+
const result = await bridge.call("sync.build", { parent, spec: typed, replaces, quiet }, { studioId, timeoutMs: 120_000 });
|
|
664
|
+
const built = result.path ?? `${parent}.${spec.name ?? spec.className}`;
|
|
665
|
+
return { path: built, exported: await exportTree(bridge, studioId, built, quiet), carried: result.carried };
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Build files, both ways, with the same three-way logic as scripts.
|
|
669
|
+
*
|
|
670
|
+
* The manifest holds the file's hash and Studio's export hash from the last
|
|
671
|
+
* sync. The file changed alone: rebuild the tree. Studio changed alone (someone
|
|
672
|
+
* moved a frame in the editor): write the new export into the file. Both
|
|
673
|
+
* changed: a conflict, with Studio's export beside it to merge from -- which is
|
|
674
|
+
* the fix for a build silently undoing work done in Studio.
|
|
675
|
+
*/
|
|
676
|
+
async function syncBuilds(bridge, studioId, manifest, disk, studio, report, options) {
|
|
677
|
+
const { dir, direction, prefer, dryRun, quiet } = options;
|
|
678
|
+
const remembered = manifest.conflicts ?? {};
|
|
679
|
+
const files = options.only ?? [...new Set([...Object.keys(manifest.builds), ...disk.builds.keys()])].sort();
|
|
680
|
+
const conflicts = {};
|
|
681
|
+
const run = new Date().toISOString().replace(/[:.]/g, "-");
|
|
682
|
+
for (const file of files) {
|
|
683
|
+
const entry = manifest.builds[file];
|
|
684
|
+
const diskHash = disk.builds.get(file);
|
|
685
|
+
try {
|
|
686
|
+
if (diskHash === undefined) {
|
|
687
|
+
if (options.only) {
|
|
688
|
+
report.failures.push({ file, reason: "no such build file" });
|
|
689
|
+
}
|
|
690
|
+
else if (entry && !dryRun) {
|
|
691
|
+
// The file was deleted: stop tracking. The tree in Studio stays.
|
|
692
|
+
delete manifest.builds[file];
|
|
693
|
+
report.lines.push(`forget ${file} (file deleted; the tree in Studio is left as it is)`);
|
|
694
|
+
}
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
const parent = entry?.parent ?? parentOfBuild(file, studio);
|
|
698
|
+
if (!parent) {
|
|
699
|
+
report.failures.push({ file, reason: "its folder does not match an instance in Studio; export the tree to place it" });
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
let target = entry?.path;
|
|
703
|
+
if (target === undefined) {
|
|
704
|
+
const raw = JSON.parse(await readText(dir, file));
|
|
705
|
+
target = `${parent}.${raw.name ?? raw.className}`;
|
|
706
|
+
}
|
|
707
|
+
const current = await exportTree(bridge, studioId, target, quiet);
|
|
708
|
+
const studioHash = current ? hashOf(current.text) : undefined;
|
|
709
|
+
const diskText = await readText(dir, file);
|
|
710
|
+
const diskChanged = entry === undefined || diskHash !== entry.hash;
|
|
711
|
+
// A record from before build files were two-way has no Studio hash: take
|
|
712
|
+
// today's as the baseline rather than calling everything changed.
|
|
713
|
+
const studioChanged = entry !== undefined && entry.studio !== undefined && studioHash !== entry.studio;
|
|
714
|
+
const memory = remembered[file];
|
|
715
|
+
const doBuild = async () => {
|
|
716
|
+
if (direction === "pull")
|
|
717
|
+
return report.held.push(`build ${file}`);
|
|
718
|
+
if (dryRun)
|
|
719
|
+
return report.builds.push(file);
|
|
720
|
+
const built = await buildFrom(bridge, studioId, dir, file, parent, quiet, current?.path);
|
|
721
|
+
manifest.builds[file] = { path: built.path, parent, hash: diskHash, studio: built.exported ? hashOf(built.exported.text) : undefined };
|
|
722
|
+
report.builds.push(file);
|
|
723
|
+
report.lines.push(`build ${file}`);
|
|
724
|
+
for (const moved of built.carried.filter((item) => !item.exact)) {
|
|
725
|
+
report.failures.push({ file, reason: `${moved.path} kept, but its folder is gone from the new tree; it now sits at the root` });
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
const doPull = async (exported) => {
|
|
729
|
+
if (direction === "push")
|
|
730
|
+
return report.held.push(`export ${file}`);
|
|
731
|
+
if (dryRun)
|
|
732
|
+
return tally(report, { kind: "pull", file, path: exported.path, revision: "", className: "" });
|
|
733
|
+
await writeText(dir, file, exported.text);
|
|
734
|
+
manifest.builds[file] = { path: exported.path, parent, hash: hashOf(exported.text), studio: hashOf(exported.text) };
|
|
735
|
+
report.lines.push(`export ${file}`);
|
|
736
|
+
report.counts.export = (report.counts.export ?? 0) + 1;
|
|
737
|
+
};
|
|
738
|
+
const conflict = (reason) => {
|
|
739
|
+
report.conflicts.push({ file, path: target, reason: `${reason}. Studio's version: ${STATE_DIR}/conflicts/${file}.` });
|
|
740
|
+
if (current)
|
|
741
|
+
conflicts[file] = { studio: studioHash, disk: diskHash, text: current.text };
|
|
742
|
+
};
|
|
743
|
+
if (!current) {
|
|
744
|
+
// Nothing in Studio at that path.
|
|
745
|
+
if (entry === undefined || diskChanged || prefer === "disk") {
|
|
746
|
+
await doBuild();
|
|
747
|
+
}
|
|
748
|
+
else if (!dryRun && direction !== "push") {
|
|
749
|
+
await trash(dir, file, run);
|
|
750
|
+
delete manifest.builds[file];
|
|
751
|
+
report.lines.push(`delete-disk ${file} (the tree was deleted in Studio)`);
|
|
752
|
+
}
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
if (options.only) {
|
|
756
|
+
// An explicit build still refuses to undo Studio edits it has not seen.
|
|
757
|
+
if (studioChanged && prefer !== "disk")
|
|
758
|
+
conflict("the tree was edited in Studio since the last sync; pass prefer: \"disk\" to rebuild over it");
|
|
759
|
+
else
|
|
760
|
+
await doBuild();
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (normalise(current.text) === diskText) {
|
|
764
|
+
if (!dryRun)
|
|
765
|
+
manifest.builds[file] = { path: current.path, parent, hash: diskHash, studio: studioHash };
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
if (entry !== undefined && entry.studio === undefined && !dryRun)
|
|
769
|
+
entry.studio = studioHash;
|
|
770
|
+
const studioMoved = memory !== undefined && studioHash !== memory.studio;
|
|
771
|
+
const diskMoved = memory !== undefined && diskHash !== memory.disk;
|
|
772
|
+
if (memory && diskMoved && !studioMoved)
|
|
773
|
+
await doBuild();
|
|
774
|
+
else if (memory && studioMoved && !diskMoved)
|
|
775
|
+
await doPull(current);
|
|
776
|
+
else if (diskChanged && !studioChanged)
|
|
777
|
+
await doBuild();
|
|
778
|
+
else if (studioChanged && !diskChanged)
|
|
779
|
+
await doPull(current);
|
|
780
|
+
else if (!diskChanged && !studioChanged)
|
|
781
|
+
continue;
|
|
782
|
+
else if (prefer === "disk")
|
|
783
|
+
await doBuild();
|
|
784
|
+
else if (prefer === "studio")
|
|
785
|
+
await doPull(current);
|
|
786
|
+
else
|
|
787
|
+
conflict(entry === undefined ? "the file and the tree in Studio differ, and sync has no record of either" : "changed in both Studio and the file since the last sync");
|
|
788
|
+
}
|
|
789
|
+
catch (cause) {
|
|
790
|
+
report.failures.push({ file, reason: cause instanceof ToolError ? `${cause.code}: ${cause.message}` : String(cause) });
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
report.buildConflicts = conflicts;
|
|
794
|
+
}
|
|
795
|
+
/** Rebuilds named build files now (default: every changed one). */
|
|
796
|
+
export function runBuild(bridge, options) {
|
|
797
|
+
return exclusive(options.dir, async () => {
|
|
798
|
+
const session = await targetSession(bridge, options.studioId);
|
|
799
|
+
const manifest = (await loadManifest(options.dir)) ?? emptyManifest();
|
|
800
|
+
assertPlace(options, manifest, session);
|
|
801
|
+
const disk = await scanDisk(options.dir);
|
|
802
|
+
const scan = await bridge.call("sync.scan", { roots: options.roots ?? manifest.roots, revisions: false }, { studioId: session.studioId, timeoutMs: 120_000 });
|
|
803
|
+
const report = emptyReport(options.dir, session.placeName);
|
|
804
|
+
const only = options.files?.map((file) => file.replace(/\\/g, "/"));
|
|
805
|
+
await syncBuilds(bridge, session.studioId, manifest, disk, layout(scan), report, { ...options, direction: "push", only });
|
|
806
|
+
rememberBuildConflicts(manifest, report);
|
|
807
|
+
manifest.placeId ||= session.placeId;
|
|
808
|
+
manifest.placeName ||= session.placeName;
|
|
809
|
+
await saveManifest(options.dir, manifest);
|
|
810
|
+
return report;
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
function rememberBuildConflicts(manifest, report) {
|
|
814
|
+
const entries = Object.entries(report.buildConflicts ?? {});
|
|
815
|
+
if (entries.length === 0)
|
|
816
|
+
return;
|
|
817
|
+
manifest.conflicts ??= {};
|
|
818
|
+
for (const [file, memory] of entries) {
|
|
819
|
+
manifest.conflicts[file] = { studio: memory.studio, disk: memory.disk };
|
|
820
|
+
void writeText(path.join(report.dir, STATE_DIR), `conflicts/${file}`, memory.text).catch(() => undefined);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
function assertPlace(options, manifest, session) {
|
|
824
|
+
if (manifest.placeId && session.placeId && manifest.placeId !== session.placeId && !options.rebind) {
|
|
825
|
+
throw new ToolError("PLACE_MISMATCH", `${options.dir} is synced with place ${manifest.placeId} (${manifest.placeName ?? "unnamed"}), not ${session.placeId} (${session.placeName}).`, "Use a different `dir` for this place. Pass `rebind: true` only if this folder really should follow the new place.");
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Writes instance trees as build files beside the synced scripts: a ScreenGui
|
|
830
|
+
* at StarterGui.Shop becomes `StarterGui/Shop.build.json`, next to the
|
|
831
|
+
* `StarterGui/Shop/` folder its LocalScripts sync into.
|
|
832
|
+
*/
|
|
833
|
+
export function runExport(bridge, options) {
|
|
834
|
+
return exclusive(options.dir, async () => {
|
|
835
|
+
const session = await targetSession(bridge, options.studioId);
|
|
836
|
+
const studioId = session.studioId;
|
|
837
|
+
const manifest = (await loadManifest(options.dir)) ?? emptyManifest();
|
|
838
|
+
assertPlace(options, manifest, session);
|
|
839
|
+
const scan = await bridge.call("sync.scan", { roots: options.roots ?? manifest.roots, revisions: false }, { studioId, timeoutMs: 120_000 });
|
|
840
|
+
const studio = layout(scan);
|
|
841
|
+
const out = [];
|
|
842
|
+
for (const wanted of options.paths) {
|
|
843
|
+
const exported = await exportTree(bridge, studioId, wanted);
|
|
844
|
+
if (!exported)
|
|
845
|
+
throw new ToolError("NOT_FOUND", `Nothing at ${wanted}.`, "Check the path with find or tree.");
|
|
846
|
+
const chain = exported.chain;
|
|
847
|
+
if (chain.length < 2) {
|
|
848
|
+
throw new ToolError("BAD_PATH", `${exported.path} is a service; a build file replaces its tree, and a service cannot be replaced.`, "Export the instances inside it instead, e.g. StarterGui.Shop.");
|
|
849
|
+
}
|
|
850
|
+
const folders = [];
|
|
851
|
+
for (const link of chain.slice(0, -1)) {
|
|
852
|
+
const known = studio.dirOf.get(link.path);
|
|
853
|
+
folders.push(known ? known.split("/").pop() : encodeName(link.name, "dir") + (link.ordinal && link.ordinal > 1 ? `~${link.ordinal}` : ""));
|
|
854
|
+
}
|
|
855
|
+
const last = chain[chain.length - 1];
|
|
856
|
+
const stem = encodeName(last.name, "dir") + (last.ordinal && last.ordinal > 1 ? `~${last.ordinal}` : "");
|
|
857
|
+
const file = [...folders, `${stem}${BUILD_SUFFIX}`].join("/");
|
|
858
|
+
await writeText(options.dir, file, exported.text);
|
|
859
|
+
const hash = hashOf(exported.text);
|
|
860
|
+
manifest.builds[file] = { path: exported.path, parent: chain[chain.length - 2].path, hash, studio: hash };
|
|
861
|
+
if (manifest.conflicts?.[file])
|
|
862
|
+
delete manifest.conflicts[file];
|
|
863
|
+
out.push({ path: exported.path, file, instances: exported.instances, scripts: exported.scripts, skipped: exported.skipped });
|
|
864
|
+
}
|
|
865
|
+
manifest.placeId ||= session.placeId;
|
|
866
|
+
manifest.placeName ||= session.placeName;
|
|
867
|
+
await saveManifest(options.dir, manifest);
|
|
868
|
+
return out;
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
/** How often build trees are compared with Studio; they send no events. */
|
|
872
|
+
const BUILD_CHECK_MS = 10_000;
|
|
873
|
+
/**
|
|
874
|
+
* Keeps a folder and Studio in step until stopped.
|
|
875
|
+
*
|
|
876
|
+
* Runs a sync when something changed and only then: the disk says so through
|
|
877
|
+
* a file watcher, Studio through `sync.changes`, which the plugin answers from
|
|
878
|
+
* edit and property events without touching any script. A slow shape check
|
|
879
|
+
* covers what neither sees -- a folder renamed in Studio moves every script
|
|
880
|
+
* inside it without a single script event.
|
|
881
|
+
*
|
|
882
|
+
* Most events are echoes: writing a file into Studio fires a Studio edit event,
|
|
883
|
+
* pulling one to disk fires a file event. Each is checked against the manifest
|
|
884
|
+
* first -- a file hash, or a revision the plugin has cached -- and dropped when
|
|
885
|
+
* it matches what the last sync wrote, so a watch settles instead of running a
|
|
886
|
+
* second sync after every real one.
|
|
887
|
+
*/
|
|
888
|
+
class Watch {
|
|
889
|
+
bridge;
|
|
890
|
+
options;
|
|
891
|
+
state;
|
|
892
|
+
watcher = null;
|
|
893
|
+
timers = [];
|
|
894
|
+
debounce = null;
|
|
895
|
+
running = false;
|
|
896
|
+
again = false;
|
|
897
|
+
token;
|
|
898
|
+
shape = "";
|
|
899
|
+
stopped = false;
|
|
900
|
+
files = new Set();
|
|
901
|
+
dirty = new Set();
|
|
902
|
+
structural = false;
|
|
903
|
+
buildsDue = false;
|
|
904
|
+
constructor(bridge, options, studioId) {
|
|
905
|
+
this.bridge = bridge;
|
|
906
|
+
this.options = options;
|
|
907
|
+
this.state = { dir: options.dir, studioId, since: Date.now(), cycles: 0, echoes: 0, conflicts: [] };
|
|
908
|
+
}
|
|
909
|
+
async start() {
|
|
910
|
+
const first = await runSync(this.bridge, { ...this.options, studioId: this.state.studioId });
|
|
911
|
+
this.record(first, 0);
|
|
912
|
+
// Take the tracking token now, so the first poll is not mistaken for a
|
|
913
|
+
// plugin that reloaded.
|
|
914
|
+
await this.pollStudio(true);
|
|
915
|
+
await mkdir(this.options.dir, { recursive: true });
|
|
916
|
+
this.watcher = watchFolder(this.options.dir, { recursive: true }, (_event, name) => {
|
|
917
|
+
const changed = name ? posix(String(name)) : "";
|
|
918
|
+
if (changed === "" || changed.split("/").some((part) => part.startsWith(".")) || changed.endsWith(".tmp"))
|
|
919
|
+
return;
|
|
920
|
+
this.files.add(changed);
|
|
921
|
+
this.schedule(250);
|
|
922
|
+
});
|
|
923
|
+
this.watcher.on("error", (cause) => this.fail(cause));
|
|
924
|
+
this.timers.push(setInterval(() => void this.pollStudio(false), 500));
|
|
925
|
+
this.timers.push(setInterval(() => void this.checkShape(), 5_000));
|
|
926
|
+
this.timers.push(setInterval(() => {
|
|
927
|
+
this.buildsDue = true;
|
|
928
|
+
this.schedule(0);
|
|
929
|
+
}, BUILD_CHECK_MS));
|
|
930
|
+
return first;
|
|
931
|
+
}
|
|
932
|
+
stop() {
|
|
933
|
+
this.stopped = true;
|
|
934
|
+
this.watcher?.close();
|
|
935
|
+
for (const timer of this.timers)
|
|
936
|
+
clearInterval(timer);
|
|
937
|
+
if (this.debounce)
|
|
938
|
+
clearTimeout(this.debounce);
|
|
939
|
+
void this.bridge.call("sync.stop", {}, { studioId: this.state.studioId, timeoutMs: 5_000 }).catch(() => undefined);
|
|
940
|
+
}
|
|
941
|
+
schedule(delay) {
|
|
942
|
+
if (this.stopped)
|
|
943
|
+
return;
|
|
944
|
+
if (this.debounce)
|
|
945
|
+
clearTimeout(this.debounce);
|
|
946
|
+
this.debounce = setTimeout(() => void this.cycle(), delay);
|
|
947
|
+
}
|
|
948
|
+
async pollStudio(initial) {
|
|
949
|
+
if (this.stopped)
|
|
950
|
+
return;
|
|
951
|
+
try {
|
|
952
|
+
const changes = await this.bridge.call("sync.changes", { token: this.token }, { studioId: this.state.studioId, timeoutMs: 5_000 });
|
|
953
|
+
const fresh = changes.token !== this.token;
|
|
954
|
+
this.token = changes.token;
|
|
955
|
+
if (initial)
|
|
956
|
+
return;
|
|
957
|
+
// A new token means the plugin reloaded and forgot what it had seen.
|
|
958
|
+
if (fresh || changes.structural)
|
|
959
|
+
this.structural = true;
|
|
960
|
+
for (const changed of changes.dirty)
|
|
961
|
+
this.dirty.add(changed);
|
|
962
|
+
if (this.structural || this.dirty.size > 0)
|
|
963
|
+
this.schedule(150);
|
|
964
|
+
}
|
|
965
|
+
catch (cause) {
|
|
966
|
+
if (!(await this.reattach(cause)))
|
|
967
|
+
this.fail(cause);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Studio restarted, or the place was reopened: the session this watch was
|
|
972
|
+
* pinned to is gone. Follow the new one -- but only to the SAME place, since
|
|
973
|
+
* following whatever window is active would sync this folder into another game.
|
|
974
|
+
*/
|
|
975
|
+
async reattach(cause) {
|
|
976
|
+
if (!(cause instanceof ToolError) || !["UNKNOWN_STUDIO", "NO_STUDIO", "DISCONNECTED"].includes(cause.code))
|
|
977
|
+
return false;
|
|
978
|
+
try {
|
|
979
|
+
const manifest = await loadManifest(this.options.dir);
|
|
980
|
+
const { list } = await this.bridge.sessions();
|
|
981
|
+
const next = list.find((session) => !session.context?.startsWith("playtest") && manifest?.placeId !== undefined && session.placeId === manifest.placeId);
|
|
982
|
+
if (!next || next.studioId === this.state.studioId)
|
|
983
|
+
return false;
|
|
984
|
+
this.state.studioId = next.studioId;
|
|
985
|
+
this.token = undefined;
|
|
986
|
+
this.structural = true;
|
|
987
|
+
this.state.lastError = undefined;
|
|
988
|
+
pushNotice(`reconnected to ${next.placeName} after Studio restarted.`);
|
|
989
|
+
this.schedule(0);
|
|
990
|
+
return true;
|
|
991
|
+
}
|
|
992
|
+
catch {
|
|
993
|
+
return false;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
async checkShape() {
|
|
997
|
+
if (this.stopped || this.running)
|
|
998
|
+
return;
|
|
999
|
+
try {
|
|
1000
|
+
const { shape } = await this.bridge.call("sync.shape", { roots: this.options.roots }, { studioId: this.state.studioId, timeoutMs: 30_000 });
|
|
1001
|
+
if (this.shape !== "" && shape !== this.shape) {
|
|
1002
|
+
this.structural = true;
|
|
1003
|
+
this.schedule(0);
|
|
1004
|
+
}
|
|
1005
|
+
this.shape = shape;
|
|
1006
|
+
}
|
|
1007
|
+
catch (cause) {
|
|
1008
|
+
this.fail(cause);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Whether anything in the queue is a real change rather than an echo.
|
|
1013
|
+
* Errs towards "yes": a check that cannot decide runs the sync.
|
|
1014
|
+
*/
|
|
1015
|
+
async worthRunning(files, dirty) {
|
|
1016
|
+
if (this.structural || this.buildsDue)
|
|
1017
|
+
return true;
|
|
1018
|
+
const manifest = await loadManifest(this.options.dir);
|
|
1019
|
+
if (!manifest)
|
|
1020
|
+
return true;
|
|
1021
|
+
for (const file of files) {
|
|
1022
|
+
const hash = await readText(this.options.dir, file).then(hashOf, () => undefined);
|
|
1023
|
+
const known = manifest.files[file]?.hash ?? manifest.builds[file]?.hash;
|
|
1024
|
+
if (hash === undefined ? known !== undefined : hash !== known) {
|
|
1025
|
+
// A folder event names the folder, not a file; only a real file counts.
|
|
1026
|
+
if (hash !== undefined || classFromFile(file.split("/").pop()) || file.endsWith(BUILD_SUFFIX))
|
|
1027
|
+
return true;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
if (dirty.length > 0) {
|
|
1031
|
+
const byPath = new Map(Object.values(manifest.files).map((entry) => [entry.path, entry.revision]));
|
|
1032
|
+
if (dirty.some((studioPath) => !byPath.has(studioPath)))
|
|
1033
|
+
return true;
|
|
1034
|
+
const { items } = await this.bridge.call("sync.revisions", { paths: dirty }, { studioId: this.state.studioId, timeoutMs: 10_000 });
|
|
1035
|
+
if (items.some((item) => item.missing || item.revision !== byPath.get(item.path)))
|
|
1036
|
+
return true;
|
|
1037
|
+
}
|
|
1038
|
+
return false;
|
|
1039
|
+
}
|
|
1040
|
+
async cycle() {
|
|
1041
|
+
if (this.stopped)
|
|
1042
|
+
return;
|
|
1043
|
+
if (this.running) {
|
|
1044
|
+
this.again = true;
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
this.running = true;
|
|
1048
|
+
const files = [...this.files];
|
|
1049
|
+
const dirty = [...this.dirty];
|
|
1050
|
+
const builds = this.buildsDue || files.some((file) => file.endsWith(BUILD_SUFFIX));
|
|
1051
|
+
this.files.clear();
|
|
1052
|
+
this.dirty.clear();
|
|
1053
|
+
try {
|
|
1054
|
+
if (!(await this.worthRunning(files, dirty))) {
|
|
1055
|
+
this.state.echoes += 1;
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
this.structural = false;
|
|
1059
|
+
this.buildsDue = false;
|
|
1060
|
+
const started = Date.now();
|
|
1061
|
+
const report = await runSync(this.bridge, { ...this.options, studioId: this.state.studioId, quiet: true, builds });
|
|
1062
|
+
this.record(report, Date.now() - started);
|
|
1063
|
+
}
|
|
1064
|
+
catch (cause) {
|
|
1065
|
+
this.fail(cause);
|
|
1066
|
+
}
|
|
1067
|
+
finally {
|
|
1068
|
+
this.running = false;
|
|
1069
|
+
if (this.again) {
|
|
1070
|
+
this.again = false;
|
|
1071
|
+
this.schedule(100);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
record(report, ms) {
|
|
1076
|
+
this.state.cycles += 1;
|
|
1077
|
+
this.state.conflicts = report.conflicts;
|
|
1078
|
+
if (report.lines.length > 0 || report.conflicts.length > 0) {
|
|
1079
|
+
this.state.lastCycle = { at: Date.now(), summary: summarise(report), ms };
|
|
1080
|
+
}
|
|
1081
|
+
for (const file of report.newConflicts ?? []) {
|
|
1082
|
+
pushNotice(`conflict in ${file}: changed on both sides; Studio's version is in ${STATE_DIR}/conflicts/${file}. Merge into the file, or run sync with prefer.`);
|
|
1083
|
+
}
|
|
1084
|
+
for (const failure of report.failures.slice(0, 3))
|
|
1085
|
+
pushNotice(`${failure.file}: ${failure.reason}`);
|
|
1086
|
+
this.state.lastError = undefined;
|
|
1087
|
+
}
|
|
1088
|
+
fail(cause) {
|
|
1089
|
+
const message = cause instanceof ToolError ? `${cause.code}: ${cause.message}` : String(cause);
|
|
1090
|
+
if (this.state.lastError?.message !== message) {
|
|
1091
|
+
pushNotice(`stopped syncing for now: ${message}. It retries on the next change.`);
|
|
1092
|
+
void this.bridge
|
|
1093
|
+
.call("sync.log", { lines: [{ level: "warn", message: "sync watch: " + message }] }, { studioId: this.state.studioId, timeoutMs: 5_000 })
|
|
1094
|
+
.catch(() => undefined);
|
|
1095
|
+
}
|
|
1096
|
+
this.state.lastError = { at: Date.now(), message };
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
const watches = new Map();
|
|
1100
|
+
export async function startWatch(bridge, options) {
|
|
1101
|
+
const existing = watches.get(options.dir);
|
|
1102
|
+
if (existing)
|
|
1103
|
+
return { state: existing.state, first: emptyReport(options.dir, ""), already: true };
|
|
1104
|
+
const session = await targetSession(bridge, options.studioId);
|
|
1105
|
+
const watch = new Watch(bridge, { ...options, direction: "both" }, session.studioId);
|
|
1106
|
+
const first = await watch.start();
|
|
1107
|
+
watches.set(options.dir, watch);
|
|
1108
|
+
return { state: watch.state, first, already: false };
|
|
1109
|
+
}
|
|
1110
|
+
export function stopWatch(dir) {
|
|
1111
|
+
const watch = watches.get(dir);
|
|
1112
|
+
if (!watch)
|
|
1113
|
+
return null;
|
|
1114
|
+
watch.stop();
|
|
1115
|
+
watches.delete(dir);
|
|
1116
|
+
return watch.state;
|
|
1117
|
+
}
|
|
1118
|
+
export function watchState(dir) {
|
|
1119
|
+
return watches.get(dir)?.state ?? null;
|
|
1120
|
+
}
|
|
1121
|
+
export function stopAllWatches() {
|
|
1122
|
+
for (const dir of [...watches.keys()])
|
|
1123
|
+
stopWatch(dir);
|
|
1124
|
+
}
|
|
1125
|
+
function emptyReport(dir, place) {
|
|
1126
|
+
return { dir, place, dryRun: false, lines: [], counts: {}, conflicts: [], held: [], failures: [], fileSynced: [], builds: [] };
|
|
1127
|
+
}
|
|
1128
|
+
/** One line for the whole run, e.g. "3 written to Studio, 1 created on disk". */
|
|
1129
|
+
export function summarise(report) {
|
|
1130
|
+
const parts = Object.entries(report.counts)
|
|
1131
|
+
.filter(([kind]) => kind in LABELS)
|
|
1132
|
+
.map(([kind, count]) => `${count} ${LABELS[kind]}`);
|
|
1133
|
+
if (report.builds.length > 0)
|
|
1134
|
+
parts.push(`${report.builds.length} ${report.dryRun ? "to build" : "built"}`);
|
|
1135
|
+
if (report.conflicts.length > 0)
|
|
1136
|
+
parts.push(`${report.conflicts.length} conflict${report.conflicts.length === 1 ? "" : "s"}`);
|
|
1137
|
+
if (report.failures.length > 0)
|
|
1138
|
+
parts.push(`${report.failures.length} failed`);
|
|
1139
|
+
return parts.length > 0 ? parts.join(", ") : "nothing to do";
|
|
1140
|
+
}
|
|
1141
|
+
//# sourceMappingURL=sync.js.map
|