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,414 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { TreeNode } from "./treeManager.js";
4
+ import { config } from "../config.js";
5
+ import { log } from "../util/log.js";
6
+
7
+ /**
8
+ * Mapping of GUID to file path
9
+ */
10
+ export interface FileMapping {
11
+ guid: string;
12
+ filePath: string;
13
+ className: string;
14
+ }
15
+
16
+ /**
17
+ * Handles writing the virtual tree to the filesystem
18
+ */
19
+ export class FileWriter {
20
+ private baseDir: string;
21
+ private fileMappings: Map<string, FileMapping> = new Map();
22
+ private pathToGuid: Map<string, string> = new Map(); // Reverse index for O(1) path lookups
23
+
24
+ constructor(baseDir: string = config.syncDir) {
25
+ this.baseDir = path.resolve(baseDir);
26
+ this.ensureDirectory(this.baseDir);
27
+ }
28
+
29
+ /**
30
+ * Write all script nodes to the filesystem
31
+ */
32
+ public writeTree(nodes: Map<string, TreeNode>): void {
33
+ log.info("Writing tree to filesystem...");
34
+
35
+ // Clear existing mappings
36
+ this.fileMappings.clear();
37
+ this.pathToGuid.clear();
38
+
39
+ // Collect all script nodes for batch writing
40
+ const scriptNodes: TreeNode[] = [];
41
+ for (const node of nodes.values()) {
42
+ if (this.isScriptNode(node)) {
43
+ scriptNodes.push(node);
44
+ }
45
+ }
46
+
47
+ this.writeBatch(scriptNodes);
48
+
49
+ log.success(`Wrote ${this.fileMappings.size} scripts to filesystem`);
50
+ }
51
+
52
+ /**
53
+ * Write multiple scripts in a batch for improved I/O efficiency
54
+ */
55
+ public writeBatch(nodes: TreeNode[]): void {
56
+ // Pre-compute all file paths and collect writes
57
+ const writes: { node: TreeNode; filePath: string; dirPath: string }[] = [];
58
+ const dirsToCreate = new Set<string>();
59
+ const batchPathToGuid = new Map<string, string>();
60
+
61
+ for (const node of nodes) {
62
+ if (!this.isScriptNode(node) || node.source === undefined) continue;
63
+ const filePath = this.getFilePathWithCollisionMap(node, batchPathToGuid);
64
+ const dirPath = path.dirname(filePath);
65
+ writes.push({ node, filePath, dirPath });
66
+ dirsToCreate.add(dirPath);
67
+ batchPathToGuid.set(path.resolve(filePath), node.guid);
68
+ }
69
+
70
+ // Batch create all directories first (sorted by depth to ensure parents exist)
71
+ const sortedDirs = Array.from(dirsToCreate).sort(
72
+ (a, b) => a.length - b.length,
73
+ );
74
+ for (const dir of sortedDirs) {
75
+ this.ensureDirectory(dir);
76
+ }
77
+
78
+ for (const { node, filePath } of writes) {
79
+ try {
80
+ fs.writeFileSync(filePath, node.source!, "utf-8");
81
+
82
+ this.fileMappings.set(node.guid, {
83
+ guid: node.guid,
84
+ filePath: filePath,
85
+ className: node.className,
86
+ });
87
+ this.pathToGuid.set(path.resolve(filePath), node.guid);
88
+
89
+ log.script(this.getRelativePath(filePath), "updated");
90
+ } catch (error) {
91
+ log.error(`Failed to write script ${filePath}:`, error);
92
+ }
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Write or update a single script
98
+ */
99
+ public writeScript(node: TreeNode): string | null {
100
+ if (!this.isScriptNode(node)) {
101
+ return null;
102
+ }
103
+
104
+ // Allow empty-string sources on new scripts; only skip if source is truly undefined
105
+ if (node.source === undefined) {
106
+ return null;
107
+ }
108
+
109
+ const existingMapping = this.fileMappings.get(node.guid);
110
+ const filePath = this.getFilePath(node);
111
+ const dirPath = path.dirname(filePath);
112
+ const previousPath = existingMapping?.filePath;
113
+ const pathChanged = previousPath && previousPath !== filePath;
114
+
115
+ // Ensure directory exists
116
+ this.ensureDirectory(dirPath);
117
+
118
+ // Write file
119
+ try {
120
+ fs.writeFileSync(filePath, node.source, "utf-8");
121
+
122
+ // If the target path changed for this guid, remove the old file to avoid stale copies
123
+ if (pathChanged && previousPath && fs.existsSync(previousPath)) {
124
+ fs.unlinkSync(previousPath);
125
+ this.pathToGuid.delete(path.resolve(previousPath));
126
+ this.cleanupParentsIfEmpty(path.dirname(previousPath));
127
+ }
128
+
129
+ // Update mapping and reverse index
130
+ this.fileMappings.set(node.guid, {
131
+ guid: node.guid,
132
+ filePath: filePath,
133
+ className: node.className,
134
+ });
135
+ this.pathToGuid.set(path.resolve(filePath), node.guid);
136
+
137
+ log.script(this.getRelativePath(filePath), "updated");
138
+ return filePath;
139
+ } catch (error) {
140
+ log.error(`Failed to write script ${filePath}:`, error);
141
+ return null;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Delete a script file
147
+ */
148
+ public deleteScript(guid: string): boolean {
149
+ const mapping = this.fileMappings.get(guid);
150
+ if (!mapping) {
151
+ return false;
152
+ }
153
+
154
+ try {
155
+ const deleted = this.deleteFilePathInternal(mapping.filePath);
156
+ this.fileMappings.delete(guid);
157
+ this.pathToGuid.delete(path.resolve(mapping.filePath));
158
+ return deleted;
159
+ } catch (error) {
160
+ log.error(`Failed to delete script ${mapping.filePath}:`, error);
161
+ return false;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Delete a script file by path even if the mapping is missing
167
+ */
168
+ public deleteFilePath(filePath: string): boolean {
169
+ try {
170
+ return this.deleteFilePathInternal(filePath);
171
+ } catch (error) {
172
+ log.error(`Failed to delete script ${filePath}:`, error);
173
+ return false;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Get the filesystem path for a node
179
+ */
180
+ public getFilePath(node: TreeNode): string {
181
+ return this.getFilePathWithCollisionMap(node);
182
+ }
183
+
184
+ /**
185
+ * Get the filesystem path for a node, with optional collision map for batch operations
186
+ */
187
+ private getFilePathWithCollisionMap(
188
+ node: TreeNode,
189
+ batchCollisionMap?: Map<string, string>,
190
+ ): string {
191
+ // Build the path from the node's hierarchy. For scripts, we only use the parent path
192
+ // as directories, then add the script file name. This prevents creating an extra
193
+ // folder named after the script itself.
194
+ const parts: string[] = [];
195
+
196
+ const dirSegments = this.isScriptNode(node)
197
+ ? node.path.slice(0, Math.max(0, node.path.length - 1))
198
+ : node.path;
199
+
200
+ for (const segment of dirSegments) {
201
+ parts.push(this.sanitizeName(segment));
202
+ }
203
+
204
+ // If this is a script, add the script name as a file
205
+ if (this.isScriptNode(node)) {
206
+ const scriptName = this.getScriptFileName(node);
207
+ parts.push(scriptName);
208
+ }
209
+
210
+ const desiredPath = path.join(this.baseDir, ...parts);
211
+ const normalizedDesiredPath = path.resolve(desiredPath);
212
+
213
+ // Check for collisions in both the persistent mappings and the batch collision map
214
+ const existingGuid = this.findGuidByFilePath(desiredPath);
215
+ const batchGuid = batchCollisionMap?.get(normalizedDesiredPath);
216
+ const collision = existingGuid || batchGuid;
217
+
218
+ // If another GUID already owns this path, disambiguate using a stable suffix
219
+ if (collision && collision !== node.guid) {
220
+ const ext = config.scriptExtension;
221
+ const uniqueName = `${this.sanitizeName(node.name)}__${node.guid.slice(
222
+ 0,
223
+ 8,
224
+ )}${ext}`;
225
+ const uniqueParts = [...parts.slice(0, -1), uniqueName];
226
+ return path.join(this.baseDir, ...uniqueParts);
227
+ }
228
+
229
+ return desiredPath;
230
+ }
231
+
232
+ /**
233
+ * Get the appropriate filename for a script node
234
+ */
235
+ private getScriptFileName(node: TreeNode): string {
236
+ const ext = config.scriptExtension;
237
+
238
+ // If the script has the same name as its parent, use init pattern
239
+ // const parentName = node.path[node.path.length - 2]; // 2 to get parent
240
+ // if (node.name === parentName) {
241
+ // log.info(
242
+ // `Using init file pattern for script ${node.name} because it matches its parent directory name (${parentName}).`
243
+ // );
244
+ // return `init${ext}`;
245
+ // }
246
+
247
+ let name = this.sanitizeName(node.name);
248
+
249
+ if (node.className === "Script") {
250
+ name = `${name}.server`;
251
+ } else if (node.className === "LocalScript") {
252
+ name = `${name}.client`;
253
+ } else if (node.className === "ModuleScript") {
254
+ if (config.suffixModuleScripts) {
255
+ name = `${name}.module`;
256
+ }
257
+ }
258
+
259
+ return `${name}${ext}`;
260
+ }
261
+
262
+ /**
263
+ * Sanitize a name for use in filesystem
264
+ */
265
+ private sanitizeName(name: string): string {
266
+ // Replace invalid filesystem characters
267
+ return name.replace(/[<>:"|?*]/g, "_");
268
+ }
269
+
270
+ /**
271
+ * Check if a node is a script
272
+ */
273
+ private isScriptNode(node: TreeNode): boolean {
274
+ return (
275
+ node.className === "Script" ||
276
+ node.className === "LocalScript" ||
277
+ node.className === "ModuleScript"
278
+ );
279
+ }
280
+
281
+ /**
282
+ * Ensure a directory exists
283
+ */
284
+ private ensureDirectory(dirPath: string): void {
285
+ if (!fs.existsSync(dirPath)) {
286
+ fs.mkdirSync(dirPath, { recursive: true });
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Internal helper to remove a file and clean mapping
292
+ */
293
+ private deleteFilePathInternal(filePath: string): boolean {
294
+ const normalized = path.resolve(filePath);
295
+
296
+ if (fs.existsSync(normalized)) {
297
+ fs.unlinkSync(normalized);
298
+ log.script(this.getRelativePath(normalized), "deleted");
299
+ }
300
+
301
+ const guid = this.pathToGuid.get(normalized);
302
+ if (guid) {
303
+ this.fileMappings.delete(guid);
304
+ this.pathToGuid.delete(normalized);
305
+ }
306
+
307
+ return true;
308
+ }
309
+
310
+ /**
311
+ * Find the GUID that currently owns a file path, if any
312
+ */
313
+ private findGuidByFilePath(filePath: string): string | undefined {
314
+ const normalized = path.resolve(filePath);
315
+ for (const [guid, mapping] of this.fileMappings) {
316
+ if (path.resolve(mapping.filePath) === normalized) {
317
+ return guid;
318
+ }
319
+ }
320
+ return undefined;
321
+ }
322
+
323
+ /**
324
+ * Get path relative to base directory
325
+ */
326
+ private getRelativePath(filePath: string): string {
327
+ return path.relative(this.baseDir, filePath);
328
+ }
329
+
330
+ /**
331
+ * Get file mapping by GUID
332
+ */
333
+ public getMapping(guid: string): FileMapping | undefined {
334
+ return this.fileMappings.get(guid);
335
+ }
336
+
337
+ /**
338
+ * Get GUID by file path
339
+ */
340
+ public getGuidByPath(filePath: string): string | undefined {
341
+ return this.pathToGuid.get(path.resolve(filePath));
342
+ }
343
+
344
+ /**
345
+ * Get all file mappings
346
+ */
347
+ public getAllMappings(): Map<string, FileMapping> {
348
+ return this.fileMappings;
349
+ }
350
+
351
+ /**
352
+ * Get the base directory
353
+ */
354
+ public getBaseDir(): string {
355
+ return this.baseDir;
356
+ }
357
+
358
+ /**
359
+ * Clean up empty directories
360
+ */
361
+ public cleanupEmptyDirectories(): void {
362
+ this.cleanupEmptyDirsRecursive(this.baseDir);
363
+ }
364
+
365
+ private cleanupEmptyDirsRecursive(dirPath: string): boolean {
366
+ if (!fs.existsSync(dirPath)) {
367
+ return false;
368
+ }
369
+
370
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
371
+
372
+ // Recursively check subdirectories
373
+ for (const entry of entries) {
374
+ if (entry.isDirectory()) {
375
+ const subPath = path.join(dirPath, entry.name);
376
+ this.cleanupEmptyDirsRecursive(subPath);
377
+ }
378
+ }
379
+
380
+ // Check if directory is now empty
381
+ const updatedEntries = fs.readdirSync(dirPath);
382
+ if (updatedEntries.length === 0 && dirPath !== this.baseDir) {
383
+ fs.rmdirSync(dirPath);
384
+ return true;
385
+ }
386
+
387
+ return false;
388
+ }
389
+
390
+ /**
391
+ * Walk up from a directory and remove empty parents until baseDir is reached.
392
+ */
393
+ private cleanupParentsIfEmpty(startDir: string): void {
394
+ let current = path.resolve(startDir);
395
+ const root = this.baseDir;
396
+
397
+ while (current.startsWith(root)) {
398
+ if (current === root) {
399
+ break;
400
+ }
401
+
402
+ const entries = fs.existsSync(current)
403
+ ? fs.readdirSync(current, { withFileTypes: true })
404
+ : [];
405
+
406
+ if (entries.length === 0) {
407
+ fs.rmdirSync(current);
408
+ current = path.dirname(current);
409
+ } else {
410
+ break;
411
+ }
412
+ }
413
+ }
414
+ }