rojo/.cursor/plans/rokit_install_audit_fixes_e465275a.plan.md
Joe 4918fb80c7 Add plugin upload workflow and related enhancements
- Introduced a new upload workflow for the Atlas plugin to Roblox using Lune and OpenCloud.
- Added `UploadDetails.json` to store plugin metadata.
- Updated `build.rs` to include `UploadDetails.json` in the plugin snapshot.
- Enhanced `plugin.project.json` to reference the new `UploadDetails`.
- Added a new submodule for `opencloud-execute` to facilitate plugin uploads.
- Created scripts for publishing the plugin via both PowerShell and Bash.
- Updated GitHub Actions workflow to include steps for installing Rokit and uploading the plugin.

These changes aim to streamline the plugin release process and improve integration with Roblox's marketplace.
2026-02-19 06:12:03 -06:00

17 KiB

name overview todos isProject
Rokit Install Audit Fixes Fix the EPERM crash when deleting a globally installed atlas.exe, and harden the entire rokit installation pipeline in the VS Code extension for reliability across Windows, macOS, and Linux.
id content status
bug1-elevated-delete Add elevatedDelete() helper and fix the 'Switch to Rokit' flow in openMenu.ts to handle EPERM/EACCES/EBUSY with platform-specific elevation completed
id content status
bug2-progress Wrap installRojo() in vscode.window.withProgress() with stage reporting completed
id content status
bug3-github-api Replace eryn.io proxy URL with official GitHub API URL in installRojo.ts completed
id content status
bug4-temp-cleanup Delete temp rokit binary after successful self-install completed
id content status
bug5-path-dedup Deduplicate PATH entry and verify rokit accessibility before rokit add completed
id content status
bug6-stream Replace manual stream piping with stream.promises.pipeline() completed
id content status
bug7-rokit-add-existing Use 'rokit add --global --force' + 'rokit trust' + verify atlas --version + non-fatal plugin install completed
id content status
feat-check-updates Add 'Check for Updates' menu item in openMenu.ts for rokit-managed installs, handle project-level + global rokit update, reinstall Studio plugin after update completed
id content status
feat-auto-update-check Add background update check on activation using rokit update --check (once per day, project + global), prompt user with Update button that triggers rokit update + plugin reinstall completed
false

Rokit Installation Audit and Fixes

Audit Findings

How Rokit Installation Works (No Elevation Needed)

Rokit installs entirely to ~/.rokit/ (user-level directory). rokit self-install modifies user-level PATH environment variables (HKCU on Windows, shell rc files on Unix). Neither rokit installation nor rokit add requires elevation.

The only scenario requiring elevation is the "Switch to Rokit" migration flow, which tries to delete an existing globally installed atlas.exe from a protected directory like C:\Program Files\Atlas (written by install.ps1) or /usr/local/bin/atlas (written by install.sh).

Issues Found (by severity)


BUG 1 -- CRITICAL: fs.unlink fails without elevation, crashes the entire flow

In vscode-rojo/src/commands/openMenu.ts line 133:

return fs.unlink(install.resolvedPath)

When atlas.exe is in C:\Program Files\Atlas or /usr/local/bin/, this throws EPERM/EACCES. The .then() error handler on line 139 catches it but just shows a generic message and the flow dies. Additionally, on Windows the exe may be locked by a running process (atlas serve), causing EBUSY even with elevation.

Fix: Add an elevatedDelete() helper that:

  1. Tries fs.unlink() first
  2. On EPERM/EACCES, uses platform-specific elevated delete:
  • Windows: powershell -Command "Start-Process powershell -ArgumentList '-Command Remove-Item \"path\"' -Verb RunAs -Wait" (triggers UAC prompt)
  • macOS: osascript -e 'do shell script "rm path" with administrator privileges' (triggers password dialog)
  • Linux: pkexec rm path (triggers PolicyKit dialog)
  1. On EBUSY (Windows exe locked), shows a specific error message telling the user to stop atlas serve first

BUG 2 -- HIGH: No progress indicator during installation

In vscode-rojo/src/installRojo.ts installRojo():

The entire flow (download rokit zip ~3.5MB, extract, self-install, rokit add atlas) runs silently. Users see zero feedback and may think the extension froze.

Fix: Wrap the body of installRojo() in vscode.window.withProgress() with ProgressLocation.Notification and cancellable: false. Report progress at each stage:

  • "Downloading Rokit..."
  • "Installing Rokit..."
  • "Installing Atlas via Rokit..."

BUG 3 -- HIGH: Third-party release URL is a single point of failure

In vscode-rojo/src/installRojo.ts line 113:

const latestReleaseResponse = await fetch(
  "https://latest-github-release.eryn.io/rojo-rbx/rokit",
)

This third-party proxy (eryn.io) is not under our control. If it goes down, installation breaks. The official rokit install scripts use the GitHub API directly.

Fix: Replace with the official GitHub API URL: https://api.github.com/repos/rojo-rbx/rokit/releases/latest. Verified: response schema is identical, both return the same GitHubRelease shape. Add an Accept: application/vnd.github+json header per GitHub API best practice.


BUG 4 -- MEDIUM: Temp files never cleaned up

In vscode-rojo/src/installRojo.ts line 143-170:

The rokit binary is downloaded to os.tmpdir() but never deleted after rokit self-install succeeds.

Fix: Add fs.promises.unlink(tempPath).catch(() => {}) after the exec(self-install) call succeeds.


BUG 5 -- MEDIUM: PATH update is fragile

In vscode-rojo/src/installRojo.ts lines 177-181:

if ("PATH" in process.env) {
  const envPath = process.env.PATH!.split(path.delimiter)
  envPath.push(path.join(os.homedir(), ".rokit", "bin"))
  process.env.PATH = envPath.join(path.delimiter)
}

This only updates the extension's own process environment. But the subsequent rokit add --global on line 184 runs via exec() which inherits this env, so it should work. The concern is:

  • It doesn't check if the path is already present (could add duplicates on repeated attempts)
  • The rokit binary path might differ on some systems

Fix: Check for duplicates before appending. Also verify the rokit binary is actually accessible after the PATH update by doing a quick which("rokit") check before proceeding to rokit add.


BUG 6 -- LOW: Stream error handling is fragile

In vscode-rojo/src/installRojo.ts lines 51-60, 149-163:

promisifyStream resolves on whichever of "close"/"finish"/"end" fires first. The pipeline/pipe chain has multiple potential failure modes that aren't properly propagated.

Fix: Use stream.promises.pipeline() (available in Node 15+, which VS Code ships with) instead of manually piping and tracking events. This gives proper error propagation and backpressure handling. Alternatively, simplify to await promisify(stream.pipeline)(download.body!, unzipper.ParseOne(), fs.createWriteStream(tempPath)).


BUG 7 -- HIGH: rokit add --global crashes if atlas is already configured

In vscode-rojo/src/installRojo.ts line 184:

await exec("rokit add --global UserGeneratedLLC/rojo atlas")

This is the only action after rokit is confirmed installed. It fails if atlas is already in the project's rokit.toml or already added globally. The scenario:

  1. Project has rokit.toml with atlas listed but rokit install hasn't been run yet
  2. which("atlas") finds the rokit shim at ~/.rokit/bin/atlas
  3. atlas --version fails (rokit says tool not installed), stderr contains "rokit" -> getRojoInstall returns null
  4. User sees "Install Atlas now", clicks it
  5. isRokitInstalled() returns true (skips rokit download)
  6. rokit add --global fails because atlas is already configured -> onboarding dies

Fix: Use --force to handle "already exists", trust the tool, then verify and install the plugin:

await exec("rokit add --global --force UserGeneratedLLC/rojo atlas")
await exec("rokit trust UserGeneratedLLC/rojo")
await exec("atlas --version")

// Install the Studio plugin (non-fatal -- don't crash onboarding if Studio isn't installed)
try {
  await exec("atlas plugin install")
} catch (e: any) {
  vscode.window.showWarningMessage(
    `Atlas installed successfully, but the Studio plugin could not be installed: ${e.stderr || e}. ` +
    `You can install it later from the Atlas menu or by running "atlas plugin install".`
  )
}
  • --force on rokit add handles the "already exists" case cleanly -- no try/catch needed, it re-adds/updates regardless
  • rokit trust marks UserGeneratedLLC/rojo as trusted so rokit won't prompt the user for confirmation when running atlas (without trust, rokit install and first-run shims can block waiting for user input)
  • atlas --version verifies the tool is actually usable after add
  • atlas plugin install writes the embedded Studio plugin to disk (non-fatal if Studio isn't installed yet)


FEATURE: "Check for Updates" menu item for Rokit-managed installs

In vscode-rojo/src/commands/openMenu.ts, the generateProjectMenu() return array (line 315) has action items like Studio, Syncback, Sourcemap, Install Plugin. There is no way to update Atlas from the extension.

generateProjectMenu() already tracks installType (line 168) and whether it's InstallType.rokit. We use this to conditionally show the item.

Implementation:

  1. Add a menu item in generateProjectMenu() return array, conditionally included when installType === InstallType.rokit:
...(installType === InstallType.rokit
  ? [
      {
        label: "$(sync) Check for Updates",
        description: `Atlas v${allRojoVersions[0]}`,
        action: "checkUpdates",
        projectFile: projectFiles[0],
      },
    ]
  : []),
  1. Add "checkUpdates" case in the onDidAccept handler (after the "install" case). The handler needs to support both project-level rokit.toml and global installations:
case "checkUpdates": {
  if (!selectedItem.projectFile) return
  const updateFolder = path.dirname(selectedItem.projectFile.path.fsPath)

  input.hide()

  await vscode.window.withProgress(
    { location: vscode.ProgressLocation.Notification, title: "Checking for Atlas updates..." },
    async (progress) => {
      // Try project-level update first, fall back to global
      let updateOutput: string
      try {
        const result = await exec("rokit update atlas", { cwd: updateFolder })
        updateOutput = result.stdout || result.stderr
      } catch {
        try {
          const result = await exec("rokit update --global atlas")
          updateOutput = result.stdout || result.stderr
        } catch (e: any) {
          vscode.window.showErrorMessage(`Could not update Atlas: ${e.stderr || e}`)
          return
        }
      }

      // Re-install the Studio plugin so it matches the new binary.
      // The plugin is embedded in the atlas binary at compile time
      // (include_bytes! in src/cli/plugin.rs), so updating the binary
      // without re-installing leaves a stale AtlasManagedPlugin.rbxm
      // in Studio's plugins folder. Mismatched versions break live sync
      // due to protocol version enforcement.
      progress.report({ message: "Reinstalling Studio plugin..." })
      try {
        await exec("atlas plugin install")
      } catch (e: any) {
        vscode.window.showWarningMessage(
          `Atlas updated, but plugin reinstall failed: ${e.stderr || e}. ` +
          `Run "atlas plugin install" manually to avoid version mismatch.`
        )
      }

      vscode.window.showInformationMessage(updateOutput.trim() || "Atlas is up to date.")
    },
  )
  break
}

The strategy: try rokit update atlas in the project directory first. If atlas isn't in the project's rokit.toml, this fails and we fall back to rokit update --global atlas. Rokit's output tells the user whether an update was applied or they're already current.

After a successful update, immediately runs atlas plugin install to overwrite the stale AtlasManagedPlugin.rbxm in Studio's plugins folder. The plugin is baked into the atlas binary via include_bytes! at compile time (src/cli/plugin.rs:12), so the .rbxm in Studio never auto-updates -- it must be explicitly reinstalled. If the plugin reinstall fails (e.g. Studio not installed), a warning is shown with manual instructions rather than failing the whole update.


FEATURE: Background update check on extension activation

In vscode-rojo/src/extension.ts activate():

Rokit has rokit update atlas --check which checks for updates without installing. Supports both project-level (rokit update atlas --check in the project dir, reads rokit.toml) and global (rokit update --global atlas --check).

Implementation: Add a new file vscode-rojo/src/checkForUpdates.ts with a checkForAtlasUpdates(context) function, called fire-and-forget from activate():

export async function checkForAtlasUpdates(context: vscode.ExtensionContext) {
  // Throttle: once per day
  const lastCheck = context.globalState.get<number>("atlas::lastUpdateCheck")
  const ONE_DAY = 24 * 60 * 60 * 1000
  if (lastCheck && Date.now() - lastCheck < ONE_DAY) return

  // Check if rokit is available
  const rokitPath = await which("rokit").catch(() => null)
  if (!rokitPath) return

  const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath

  // rokit update --check prints update info if available, or "up to date" if current.
  // Check both project-level and global.
  let checkOutput: string | null = null
  let updateScope: "project" | "global" = "global"

  // Try project-level first (if workspace is open)
  if (workspaceFolder) {
    try {
      const result = await exec("rokit update atlas --check", { cwd: workspaceFolder })
      const output = (result.stdout + result.stderr).trim()
      if (output && !output.toLowerCase().includes("up to date")) {
        checkOutput = output
        updateScope = "project"
      }
    } catch {
      // atlas not in project rokit.toml -- fall through
    }
  }

  // Try global if no project-level update found
  if (!checkOutput) {
    try {
      const result = await exec("rokit update --global atlas --check")
      const output = (result.stdout + result.stderr).trim()
      if (output && !output.toLowerCase().includes("up to date")) {
        checkOutput = output
        updateScope = "global"
      }
    } catch {
      // atlas not installed globally via rokit
    }
  }

  context.globalState.update("atlas::lastUpdateCheck", Date.now())

  if (!checkOutput) return

  const choice = await vscode.window.showInformationMessage(
    `An Atlas update is available. ${checkOutput}`,
    "Update",
    "Dismiss",
  )

  if (choice !== "Update") return

  await vscode.window.withProgress(
    { location: vscode.ProgressLocation.Notification, title: "Updating Atlas..." },
    async (progress) => {
      try {
        if (updateScope === "project" && workspaceFolder) {
          await exec("rokit update atlas", { cwd: workspaceFolder })
        } else {
          await exec("rokit update --global atlas")
        }
      } catch (e: any) {
        vscode.window.showErrorMessage(`Could not update Atlas: ${e.stderr || e}`)
        return
      }

      progress.report({ message: "Reinstalling Studio plugin..." })
      try {
        await exec("atlas plugin install")
      } catch {
        vscode.window.showWarningMessage(
          "Atlas updated, but Studio plugin reinstall failed. Run 'atlas plugin install' manually.",
        )
      }

      vscode.window.showInformationMessage("Atlas updated successfully.")
    },
  )
}

Called from activate() as fire-and-forget (no await) so it never blocks extension startup:

// In activate(), after all synchronous setup is done:
checkForAtlasUpdates(context).catch(() => {})

Key design decisions:

  • Uses rokit update --check: Lets rokit handle version resolution natively -- no GitHub API calls, no manual semver parsing
  • Project-level first, then global: Checks the project's rokit.toml first (if workspace is open), falls back to global. When updating, uses the same scope that found the update
  • Throttle: Once per day via globalState, so frequent restarts don't spam the user
  • Silent failures: Every step is wrapped in try/catch that returns early. Missing rokit, no atlas configured, network errors -- all silently ignored
  • Non-blocking: Called without await so extension activation is never delayed

Summary of What's Working Correctly

  • Asset name regex (-(?<platform>\w+)-(?<arch>\w+)\.zip$) correctly matches all 6 rokit 1.2.0 release assets (linux/macos/windows x x86_64/aarch64, all .zip)
  • Platform/arch mapping covers all common Node.js platforms (win32, darwin, linux) and architectures (x64, arm64)
  • rokit self-install correctly sets up ~/.rokit/bin in user PATH on all platforms
  • chmod 0o755 is correctly applied on Unix after extraction
  • getRojoInstall correctly detects rokit-managed vs globally installed atlas

Files to Change