UNPKG

rbx-forge

Version:

A roblox-ts and Luau CLI tool for fully-managed Rojo projects

1,657 lines (1,631 loc) 89.6 kB
#!/usr/bin/env node import { cancel, confirm, intro, isCancel, log, multiselect, note, outro, select, spinner, taskLog, tasks } from "@clack/prompts"; import ansis from "ansis"; import { Command } from "commander"; import process, { platform } from "node:process"; import fs, { access, mkdir, stat, writeFile } from "node:fs/promises"; import { scope, type } from "arktype"; import { ExecaError, execa } from "execa"; import { createInterface } from "node:readline"; import { detect, getUserAgent, resolveCommand } from "package-manager-detector"; import { updateConfig } from "c12/update"; import dedent from "dedent"; import { loadConfig } from "c12"; import path from "node:path"; import { detectCodeFormat } from "magicast"; import os from "node:os"; import chokidar from "chokidar"; import { setTimeout as setTimeout$1 } from "node:timers/promises"; import { createServer } from "node:net"; import picomatch from "picomatch"; //#region rolldown:runtime var __defProp = Object.defineProperty; var __export = (all) => { let target = {}; for (var name$1 in all) __defProp(target, name$1, { get: all[name$1], enumerable: true }); return target; }; //#endregion //#region package.json var name = "rbx-forge"; var version = "1.0.0-beta.2"; //#endregion //#region src/utils/is-wsl.ts function isWsl() { return platform === "linux" && process.env["WSL_DISTRO_NAME"] !== void 0; } //#endregion //#region src/constants.ts /** Suffix for lock files created by Roblox Studio when opening place files. */ const STUDIO_LOCKFILE_SUFFIX = ".lock"; /** The CLI command name for rbx-forge. */ const CLI_COMMAND = "rbx-forge"; /** The configuration file name for rbx-forge projects. */ const CONFIG_FILE = "rbx-forge.config"; /** Suffix for lock files tracking running Rojo instances. */ const ROJO_LOCKFILE_SUFFIX = ".rojo.lock"; /** The default port used by Rojo serve. */ const ROJO_DEFAULT_PORT = 34872; /** Maximum number of ports to try when searching for an available port. */ const ROJO_MAX_PORT_ATTEMPTS = 10; //#endregion //#region src/config/create.ts async function createProjectConfig(projectType) { await updateConfig({ configFile: CONFIG_FILE, createExtension: ".ts", cwd: ".", onCreate: ({ configFile: filePath }) => { log.info(`Creating new config file: ${filePath}`); return dedent` import { defineConfig } from "rbx-forge"; export default defineConfig({ projectType: "${projectType}", }); `; } }); } //#endregion //#region src/config/defaults.ts const defaults = { buildOutputPath: "game.rbxl", commandNames: { build: "forge:build", compile: "forge:compile", init: "init", open: "forge:open", restart: "forge:restart", serve: "forge:serve", start: "forge:start", stop: "forge:stop", syncback: "forge:syncback", typegen: "forge:typegen", watch: "forge:watch" }, luau: { watch: { args: [], command: "" } }, projectType: "rbxts", rbxts: { args: ["--verbose"], command: "rbxtsc", watchOnOpen: true }, rojoAlias: "rojo", rojoProjectPath: "default.project.json", suppressNoTaskRunnerWarning: false, syncback: { projectPath: void 0, runOnStart: false }, syncbackInputPath: "game.rbxl", typegen: { exclude: ["**/node_modules/**"], include: ["**"], maxDepth: void 0 }, typegenOutputPath: "src/services.d.ts" }; //#endregion //#region src/config/schema.ts const configSchema = type({ "buildOutputPath?": "string", "commandNames?": { "build?": "string", "compile?": "string", "init?": "string", "open?": "string", "restart?": "string", "serve?": "string", "start?": "string", "stop?": "string", "syncback?": "string", "typegen?": "string", "watch?": "string" }, "luau?": { "watch?": { "args?": "string[]", "command?": "string" } }, "projectType": "'rbxts' | 'luau'", "rbxts?": { "args?": "string[]", "command?": "string", "watchOnOpen?": "boolean" }, "rojoAlias?": "string", "rojoProjectPath?": "string", "suppressNoTaskRunnerWarning?": "boolean", "syncback?": { "projectPath?": "string | undefined", "runOnStart?": "boolean" }, "syncbackInputPath?": "string", "typegen?": { "exclude?": "string[]", "include?": "string[]", "maxDepth?": "number | undefined" }, "typegenOutputPath?": "string" }); //#endregion //#region src/config/loader.ts async function loadProjectConfig() { const { config: rawConfig } = await loadConfig({ defaults, name: "rbx-forge", packageJson: true }); const validated = configSchema(rawConfig); if (validated instanceof type.errors) { log.error("Invalid configuration:"); log.error(validated.summary); process.exit(1); } return validated; } //#endregion //#region src/config/index.ts /** * Define a typed configuration for rbx-forge. * * @example * * ```typescript * import { defineConfig } from "rbx-forge"; * * export default defineConfig({ * buildOutputPath: "build/game.rbxl", * }); * ``` * * @param config - The configuration object. * @returns The same configuration object with type checking. */ function defineConfig(config) { return config; } //#endregion //#region src/utils/command-names.ts /** * Resolves the configured script name for a command. * * Used during initialization to generate task runner scripts with custom names. * Users can customize these names to match their project conventions while the * CLI maintains consistent base command names. * * @example * * ```typescript * const config = { commandNames: { build: "forge:build" } }; * getCommandName("build", config); // "forge:build" * // Generates: "forge:build": "rbx-forge build" * * const defaultConfig = {}; * getCommandName("serve", defaultConfig); // "serve" * // Generates: "serve": "rbx-forge serve" * ``` * * @param baseName - The base command name (e.g., "build", "serve"). * @param config - The project configuration. * @returns The configured script name for task runner integration. */ function getCommandName(baseName, config) { return config.commandNames[baseName]; } //#endregion //#region src/utils/detect-task-runner.ts /** * Detects available task runners by checking for mise tasks or npm scripts. * * Checks for task runner availability in priority order (mise > npm). For mise, * checks if project-level tasks are configured. For npm, checks if package.json * has scripts defined. * * This is used as a fallback when no calling context is detected (i.e., when * rbx-forge is invoked directly rather than through a task runner script). * * @example * * ```typescript * // Project has mise tasks configured locally * await detectAvailableTaskRunner(); // "mise" (higher priority) * * // Project only has package.json with scripts * await detectAvailableTaskRunner(); // "npm" * * // Project has no task runner configuration * await detectAvailableTaskRunner(); // "none" * ``` * * @returns The first available task runner, or "none" if neither is available. */ async function detectAvailableTaskRunner() { const cwd = process.cwd(); if (await hasMiseTasks()) return "mise"; try { const packageJsonPath = path.join(cwd, "package.json"); const packageJsonContent = await fs.readFile(packageJsonPath, "utf8"); const packageJson = JSON.parse(packageJsonContent); if (packageJson.scripts && Object.keys(packageJson.scripts).length > 0) return "npm"; } catch {} return "none"; } /** * Detects which task runner invoked the current process. * * Checks environment variables set by npm/pnpm/yarn/mise to determine the * calling context. This ensures command chaining uses the same task runner that * initiated the process, providing consistent behavior and respecting user * customizations. * * Environment variables checked: * * - `npm_lifecycle_event`: Set by npm/pnpm/yarn when running scripts * - `MISE_TASK_NAME`: Set by mise when running tasks. * * @example * * ```typescript * // User runs: npm run forge:build * getCallingTaskRunner(); // "npm" * * // User runs: mise run forge:build * getCallingTaskRunner(); // "mise" * * // User runs: rbx-forge build (directly) * getCallingTaskRunner(); // null * ``` * * @returns The detected task runner ("mise", "npm") or null if called directly. */ function getCallingTaskRunner() { if (process.env["MISE_TASK_NAME"] !== void 0) return "mise"; if (process.env["npm_lifecycle_event"] !== void 0) return "npm"; return null; } /** * Checks if mise has project-level tasks configured. Uses --local flag to * explicitly check for project tasks only. * * @returns True if mise has local tasks, false otherwise. */ async function hasMiseTasks() { try { const result = await execa("mise", [ "tasks", "ls", "--local" ], { cwd: process.cwd(), reject: false }); return result.exitCode === 0 && result.stdout.trim().length > 0; } catch { return false; } } //#endregion //#region src/utils/kill-process-tree.ts /** * Kills a process and all its descendants (children, grandchildren, etc.). * * Platform-specific implementation: * * - Windows: Uses `taskkill /pid PID /T /F` to kill the process tree * - MacOS: Uses `pgrep -P PID` to recursively find children (pgrep: process * grep). * - Linux: Uses `ps -o pid --no-headers --ppid PID` to recursively find children. * * @param pid - The process ID to kill along with all descendants. * @param signal - The signal to send (default: SIGTERM). Ignored on Windows. */ async function killProcessTree(pid, signal = "SIGTERM") { if (pid === void 0) throw new Error("Cannot kill process tree: PID is undefined"); await (process.platform === "win32" ? killProcessTreeWindows(pid) : killProcessTreeUnix(pid, signal)); } /** * Recursively builds a process tree by finding all descendants of a given PID. * * @param parentPid - The parent process ID. * @returns Array of all descendant PIDs (including the parent). */ async function buildProcessTree(parentPid) { const tree = { [parentPid]: [] }; const processQueue = [parentPid]; while (processQueue.length > 0) { const currentPid = processQueue.shift(); if (currentPid === void 0) break; const children = await getChildPids(currentPid); tree[currentPid] = children; for (const childPid of children) { tree[childPid] = []; processQueue.push(childPid); } } const allPids = []; const visited = /* @__PURE__ */ new Set(); function collectPids(pid) { if (visited.has(pid)) return; visited.add(pid); for (const childPid of tree[pid] ?? []) collectPids(childPid); allPids.push(pid); } collectPids(parentPid); return allPids; } /** * Gets child process IDs for a given parent PID. * * @param pid - The parent process ID. * @returns Array of child PIDs. */ async function getChildPids(pid) { try { const output = (await execa(process.platform === "darwin" ? "pgrep" : "ps", process.platform === "darwin" ? ["-P", String(pid)] : [ "-o", "pid", "--no-headers", "--ppid", String(pid) ])).stdout.trim(); if (output.length === 0) return []; return output.split("\n").map((line) => Number.parseInt(line.trim(), 10)); } catch { return []; } } /** * Kills multiple PIDs with the given signal. * * @param pids - Array of process IDs to kill. * @param signal - The signal to send. */ function killPids(pids, signal) { for (const pid of pids) try { process.kill(pid, signal); } catch (err) { if (err instanceof Error && "code" in err && err.code === "ESRCH") continue; throw err; } } /** * Kills a process tree on Unix systems (macOS, Linux) by recursively finding * all descendants and killing them. * * @param pid - The process ID to kill. * @param signal - The signal to send (e.g., SIGTERM, SIGKILL). */ async function killProcessTreeUnix(pid, signal) { killPids(await buildProcessTree(pid), signal); } /** * Kills a process tree on Windows using taskkill. * * @param pid - The process ID to kill. */ async function killProcessTreeWindows(pid) { try { await execa("taskkill", [ "/pid", String(pid), "/T", "/F" ]); } catch (err) { if (err instanceof Error && "exitCode" in err && err.exitCode === 128) return; throw err; } } //#endregion //#region src/utils/process-manager.ts /** * Global process lifecycle manager for proper cleanup. * * Tracks child processes, ensuring they're properly terminated during * application shutdown. Supports graceful shutdown with configurable timeouts * and force-kill fallback. */ var ProcessManager = class { /** Configuration for shutdown behavior. */ config = { cleanupTimeout: 5e3, gracefulShutdownTimeout: 3e3 }; exitListeners = /* @__PURE__ */ new WeakMap(); processes = /* @__PURE__ */ new Set(); cleanupPromise = null; isShuttingDown = false; /** * Cleanup all tracked resources. * * @returns A promise that resolves when cleanup is complete. */ async cleanup() { if (this.cleanupPromise) return this.cleanupPromise; this.isShuttingDown = true; this.cleanupPromise = this.performCleanup(); return this.cleanupPromise; } /** * Configure the ProcessManager behavior. * * @param config - Partial configuration to apply. */ configure(config) { if (config.gracefulShutdownTimeout !== void 0) this.config.gracefulShutdownTimeout = config.gracefulShutdownTimeout; if (config.cleanupTimeout !== void 0) this.config.cleanupTimeout = config.cleanupTimeout; } /** * Register a child process for tracking and cleanup. * * Processes registered here will be gracefully terminated (SIGTERM, then * SIGKILL after timeout) during application shutdown. On Windows, the * process will be killed immediately as signals are not supported. * * @param childProcess - The child process to register. */ register(childProcess) { if (this.isShuttingDown) { console.warn("ProcessManager is shutting down, cannot register new process"); return; } this.processes.add(childProcess); /** Store the listener so we can remove it later. */ const exitListener = () => { this.unregister(childProcess); }; this.exitListeners.set(childProcess, exitListener); childProcess.on("exit", exitListener); } /** * Check if ProcessManager is currently shutting down. * * @returns True if shutdown is in progress. */ get shuttingDown() { return this.isShuttingDown; } /** * Unregister a child process from tracking. * * Use this when you want to manually remove a process from tracking without * waiting for it to exit. * * @param childProcess - The child process to unregister. */ unregister(childProcess) { this.processes.delete(childProcess); const listener = this.exitListeners.get(childProcess); if (listener) { childProcess.off("exit", listener); this.exitListeners.delete(childProcess); } } /** Terminates all registered processes with timeout handling. */ async cleanupProcesses() { const processCleanupPromises = this.collectProcessCleanupPromises(); if (processCleanupPromises.length === 0) return; try { await this.withTimeout(Promise.all(processCleanupPromises), this.config.cleanupTimeout); } catch { await this.forceKillRemainingProcesses(); } this.processes.clear(); } /** * Collects cleanup promises for all active processes. * * @returns Array of promises for process termination. */ collectProcessCleanupPromises() { const promises = []; for (const task of this.processes) if (!task.killed && task.exitCode === null) promises.push(this.terminateProcess(task)); return promises; } /** * Force kills a process tree that didn't terminate gracefully. * * @param childProcess - The process to force kill. */ async forceKillProcess(childProcess) { if (childProcess.killed) return; console.warn(`Force killing process ${childProcess.pid}`); try { await killProcessTree(childProcess.pid, "SIGKILL"); } catch {} } /** Force kills any remaining process trees that didn't terminate gracefully. */ async forceKillRemainingProcesses() { const killPromises = []; for (const proc of this.processes) { if (proc.killed || proc.exitCode !== null) continue; console.warn(`Force killing unresponsive process ${proc.pid}`); killPromises.push(killProcessTree(proc.pid, "SIGKILL").catch(() => {})); } await Promise.all(killPromises); } /** * Performs the actual cleanup of all tracked resources. * * Terminates all tracked processes. Processes that don't terminate within * the cleanup timeout will be force-killed. * * @returns A promise that resolves when cleanup is complete. */ async performCleanup() { await this.cleanupProcesses(); } /** * Terminates a single child process gracefully. * * On Unix-like systems: sends SIGTERM, then SIGKILL after timeout. On * Windows: kills immediately (signals not supported). * * @param childProcess - The process to terminate. * @returns Promise that resolves when the process exits. */ async terminateProcess(childProcess) { return new Promise((resolve) => { if (childProcess.killed || childProcess.exitCode !== null) { resolve(); return; } const timeout = setTimeout(() => { this.forceKillProcess(childProcess).finally(() => { resolve(); }); }, this.config.gracefulShutdownTimeout); childProcess.on("exit", () => { clearTimeout(timeout); resolve(); }); this.tryGracefulKill(childProcess).then((success) => { if (success) return; clearTimeout(timeout); resolve(); }); }); } /** * Attempts to gracefully kill a process and all its descendants. * * @param childProcess - The process to kill. * @returns Promise that resolves to true if kill signal was sent, false if * process already dead. */ async tryGracefulKill(childProcess) { try { await killProcessTree(childProcess.pid, "SIGTERM"); return true; } catch { return false; } } /** * Wraps a promise with a timeout. * * @param promise - The promise to wrap. * @param timeoutMs - Timeout in milliseconds. * @returns The promise result or throws if timeout is exceeded. */ async withTimeout(promise, timeoutMs) { let timeoutHandle; const timeoutPromise = new Promise((_resolve, reject) => { timeoutHandle = setTimeout(() => { reject(/* @__PURE__ */ new Error(`Operation timed out after ${timeoutMs}ms`)); }, timeoutMs); }); try { return await Promise.race([promise, timeoutPromise]); } finally { if (timeoutHandle !== void 0) clearTimeout(timeoutHandle); } } }; const processManager = new ProcessManager(); let signalHandlersSetup = false; /** * Setup signal handlers for graceful shutdown. * * When a signal is received, this triggers ProcessManager cleanup. The * application is responsible for detecting graceful shutdown and calling * process.exit() with the appropriate exit code. */ function setupSignalHandlers() { if (signalHandlersSetup) return; signalHandlersSetup = true; for (const signal of [ "SIGINT", "SIGTERM", "SIGQUIT" ]) process.on(signal, () => { processManager.cleanup(); }); } //#endregion //#region src/utils/run.ts function createSpinner(message) { if (message === void 0) return; const activeSpinner = spinner(); activeSpinner.start(message); return activeSpinner; } async function findCommandForPackageManager(command, args = [], packageManager) { const agent = packageManager ?? getUserAgent(); if (!agent) throw new Error("Could not detect current package manager."); const result = resolveCommand(agent, command, args); if (!result) throw new Error(`Could not resolve ${command} command for package manager: ${agent}`); return result; } /** * Execute a shell command with pretty output using execa and @clack/prompts. * * @example * * ```ts * // Simple usage * await run("rojo", ["build"]); * * // With spinner * await run("rojo", ["build"], { * spinnerMessage: "Building project...", * successMessage: "Build complete!", * }); * * // Get output * const { stdout } = await run("rojo", ["--version"]); * console.log(stdout); * ``` * * @param command - The command to execute (e.g., "rojo", "npm"). * @param args - Array of arguments to pass to the command. * @param options - Configuration options for execution and display. * @returns Promise resolving to the execa result. */ async function run(command, args = [], options$5 = {}) { const { customSpinner, shouldRegisterProcess = false, shouldShowCommand = true, shouldStreamOutput = true, spinnerMessage, successMessage,...execaOptions } = options$5; if (shouldShowCommand) log.step(`${command} ${args.join(" ")}`); const activeSpinner = customSpinner ?? createSpinner(spinnerMessage); const subprocess = execa(command, args, { ...execaOptions, stderr: shouldStreamOutput ? "inherit" : execaOptions.stderr ?? "pipe", stdout: shouldStreamOutput ? "inherit" : execaOptions.stdout ?? "pipe" }); if (shouldRegisterProcess) processManager.register(subprocess); return handleSubprocess(subprocess, activeSpinner, successMessage); } /** * Execute a command and return only stdout as a string Useful for getting * command output without streaming. * * @example * * ```ts * const version = await runOutput("rojo", ["--version"]); * console.log(version); // "7.4.1" * ``` * * @param command - The command to execute (e.g., "rojo", "npm"). * @param args - Array of arguments to pass to the command. * @param options - Configuration options (shouldStreamOutput is disabled by * default). * @returns Promise resolving to the trimmed stdout string. */ async function runOutput(command, args = [], options$5 = {}) { const result = await run(command, args, { ...options$5, shouldShowCommand: false, shouldStreamOutput: false }); return String(result.stdout).trim(); } /** * Runs a script via the appropriate task runner. * * This function enables command chaining while respecting the calling context. * If the current process was invoked via npm/mise, subsequent commands will use * the same task runner. This ensures consistency and allows users to hook into * commands by customizing scripts in package.json or .mise.toml. * * Priority order: * * 1. Use the task runner that invoked the current process (context-aware) * 2. Auto-detect available task runners (mise > npm) * 3. Fallback to direct CLI invocation. * * @example Implementing a start command that chains build → serve * * ```typescript * // src/commands/start.ts * import { loadProjectConfig } from "../config"; * import { runScript } from "../utils/run-script"; * * export async function action(): Promise<void> { * const config = await loadProjectConfig(); * * // Build the project first * await runScript("build", config); * * // Then start the dev server * await runScript("serve", config); * } * * // If user customized their scripts: * // package.json: "forge:build": "npm run typecheck && rbx-forge build" * // The typecheck will run before building, respecting user hooks! * ``` * * @param scriptName - The base script name to run (e.g., "build", "serve"). * @param args - Additional arguments to pass to the script. * @param options - Optional run options (e.g., cancelSignal for cancellation). */ async function runScript(scriptName, args = [], options$5 = {}) { const config = await loadProjectConfig(); const resolvedName = getCommandName(scriptName, config); const runner = getCallingTaskRunner() ?? await detectAvailableTaskRunner(); if (runner === "mise") { await run("mise", [ "run", resolvedName, ...args ], { shouldShowCommand: false, ...options$5 }); return; } if (runner === "npm") { await runWithPackageManager(resolvedName, args, options$5); return; } const hasShownWarning = process.env["RBX_FORGE_NO_TASK_RUNNER_WARNING_SHOWN"] === "1"; const shouldSuppressWarning = config.suppressNoTaskRunnerWarning; if (!hasShownWarning && !shouldSuppressWarning) { log.warn(ansis.yellow("⚠ No task runner detected - running command directly. This may skip user-defined hooks.")); process.env["RBX_FORGE_NO_TASK_RUNNER_WARNING_SHOWN"] = "1"; } await run(CLI_COMMAND, [scriptName, ...args], { shouldShowCommand: false, ...options$5 }); } /** * Execute a command with streaming output to a task logger. * * Useful for long-running processes like compilers and watchers that produce * verbose output. Unlike run(), this creates a scrolling task log that shows * the last N lines of output. * * @example * * ```ts * const { subprocess, taskLogger } = await runWithTaskLog( * "rbxtsc", * ["--verbose"], * { * taskName: "Compiling TypeScript...", * }, * ); * * try { * await subprocess; * taskLogger.success("Compilation complete"); * } catch (err) { * taskLogger.error("Compilation failed"); * throw err; * } * ``` * * @param command - The command to execute (e.g., "rbxtsc", "npm"). * @param args - Array of arguments to pass to the command. * @param options - Configuration options including task name and message limit. * @returns Object containing the subprocess promise and task logger instance. */ function runWithTaskLog(command, args, options$5) { const { messageLimit = 12, shouldRegisterProcess = false, taskName,...execaOptions } = options$5; const taskLogger = taskLog({ limit: messageLimit, title: taskName }); const subprocess = execa(command, args, { ...execaOptions, all: true, buffer: false }); if (shouldRegisterProcess) processManager.register(subprocess); const rl = createInterface({ crlfDelay: Number.POSITIVE_INFINITY, input: subprocess.all }); rl.on("line", (line) => { taskLogger.message(line); }); subprocess.finally(() => { rl.close(); }).catch(() => {}); return { subprocess, taskLogger }; } async function handleSubprocess(subprocess, activeSpinner, successMessage) { try { const result = await subprocess; if (activeSpinner !== void 0 && successMessage !== void 0) activeSpinner.stop(successMessage); return result; } catch (err) { activeSpinner?.stop("Command failed"); throw err; } } async function runWithPackageManager(resolvedName, runArguments, options$5 = {}) { const { args, command } = await findCommandForPackageManager("run", [resolvedName]); await run(command, [...args, ...runArguments], { shouldShowCommand: false, ...options$5 }); } //#endregion //#region src/utils/rojo.ts const rojoSourceMapSchema = scope({ RojoSourceMap: { "children?": "RojoSourceMap[]", "className": "string", "name": "string" } }).type("RojoSourceMap"); async function checkRojoInstallation() { try { const rojoVersion = await runOutput("rojo", ["--version"]); return `Found Rojo ${ansis.cyan(rojoVersion)}`; } catch { cancel(ansis.yellow("⚠ Rojo not found - please install Rojo to use this tool")); process.exit(2); } } /** * Get the correct rojo executable name for the current platform. * * WSL (Windows Subsystem for Linux) needs to call the Windows executable (.exe) * to properly interact with the Windows filesystem and processes. * * @param config - Optional resolved config containing the rojo alias. * @returns The rojo command with `.exe` suffix on WSL, without on other * platforms. */ function getRojoCommand(config) { const baseCommand = config.rojoAlias; return isWsl() ? `${baseCommand}.exe` : baseCommand; } /** * Executes `rojo sourcemap` and returns the parsed JSON output. * * @param config - Optional resolved config containing the rojo alias. * @param rojoProjectPath - Optional path to a specific Rojo project file. * @returns The parsed Rojo source map. */ async function getRojoSourceMap(config, rojoProjectPath) { const rojo = getRojoCommand(config); const args = ["sourcemap"]; if (rojoProjectPath !== void 0 && rojoProjectPath.length > 0) args.push(rojoProjectPath); try { const output = await runOutput(rojo, args); return validateSourceMap(JSON.parse(output)); } catch (err) { const errorMessage = err instanceof Error ? err.message : "Failed to execute rojo sourcemap"; log.error(errorMessage); throw err; } } /** * Validates parsed JSON data against the Rojo sourcemap schema. * * @param parsed - The parsed JSON data to validate. * @returns The validated Rojo source map. */ function validateSourceMap(parsed) { const validated = rojoSourceMapSchema(parsed); if (validated instanceof type.errors) { log.error("Invalid Rojo sourcemap format:"); log.error(validated.summary); throw new Error(`Invalid Rojo sourcemap format: ${validated.summary}`); } return validated; } //#endregion //#region src/utils/format-duration.ts /** * Format a duration from a start time to now. * * @example * * ```ts * const startTime = performance.now(); * // ... do work ... * console.log(formatDuration(startTime)); // "1.2s" * ``` * * @param startTime - The start time from performance.now(). * @returns Formatted duration string (e.g., "1.2s"). */ function formatDuration(startTime) { return `${((performance.now() - startTime) / 1e3).toFixed(1)}s`; } //#endregion //#region src/commands/build.ts var build_exports = /* @__PURE__ */ __export({ COMMAND: () => COMMAND$10, DESCRIPTION: () => DESCRIPTION$10, action: () => action$10, options: () => options$4 }); const COMMAND$10 = "build"; const DESCRIPTION$10 = "Build the Rojo project"; const options$4 = [ { description: "Where to output the result (overrides config). Should end in .rbxm, .rbxl, .rbxmx, or .rbxlx", flags: "-o, --output <path>" }, { description: "Output to local plugins folder. Should end in .rbxm or .rbxl", flags: "-p, --plugin <name>" }, { description: "Sets verbosity level. Can be specified multiple times", flags: "-v, --verbose" }, { description: "Automatically rebuild when any input files change", flags: "-w, --watch" }, { description: "Set color behavior (auto, always, or never)", flags: "--color <mode>" }, { description: "Path to the project to build (defaults to current directory)", flags: "--project <path>" } ]; async function action$10(commandOptions = {}) { validateOptions(commandOptions); const config = await loadProjectConfig(); const rojo = getRojoCommand(config); const outputPath = commandOptions.plugin ?? commandOptions.output ?? config.buildOutputPath; const isPluginOutput = commandOptions.plugin !== void 0; const rojoArgs = buildRojoArguments$1(commandOptions, outputPath, isPluginOutput, config); displayBuildInfo(outputPath, isPluginOutput); if (commandOptions.watch === true) setupSignalHandlers(); const startTime = performance.now(); const spinner$1 = createSpinner("Building project..."); await run(rojo, rojoArgs, { shouldRegisterProcess: commandOptions.watch === true, shouldStreamOutput: commandOptions.verbose !== void 0 || commandOptions.watch === true }); const duration = formatDuration(startTime); const statsDisplay = [ outputPath, await getFileSize(outputPath, isPluginOutput), duration ].filter(Boolean).join(", "); spinner$1.stop(`Build complete (${ansis.dim(statsDisplay)})`); } function buildRojoArguments$1(buildOptions, outputPath, isPluginOutput, config) { const args = ["build"]; if (isPluginOutput && buildOptions.plugin !== void 0) args.push("--plugin", buildOptions.plugin); else args.push("--output", outputPath); const projectPath = buildOptions.project ?? config.rojoProjectPath; if (projectPath.length > 0) args.push(projectPath); if (buildOptions.verbose !== void 0 && buildOptions.verbose !== false) { const verboseCount = typeof buildOptions.verbose === "number" ? buildOptions.verbose : 1; for (let index = 0; index < verboseCount; index++) args.push("--verbose"); } if (buildOptions.watch === true) args.push("--watch"); if (buildOptions.color !== void 0 && buildOptions.color.length > 0) args.push("--color", buildOptions.color); return args; } function displayBuildInfo(outputPath, isPluginOutput) { log.info(ansis.bold("→ Building project")); const outputDisplay = isPluginOutput ? `plugin: ${outputPath}` : outputPath; log.step(`Output: ${ansis.cyan(outputDisplay)}`); } /** * Get the file size of the output file. * * @param outputPath - Path to the output file. * @param isPluginOutput - Whether the output is a plugin. * @returns Formatted file size string or empty string. */ async function getFileSize(outputPath, isPluginOutput) { if (isPluginOutput) return ""; try { const sizeMb = (await stat(outputPath)).size / (1024 * 1024); return sizeMb < .1 ? `${(sizeMb * 1024).toFixed(1)} KB` : `${sizeMb.toFixed(1)} MB`; } catch { return ""; } } function validateOptions(buildOptions) { const hasOutput = buildOptions.output !== void 0 && buildOptions.output.length > 0; const hasPlugin = buildOptions.plugin !== void 0 && buildOptions.plugin.length > 0; if (hasOutput && hasPlugin) { log.error("Cannot use both --output and --plugin options together"); process.exit(1); } } //#endregion //#region src/commands/compile.ts var compile_exports = /* @__PURE__ */ __export({ COMMAND: () => COMMAND$9, DESCRIPTION: () => DESCRIPTION$9, action: () => action$9 }); const COMMAND$9 = "compile"; const DESCRIPTION$9 = "Compile TypeScript to Luau"; async function action$9() { const config = await loadProjectConfig(); const rbxtsc = config.rbxts.command; const rbxtscArgs = config.rbxts.args; log.info(ansis.bold("→ Compiling TypeScript")); log.step(`Compiler: ${ansis.cyan(rbxtsc)}`); log.step(`Arguments: ${ansis.dim(rbxtscArgs.join(" "))}`); await runCompilation(rbxtsc, rbxtscArgs); } async function runCompilation(rbxtsc, rbxtscArgs) { const startTime = performance.now(); const { subprocess, taskLogger } = runWithTaskLog(rbxtsc, rbxtscArgs, { taskName: "Compiling TypeScript..." }); try { await subprocess; const stats = formatDuration(startTime); taskLogger.success(`Compilation complete (${ansis.dim(stats)})`); } catch (err) { const stats = formatDuration(startTime); taskLogger.error(`Compilation failed (${ansis.dim(stats)})`); throw err; } } //#endregion //#region src/utils/mise.ts const miseTasksArraySchema = type([{ description: "string", name: "string", run: "string[]", source: "string" }, "[]"]); async function checkMiseInstallation() { try { await runOutput("mise", ["version"]); return true; } catch { return false; } } /** * Adds rbx-forge tasks to mise using the mise CLI. Exits with code 2 if mise is * not installed. * * @returns Success message. */ async function updateMiseToml() { if (!await checkMiseInstallation()) { cancel(ansis.yellow("⚠ mise not found - please install mise to continue")); process.exit(2); } const config = await loadProjectConfig(); const existingTasks = await getExistingMiseTasks(); const { added, skipped } = await addMiseTasks(COMMANDS.filter((cmd) => { return SCRIPT_NAMES.includes(cmd.COMMAND); }), existingTasks, config); if (added === 0) return ""; const message = `Added ${added} task(s) to ${ansis.magenta("mise.toml")}`; return skipped > 0 ? `${message} (${skipped} skipped)` : message; } async function addMiseTask(miseTaskName, commandName, description) { await run("mise", [ "task", "add", miseTaskName, "--description", description, "--", CLI_COMMAND, commandName ], { shouldShowCommand: false, shouldStreamOutput: false }); } async function addMiseTasks(commands, existingTasks, config) { let added = 0; let skipped = 0; for (const cmd of commands) { const taskName = cmd.COMMAND; const resolvedScriptName = getCommandName(taskName, config); const description = cmd.DESCRIPTION; const existingTask = existingTasks.get(resolvedScriptName); if (existingTask) if (await confirmTaskOverwrite(existingTask)) { await addMiseTask(resolvedScriptName, taskName, description); added++; } else skipped++; else { await addMiseTask(resolvedScriptName, taskName, description); added++; } } return { added, skipped }; } async function confirmTaskOverwrite(existingTask) { const currentCommand = existingTask.run.join(" "); log.message(`Current task: ${ansis.cyan(currentCommand)}`); const shouldOverwrite = await confirm({ initialValue: false, message: `Task "${existingTask.name}" already exists. Overwrite?` }); if (isCancel(shouldOverwrite)) { cancel("Operation cancelled"); process.exit(0); } return shouldOverwrite; } /** * Gets existing mise tasks by parsing `mise tasks ls --json`. * * @returns Map of task names to their details. */ async function getExistingMiseTasks() { try { const output = await runOutput("mise", [ "tasks", "ls", "--json" ]); const validated = miseTasksArraySchema(JSON.parse(output)); if (validated instanceof type.errors) return /* @__PURE__ */ new Map(); return new Map(validated.map((task) => [task.name, task])); } catch { return /* @__PURE__ */ new Map(); } } //#endregion //#region src/utils/package-json.ts function getPackageJsonPath() { return path.join(process.cwd(), "package.json"); } async function readPackageJson(packageJsonPath) { try { await fs.access(packageJsonPath); const content = await fs.readFile(packageJsonPath, "utf8"); return JSON.parse(content); } catch { log.warn(ansis.yellow("No package.json found - skipping script installation. Run npm init first.")); return null; } } /** * Updates the package.json file with rbx-forge scripts. * * @returns Success message or empty string if package.json doesn't exist. */ async function updatePackageJson() { const packageJsonPath = getPackageJsonPath(); const packageJson = await readPackageJson(packageJsonPath); if (!packageJson) return ""; const { added, skipped } = await addScriptsToPackageJson(packageJson, await loadProjectConfig()); await writePackageJson(packageJsonPath, packageJson); if (added === 0) return ""; const message = `Added ${added} script(s) to ${ansis.magenta("package.json")}`; return skipped > 0 ? `${message} (${skipped} skipped)` : message; } async function writePackageJson(packageJsonPath, packageJson) { const format = detectCodeFormat(await fs.readFile(packageJsonPath, "utf8")); const indentation = format.useTabs === true ? " " : " ".repeat(format.tabWidth ?? 2); await fs.writeFile(packageJsonPath, `${JSON.stringify(packageJson, void 0, indentation)}\n`); } async function addScriptEntry(packageJson, scriptName, scriptCommand) { const { scripts } = packageJson; if (!scripts) return "skipped"; if (scripts[scriptName] !== void 0) { const shouldOverwrite = await confirm({ initialValue: false, message: `Script "${scriptName}" already exists. Overwrite?\n Current: "${scripts[scriptName]}"` }); if (isCancel(shouldOverwrite)) { cancel("Operation cancelled"); process.exit(0); } if (shouldOverwrite) { scripts[scriptName] = scriptCommand; return "added"; } return "skipped"; } scripts[scriptName] = scriptCommand; return "added"; } async function addScriptsToPackageJson(packageJson, config) { packageJson.scripts ??= {}; let added = 0; let skipped = 0; for (const scriptName of SCRIPT_NAMES) if (await addScriptEntry(packageJson, getCommandName(scriptName, config), `${CLI_COMMAND} ${scriptName}`) === "added") added++; else skipped++; return { added, skipped }; } //#endregion //#region src/commands/init.ts var init_exports = /* @__PURE__ */ __export({ COMMAND: () => COMMAND$8, DESCRIPTION: () => DESCRIPTION$8, action: () => action$8 }); const COMMAND$8 = "init"; const DESCRIPTION$8 = `Initialize a new ${name} project`; const OPERATION_CANCELLED = "Operation cancelled"; async function action$8() { intro(ansis.bold(`🔨 ${name} init`)); const { projectType, taskRunners } = await getUserInput(); await runInitializationTasks(projectType, taskRunners); await showNextSteps(taskRunners); outro(ansis.green("✨ You're all set!")); } async function addRbxForgeToPackageJson() { const packageJsonPath = getPackageJsonPath(); const packageJson = await readPackageJson(packageJsonPath); if (!packageJson) return; const hasInDeps = packageJson.dependencies?.[name] !== void 0; const hasInDevelopmentDeps = packageJson.devDependencies?.[name] !== void 0; if (hasInDeps || hasInDevelopmentDeps) return; const shouldAddRbxForge = await confirm({ initialValue: true, message: `Add ${name} to devDependencies? (recommended)` }); if (isCancel(shouldAddRbxForge)) { cancel(OPERATION_CANCELLED); process.exit(0); } if (shouldAddRbxForge) { packageJson.devDependencies ??= {}; packageJson.devDependencies[name] = `^${version}`; await writePackageJson(packageJsonPath, packageJson); log.success(`Added ${name}@^${version} to ${ansis.magenta("devDependencies")}`); } } async function createForgeConfig(projectType) { await createProjectConfig(projectType); const configFileName = `${name}.config.ts`; return `Config file created at ${ansis.magenta(configFileName)}`; } async function createRojoProject() { try { await run("rojo", ["init"], { shouldShowCommand: false, shouldStreamOutput: false }); } catch { log.message(ansis.gray("Rojo project structure already exists, skipping")); return ""; } return "Project structure created"; } async function getInstallCommand(shouldUseMise, shouldUseNpm) { if (shouldUseMise) return "mise install"; if (shouldUseNpm) { const { name: name$1 } = await detect() ?? { agent: "npm" }; try { const { args, command } = await findCommandForPackageManager("install", [], name$1); return `${command} ${args.join(" ")}`; } catch { return "npm install"; } } throw new Error("This should not be called if no task runner is used."); } async function getTaskRunnerCommand(scriptName, shouldUseMise, shouldUseNpm) { if (shouldUseMise) return `mise run ${scriptName}`; if (shouldUseNpm) { const { name: name$1 } = await detect() ?? { agent: "npm" }; try { const { args, command } = await findCommandForPackageManager("run", [scriptName], name$1); return `${command} ${args.join(" ")}`; } catch { return `npm run ${scriptName}`; } } return `${name} ${scriptName.replace(/^forge:/, "")}`; } async function getUserInput() { return { projectType: await selectProjectType(), taskRunners: await selectTaskRunners() }; } async function runInitializationTasks(projectType, taskRunners) { const initTasks = [ { task: checkRojoInstallation, title: "Checking Rojo installation" }, { task: createRojoProject, title: "Creating Rojo project structure" }, { task: async () => createForgeConfig(projectType), title: `Creating ${name} config` } ]; if (taskRunners.includes("npm")) { await addRbxForgeToPackageJson(); initTasks.push({ task: async () => updatePackageJson(), title: "Adding npm scripts to package.json" }); } if (taskRunners.includes("mise")) initTasks.push({ task: async () => updateMiseToml(), title: "Adding mise tasks to .mise.toml" }); await tasks(initTasks); } async function selectProjectType() { const projectType = await select({ message: "Pick a project type.", options: [{ label: "TypeScript", value: "rbxts" }, { label: "Luau", value: "luau" }] }); if (isCancel(projectType)) { cancel(OPERATION_CANCELLED); process.exit(0); } return projectType; } async function selectTaskRunners() { const { agent } = await detect() ?? { agent: "npm" }; const taskRunners = await multiselect({ message: "Pick task runner(s) (optional).", options: [ { hint: "default", label: agent, value: "npm" }, { label: "mise", value: "mise" }, { disabled: true, hint: "coming soon", label: "lune", value: "lune" } ], required: false }); if (isCancel(taskRunners)) { cancel(OPERATION_CANCELLED); process.exit(0); } return taskRunners; } async function showNextSteps(taskRunners) { const shouldUseMise = taskRunners.includes("mise"); const shouldUseNpm = taskRunners.includes("npm"); const config = await loadProjectConfig(); const buildScriptName = getCommandName("build", config); const serveScriptName = getCommandName("serve", config); const buildCommand = await getTaskRunnerCommand(buildScriptName, shouldUseMise, shouldUseNpm); const serveCommand = await getTaskRunnerCommand(serveScriptName, shouldUseMise, shouldUseNpm); const steps = []; let step = 1; function addStep(stepDescription) { steps.push(` ${step}. ${stepDescription}`); step++; } if (shouldUseMise || shouldUseNpm) { const installCommand = await getInstallCommand(shouldUseMise, shouldUseNpm); addStep(`Run ${ansis.cyan(installCommand)} to install dependencies`); } addStep(`Run ${ansis.cyan(buildCommand)} to build your project`); addStep(`Run ${ansis.cyan(serveCommand)} to start development`); note(`Next steps:\n\n${steps.join("\n")}`, "Next Steps"); } //#endregion //#region src/utils/get-windows-path.ts /** * Converts a WSL path to a Windows path. * * @example * * ```ts * const winPath = await getWindowsPath("/home/user/project/game.rbxl"); * // Returns: "\\\\wsl$\\Ubuntu\\home\\user\\project\\game.rbxl" * ``` * * @param fsPath - The WSL filesystem path to convert. * @returns The Windows-formatted path. */ async function getWindowsPath(fsPath) { return (await runOutput("wslpath", ["-w", fsPath], { shouldShowCommand: false })).trim(); } //#endregion //#region src/utils/lockfile.ts /** * Maximum number of retry attempts for lockfile cleanup when file is busy * (common on Windows). */ const MAX_LOCKFILE_CLEANUP_RETRIES = 10; /** Base delay in milliseconds for exponential backoff between retry attempts. */ const BASE_RETRY_DELAY_MS = 100; /** * Removes a lockfile with retry logic for EBUSY errors. * * This is used for Studio lockfile cleanup. Retries with exponential backoff if * the file is busy (common on Windows). * * @param lockFilePath - Absolute path to the lockfile to remove. */ async function cleanupLockfile(lockFilePath) { for (let attempt = 0; attempt < MAX_LOCKFILE_CLEANUP_RETRIES; attempt++) try { await fs.rm(lockFilePath); return; } catch (err) { if (err instanceof Error && "code" in err && err.code === "ENOENT") return; const isEbusy = err instanceof Error && "code" in err && err.code === "EBUSY"; const isLastAttempt = attempt === MAX_LOCKFILE_CLEANUP_RETRIES - 1; if (isEbusy && !isLastAttempt) { const delayMs = BASE_RETRY_DELAY_MS * 2 ** attempt; await new Promise((resolve) => { setTimeout(resolve, delayMs); }); continue; } const errorMessage = err instanceof Error ? err.message : String(err); log.warn(`Failed to clean up lockfile: ${errorMessage}\nPlease manually delete: ${ansis.cyan(lockFilePath)}`); return; } } /** * Constructs the full path to a lockfile from config and suffix. * * @param config - The resolved project configuration. * @param suffix - The lockfile suffix (e.g., ".lock", ".rojo.lock"). * @returns Absolute path to the lockfile. */ function getLockFilePath(config, suffix) { const projectPath = process.cwd(); return path.join(projectPath, config.buildOutputPath + suffix); } /** * Reads a lockfile and returns its contents as lines. * * @param lockPath - Path to the lockfile. * @returns Array of lines from the lockfile, or null if file doesn't exist. */ async function readLockfileRaw(lockPath) { try { return (await fs.readFile(lockPath, "utf-8")).split("\n"); } catch (err) { if (err instanceof Error && "code" in err && err.code === "ENOENT") return null; const errorMessage = err instanceof Error ? err.message : String(err); log.warn(`Failed to read lockfile: ${errorMessage}`); return null; } } //#endregion //#region src/utils/run-platform.ts /** * Execute platform-specific callbacks. * * @example * * ```ts * await runPlatform({ * darwin: async () => run("open", ["file.txt"]), * win32: async () => run("start", ["file.txt"]), * linux: async () => run("xdg-open", ["file.txt"]), * }); * ``` * * @template R - The return type of the callback functions. * @param callbacks - Platform-specific callback functions. * @returns The result of the platform-specific callback. * @throws If no callback is provided for the current platform. */ function runPlatform(callbacks) { const callback = callbacks[os.platform()]; if (callback) return callback(); throw new Error(`Platform ${os.platform()} not supported`); } //#endregion //#region src/utils/studio-lock-watcher.ts /** * Helper to construct Studio lock file path from config. * * @param config - The resolved project configuration. * @returns Absolute path to the Studio lock file. */ function getStudioLockFilePath(config) { return getLockFilePath(config, STUDIO_LOCKFILE_SUFFIX); } /** * Watches for Studio lock file removal and calls callback when detected. * Handles cleanup, errors, and graceful shutdown automatically via * SIGINT/SIGTERM handlers. * * Returns a Promise that resolves when the Studio lock file is removed (Studio * closes). * * @example * * ```typescript * await watchStudioLockFile(getStudioLockFilePath(config), { * onStudioClose: () => { * log.info("Studio closed!"); * }, * }); * // Execution continues here after Studio closes * ``` * * @param studioLockFilePath - Absolute path to the Studio lock file. * @param options - Configuration options for watcher behavior. * @returns Promise that resolves when Studio closes the file. */ async function watchStudioLockFile(studioLockFilePath, options$5) { if (await checkFileExists(studioLockFilePath) && options$5.onStudioOpen !== void 0) await options$5.onStudioOpen(); return new Promise((resolve, reject) => { const watcher = chokidar.watch(studioLockFilePath, { ignoreInitial: true }); setupWatcherEvents$1({ cleanup: createCleanupHandler(watcher), options: options$5, reject, resolve, watcher }); }); } /** * Check if a file exists. * * @param filePath - Path to check. * @returns True if file exists, false otherwise. */ async function checkFileExists(filePath) { try { await fs.access(filePath); return true; } catch { return false; } } /** * Create a cleanup handler that closes the watcher and prevents duplicate * cleanup. * * @param watcher - The FSWatcher instance to clean up. * @returns The cleanup function. */ function createCleanupHandler(watcher) { let isShuttingDown = false; return async () => { if (isShuttingDown) return; isShuttingDown = true; try { await watcher.close(); } catch (err) { const message = err instanceof Error ? err.message : String(err); log.warn(`Error closing w