rbx-forge
Version:
A roblox-ts and Luau CLI tool for fully-managed Rojo projects
1,657 lines (1,631 loc) • 89.6 kB
JavaScript
#!/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