@ldgerrits/rogen 1.4.1 → 1.4.2

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 CHANGED
@@ -5,20 +5,22 @@
5
5
  </div>
6
6
 
7
7
  <div align="center">
8
- <a href="https://rogen-playfully.vercel.app"><strong>Documentation</strong></a>
8
+ <a href="https://rogen-playfully.vercel.app"><strong>documentation</strong></a>
9
9
  </div>
10
10
 
11
11
  ---
12
12
 
13
- A CLI tool enabling feature-based architecture through automated routing of client, server, and shared code to Roblox services.
13
+ <div align="center">
14
+ A CLI tool that enables feature-based architecture for Roblox
15
+ </div>
14
16
 
15
17
  ## Features
16
18
 
17
- - **Feature Folders:** Store related client, server and shared scripts inside a single folder.
18
- - **Auto-Routing:** Route files to Roblox services automatically using Rojo or Argon.
19
+ - **Feature Folders:** Keep related client, server, and shared code inside a single folder.
20
+ - **Auto-Routing:** Map files to Roblox services automatically using Rojo or Argon.
19
21
  - **Multi-Place Support:** Merge multiple source directories to share core systems across different places.
20
- - **Environment Tags:** Filter test files and inject mock files at build time using tags.
21
- - **Custom Pathing:** Control the final output structure using invisible folders and marker files.
22
+ - **Environment Tags:** Filter test files and inject mock dependencies at build time using tags.
23
+ - **Granular Control:** Shape the project tree using invisible folders, hoisting, and marker files.
22
24
  - **Modern Tooling:** Support Luau, roblox-ts, and Darklua pipelines out of the box.
23
25
 
24
26
  ## Contributing
package/dist/build.js CHANGED
@@ -3,7 +3,7 @@ import path from "path";
3
3
  import picomatch from "picomatch";
4
4
  import { applyCasing, getOrCreateNode, pruneObject, sortObject, findMissingPaths, toPosix, collapseFolders, isInitFile, isValidSource, findExposedDataFiles, } from "./tree.js";
5
5
  import { resolveRoute, } from "./route.js";
6
- import { serviceParents, generateRoutingMaps, defaultConfig, } from "./constants.js";
6
+ import { serviceParents, generateRoutingMaps, defaultConfig, services, } from "./constants.js";
7
7
  const SYSTEM_MARKERS = {
8
8
  structure: true,
9
9
  verbatim: true,
@@ -116,6 +116,25 @@ export async function build(targetConfig, baseProjectTree, config, env, sourcePa
116
116
  modeCopy.build = cliArgs.build;
117
117
  }
118
118
  const rojoTree = structuredClone(baseProjectTree);
119
+ // Pre-populate all routing containers to prevent desync issues
120
+ for (const targetService of Object.values(services)) {
121
+ let current = rojoTree.tree;
122
+ if (serviceParents[targetService]) {
123
+ current = getOrCreateNode(current, serviceParents[targetService]);
124
+ }
125
+ getOrCreateNode(current, targetService);
126
+ }
127
+ if (config.aliases) {
128
+ for (const aliasPath of Object.values(config.aliases)) {
129
+ if (typeof aliasPath === "string") {
130
+ const parts = aliasPath.split(".");
131
+ let current = rojoTree.tree;
132
+ for (const part of parts) {
133
+ current = getOrCreateNode(current, part);
134
+ }
135
+ }
136
+ }
137
+ }
119
138
  const mergedTags = { ...(config.tags || {}), ...(modeCopy.tags || {}) };
120
139
  const knownTags = new Set(Object.keys(config.tags || {}).map((t) => t.toLowerCase()));
121
140
  const activeTags = new Set(Object.keys(mergedTags)
@@ -146,6 +165,7 @@ export async function build(targetConfig, baseProjectTree, config, env, sourcePa
146
165
  tagRegexes,
147
166
  };
148
167
  const combinedGlobIgnorePaths = Array.from(new Set([
168
+ ...(rojoTree.globIgnorePaths || []),
149
169
  ...(config.globIgnorePaths || []),
150
170
  ...(modeCopy.globIgnorePaths || []),
151
171
  ]));
@@ -167,10 +187,8 @@ export async function build(targetConfig, baseProjectTree, config, env, sourcePa
167
187
  };
168
188
  walkSource(sourcePath, sourcePath, listings, (filepath, isInit, isSync) => {
169
189
  const relativePath = path.relative(sourcePath, filepath);
170
- if (relativePath && isIgnored(toPosix(relativePath)))
171
- return;
172
190
  const { targetService, wrapperFolder, virtualParts, nodeName, projectPath, dropped, unwrap, } = resolveRoute(relativePath, isInit, newContext, isSync);
173
- if (dropped)
191
+ if (dropped || (projectPath && isIgnored(toPosix(projectPath))))
174
192
  return;
175
193
  fileCount++;
176
194
  let current = rojoTree.tree;
@@ -194,10 +212,13 @@ export async function build(targetConfig, baseProjectTree, config, env, sourcePa
194
212
  const existingNode = existingNodeRaw || {};
195
213
  const newNode = {
196
214
  ...existingNode,
197
- $path: projectPath,
215
+ $path: { optional: projectPath },
198
216
  };
199
217
  if (newNode.$className === "Folder") {
200
218
  delete newNode.$className;
219
+ if (newNode.$ignoreUnknownInstances === false) {
220
+ delete newNode.$ignoreUnknownInstances;
221
+ }
201
222
  }
202
223
  current[nodeName] = newNode;
203
224
  nodeOrigins.set(newNode, {
@@ -209,8 +230,11 @@ export async function build(targetConfig, baseProjectTree, config, env, sourcePa
209
230
  const outputDir = path.dirname(modeCopy.output);
210
231
  const removed = [];
211
232
  const prunedTree = pruneObject(rojoTree.tree, context.build, outputDir, removed);
212
- collapseFolders(prunedTree, context.build, outputDir);
233
+ collapseFolders(prunedTree, context.build, outputDir, isIgnored);
213
234
  rojoTree.tree = prunedTree;
235
+ if (combinedGlobIgnorePaths.length > 0) {
236
+ rojoTree.globIgnorePaths = combinedGlobIgnorePaths;
237
+ }
214
238
  const sortedTree = sortObject(rojoTree);
215
239
  const missingPaths = findMissingPaths(sortedTree.tree, context.build, outputDir);
216
240
  const exposedDataFiles = findExposedDataFiles(sortedTree.tree);
package/dist/bundle.cjs CHANGED
@@ -8858,14 +8858,15 @@ var import_chokidar = __toESM(require_chokidar(), 1);
8858
8858
  var import_util = require("util");
8859
8859
  function printHelp(logger) {
8860
8860
  const helpText = `
8861
- Rogen - Feature-based folder routing for Roblox
8861
+ Rogen - Feature-based architecture for Roblox
8862
8862
 
8863
8863
  Usage:
8864
8864
  rogen [command] [options]
8865
8865
 
8866
8866
  Commands:
8867
8867
  init Generate a .rogen.json config file
8868
- watch Watch the source and generate automatically
8868
+ build Build the project
8869
+ watch Watch the source and build automatically
8869
8870
 
8870
8871
  Options:
8871
8872
  -c, --config <path> Specify custom config file
@@ -8916,6 +8917,7 @@ function parseCliArgs(args = process.argv.slice(2), logger) {
8916
8917
  parsedArgs.help = true;
8917
8918
  } else if (subcommand === "version") {
8918
8919
  parsedArgs.version = true;
8920
+ } else if (subcommand === "build") {
8919
8921
  } else {
8920
8922
  const errorMsg = `unknown subcommand "${positionals[0]}".
8921
8923
  Run 'rogen --help' to see a list of available commands.`;
@@ -8964,7 +8966,7 @@ var import_fs = __toESM(require("fs"), 1);
8964
8966
  var import_path = __toESM(require("path"), 1);
8965
8967
 
8966
8968
  // src/constants.ts
8967
- var version = "1.4.1";
8969
+ var version = "1.4.2";
8968
8970
  var defaultProject = {
8969
8971
  name: "roblox-game",
8970
8972
  tree: { $className: "DataModel" }
@@ -9018,6 +9020,10 @@ var serviceParents = {
9018
9020
  StarterPlayerScripts: "StarterPlayer",
9019
9021
  StarterCharacterScripts: "StarterPlayer"
9020
9022
  };
9023
+ var combinedServices = /* @__PURE__ */ new Set([
9024
+ ...Object.values(services),
9025
+ ...Object.values(serviceParents)
9026
+ ]);
9021
9027
  var serverContainers = /* @__PURE__ */ new Set([
9022
9028
  "ServerScriptService",
9023
9029
  "ServerStorage"
@@ -9418,9 +9424,27 @@ function applyCasing(value, casing) {
9418
9424
  const firstCharacter = casing === "PascalCase" ? value[0].toUpperCase() : value[0].toLowerCase();
9419
9425
  return firstCharacter + value.slice(1);
9420
9426
  }
9427
+ function getPathString(node) {
9428
+ const p = node.$path;
9429
+ if (typeof p === "string") return p;
9430
+ if (p && typeof p === "object" && typeof p.optional === "string")
9431
+ return p.optional;
9432
+ return void 0;
9433
+ }
9421
9434
  function getOrCreateNode(parent, key, className) {
9435
+ const isRootService = combinedServices.has(key) || className && combinedServices.has(className);
9422
9436
  if (!parent[key]) {
9423
- parent[key] = className == null ? {} : { $className: className };
9437
+ parent[key] = className == null ? {} : {
9438
+ $className: className,
9439
+ ...isRootService ? {} : { $ignoreUnknownInstances: false }
9440
+ };
9441
+ } else if (className != null) {
9442
+ const existing = parent[key];
9443
+ if (existing.$ignoreUnknownInstances === void 0 && existing.$path === void 0) {
9444
+ if (!isRootService) {
9445
+ existing.$ignoreUnknownInstances = false;
9446
+ }
9447
+ }
9424
9448
  }
9425
9449
  return parent[key];
9426
9450
  }
@@ -9430,16 +9454,20 @@ function pruneObject(node, buildDir, outputDir, removed = [], treePath = "") {
9430
9454
  if (typeof val !== "object" || val === null) continue;
9431
9455
  const childTreePath = treePath ? `${treePath}.${key}` : key;
9432
9456
  const childNode = val;
9433
- if (childNode.$path) {
9434
- if (hasPathPrefix(childNode.$path, buildDir)) continue;
9435
- const absolutePath = import_path2.default.resolve(outputDir, childNode.$path);
9436
- if (!import_fs2.default.existsSync(absolutePath)) {
9437
- delete node[key];
9438
- removed.push({
9439
- treePath: childTreePath,
9440
- rojoPath: childNode.$path
9441
- });
9442
- continue;
9457
+ const childPath = getPathString(childNode);
9458
+ if (childPath) {
9459
+ if (hasPathPrefix(childPath, buildDir)) continue;
9460
+ const isOptional = typeof childNode.$path === "object";
9461
+ if (!isOptional) {
9462
+ const absolutePath = import_path2.default.resolve(outputDir, childPath);
9463
+ if (!import_fs2.default.existsSync(absolutePath)) {
9464
+ delete node[key];
9465
+ removed.push({
9466
+ treePath: childTreePath,
9467
+ rojoPath: childPath
9468
+ });
9469
+ continue;
9470
+ }
9443
9471
  }
9444
9472
  }
9445
9473
  pruneObject(childNode, buildDir, outputDir, removed, childTreePath);
@@ -9462,14 +9490,15 @@ function findMissingPaths(node, buildDir, outputDir, missing = [], treePath = ""
9462
9490
  if (typeof val !== "object" || val === null) continue;
9463
9491
  const childTreePath = treePath ? `${treePath}.${key}` : key;
9464
9492
  const childNode = val;
9465
- if (childNode.$path && hasPathPrefix(childNode.$path, buildDir)) {
9466
- const absolutePath = import_path2.default.resolve(outputDir, childNode.$path);
9493
+ const childPath = getPathString(childNode);
9494
+ if (childPath && hasPathPrefix(childPath, buildDir)) {
9495
+ const absolutePath = import_path2.default.resolve(outputDir, childPath);
9467
9496
  if (!import_fs2.default.existsSync(absolutePath)) {
9468
9497
  missing.push({
9469
9498
  parent: node,
9470
9499
  key,
9471
9500
  treePath: childTreePath,
9472
- path: childNode.$path,
9501
+ path: childPath,
9473
9502
  absolutePath
9474
9503
  });
9475
9504
  }
@@ -9488,6 +9517,7 @@ var isScript = (filename) => /\.(tsx?|luau|lua)$/i.test(filename) && !filename.t
9488
9517
  var isModel = (filename) => /\.(rbxm|rbxmx)$/i.test(filename);
9489
9518
  function isData(filename) {
9490
9519
  if (isScript(filename) || isModel(filename)) return false;
9520
+ if (filename.toLowerCase().endsWith(".d.ts")) return false;
9491
9521
  if (filename.startsWith(".")) return false;
9492
9522
  return /\.[a-z0-9]+$/i.test(filename);
9493
9523
  }
@@ -9505,7 +9535,7 @@ function getRojoBaseName(filename) {
9505
9535
  }
9506
9536
  return filename;
9507
9537
  }
9508
- function collapseFolders(node, buildDir, outputDir) {
9538
+ function collapseFolders(node, buildDir, outputDir, isIgnored = () => false) {
9509
9539
  let childCount = 0;
9510
9540
  let canCollapse = node.$path === void 0;
9511
9541
  let commonDir = null;
@@ -9514,12 +9544,13 @@ function collapseFolders(node, buildDir, outputDir) {
9514
9544
  const val = node[key];
9515
9545
  if (typeof val !== "object" || val === null) continue;
9516
9546
  const childNode = val;
9517
- collapseFolders(childNode, buildDir, outputDir);
9547
+ collapseFolders(childNode, buildDir, outputDir, isIgnored);
9518
9548
  childCount++;
9519
- if (!childNode.$path) {
9549
+ const childPath = getPathString(childNode);
9550
+ if (!childPath) {
9520
9551
  canCollapse = false;
9521
9552
  } else {
9522
- const childAbsPath = import_path2.default.resolve(outputDir, childNode.$path);
9553
+ const childAbsPath = import_path2.default.resolve(outputDir, childPath);
9523
9554
  const parentDir = import_path2.default.dirname(childAbsPath);
9524
9555
  if (commonDir === null) {
9525
9556
  commonDir = parentDir;
@@ -9539,10 +9570,11 @@ function collapseFolders(node, buildDir, outputDir) {
9539
9570
  if (!hasPathPrefix(toPosix(commonDir), toPosix(absoluteBuildDir))) {
9540
9571
  return;
9541
9572
  }
9573
+ const relativeCommonDir = toPosix(import_path2.default.relative(outputDir, commonDir));
9542
9574
  try {
9543
9575
  const diskItems = import_fs2.default.readdirSync(commonDir);
9544
9576
  const visibleDiskItems = diskItems.filter(
9545
- (item) => !item.startsWith(".")
9577
+ (item) => !item.startsWith(".") && !isIgnored(toPosix(import_path2.default.join(relativeCommonDir, item)))
9546
9578
  );
9547
9579
  if (visibleDiskItems.length !== childCount) {
9548
9580
  return;
@@ -9550,13 +9582,12 @@ function collapseFolders(node, buildDir, outputDir) {
9550
9582
  } catch {
9551
9583
  return;
9552
9584
  }
9553
- const relativeCommonDir = toPosix(import_path2.default.relative(outputDir, commonDir));
9554
9585
  for (const key in node) {
9555
9586
  if (!key.startsWith("$")) {
9556
9587
  delete node[key];
9557
9588
  }
9558
9589
  }
9559
- node.$path = relativeCommonDir;
9590
+ node.$path = { optional: relativeCommonDir };
9560
9591
  delete node.$className;
9561
9592
  }
9562
9593
  function findExposedDataFiles(node, exposed = []) {
@@ -9565,8 +9596,9 @@ function findExposedDataFiles(node, exposed = []) {
9565
9596
  const val = node[key];
9566
9597
  if (typeof val !== "object" || val === null) continue;
9567
9598
  const childNode = val;
9568
- if (childNode.$path && isData(childNode.$path)) {
9569
- exposed.push({ parent: node, key, path: childNode.$path });
9599
+ const childPath = getPathString(childNode);
9600
+ if (childPath && isData(childPath)) {
9601
+ exposed.push({ parent: node, key, path: childPath });
9570
9602
  }
9571
9603
  findExposedDataFiles(childNode, exposed);
9572
9604
  }
@@ -10003,6 +10035,24 @@ async function build(targetConfig, baseProjectTree, config, env, sourcePaths, cl
10003
10035
  modeCopy.build = cliArgs.build;
10004
10036
  }
10005
10037
  const rojoTree = structuredClone(baseProjectTree);
10038
+ for (const targetService of Object.values(services)) {
10039
+ let current = rojoTree.tree;
10040
+ if (serviceParents[targetService]) {
10041
+ current = getOrCreateNode(current, serviceParents[targetService]);
10042
+ }
10043
+ getOrCreateNode(current, targetService);
10044
+ }
10045
+ if (config.aliases) {
10046
+ for (const aliasPath of Object.values(config.aliases)) {
10047
+ if (typeof aliasPath === "string") {
10048
+ const parts = aliasPath.split(".");
10049
+ let current = rojoTree.tree;
10050
+ for (const part of parts) {
10051
+ current = getOrCreateNode(current, part);
10052
+ }
10053
+ }
10054
+ }
10055
+ }
10006
10056
  const mergedTags = { ...config.tags || {}, ...modeCopy.tags || {} };
10007
10057
  const knownTags = new Set(
10008
10058
  Object.keys(config.tags || {}).map((t) => t.toLowerCase())
@@ -10038,6 +10088,7 @@ async function build(targetConfig, baseProjectTree, config, env, sourcePaths, cl
10038
10088
  };
10039
10089
  const combinedGlobIgnorePaths = Array.from(
10040
10090
  /* @__PURE__ */ new Set([
10091
+ ...rojoTree.globIgnorePaths || [],
10041
10092
  ...config.globIgnorePaths || [],
10042
10093
  ...modeCopy.globIgnorePaths || []
10043
10094
  ])
@@ -10067,7 +10118,6 @@ async function build(targetConfig, baseProjectTree, config, env, sourcePaths, cl
10067
10118
  listings,
10068
10119
  (filepath, isInit, isSync) => {
10069
10120
  const relativePath2 = import_path4.default.relative(sourcePath, filepath);
10070
- if (relativePath2 && isIgnored(toPosix(relativePath2))) return;
10071
10121
  const {
10072
10122
  targetService,
10073
10123
  wrapperFolder,
@@ -10077,7 +10127,8 @@ async function build(targetConfig, baseProjectTree, config, env, sourcePaths, cl
10077
10127
  dropped,
10078
10128
  unwrap
10079
10129
  } = resolveRoute(relativePath2, isInit, newContext, isSync);
10080
- if (dropped) return;
10130
+ if (dropped || projectPath && isIgnored(toPosix(projectPath)))
10131
+ return;
10081
10132
  fileCount++;
10082
10133
  let current = rojoTree.tree;
10083
10134
  if (serviceParents[targetService]) {
@@ -10109,10 +10160,13 @@ async function build(targetConfig, baseProjectTree, config, env, sourcePaths, cl
10109
10160
  const existingNode = existingNodeRaw || {};
10110
10161
  const newNode = {
10111
10162
  ...existingNode,
10112
- $path: projectPath
10163
+ $path: { optional: projectPath }
10113
10164
  };
10114
10165
  if (newNode.$className === "Folder") {
10115
10166
  delete newNode.$className;
10167
+ if (newNode.$ignoreUnknownInstances === false) {
10168
+ delete newNode.$ignoreUnknownInstances;
10169
+ }
10116
10170
  }
10117
10171
  current[nodeName] = newNode;
10118
10172
  nodeOrigins.set(newNode, {
@@ -10130,8 +10184,11 @@ async function build(targetConfig, baseProjectTree, config, env, sourcePaths, cl
10130
10184
  outputDir,
10131
10185
  removed
10132
10186
  );
10133
- collapseFolders(prunedTree, context.build, outputDir);
10187
+ collapseFolders(prunedTree, context.build, outputDir, isIgnored);
10134
10188
  rojoTree.tree = prunedTree;
10189
+ if (combinedGlobIgnorePaths.length > 0) {
10190
+ rojoTree.globIgnorePaths = combinedGlobIgnorePaths;
10191
+ }
10135
10192
  const sortedTree = sortObject(rojoTree);
10136
10193
  const missingPaths = findMissingPaths(
10137
10194
  sortedTree.tree,
@@ -10242,7 +10299,7 @@ var ConsoleLogger = class extends AbstractLogger {
10242
10299
  };
10243
10300
 
10244
10301
  // src/execute.ts
10245
- async function execute(sourcePaths, env, activeModes, baseProjectTree, config, cliArgs, anchor, logger = new ConsoleLogger()) {
10302
+ async function execute(sourcePaths, env, activeModes, baseProjectTree, config, cliArgs, anchor, logger = new ConsoleLogger(), isInitialRun = false) {
10246
10303
  try {
10247
10304
  for (const activeMode of activeModes) {
10248
10305
  const targetConfig = activeMode.config;
@@ -10281,15 +10338,16 @@ async function execute(sourcePaths, env, activeModes, baseProjectTree, config, c
10281
10338
  }
10282
10339
  }
10283
10340
  if (buildResult.missingPaths.length > 0) {
10341
+ const isCompiledProject = env.isTsProject || env.isDarkluaProject;
10284
10342
  for (const item of buildResult.missingPaths) {
10285
10343
  const ext = import_path5.default.extname(item.absolutePath).toLowerCase();
10286
- if (ext === ".luau" || ext === ".lua") {
10344
+ if (isCompiledProject && (ext === ".luau" || ext === ".lua")) {
10287
10345
  const dir = import_path5.default.dirname(item.absolutePath);
10288
10346
  if (!import_fs4.default.existsSync(dir)) {
10289
10347
  import_fs4.default.mkdirSync(dir, { recursive: true });
10290
10348
  }
10291
10349
  import_fs4.default.writeFileSync(item.absolutePath, "");
10292
- } else if (ext === "") {
10350
+ } else if (isCompiledProject && ext === "") {
10293
10351
  if (!import_fs4.default.existsSync(item.absolutePath)) {
10294
10352
  import_fs4.default.mkdirSync(item.absolutePath, {
10295
10353
  recursive: true
@@ -10312,14 +10370,18 @@ async function execute(sourcePaths, env, activeModes, baseProjectTree, config, c
10312
10370
  shouldWrite = false;
10313
10371
  }
10314
10372
  }
10315
- if (!shouldWrite) {
10373
+ if (!shouldWrite && cliArgs.watch && !isInitialRun) {
10316
10374
  continue;
10317
10375
  }
10318
- const outputDir = import_path5.default.dirname(buildResult.output);
10319
- if (!import_fs4.default.existsSync(outputDir)) {
10320
- import_fs4.default.mkdirSync(outputDir, { recursive: true });
10376
+ if (shouldWrite) {
10377
+ const outputDir = import_path5.default.dirname(buildResult.output);
10378
+ if (!import_fs4.default.existsSync(outputDir)) {
10379
+ import_fs4.default.mkdirSync(outputDir, { recursive: true });
10380
+ }
10381
+ const tempOutput = `${buildResult.output}.tmp`;
10382
+ import_fs4.default.writeFileSync(tempOutput, finalContent);
10383
+ import_fs4.default.renameSync(tempOutput, buildResult.output);
10321
10384
  }
10322
- import_fs4.default.writeFileSync(buildResult.output, finalContent);
10323
10385
  const totalRemoved = buildResult.removed.length + dropped.length;
10324
10386
  if (totalRemoved > 0) {
10325
10387
  if (cliArgs.watch) {
@@ -10341,22 +10403,30 @@ async function execute(sourcePaths, env, activeModes, baseProjectTree, config, c
10341
10403
  logger.warn(collision);
10342
10404
  }
10343
10405
  }
10344
- if (cliArgs.watch) {
10406
+ if (!isInitialRun) {
10345
10407
  const outputName = import_path5.default.basename(buildResult.output);
10346
10408
  logger.success(
10347
10409
  `[${modeName}] Rebuilt "${buildResult.name}" -> ${outputName}`
10348
10410
  );
10349
10411
  } else {
10350
- logger.success(`Generated Rojo tree for "${buildResult.name}"`);
10351
- logger.info(` Processed: ${buildResult.fileCount} files`);
10352
- logger.info(` Build dir: ${buildResult.buildDir}`);
10353
- const activeTags = Object.keys(targetConfig.tags || {}).filter(
10354
- (t) => targetConfig.tags[t]
10355
- );
10356
- if (activeTags.length > 0) {
10357
- logger.info(` Tags: ${activeTags.join(", ")}`);
10412
+ if (shouldWrite) {
10413
+ logger.success(
10414
+ `Generated Rojo tree for "${buildResult.name}"`
10415
+ );
10416
+ logger.info(` Processed: ${buildResult.fileCount} files`);
10417
+ logger.info(` Build dir: ${buildResult.buildDir}`);
10418
+ const activeTags = Object.keys(
10419
+ targetConfig.tags || {}
10420
+ ).filter((t) => targetConfig.tags[t]);
10421
+ if (activeTags.length > 0) {
10422
+ logger.info(` Tags: ${activeTags.join(", ")}`);
10423
+ }
10424
+ logger.info(` Output to: ${buildResult.output}`);
10425
+ } else {
10426
+ logger.success(
10427
+ `Rojo tree for "${buildResult.name}" is already up to date`
10428
+ );
10358
10429
  }
10359
- logger.info(` Output to: ${buildResult.output}`);
10360
10430
  }
10361
10431
  }
10362
10432
  return true;
@@ -10440,7 +10510,8 @@ async function main() {
10440
10510
  config,
10441
10511
  cliArgs,
10442
10512
  anchor,
10443
- logger
10513
+ logger,
10514
+ true
10444
10515
  );
10445
10516
  if (!success) {
10446
10517
  process.exit(1);
@@ -10449,9 +10520,14 @@ async function main() {
10449
10520
  logger.info(
10450
10521
  `Watching for file changes in: "${sourceDirs.join(", ")}" (Ctrl+C to stop)`
10451
10522
  );
10523
+ const isWindows = process.platform === "win32";
10452
10524
  const watcher = import_chokidar.default.watch(sourcePaths, {
10453
10525
  persistent: true,
10454
- ignoreInitial: true
10526
+ ignoreInitial: true,
10527
+ // Use polling on Windows to avoid "folder in use" locks
10528
+ usePolling: isWindows,
10529
+ interval: isWindows ? 400 : void 0,
10530
+ binaryInterval: isWindows ? 800 : void 0
10455
10531
  });
10456
10532
  let debounceTimeout;
10457
10533
  let isBuilding = false;
@@ -10470,7 +10546,8 @@ async function main() {
10470
10546
  config,
10471
10547
  cliArgs,
10472
10548
  anchor,
10473
- logger
10549
+ logger,
10550
+ false
10474
10551
  );
10475
10552
  } catch (err) {
10476
10553
  logger.error(err instanceof Error ? err : String(err));
package/dist/cli.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import { parseArgs } from "util";
2
2
  export function printHelp(logger) {
3
3
  const helpText = `
4
- Rogen - Feature-based folder routing for Roblox
4
+ Rogen - Feature-based architecture for Roblox
5
5
 
6
6
  Usage:
7
7
  rogen [command] [options]
8
8
 
9
9
  Commands:
10
10
  init Generate a .rogen.json config file
11
- watch Watch the source and generate automatically
11
+ build Build the project
12
+ watch Watch the source and build automatically
12
13
 
13
14
  Options:
14
15
  -c, --config <path> Specify custom config file
@@ -64,6 +65,9 @@ export function parseCliArgs(args = process.argv.slice(2), logger) {
64
65
  else if (subcommand === "version") {
65
66
  parsedArgs.version = true;
66
67
  }
68
+ else if (subcommand === "build") {
69
+ // build is the default command, so it falls through cleanly
70
+ }
67
71
  else {
68
72
  const errorMsg = `unknown subcommand "${positionals[0]}".\nRun 'rogen --help' to see a list of available commands.`;
69
73
  if (logger) {
package/dist/constants.js CHANGED
@@ -1,4 +1,4 @@
1
- export const version = "1.4.1";
1
+ export const version = "1.4.2";
2
2
  export const defaultProject = {
3
3
  name: "roblox-game",
4
4
  tree: { $className: "DataModel" },
@@ -52,6 +52,10 @@ export const serviceParents = {
52
52
  StarterPlayerScripts: "StarterPlayer",
53
53
  StarterCharacterScripts: "StarterPlayer",
54
54
  };
55
+ export const combinedServices = new Set([
56
+ ...Object.values(services),
57
+ ...Object.values(serviceParents),
58
+ ]);
55
59
  export const serverContainers = new Set([
56
60
  "ServerScriptService",
57
61
  "ServerStorage",
package/dist/execute.js CHANGED
@@ -2,7 +2,7 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import { build } from "./build.js";
4
4
  import { ConsoleLogger, getTimeStamp } from "./logger.js";
5
- export async function execute(sourcePaths, env, activeModes, baseProjectTree, config, cliArgs, anchor, logger = new ConsoleLogger()) {
5
+ export async function execute(sourcePaths, env, activeModes, baseProjectTree, config, cliArgs, anchor, logger = new ConsoleLogger(), isInitialRun = false) {
6
6
  try {
7
7
  for (const activeMode of activeModes) {
8
8
  const targetConfig = activeMode.config;
@@ -24,16 +24,22 @@ export async function execute(sourcePaths, env, activeModes, baseProjectTree, co
24
24
  }
25
25
  }
26
26
  if (buildResult.missingPaths.length > 0) {
27
+ // Only synthesize placeholder stubs/dirs for compiled projects (ts/darklua),
28
+ // where the build output legitimately does not exist yet at generation time. For a luau
29
+ // project the build dir IS the source, so a missing path means the file was removed —
30
+ // recreating it would fight an in-progress delete and desync Rojo. Drop the stale node instead.
31
+ const isCompiledProject = env.isTsProject || env.isDarkluaProject;
27
32
  for (const item of buildResult.missingPaths) {
28
33
  const ext = path.extname(item.absolutePath).toLowerCase();
29
- if (ext === ".luau" || ext === ".lua") {
34
+ if (isCompiledProject &&
35
+ (ext === ".luau" || ext === ".lua")) {
30
36
  const dir = path.dirname(item.absolutePath);
31
37
  if (!fs.existsSync(dir)) {
32
38
  fs.mkdirSync(dir, { recursive: true });
33
39
  }
34
40
  fs.writeFileSync(item.absolutePath, "");
35
41
  }
36
- else if (ext === "") {
42
+ else if (isCompiledProject && ext === "") {
37
43
  if (!fs.existsSync(item.absolutePath)) {
38
44
  fs.mkdirSync(item.absolutePath, {
39
45
  recursive: true,
@@ -54,14 +60,20 @@ export async function execute(sourcePaths, env, activeModes, baseProjectTree, co
54
60
  shouldWrite = false;
55
61
  }
56
62
  }
57
- if (!shouldWrite) {
63
+ if (!shouldWrite && cliArgs.watch && !isInitialRun) {
58
64
  continue;
59
65
  }
60
- const outputDir = path.dirname(buildResult.output);
61
- if (!fs.existsSync(outputDir)) {
62
- fs.mkdirSync(outputDir, { recursive: true });
66
+ if (shouldWrite) {
67
+ const outputDir = path.dirname(buildResult.output);
68
+ if (!fs.existsSync(outputDir)) {
69
+ fs.mkdirSync(outputDir, { recursive: true });
70
+ }
71
+ // Write atomically (temp + rename) so a watcher (e.g. Rojo) never reads a
72
+ // half-written project file during rapid regenerations.
73
+ const tempOutput = `${buildResult.output}.tmp`;
74
+ fs.writeFileSync(tempOutput, finalContent);
75
+ fs.renameSync(tempOutput, buildResult.output);
63
76
  }
64
- fs.writeFileSync(buildResult.output, finalContent);
65
77
  const totalRemoved = buildResult.removed.length + dropped.length;
66
78
  if (totalRemoved > 0) {
67
79
  if (cliArgs.watch) {
@@ -82,19 +94,24 @@ export async function execute(sourcePaths, env, activeModes, baseProjectTree, co
82
94
  logger.warn(collision);
83
95
  }
84
96
  }
85
- if (cliArgs.watch) {
97
+ if (!isInitialRun) {
86
98
  const outputName = path.basename(buildResult.output);
87
99
  logger.success(`[${modeName}] Rebuilt "${buildResult.name}" -> ${outputName}`);
88
100
  }
89
101
  else {
90
- logger.success(`Generated Rojo tree for "${buildResult.name}"`);
91
- logger.info(` Processed: ${buildResult.fileCount} files`);
92
- logger.info(` Build dir: ${buildResult.buildDir}`);
93
- const activeTags = Object.keys(targetConfig.tags || {}).filter((t) => targetConfig.tags[t]);
94
- if (activeTags.length > 0) {
95
- logger.info(` Tags: ${activeTags.join(", ")}`);
102
+ if (shouldWrite) {
103
+ logger.success(`Generated Rojo tree for "${buildResult.name}"`);
104
+ logger.info(` Processed: ${buildResult.fileCount} files`);
105
+ logger.info(` Build dir: ${buildResult.buildDir}`);
106
+ const activeTags = Object.keys(targetConfig.tags || {}).filter((t) => targetConfig.tags[t]);
107
+ if (activeTags.length > 0) {
108
+ logger.info(` Tags: ${activeTags.join(", ")}`);
109
+ }
110
+ logger.info(` Output to: ${buildResult.output}`);
111
+ }
112
+ else {
113
+ logger.success(`Rojo tree for "${buildResult.name}" is already up to date`);
96
114
  }
97
- logger.info(` Output to: ${buildResult.output}`);
98
115
  }
99
116
  }
100
117
  return true;
package/dist/index.js CHANGED
@@ -67,15 +67,20 @@ async function main() {
67
67
  });
68
68
  const env = getEnvironment(anchor, cliArgs.mode);
69
69
  const activeModes = resolveActiveModes(config, cliArgs.mode, env);
70
- const success = await execute(sourcePaths, env, activeModes, config.project, config, cliArgs, anchor, logger);
70
+ const success = await execute(sourcePaths, env, activeModes, config.project, config, cliArgs, anchor, logger, true);
71
71
  if (!success) {
72
72
  process.exit(1);
73
73
  }
74
74
  if (cliArgs.watch) {
75
75
  logger.info(`Watching for file changes in: "${sourceDirs.join(", ")}" (Ctrl+C to stop)`);
76
+ const isWindows = process.platform === "win32";
76
77
  const watcher = chokidar.watch(sourcePaths, {
77
78
  persistent: true,
78
79
  ignoreInitial: true,
80
+ // Use polling on Windows to avoid "folder in use" locks
81
+ usePolling: isWindows,
82
+ interval: isWindows ? 400 : undefined,
83
+ binaryInterval: isWindows ? 800 : undefined,
79
84
  });
80
85
  let debounceTimeout;
81
86
  let isBuilding = false;
@@ -87,7 +92,7 @@ async function main() {
87
92
  return;
88
93
  isBuilding = true;
89
94
  try {
90
- execute(sourcePaths, env, activeModes, config.project, config, cliArgs, anchor, logger);
95
+ execute(sourcePaths, env, activeModes, config.project, config, cliArgs, anchor, logger, false);
91
96
  }
92
97
  catch (err) {
93
98
  logger.error(err instanceof Error ? err : String(err));
package/dist/tree.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
+ import { combinedServices } from "./constants.js";
3
4
  function hasPathPrefix(p, dir) {
4
5
  return p === dir || p.startsWith(dir + "/");
5
6
  }
@@ -12,9 +13,36 @@ export function applyCasing(value, casing) {
12
13
  : value[0].toLowerCase();
13
14
  return firstCharacter + value.slice(1);
14
15
  }
16
+ export function getPathString(node) {
17
+ const p = node.$path;
18
+ if (typeof p === "string")
19
+ return p;
20
+ if (p && typeof p === "object" && typeof p.optional === "string")
21
+ return p.optional;
22
+ return undefined;
23
+ }
15
24
  export function getOrCreateNode(parent, key, className) {
25
+ const isRootService = combinedServices.has(key) ||
26
+ (className && combinedServices.has(className));
16
27
  if (!parent[key]) {
17
- parent[key] = className == null ? {} : { $className: className };
28
+ parent[key] =
29
+ className == null
30
+ ? {}
31
+ : {
32
+ $className: className,
33
+ ...(isRootService
34
+ ? {}
35
+ : { $ignoreUnknownInstances: false }),
36
+ };
37
+ }
38
+ else if (className != null) {
39
+ const existing = parent[key];
40
+ if (existing.$ignoreUnknownInstances === undefined &&
41
+ existing.$path === undefined) {
42
+ if (!isRootService) {
43
+ existing.$ignoreUnknownInstances = false;
44
+ }
45
+ }
18
46
  }
19
47
  return parent[key];
20
48
  }
@@ -25,17 +53,23 @@ export function pruneObject(node, buildDir, outputDir, removed = [], treePath =
25
53
  continue;
26
54
  const childTreePath = treePath ? `${treePath}.${key}` : key;
27
55
  const childNode = val;
28
- if (childNode.$path) {
29
- if (hasPathPrefix(childNode.$path, buildDir))
30
- continue;
31
- const absolutePath = path.resolve(outputDir, childNode.$path);
32
- if (!fs.existsSync(absolutePath)) {
33
- delete node[key];
34
- removed.push({
35
- treePath: childTreePath,
36
- rojoPath: childNode.$path,
37
- });
56
+ const childPath = getPathString(childNode);
57
+ if (childPath) {
58
+ if (hasPathPrefix(childPath, buildDir))
38
59
  continue;
60
+ // Optional paths are allowed to be absent so only required (string) template
61
+ // paths are pruned when missing.
62
+ const isOptional = typeof childNode.$path === "object";
63
+ if (!isOptional) {
64
+ const absolutePath = path.resolve(outputDir, childPath);
65
+ if (!fs.existsSync(absolutePath)) {
66
+ delete node[key];
67
+ removed.push({
68
+ treePath: childTreePath,
69
+ rojoPath: childPath,
70
+ });
71
+ continue;
72
+ }
39
73
  }
40
74
  }
41
75
  pruneObject(childNode, buildDir, outputDir, removed, childTreePath);
@@ -61,14 +95,15 @@ export function findMissingPaths(node, buildDir, outputDir, missing = [], treePa
61
95
  continue;
62
96
  const childTreePath = treePath ? `${treePath}.${key}` : key;
63
97
  const childNode = val;
64
- if (childNode.$path && hasPathPrefix(childNode.$path, buildDir)) {
65
- const absolutePath = path.resolve(outputDir, childNode.$path);
98
+ const childPath = getPathString(childNode);
99
+ if (childPath && hasPathPrefix(childPath, buildDir)) {
100
+ const absolutePath = path.resolve(outputDir, childPath);
66
101
  if (!fs.existsSync(absolutePath)) {
67
102
  missing.push({
68
103
  parent: node,
69
104
  key,
70
105
  treePath: childTreePath,
71
- path: childNode.$path,
106
+ path: childPath,
72
107
  absolutePath,
73
108
  });
74
109
  }
@@ -83,6 +118,8 @@ const isModel = (filename) => /\.(rbxm|rbxmx)$/i.test(filename);
83
118
  export function isData(filename) {
84
119
  if (isScript(filename) || isModel(filename))
85
120
  return false;
121
+ if (filename.toLowerCase().endsWith(".d.ts"))
122
+ return false;
86
123
  if (filename.startsWith("."))
87
124
  return false;
88
125
  return /\.[a-z0-9]+$/i.test(filename);
@@ -103,7 +140,7 @@ function getRojoBaseName(filename) {
103
140
  }
104
141
  return filename;
105
142
  }
106
- export function collapseFolders(node, buildDir, outputDir) {
143
+ export function collapseFolders(node, buildDir, outputDir, isIgnored = () => false) {
107
144
  let childCount = 0;
108
145
  let canCollapse = node.$path === undefined; // Prevent overwriting a folder with an explicit $path
109
146
  let commonDir = null;
@@ -115,13 +152,14 @@ export function collapseFolders(node, buildDir, outputDir) {
115
152
  continue;
116
153
  const childNode = val;
117
154
  // Process deepest nested children first
118
- collapseFolders(childNode, buildDir, outputDir);
155
+ collapseFolders(childNode, buildDir, outputDir, isIgnored);
119
156
  childCount++;
120
- if (!childNode.$path) {
157
+ const childPath = getPathString(childNode);
158
+ if (!childPath) {
121
159
  canCollapse = false;
122
160
  }
123
161
  else {
124
- const childAbsPath = path.resolve(outputDir, childNode.$path);
162
+ const childAbsPath = path.resolve(outputDir, childPath);
125
163
  const parentDir = path.dirname(childAbsPath);
126
164
  // All children should share the same directory
127
165
  if (commonDir === null) {
@@ -143,10 +181,12 @@ export function collapseFolders(node, buildDir, outputDir) {
143
181
  if (!hasPathPrefix(toPosix(commonDir), toPosix(absoluteBuildDir))) {
144
182
  return;
145
183
  }
184
+ const relativeCommonDir = toPosix(path.relative(outputDir, commonDir));
146
185
  try {
147
186
  const diskItems = fs.readdirSync(commonDir);
148
- // filter out marker files so they do not prevent folders from collapsing
149
- const visibleDiskItems = diskItems.filter((item) => !item.startsWith("."));
187
+ // filter out marker files and ignored files so they do not prevent folders from collapsing
188
+ const visibleDiskItems = diskItems.filter((item) => !item.startsWith(".") &&
189
+ !isIgnored(toPosix(path.join(relativeCommonDir, item))));
150
190
  if (visibleDiskItems.length !== childCount) {
151
191
  return;
152
192
  }
@@ -155,13 +195,12 @@ export function collapseFolders(node, buildDir, outputDir) {
155
195
  return;
156
196
  }
157
197
  // Replace all child files with a single folder $path
158
- const relativeCommonDir = toPosix(path.relative(outputDir, commonDir));
159
198
  for (const key in node) {
160
199
  if (!key.startsWith("$")) {
161
200
  delete node[key];
162
201
  }
163
202
  }
164
- node.$path = relativeCommonDir;
203
+ node.$path = { optional: relativeCommonDir };
165
204
  delete node.$className;
166
205
  }
167
206
  export function findExposedDataFiles(node, exposed = []) {
@@ -172,9 +211,10 @@ export function findExposedDataFiles(node, exposed = []) {
172
211
  if (typeof val !== "object" || val === null)
173
212
  continue;
174
213
  const childNode = val;
214
+ const childPath = getPathString(childNode);
175
215
  // Check if the current node exposes a raw data file path
176
- if (childNode.$path && isData(childNode.$path)) {
177
- exposed.push({ parent: node, key, path: childNode.$path });
216
+ if (childPath && isData(childPath)) {
217
+ exposed.push({ parent: node, key, path: childPath });
178
218
  }
179
219
  findExposedDataFiles(childNode, exposed);
180
220
  }
package/package.json CHANGED
@@ -1,10 +1,20 @@
1
1
  {
2
2
  "name": "@ldgerrits/rogen",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
+ "description": "A CLI tool that enables feature-based architecture for Roblox",
4
5
  "publishConfig": {
5
6
  "access": "public"
6
7
  },
7
- "description": "A CLI tool enabling feature-based architecture through automated routing of client, server, and shared code to Roblox services",
8
+ "homepage": "https://rogen-playfully.vercel.app/",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/LDGerrits/rogen.git"
12
+ },
13
+ "keywords": [
14
+ "roblox",
15
+ "organization",
16
+ "feature-based architecture"
17
+ ],
8
18
  "author": "ldgerrits",
9
19
  "license": "MIT",
10
20
  "type": "module",