azul-sync 1.3.0

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.
Files changed (133) hide show
  1. package/.gitattributes +1 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  4. package/README.md +142 -0
  5. package/dist/build.d.ts +19 -0
  6. package/dist/build.d.ts.map +1 -0
  7. package/dist/build.js +92 -0
  8. package/dist/build.js.map +1 -0
  9. package/dist/cli.d.ts +3 -0
  10. package/dist/cli.d.ts.map +1 -0
  11. package/dist/cli.js +397 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/config.d.ts +26 -0
  14. package/dist/config.d.ts.map +1 -0
  15. package/dist/config.js +105 -0
  16. package/dist/config.js.map +1 -0
  17. package/dist/fs/fileWriter.d.ts +100 -0
  18. package/dist/fs/fileWriter.d.ts.map +1 -0
  19. package/dist/fs/fileWriter.js +342 -0
  20. package/dist/fs/fileWriter.js.map +1 -0
  21. package/dist/fs/treeManager.d.ts +84 -0
  22. package/dist/fs/treeManager.d.ts.map +1 -0
  23. package/dist/fs/treeManager.js +365 -0
  24. package/dist/fs/treeManager.js.map +1 -0
  25. package/dist/fs/watcher.d.ts +39 -0
  26. package/dist/fs/watcher.d.ts.map +1 -0
  27. package/dist/fs/watcher.js +120 -0
  28. package/dist/fs/watcher.js.map +1 -0
  29. package/dist/index.d.ts +61 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +349 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/ipc/httpPolling.d.ts +56 -0
  34. package/dist/ipc/httpPolling.d.ts.map +1 -0
  35. package/dist/ipc/httpPolling.js +171 -0
  36. package/dist/ipc/httpPolling.js.map +1 -0
  37. package/dist/ipc/messages.d.ts +112 -0
  38. package/dist/ipc/messages.d.ts.map +1 -0
  39. package/dist/ipc/messages.js +5 -0
  40. package/dist/ipc/messages.js.map +1 -0
  41. package/dist/ipc/server.d.ts +50 -0
  42. package/dist/ipc/server.d.ts.map +1 -0
  43. package/dist/ipc/server.js +168 -0
  44. package/dist/ipc/server.js.map +1 -0
  45. package/dist/pack.d.ts +19 -0
  46. package/dist/pack.d.ts.map +1 -0
  47. package/dist/pack.js +225 -0
  48. package/dist/pack.js.map +1 -0
  49. package/dist/push.d.ts +43 -0
  50. package/dist/push.d.ts.map +1 -0
  51. package/dist/push.js +532 -0
  52. package/dist/push.js.map +1 -0
  53. package/dist/rojo.d.ts +9 -0
  54. package/dist/rojo.d.ts.map +1 -0
  55. package/dist/rojo.js +114 -0
  56. package/dist/rojo.js.map +1 -0
  57. package/dist/snapshot/rojo.d.ts +39 -0
  58. package/dist/snapshot/rojo.d.ts.map +1 -0
  59. package/dist/snapshot/rojo.js +364 -0
  60. package/dist/snapshot/rojo.js.map +1 -0
  61. package/dist/snapshot.d.ts +23 -0
  62. package/dist/snapshot.d.ts.map +1 -0
  63. package/dist/snapshot.js +132 -0
  64. package/dist/snapshot.js.map +1 -0
  65. package/dist/sourcemap/generator.d.ts +78 -0
  66. package/dist/sourcemap/generator.d.ts.map +1 -0
  67. package/dist/sourcemap/generator.js +351 -0
  68. package/dist/sourcemap/generator.js.map +1 -0
  69. package/dist/sourcemap/propertyLoader.d.ts +19 -0
  70. package/dist/sourcemap/propertyLoader.d.ts.map +1 -0
  71. package/dist/sourcemap/propertyLoader.js +131 -0
  72. package/dist/sourcemap/propertyLoader.js.map +1 -0
  73. package/dist/util/id.d.ts +9 -0
  74. package/dist/util/id.d.ts.map +1 -0
  75. package/dist/util/id.js +14 -0
  76. package/dist/util/id.js.map +1 -0
  77. package/dist/util/log.d.ts +13 -0
  78. package/dist/util/log.d.ts.map +1 -0
  79. package/dist/util/log.js +51 -0
  80. package/dist/util/log.js.map +1 -0
  81. package/docs/assets/azul-logo.pdn +0 -0
  82. package/docs/assets/logo-200px.png +0 -0
  83. package/docs/assets/logo.png +0 -0
  84. package/docs/assets/plugin/toolbox.png +0 -0
  85. package/docs/assets/synced.png +0 -0
  86. package/package.json +41 -0
  87. package/plugin/README.md +54 -0
  88. package/plugin/sourcemap.json +264 -0
  89. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Actor/AzulSync.server.luau +905 -0
  90. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/AzulService.luau +1010 -0
  91. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Config.luau +29 -0
  92. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Enums.luau +11 -0
  93. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/CollapsibleTitledSection.luau +214 -0
  94. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/ColorPicker.luau +360 -0
  95. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/CustomTextButton.luau +170 -0
  96. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/DropdownMenu.luau +363 -0
  97. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/HorizontalLine.luau +43 -0
  98. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/ImageButtonWithText.luau +181 -0
  99. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledCheckbox.luau +295 -0
  100. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledColorInputPicker.luau +294 -0
  101. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledMultiChoice.luau +163 -0
  102. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledNumberInput.luau +312 -0
  103. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledRadioButton.luau +55 -0
  104. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledSlider.luau +151 -0
  105. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledTextInput.luau +222 -0
  106. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledToggleButton.luau +73 -0
  107. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/StatefulImageButton.luau +125 -0
  108. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticalScrollingFrame.luau +100 -0
  109. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticalSpacer.luau +35 -0
  110. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticallyScalingListFrame.luau +107 -0
  111. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/GuiUtilities.luau +429 -0
  112. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/RbxGui.luau +4363 -0
  113. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/UI.luau +425 -0
  114. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/WebSocketClient.luau +161 -0
  115. package/src/build.ts +120 -0
  116. package/src/cli.ts +496 -0
  117. package/src/config.ts +170 -0
  118. package/src/fs/fileWriter.ts +414 -0
  119. package/src/fs/treeManager.ts +458 -0
  120. package/src/fs/watcher.ts +142 -0
  121. package/src/index.ts +450 -0
  122. package/src/ipc/httpPolling.ts +214 -0
  123. package/src/ipc/messages.ts +159 -0
  124. package/src/ipc/server.ts +196 -0
  125. package/src/pack.ts +309 -0
  126. package/src/push.ts +726 -0
  127. package/src/snapshot/rojo.ts +467 -0
  128. package/src/snapshot.ts +161 -0
  129. package/src/sourcemap/generator.ts +504 -0
  130. package/src/sourcemap/propertyLoader.ts +195 -0
  131. package/src/util/id.ts +15 -0
  132. package/src/util/log.ts +94 -0
  133. package/tsconfig.json +24 -0
@@ -0,0 +1,467 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { log } from "../util/log.js";
5
+ import type { InstanceData } from "../ipc/messages.js";
6
+
7
+ interface RojoProject {
8
+ tree: Record<string, any>;
9
+ globIgnorePaths?: string | string[];
10
+ }
11
+
12
+ export interface RojoSnapshotOptions {
13
+ projectFile?: string;
14
+ cwd?: string;
15
+ destPrefix?: string[];
16
+ }
17
+
18
+ /**
19
+ * Builds InstanceData[] from a Rojo-style default.project.json (compat layer).
20
+ */
21
+ export class RojoSnapshotBuilder {
22
+ private projectFile: string;
23
+ private cwd: string;
24
+ private emittedFolders: Set<string> = new Set();
25
+ private moduleContainers: Set<string> = new Set();
26
+ private destPrefix: string[];
27
+ private ignoreMatchers: RegExp[] = [];
28
+
29
+ constructor(options: RojoSnapshotOptions = {}) {
30
+ this.cwd = path.resolve(options.cwd ?? process.cwd());
31
+ this.projectFile = path.resolve(
32
+ this.cwd,
33
+ options.projectFile ?? "default.project.json",
34
+ );
35
+ this.destPrefix = options.destPrefix ?? [];
36
+ }
37
+
38
+ public async build(): Promise<InstanceData[]> {
39
+ const project = await this.loadProjectFrom(this.projectFile);
40
+ this.prepareIgnoreMatchers(project);
41
+
42
+ const results: InstanceData[] = [];
43
+ const projectDir = path.dirname(this.projectFile);
44
+
45
+ const tree = project.tree ?? {};
46
+ const hasChildren = Object.keys(tree).some((k) => !k.startsWith("$"));
47
+ const rootPath = typeof tree.$path === "string" ? tree.$path : null;
48
+
49
+ if (!hasChildren && rootPath) {
50
+ const absRoot = path.resolve(projectDir, rootPath);
51
+ const rootKind = await this.pathKind(absRoot);
52
+
53
+ if (rootKind === "file") {
54
+ if (!this.isScriptFile(path.basename(absRoot))) {
55
+ throw new Error(
56
+ `$path target ${absRoot} must be a .lua or .luau script file.`,
57
+ );
58
+ }
59
+
60
+ const { className, scriptName } = this.classifyScript(
61
+ path.basename(absRoot),
62
+ );
63
+ const source = await fs.readFile(absRoot, "utf-8");
64
+
65
+ const destPath =
66
+ this.destPrefix.length === 0
67
+ ? [scriptName]
68
+ : this.destPrefix[this.destPrefix.length - 1] === scriptName
69
+ ? [...this.destPrefix]
70
+ : [...this.destPrefix, scriptName];
71
+
72
+ this.ensureFolder(destPath.slice(0, -1), results);
73
+ this.moduleContainers.add(destPath.join("/"));
74
+ results.push({
75
+ guid: this.makeGuid(),
76
+ className,
77
+ name: destPath[destPath.length - 1],
78
+ path: destPath,
79
+ source,
80
+ });
81
+ } else {
82
+ if (!rootKind) {
83
+ throw new Error(`$path target ${absRoot} does not exist.`);
84
+ }
85
+
86
+ await this.walkDirectory(
87
+ absRoot,
88
+ [...this.destPrefix],
89
+ results,
90
+ new Set(),
91
+ );
92
+ }
93
+ } else {
94
+ await this.walkTree(tree, [], projectDir, results);
95
+ }
96
+
97
+ // Stable ordering: shallow-first, then lexical for determinism
98
+ results.sort((a, b) => {
99
+ if (a.path.length !== b.path.length) {
100
+ return a.path.length - b.path.length;
101
+ }
102
+ return a.path.join("/").localeCompare(b.path.join("/"));
103
+ });
104
+
105
+ log.success(
106
+ `Rojo compatibility build produced ${results.length} instances`,
107
+ );
108
+ return results;
109
+ }
110
+
111
+ private async loadProjectFrom(file: string): Promise<RojoProject> {
112
+ let raw: string;
113
+ try {
114
+ raw = await fs.readFile(file, "utf-8");
115
+ } catch (error) {
116
+ throw new Error(`Rojo compatibility mode requires ${file} (not found).`);
117
+ }
118
+
119
+ try {
120
+ const parsed = JSON.parse(raw) as RojoProject;
121
+ if (!parsed || typeof parsed !== "object" || !parsed.tree) {
122
+ throw new Error("Missing tree key");
123
+ }
124
+ return parsed;
125
+ } catch (error) {
126
+ throw new Error(`Failed to parse Rojo project file at ${file}: ${error}`);
127
+ }
128
+ }
129
+
130
+ private prepareIgnoreMatchers(project: RojoProject): void {
131
+ const defaults = [
132
+ "**/.git/**",
133
+ "**/.git",
134
+ "**/.github/**",
135
+ "**/sourcemap.json",
136
+ "**/*.lock",
137
+ "**/~$*",
138
+ ];
139
+
140
+ const user = Array.isArray(project.globIgnorePaths)
141
+ ? project.globIgnorePaths
142
+ : project.globIgnorePaths
143
+ ? [project.globIgnorePaths]
144
+ : [];
145
+
146
+ const patterns = [...defaults, ...user];
147
+ this.ignoreMatchers = patterns.map((p) => this.globToRegex(p));
148
+ }
149
+
150
+ private globToRegex(glob: string): RegExp {
151
+ const escaped = glob.replace(/([|\\{}()\[\]^$+*?.])/g, "\\$1");
152
+
153
+ const regex = escaped
154
+ .replace(/\*\*/g, ".*")
155
+ .replace(/\*/g, "[^/]*")
156
+ .replace(/\?/g, "[^/]");
157
+
158
+ return new RegExp(`^${regex}$`);
159
+ }
160
+
161
+ private isIgnored(absPath: string): boolean {
162
+ const rel = path.relative(this.cwd, absPath).replace(/\\/g, "/");
163
+ for (const matcher of this.ignoreMatchers) {
164
+ if (matcher.test(rel)) {
165
+ return true;
166
+ }
167
+ }
168
+ return false;
169
+ }
170
+
171
+ private async walkTree(
172
+ node: Record<string, any>,
173
+ parentPath: string[],
174
+ projectDir: string,
175
+ results: InstanceData[],
176
+ ): Promise<void> {
177
+ for (const [name, value] of Object.entries(node)) {
178
+ if (name.startsWith("$")) continue;
179
+ if (typeof value !== "object" || value === null) continue;
180
+
181
+ const pathSegments = [...this.destPrefix, ...parentPath, name];
182
+ await this.emitNode(name, value, pathSegments, projectDir, results);
183
+ }
184
+ }
185
+
186
+ private async emitNode(
187
+ name: string,
188
+ node: Record<string, any>,
189
+ pathSegments: string[],
190
+ projectDir: string,
191
+ results: InstanceData[],
192
+ ): Promise<void> {
193
+ const className = this.resolveClassName(node, pathSegments);
194
+ const pathHint = typeof node.$path === "string" ? node.$path : undefined;
195
+ const absPath = pathHint ? path.resolve(projectDir, pathHint) : null;
196
+ const definedChildren = new Set(
197
+ Object.keys(node).filter((key) => !key.startsWith("$")),
198
+ );
199
+ const pathKind = absPath ? await this.pathKind(absPath) : null;
200
+
201
+ let initScript: { fileName: string; source: string } | null = null;
202
+ if (absPath && pathKind === "dir") {
203
+ initScript = await this.findInit(absPath);
204
+ } else if (absPath && pathKind === "file") {
205
+ const fileName = path.basename(absPath);
206
+ if (!this.isScriptFile(fileName)) {
207
+ throw new Error(`$path target ${absPath} is not a .lua/.luau file.`);
208
+ }
209
+ const source = await fs.readFile(absPath, "utf-8");
210
+ initScript = { fileName, source };
211
+ }
212
+
213
+ // If there's an init script, the folder becomes a ModuleScript at the same path.
214
+ if (initScript) {
215
+ this.ensureFolder(pathSegments.slice(0, -1), results);
216
+ this.moduleContainers.add(pathSegments.join("/"));
217
+ const scriptClass = this.classifyScript(initScript.fileName).className;
218
+ results.push({
219
+ guid: this.makeGuid(),
220
+ className: scriptClass,
221
+ name: pathSegments[pathSegments.length - 1],
222
+ path: [...pathSegments],
223
+ source: initScript.source,
224
+ });
225
+ } else {
226
+ this.ensureFolder(pathSegments.slice(0, -1), results);
227
+ results.push({
228
+ guid: this.makeGuid(),
229
+ className,
230
+ name,
231
+ path: [...pathSegments],
232
+ });
233
+ }
234
+
235
+ // Recurse into children defined in JSON
236
+ for (const [childName, childValue] of Object.entries(node)) {
237
+ if (childName.startsWith("$")) continue;
238
+ if (typeof childValue !== "object" || childValue === null) continue;
239
+ await this.emitNode(
240
+ childName,
241
+ childValue,
242
+ [...pathSegments, childName],
243
+ projectDir,
244
+ results,
245
+ );
246
+ }
247
+
248
+ // Walk filesystem for $path mappings
249
+ if (absPath && pathKind === "dir") {
250
+ await this.walkDirectory(absPath, pathSegments, results, definedChildren);
251
+ }
252
+ }
253
+
254
+ private resolveClassName(
255
+ node: Record<string, any>,
256
+ pathSegments: string[],
257
+ ): string {
258
+ if (typeof node.$className === "string") {
259
+ return node.$className;
260
+ }
261
+ if (pathSegments.length === 1) {
262
+ // Service root
263
+ return pathSegments[0];
264
+ }
265
+ return "Folder";
266
+ }
267
+
268
+ private async walkDirectory(
269
+ dirPath: string,
270
+ destPath: string[],
271
+ results: InstanceData[],
272
+ definedChildren: Set<string>,
273
+ ): Promise<void> {
274
+ if (this.isIgnored(dirPath)) return;
275
+
276
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
277
+ const initCandidates = this.getInitCandidates();
278
+
279
+ // If this directory has an init, the directory becomes that script; children attach under it
280
+ const initEntry = entries.find(
281
+ (e) => e.isFile() && initCandidates.includes(e.name),
282
+ );
283
+
284
+ if (initEntry) {
285
+ const key = destPath.join("/");
286
+ if (!this.moduleContainers.has(key)) {
287
+ this.moduleContainers.add(key);
288
+ this.ensureFolder(destPath.slice(0, -1), results);
289
+ const scriptClass = this.classifyScript(initEntry.name).className;
290
+ const source = await fs.readFile(
291
+ path.join(dirPath, initEntry.name),
292
+ "utf-8",
293
+ );
294
+ results.push({
295
+ guid: this.makeGuid(),
296
+ className: scriptClass,
297
+ name: destPath[destPath.length - 1] ?? path.basename(dirPath),
298
+ path: [...destPath],
299
+ source,
300
+ });
301
+ }
302
+ } else {
303
+ this.ensureFolder(destPath, results);
304
+ }
305
+
306
+ // Sub-project override
307
+ const subProjectPath = path.join(dirPath, "default.project.json");
308
+ if (await this.exists(subProjectPath)) {
309
+ const previousProjectFile = this.projectFile;
310
+ const previousIgnore = this.ignoreMatchers;
311
+ this.projectFile = subProjectPath;
312
+
313
+ const subProject = await this.loadProjectFrom(subProjectPath);
314
+ this.prepareIgnoreMatchers(subProject);
315
+ await this.walkTree(subProject.tree ?? {}, destPath, dirPath, results);
316
+
317
+ this.projectFile = previousProjectFile;
318
+ this.ignoreMatchers = previousIgnore;
319
+ return;
320
+ }
321
+
322
+ for (const entry of entries) {
323
+ const fullPath = path.join(dirPath, entry.name);
324
+ if (this.isIgnored(fullPath)) continue;
325
+
326
+ // Skip entries explicitly defined in the project tree
327
+ if (definedChildren.has(entry.name)) {
328
+ continue;
329
+ }
330
+
331
+ if (entry.isDirectory()) {
332
+ this.ensureFolder([...destPath, entry.name], results);
333
+ await this.walkDirectory(
334
+ fullPath,
335
+ [...destPath, entry.name],
336
+ results,
337
+ new Set(),
338
+ );
339
+ continue;
340
+ }
341
+
342
+ // Skip init files here (handled earlier)
343
+ if (initCandidates.includes(entry.name)) {
344
+ continue;
345
+ }
346
+
347
+ if (this.isScriptFile(entry.name)) {
348
+ const baseName = path.parse(entry.name).name;
349
+ if (definedChildren.has(baseName)) {
350
+ continue;
351
+ }
352
+ const { className, scriptName } = this.classifyScript(entry.name);
353
+ if (definedChildren.has(scriptName)) {
354
+ continue;
355
+ }
356
+ const source = await fs.readFile(fullPath, "utf-8");
357
+ this.ensureFolder(destPath, results);
358
+ results.push({
359
+ guid: this.makeGuid(),
360
+ className,
361
+ name: scriptName,
362
+ path: [...destPath, scriptName],
363
+ source,
364
+ });
365
+ }
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Ensure a Folder chain exists for the given path.
371
+ */
372
+ private ensureFolder(pathSegments: string[], results: InstanceData[]): void {
373
+ if (pathSegments.length === 0) return;
374
+ const key = pathSegments.join("/");
375
+ if (this.moduleContainers.has(key)) return;
376
+ if (this.emittedFolders.has(key)) return;
377
+ // ensure parents first
378
+ this.ensureFolder(pathSegments.slice(0, -1), results);
379
+ this.emittedFolders.add(key);
380
+ results.push({
381
+ guid: this.makeGuid(),
382
+ className: "Folder",
383
+ name: pathSegments[pathSegments.length - 1],
384
+ path: [...pathSegments],
385
+ });
386
+ }
387
+
388
+ private async findInit(
389
+ dirPath: string,
390
+ ): Promise<{ fileName: string; source: string } | null> {
391
+ const candidates = this.getInitCandidates();
392
+
393
+ for (const candidate of candidates) {
394
+ const full = path.join(dirPath, candidate);
395
+ if (await this.exists(full)) {
396
+ const source = await fs.readFile(full, "utf-8");
397
+ return { fileName: candidate, source };
398
+ }
399
+ }
400
+
401
+ return null;
402
+ }
403
+
404
+ private getInitCandidates(): string[] {
405
+ const bases = ["init", "init.server", "init.client", "init.module"];
406
+
407
+ const variants: string[] = [];
408
+ for (const base of bases) {
409
+ variants.push(`${base}.lua`, `${base}.luau`);
410
+ }
411
+
412
+ return [...new Set(variants)];
413
+ }
414
+
415
+ private isScriptFile(fileName: string): boolean {
416
+ return fileName.endsWith(".lua") || fileName.endsWith(".luau");
417
+ }
418
+
419
+ private classifyScript(fileName: string): {
420
+ className: "Script" | "LocalScript" | "ModuleScript";
421
+ scriptName: string;
422
+ } {
423
+ const normalized = fileName.replace(/\.lua$/i, ".luau");
424
+ const base = normalized.replace(/\.luau$/i, "");
425
+
426
+ if (base.endsWith(".server")) {
427
+ return { className: "Script", scriptName: base.replace(/\.server$/, "") };
428
+ }
429
+ if (base.endsWith(".client")) {
430
+ return {
431
+ className: "LocalScript",
432
+ scriptName: base.replace(/\.client$/, ""),
433
+ };
434
+ }
435
+ if (base.endsWith(".module")) {
436
+ return {
437
+ className: "ModuleScript",
438
+ scriptName: base.replace(/\.module$/, ""),
439
+ };
440
+ }
441
+ return { className: "ModuleScript", scriptName: base };
442
+ }
443
+
444
+ private async exists(target: string): Promise<boolean> {
445
+ try {
446
+ await fs.access(target);
447
+ return true;
448
+ } catch {
449
+ return false;
450
+ }
451
+ }
452
+
453
+ private async pathKind(target: string): Promise<"file" | "dir" | null> {
454
+ try {
455
+ const stat = await fs.stat(target);
456
+ if (stat.isDirectory()) return "dir";
457
+ if (stat.isFile()) return "file";
458
+ return null;
459
+ } catch {
460
+ return null;
461
+ }
462
+ }
463
+
464
+ private makeGuid(): string {
465
+ return randomUUID().replace(/-/g, "");
466
+ }
467
+ }
@@ -0,0 +1,161 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { log } from "./util/log.js";
5
+ import type { InstanceData } from "./ipc/messages.js";
6
+
7
+ export interface SnapshotOptions {
8
+ sourceDir: string;
9
+ destPrefix?: string[];
10
+ skipSymlinks?: boolean;
11
+ }
12
+
13
+ export class SnapshotBuilder {
14
+ private sourceDir: string;
15
+ private destPrefix: string[];
16
+ private skipSymlinks: boolean;
17
+
18
+ private folderMap = new Map<string, InstanceData>();
19
+ private results: InstanceData[] = [];
20
+ private scriptPaths = new Set<string>();
21
+
22
+ constructor(options: SnapshotOptions) {
23
+ this.sourceDir = path.resolve(options.sourceDir);
24
+ this.destPrefix = options.destPrefix ?? [];
25
+ this.skipSymlinks = options.skipSymlinks !== false; // default: skip links
26
+ }
27
+
28
+ public async build(): Promise<InstanceData[]> {
29
+ this.results = [];
30
+ this.folderMap.clear();
31
+ this.scriptPaths.clear();
32
+
33
+ await this.walk(this.sourceDir);
34
+
35
+ this.results.sort((a, b) => a.path.length - b.path.length);
36
+ return this.results;
37
+ }
38
+
39
+ private async walk(dir: string): Promise<void> {
40
+ const entries = await fs.readdir(dir, { withFileTypes: true });
41
+
42
+ const files = entries.filter((entry) => entry.isFile());
43
+ const directories = entries.filter((entry) => entry.isDirectory());
44
+
45
+ for (const entry of files) {
46
+ const fullPath = path.join(dir, entry.name);
47
+ if (entry.isSymbolicLink && entry.isSymbolicLink()) {
48
+ if (!this.skipSymlinks) {
49
+ continue;
50
+ }
51
+ log.debug(`Skipping symlinked file during snapshot: ${fullPath}`);
52
+ continue;
53
+ }
54
+
55
+ if (entry.name.endsWith(".luau") || entry.name.endsWith(".lua")) {
56
+ const relSegments = this.relativeSegments(fullPath);
57
+ const { className, scriptName } = this.classifyScript(entry.name);
58
+ const dirSegments = relSegments.slice(0, -1);
59
+ if (dirSegments.length > 0) {
60
+ this.ensureFolder(dirSegments);
61
+ }
62
+
63
+ const filePathSegments = [
64
+ ...this.destPrefix,
65
+ ...dirSegments,
66
+ scriptName,
67
+ ];
68
+ const source = await fs.readFile(fullPath, "utf-8");
69
+
70
+ this.scriptPaths.add(this.pathKey(filePathSegments));
71
+
72
+ const fileData: InstanceData = {
73
+ guid: this.makeGuid(),
74
+ className,
75
+ name: scriptName,
76
+ path: filePathSegments,
77
+ source,
78
+ };
79
+
80
+ this.results.push(fileData);
81
+ }
82
+ }
83
+
84
+ for (const entry of directories) {
85
+ const fullPath = path.join(dir, entry.name);
86
+ if (entry.isSymbolicLink && entry.isSymbolicLink()) {
87
+ if (!this.skipSymlinks) {
88
+ continue;
89
+ }
90
+ log.debug(`Skipping symlinked directory during snapshot: ${fullPath}`);
91
+ continue;
92
+ }
93
+
94
+ const relSegments = this.relativeSegments(fullPath);
95
+ if (relSegments.length > 0) {
96
+ this.ensureFolder(relSegments);
97
+ }
98
+
99
+ await this.walk(fullPath);
100
+ }
101
+ }
102
+
103
+ private ensureFolder(segments: string[]): void {
104
+ for (let i = 1; i <= segments.length; i++) {
105
+ const keySegments = segments.slice(0, i);
106
+ const full = [...this.destPrefix, ...keySegments];
107
+ const key = this.pathKey(full);
108
+ if (this.scriptPaths.has(key)) continue;
109
+ if (this.folderMap.has(key)) continue;
110
+ const data: InstanceData = {
111
+ guid: this.makeGuid(),
112
+ className: "Folder",
113
+ name: keySegments[i - 1],
114
+ path: full,
115
+ };
116
+ this.folderMap.set(key, data);
117
+ this.results.push(data);
118
+ }
119
+ }
120
+
121
+ private relativeSegments(targetPath: string): string[] {
122
+ const rel = path.relative(this.sourceDir, targetPath);
123
+ if (!rel || rel === "") return [];
124
+ return rel.split(path.sep).filter(Boolean);
125
+ }
126
+
127
+ private classifyScript(fileName: string): {
128
+ className: "Script" | "LocalScript" | "ModuleScript";
129
+ scriptName: string;
130
+ } {
131
+ if (fileName.endsWith(".lua")) {
132
+ fileName = fileName.replace(/\.lua$/i, ".luau");
133
+ }
134
+
135
+ const base = fileName.replace(/\.luau$/i, "");
136
+ if (base.endsWith(".server")) {
137
+ return { className: "Script", scriptName: base.replace(/\.server$/, "") };
138
+ }
139
+ if (base.endsWith(".client")) {
140
+ return {
141
+ className: "LocalScript",
142
+ scriptName: base.replace(/\.client$/, ""),
143
+ };
144
+ }
145
+ if (base.endsWith(".module")) {
146
+ return {
147
+ className: "ModuleScript",
148
+ scriptName: base.replace(/\.module$/, ""),
149
+ };
150
+ }
151
+ return { className: "ModuleScript", scriptName: base };
152
+ }
153
+
154
+ private makeGuid(): string {
155
+ return randomUUID().replace(/-/g, "");
156
+ }
157
+
158
+ private pathKey(segments: string[]): string {
159
+ return segments.join("/");
160
+ }
161
+ }