diff --git a/.cursor/plans/merge_plugin_upload_workflow_0943cf6c.plan.md b/.cursor/plans/merge_plugin_upload_workflow_0943cf6c.plan.md new file mode 100644 index 00000000..33a1b785 --- /dev/null +++ b/.cursor/plans/merge_plugin_upload_workflow_0943cf6c.plan.md @@ -0,0 +1,161 @@ +--- +name: Merge plugin upload workflow +overview: Manually apply the upstream commit "Upload plugin as part of release workflow" to the Atlas fork, adapting all file references and metadata to match the fork's diverged codebase. +todos: + - id: new-files + content: Create .lune/.config.luau, .lune/scripts/plugin-upload.luau, .lune/upload-plugin.luau (adapted for atlas), plugin/UploadDetails.json (with Atlas values) + status: completed + - id: submodule + content: Add opencloud-execute submodule at .lune/opencloud-execute + status: completed + - id: build-rs + content: Add UploadDetails.json to plugin snapshot in build.rs + status: completed + - id: project-json + content: Add UploadDetails node to plugin.project.json + status: completed + - id: rokit + content: Add lune tool to rokit.toml + status: completed + - id: release-yml + content: Add Roblox upload step to release.yml build-plugin job + status: completed + - id: plugin-rs + content: Apply trivial test change in src/cli/plugin.rs + status: completed + - id: local-scripts + content: Replace scripts/publish-plugin.ps1 and add scripts/publish-plugin.sh to use lune-based upload + status: completed +isProject: false +--- + +# Merge Plugin Upload Release Workflow + +Cherry-picking will produce conflicts in 6+ files due to heavy divergence. Manual application is the cleanest path. + +## Upstream bugs to fix + +**Swapped env vars in `release.yml`:** The upstream commit has `RBX_UNIVERSE_ID` mapped to `vars.PLUGIN_CI_PLACE_ID` and `RBX_PLACE_ID` mapped to `vars.PLUGIN_CI_UNIVERSE_ID` -- clearly crossed. We will use the correct mapping. + +## Local scripts replacement + +`scripts/publish-plugin.ps1` currently uses `cargo run -- upload`. Replace it (and add missing `.sh`) to use the same Lune-based upload pipeline locally. The `.lune/upload-plugin.luau` script already handles the local case (builds the plugin via `atlas build` when `CI != "true"`), so the wrapper scripts just load `.env` and run `lune run upload-plugin Atlas.rbxm`. + +`.env` uses the same names as GitHub Actions secrets/vars: + +``` +PLUGIN_UPLOAD_TOKEN=your-opencloud-api-key +PLUGIN_CI_UNIVERSE_ID=your-universe-id +PLUGIN_CI_PLACE_ID=your-place-id +``` + +The local scripts map these to `RBX_API_KEY`/`RBX_UNIVERSE_ID`/`RBX_PLACE_ID` before calling lune, matching what the CI workflow `env:` block does. + +## What the commit adds + +Automated plugin upload to Roblox as part of the GitHub release workflow, using [Lune](https://lune-org.github.io/docs) + OpenCloud Luau Execute to publish the built `.rbxm` to Roblox's plugin marketplace. + +## New files (create as-is, with Atlas adaptations) + +### `.lune/.config.luau` + +Lune config with strict mode and typedefs alias. Copy verbatim from upstream. + +### `.lune/scripts/plugin-upload.luau` + +Roblox engine script that deserializes the plugin `.rbxm`, reads `UploadDetails`, and calls `AssetService:CreateAssetVersionAsync`. Copy verbatim -- it's generic (reads values from UploadDetails at runtime). + +### `.lune/upload-plugin.luau` + +Lune entry point that reads the `.rbxm`, sends it to OpenCloud for execution. One adaptation needed: change `process.exec("rojo", ...)` to `process.exec("atlas", ...)` on the local-testing path (line 25 in upstream). + +### `plugin/UploadDetails.json` + +Atlas-specific values per user input: + +```json +{ + "assetId": 111151139098227, + "name": "Atlas", + "description": "The plugin portion of Atlas, a tool to enable professional tooling for Roblox developers.", + "creatorId": 422368266, + "creatorType": "Group" +} +``` + +## Submodule addition + +### `.lune/opencloud-execute` + +Add git submodule: `https://github.com/Dekkonot/opencloud-luau-execute-lune.git` at `.lune/opencloud-execute`. This also adds an entry to `[.gitmodules](.gitmodules)`. + +## Existing file modifications + +### `[.github/workflows/release.yml](.github/workflows/release.yml)` + +Two additions to the `build-plugin` job: + +1. **Add Rokit install step** (after "Install Rust", before "Build Atlas") so `lune` is on PATH: + +```yaml +- name: Install Rokit + uses: CompeyDev/setup-rokit@v0.1.2 +``` + +1. **Add upload step** (after "Upload Plugin to Artifacts" at line 47) with **corrected** env var mapping (upstream had these swapped): + +```yaml +- name: Upload Plugin to Roblox + env: + RBX_API_KEY: ${{ secrets.PLUGIN_UPLOAD_TOKEN }} + RBX_UNIVERSE_ID: ${{ vars.PLUGIN_CI_UNIVERSE_ID }} + RBX_PLACE_ID: ${{ vars.PLUGIN_CI_PLACE_ID }} + run: lune run upload-plugin Atlas.rbxm +``` + +### `[build.rs](build.rs)` + +Add `"UploadDetails.json"` entry to the plugin snapshot hashmap, after `"Version.txt"` on line 98: + +```rust +"UploadDetails.json" => snapshot_from_fs_path(&plugin_dir.join("UploadDetails.json"))?, +``` + +### `[plugin.project.json](plugin.project.json)` + +Add `UploadDetails` child node after `Version` (line 25): + +```json +"UploadDetails": { + "$path": "plugin/UploadDetails.json" +} +``` + +### `[rokit.toml](rokit.toml)` + +Add lune tool (latest version) after the existing entries: + +```toml +lune = "lune-org/lune@0.10.4" +``` + +### `[src/cli/plugin.rs](src/cli/plugin.rs)` + +Trivial test change on line 101: `assert!(initialize_plugin().is_ok())` becomes `let _ = initialize_plugin().unwrap();` + +### `[scripts/publish-plugin.ps1](scripts/publish-plugin.ps1)` + +Replace entirely. New version: load `.env`, export `RBX_API_KEY`/`RBX_UNIVERSE_ID`/`RBX_PLACE_ID`, run `lune run upload-plugin Atlas.rbxm`. + +### `scripts/publish-plugin.sh` (new) + +Bash equivalent: source `.env`, export vars, run `lune run upload-plugin Atlas.rbxm`. + +## Required GitHub secrets/vars + +After merging, the repo needs these configured in GitHub settings: + +- **Secret:** `PLUGIN_UPLOAD_TOKEN` -- Roblox OpenCloud API key +- **Variable:** `PLUGIN_CI_UNIVERSE_ID` -- Universe ID for the place used by OpenCloud Execute +- **Variable:** `PLUGIN_CI_PLACE_ID` -- Place ID within that universe + diff --git a/.cursor/plans/rokit_install_audit_fixes_e465275a.plan.md b/.cursor/plans/rokit_install_audit_fixes_e465275a.plan.md new file mode 100644 index 00000000..e02ce9cf --- /dev/null +++ b/.cursor/plans/rokit_install_audit_fixes_e465275a.plan.md @@ -0,0 +1,390 @@ +--- +name: Rokit Install Audit Fixes +overview: 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. +todos: + - id: bug1-elevated-delete + content: Add elevatedDelete() helper and fix the 'Switch to Rokit' flow in openMenu.ts to handle EPERM/EACCES/EBUSY with platform-specific elevation + status: completed + - id: bug2-progress + content: Wrap installRojo() in vscode.window.withProgress() with stage reporting + status: completed + - id: bug3-github-api + content: Replace eryn.io proxy URL with official GitHub API URL in installRojo.ts + status: completed + - id: bug4-temp-cleanup + content: Delete temp rokit binary after successful self-install + status: completed + - id: bug5-path-dedup + content: Deduplicate PATH entry and verify rokit accessibility before rokit add + status: completed + - id: bug6-stream + content: Replace manual stream piping with stream.promises.pipeline() + status: completed + - id: bug7-rokit-add-existing + content: Use 'rokit add --global --force' + 'rokit trust' + verify atlas --version + non-fatal plugin install + status: completed + - id: feat-check-updates + content: Add 'Check for Updates' menu item in openMenu.ts for rokit-managed installs, handle project-level + global rokit update, reinstall Studio plugin after update + status: completed + - id: feat-auto-update-check + content: 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 + status: completed +isProject: 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](install.ps1)) or `/usr/local/bin/atlas` (written by [install.sh](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](vscode-rojo/src/commands/openMenu.ts) line 133: + +```typescript +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) +3. 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](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](vscode-rojo/src/installRojo.ts) line 113: + +```typescript +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](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](vscode-rojo/src/installRojo.ts) lines 177-181: + +```typescript +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](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](vscode-rojo/src/installRojo.ts) line 184: + +```typescript +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: + +```typescript +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](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`: + +```typescript +...(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: + +```typescript +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](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()`: + +```typescript +export async function checkForAtlasUpdates(context: vscode.ExtensionContext) { + // Throttle: once per day + const lastCheck = context.globalState.get("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: + +```typescript +// 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 (`-(?\w+)-(?\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 + +- [vscode-rojo/src/installRojo.ts](vscode-rojo/src/installRojo.ts) -- Bugs 2, 3, 4, 5, 6, 7 +- [vscode-rojo/src/commands/openMenu.ts](vscode-rojo/src/commands/openMenu.ts) -- Bug 1, Check for Updates menu item +- [vscode-rojo/src/checkForUpdates.ts](vscode-rojo/src/checkForUpdates.ts) -- NEW: background update check logic +- [vscode-rojo/src/extension.ts](vscode-rojo/src/extension.ts) -- Call checkForAtlasUpdates() on activation + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00da9ee9..447eae0e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,9 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable + - name: Install Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + - name: Build Atlas run: cargo build --release --locked @@ -46,6 +49,13 @@ jobs: name: Atlas.rbxm path: Atlas.rbxm + - name: Upload Plugin to Roblox + env: + RBX_API_KEY: ${{ secrets.PLUGIN_UPLOAD_TOKEN }} + RBX_UNIVERSE_ID: ${{ vars.PLUGIN_CI_UNIVERSE_ID }} + RBX_PLACE_ID: ${{ vars.PLUGIN_CI_PLACE_ID }} + run: lune run upload-plugin Atlas.rbxm + build-vscode: needs: ["create-release"] name: Build VS Code Extension diff --git a/.gitmodules b/.gitmodules index 0de0d58e..a038db15 100644 --- a/.gitmodules +++ b/.gitmodules @@ -25,3 +25,6 @@ [submodule "vscode-rojo"] path = vscode-rojo url = https://github.com/UserGeneratedLLC/vscode-rojo.git +[submodule ".lune/opencloud-execute"] + path = .lune/opencloud-execute + url = https://github.com/Dekkonot/opencloud-luau-execute-lune.git diff --git a/.lune/.config.luau b/.lune/.config.luau new file mode 100644 index 00000000..43665fdb --- /dev/null +++ b/.lune/.config.luau @@ -0,0 +1,8 @@ +return { + luau = { + languagemode = "strict", + aliases = { + lune = "~/.lune/.typedefs/0.10.4/", + }, + }, +} diff --git a/.lune/opencloud-execute b/.lune/opencloud-execute new file mode 160000 index 00000000..8ae86dd3 --- /dev/null +++ b/.lune/opencloud-execute @@ -0,0 +1 @@ +Subproject commit 8ae86dd3ad35eb66b0a4edda7e8771a5986cd0c6 diff --git a/.lune/scripts/plugin-upload.luau b/.lune/scripts/plugin-upload.luau new file mode 100644 index 00000000..c5708796 --- /dev/null +++ b/.lune/scripts/plugin-upload.luau @@ -0,0 +1,51 @@ +local args: any = ... +assert(args, "no arguments passed to script") + +local input: buffer = args.BinaryInput + +local AssetService = game:GetService("AssetService") +local SerializationService = game:GetService("SerializationService") +local EncodingService = game:GetService("EncodingService") + +local input_hash: buffer = EncodingService:ComputeBufferHash(input, Enum.HashAlgorithm.Sha256) +local hex_hash: { string } = table.create(buffer.len(input_hash)) +for i = 0, buffer.len(input_hash) - 1 do + table.insert(hex_hash, string.format("%02x", buffer.readu8(input_hash, i))) +end + +print(`Deserializing plugin file (size: {buffer.len(input)} bytes, hash: {table.concat(hex_hash, "")})`) +local plugin = SerializationService:DeserializeInstancesAsync(input)[1] + +local UploadDetails = require(plugin.UploadDetails) :: any +local PLUGIN_ID = UploadDetails.assetId +local PLUGIN_NAME = UploadDetails.name +local PLUGIN_DESCRIPTION = UploadDetails.description +local PLUGIN_CREATOR_ID = UploadDetails.creatorId +local PLUGIN_CREATOR_TYPE = UploadDetails.creatorType + +assert(typeof(PLUGIN_ID) == "number", "UploadDetails did not contain a number field 'assetId'") +assert(typeof(PLUGIN_NAME) == "string", "UploadDetails did not contain a string field 'name'") +assert(typeof(PLUGIN_DESCRIPTION) == "string", "UploadDetails did not contain a string field 'description'") +assert(typeof(PLUGIN_CREATOR_ID) == "number", "UploadDetails did not contain a number field 'creatorId'") +assert(typeof(PLUGIN_CREATOR_TYPE) == "string", "UploadDetails did not contain a string field 'creatorType'") +assert( + Enum.AssetCreatorType:FromName(PLUGIN_CREATOR_TYPE) ~= nil, + "UploadDetails field 'creatorType' was not a valid member of Enum.AssetCreatorType" +) + +print(`Uploading to {PLUGIN_ID}`) +print(`Plugin Name: {PLUGIN_NAME}`) +print(`Plugin Description: {PLUGIN_DESCRIPTION}`) + +local result, version_or_err = AssetService:CreateAssetVersionAsync(plugin, Enum.AssetType.Plugin, PLUGIN_ID, { + ["Name"] = PLUGIN_NAME, + ["Description"] = PLUGIN_DESCRIPTION, + ["CreatorId"] = PLUGIN_CREATOR_ID, + ["CreatorType"] = Enum.AssetCreatorType:FromName(PLUGIN_CREATOR_TYPE), +}) + +if result ~= Enum.CreateAssetResult.Success then + error(`Plugin failed to upload because: {result.Name} - {version_or_err}`) +end + +print(`Plugin uploaded successfully. New version is {version_or_err}.`) diff --git a/.lune/upload-plugin.luau b/.lune/upload-plugin.luau new file mode 100644 index 00000000..b53b30c8 --- /dev/null +++ b/.lune/upload-plugin.luau @@ -0,0 +1,77 @@ +local fs = require("@lune/fs") +local process = require("@lune/process") +local stdio = require("@lune/stdio") + +local luau_execute = require("./opencloud-execute") + +local UNIVERSE_ID = process.env["RBX_UNIVERSE_ID"] +local PLACE_ID = process.env["RBX_PLACE_ID"] + +local version_string = fs.readFile("plugin/Version.txt") +local versions = { string.match(version_string, "^v?(%d+)%.(%d+)%.(%d+)(.*)$") } +if versions[4] ~= "" then + print("This release is a pre-release. Skipping uploading plugin.") + process.exit(0) +end + +local plugin_path = process.args[1] +assert( + typeof(plugin_path) == "string", + "no plugin path provided, expected usage is `lune run upload-plugin [PATH TO RBXM]`." +) + +if process.env["CI"] ~= "true" then + local atlas = process.exec("atlas", { "build", "plugin.project.json", "--output", plugin_path }) + if not atlas.ok then + stdio.ewrite("plugin upload failed because: could not build plugin.rbxm\n\n") + stdio.ewrite(atlas.stderr) + stdio.ewrite("\n") + process.exit(1) + end +else + assert(fs.isFile(plugin_path), `Plugin file did not exist at {plugin_path}`) +end +local plugin_content = fs.readFile(plugin_path) + +local engine_script = fs.readFile(".lune/scripts/plugin-upload.luau") + +print("Creating task to upload plugin") +local task = luau_execute.create_task_latest(UNIVERSE_ID, PLACE_ID, engine_script, 300, false, plugin_content) + +print("Waiting for task to finish") +local success = luau_execute.await_finish(task) +if not success then + local error = luau_execute.get_error(task) + assert(error, "could not fetch error from task") + stdio.ewrite("plugin upload failed because: task did not finish successfully\n\n") + stdio.ewrite(error.code) + stdio.ewrite("\n") + stdio.ewrite(error.message) + stdio.ewrite("\n") + process.exit(1) +end + +print("Output from task:\n") +for _, log in luau_execute.get_structured_logs(task) do + if log.messageType == "ERROR" then + stdio.write(stdio.color("red")) + stdio.write(log.message) + stdio.write("\n") + stdio.write(stdio.color("reset")) + elseif log.messageType == "INFO" then + stdio.write(stdio.color("cyan")) + stdio.write(log.message) + stdio.write("\n") + stdio.write(stdio.color("reset")) + elseif log.messageType == "WARNING" then + stdio.write(stdio.color("yellow")) + stdio.write(log.message) + stdio.write("\n") + stdio.write(stdio.color("reset")) + else + stdio.write(stdio.color("reset")) + stdio.write(log.message) + stdio.write("\n") + stdio.write(stdio.color("reset")) + end +end diff --git a/build.rs b/build.rs index b03589d0..57e5a472 100644 --- a/build.rs +++ b/build.rs @@ -96,6 +96,7 @@ fn main() -> Result<(), anyhow::Error> { "src" => snapshot_from_fs_path(&plugin_dir.join("src"))?, "Packages" => snapshot_from_fs_path(&plugin_dir.join("Packages"))?, "Version.txt" => snapshot_from_fs_path(&plugin_dir.join("Version.txt"))?, + "UploadDetails.json" => snapshot_from_fs_path(&plugin_dir.join("UploadDetails.json"))?, }), }); diff --git a/plugin.project.json b/plugin.project.json index 83265250..1742547b 100644 --- a/plugin.project.json +++ b/plugin.project.json @@ -22,6 +22,9 @@ }, "Version": { "$path": "plugin/Version.txt" + }, + "UploadDetails": { + "$path": "plugin/UploadDetails.json" } } } diff --git a/plugin/UploadDetails.json b/plugin/UploadDetails.json new file mode 100644 index 00000000..ef9fa736 --- /dev/null +++ b/plugin/UploadDetails.json @@ -0,0 +1,7 @@ +{ + "assetId": 111151139098227, + "name": "Atlas", + "description": "The plugin portion of Atlas, a tool to enable professional tooling for Roblox developers.", + "creatorId": 422368266, + "creatorType": "Group" +} diff --git a/rokit.toml b/rokit.toml index e365f787..b7af77be 100644 --- a/rokit.toml +++ b/rokit.toml @@ -3,3 +3,4 @@ selene = "Kampfkarren/selene@0.30.0" stylua = "JohnnyMorganz/stylua@2.3.1" luau-lsp = "JohnnyMorganz/luau-lsp@1.62.0" run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0" +lune = "lune-org/lune@0.10.4" diff --git a/scripts/publish-plugin.ps1 b/scripts/publish-plugin.ps1 index 6c974936..8fdaa55e 100644 --- a/scripts/publish-plugin.ps1 +++ b/scripts/publish-plugin.ps1 @@ -1,14 +1,9 @@ -# Build and publish the Rojo plugin to the Creator Store -# Loads API key from .env file, ROBLOX_API_KEY env var, or -ApiKey argument. -# Get an API key at https://create.roblox.com/credentials (assets read+write). - -param( - [string]$ApiKey -) +# Build and publish the Atlas plugin to the Creator Store via OpenCloud. +# Loads env vars from .env file in the repo root. +# Required .env vars: PLUGIN_UPLOAD_TOKEN, PLUGIN_CI_UNIVERSE_ID, PLUGIN_CI_PLACE_ID $ErrorActionPreference = "Stop" -# Load .env file if it exists $envFile = Join-Path $PSScriptRoot "..\.env" if (Test-Path $envFile) { foreach ($line in Get-Content $envFile) { @@ -22,16 +17,14 @@ if (Test-Path $envFile) { } } -# Resolve API key: argument > env var > .env (already loaded above) -if (-not $ApiKey) { - $ApiKey = $env:ROBLOX_API_KEY -} - -if (-not $ApiKey -or $ApiKey -eq "your-key-here") { - Write-Host "Error: No API key provided." -ForegroundColor Red - Write-Host "Set ROBLOX_API_KEY in .env, env var, or pass -ApiKey " -ForegroundColor Yellow +if (-not $env:PLUGIN_UPLOAD_TOKEN) { + Write-Host "Error: PLUGIN_UPLOAD_TOKEN not set. Add it to .env or set as env var." -ForegroundColor Red exit 1 } +$env:RBX_API_KEY = $env:PLUGIN_UPLOAD_TOKEN +$env:RBX_UNIVERSE_ID = $env:PLUGIN_CI_UNIVERSE_ID +$env:RBX_PLACE_ID = $env:PLUGIN_CI_PLACE_ID + Write-Host "Building and uploading plugin..." -ForegroundColor Cyan -cargo run -- upload plugin.project.json --asset_id 111151139098227 +lune run upload-plugin Atlas.rbxm diff --git a/scripts/publish-plugin.sh b/scripts/publish-plugin.sh new file mode 100755 index 00000000..e29fad4f --- /dev/null +++ b/scripts/publish-plugin.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Build and publish the Atlas plugin to the Creator Store via OpenCloud. +# Loads env vars from .env file in the repo root. +# Required .env vars: PLUGIN_UPLOAD_TOKEN, PLUGIN_CI_UNIVERSE_ID, PLUGIN_CI_PLACE_ID + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$SCRIPT_DIR/../.env" + +if [ -f "$ENV_FILE" ]; then + set -a + source "$ENV_FILE" + set +a +fi + +if [ -z "${PLUGIN_UPLOAD_TOKEN:-}" ]; then + echo "Error: PLUGIN_UPLOAD_TOKEN not set. Add it to .env or set as env var." >&2 + exit 1 +fi + +export RBX_API_KEY="$PLUGIN_UPLOAD_TOKEN" +export RBX_UNIVERSE_ID="${PLUGIN_CI_UNIVERSE_ID:-}" +export RBX_PLACE_ID="${PLUGIN_CI_PLACE_ID:-}" + +echo "Building and uploading plugin..." +lune run upload-plugin Atlas.rbxm diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs index b91320f5..99d72973 100644 --- a/src/cli/plugin.rs +++ b/src/cli/plugin.rs @@ -99,5 +99,5 @@ fn uninstall_plugin() -> anyhow::Result<()> { #[test] fn plugin_initialize() { - assert!(initialize_plugin().is_ok()) + let _ = initialize_plugin().unwrap(); } diff --git a/vscode-rojo b/vscode-rojo index e25d02ca..383f1745 160000 --- a/vscode-rojo +++ b/vscode-rojo @@ -1 +1 @@ -Subproject commit e25d02caea9afbac353e19a82e72189348306e1b +Subproject commit 383f174518c8ec25e03c18e3ed26a0af093ac6d2