From 2281c05d787a37ca3ed554735d3d2152622849dd Mon Sep 17 00:00:00 2001 From: worldisonline <242876766+worldisonline@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:13:17 +0200 Subject: [PATCH] Release Prism 0.1 --- .github/workflows/release.yml | 17 +- .gitignore | 4 + Cargo.toml | 9 + PRISM.md | 64 ++ design/prism/AUTOMATION_ARCHITECTURE.md | 785 ++++++++++++++++++ plugin/src/App/Components/Header.lua | 1 + .../Notifications/FullscreenNotification.lua | 1 + .../Components/Notifications/Notification.lua | 1 + plugin/src/App/StatusPages/Confirming.lua | 1 + plugin/src/App/StatusPages/Connecting.lua | 1 + plugin/src/App/StatusPages/Settings/init.lua | 1 + plugin/src/Assets.lua | 2 +- plugin/src/Branding.lua | 6 +- scripts/watch-build-plugin.sh | 2 +- src/bin/rojo.rs | 3 + src/cli/exec.rs | 10 +- src/cli/inspect.rs | 13 +- src/cli/mod.rs | 101 ++- src/cli/plugin.rs | 351 +++++++- src/cli/serve.rs | 199 ++++- src/main.rs | 80 +- tests/rojo_test/io_util.rs | 1 + tests/tests/cli.rs | 26 + tests/tests/mod.rs | 1 + 24 files changed, 1537 insertions(+), 143 deletions(-) create mode 100644 PRISM.md create mode 100644 design/prism/AUTOMATION_ARCHITECTURE.md create mode 100644 src/bin/rojo.rs create mode 100644 tests/tests/cli.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0a6e9d7..51ddb13b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,26 +31,19 @@ jobs: version: 'v1.1.0' - name: Build Plugin - run: rojo build plugin.project.json --output Rojo.rbxm + run: rojo build plugin.project.json --output Prism.rbxm - name: Upload Plugin to Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh release upload ${{ github.ref_name }} Rojo.rbxm + gh release upload ${{ github.ref_name }} Prism.rbxm - name: Upload Plugin to Artifacts uses: actions/upload-artifact@v4 with: - name: Rojo.rbxm - path: Rojo.rbxm - - - name: Upload Plugin to Roblox - env: - RBX_API_KEY: ${{ secrets.PLUGIN_UPLOAD_TOKEN }} - RBX_UNIVERSE_ID: ${{ vars.PLUGIN_CI_PLACE_ID }} - RBX_PLACE_ID: ${{ vars.PLUGIN_CI_UNIVERSE_ID }} - run: lune run upload-plugin Rojo.rbxm + name: Prism.rbxm + path: Prism.rbxm build: needs: ["create-release"] @@ -92,7 +85,7 @@ jobs: name: Build (${{ matrix.target }}) runs-on: ${{ matrix.os }} env: - BIN: rojo + BIN: prism steps: - uses: actions/checkout@v4 with: diff --git a/.gitignore b/.gitignore index a660919d..de8b588c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ /*.rbxl /*.rbxlx +# One-off Prism exec/inspect scripts used for local Studio verification +/create-part.lua +/inspect.lua + # Sourcemap for the Rojo plugin (for better intellisense) /sourcemap.json diff --git a/Cargo.toml b/Cargo.toml index 975a04e7..16dbc902 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "rojo" version = "7.7.0" +autobins = false rust-version = "1.88" authors = [ "Lucien Greathouse ", @@ -41,6 +42,14 @@ members = ["crates/*"] name = "librojo" path = "src/lib.rs" +[[bin]] +name = "prism" +path = "src/main.rs" + +[[bin]] +name = "rojo" +path = "src/bin/rojo.rs" + [[bench]] name = "build" harness = false diff --git a/PRISM.md b/PRISM.md new file mode 100644 index 00000000..fc220b05 --- /dev/null +++ b/PRISM.md @@ -0,0 +1,64 @@ +# Prism 0.1 + +Prism is a standalone Roblox developer tool derived from Rojo. It retains Rojo project-file and sync-protocol compatibility while adding a trusted, local automation path for a connected Prism Studio plugin. + +Prism 0.1 currently supports: + +- `prism serve [PROJECT]` +- `prism exec SCRIPT.lua` +- `prism inspect TARGET` +- `prism plugin install` +- `prism plugin list` +- `prism --help` and `prism --version` + +It does not currently provide selection, camera control, focus, screenshots, search, play/stop control, snapshots or diffs, preview/watch automation, profiling automation, or playtesting automation. + +## Build and installation + +Install Rust 1.88 or newer, initialize the repository submodules, and install the pinned tools with Rokit: + +```text +git submodule update --init --recursive +rokit install +cargo build --locked +``` + +The primary development binary is `target/debug/prism` (`target\debug\prism.exe` on Windows). Build an optimized binary with `cargo build --release --locked`; it is written to `target/release/prism` or `target\release\prism.exe`. + +Install the bundled Studio plugin after building: + +```text +cargo run --bin prism -- plugin install +``` + +Prism writes `PrismManagedPlugin.rbxm` to Roblox Studio's local Plugins directory and prints the exact path. Use `prism plugin list` to inspect likely Prism and Rojo-like local plugin files. Marketplace-installed copies must be disabled separately through Studio if they conflict. + +## Serving a project + +Pass a Rojo-compatible project file or directory explicitly: + +```text +prism serve default.project.json +``` + +With no path, Prism checks only the current directory. It prefers `default.project.json`; otherwise it uses the sole `*.project.json` file. It errors when no candidate exists or when several candidates exist, listing multiple candidates in deterministic order. It does not search parent or descendant directories. + +## Trusted local automation + +Start `prism serve`, open Roblox Studio with the Prism plugin installed, connect the plugin, and then invoke trusted local commands from another terminal: + +```text +prism exec script.lua +prism inspect Workspace +prism inspect Workspace.Map --depth 2 --properties --json +``` + +`prism exec` runs the supplied Luau as trusted code inside the connected Studio edit session. It can mutate the open place. Run it only against a local server and only with scripts you have reviewed and trust. The serve and automation APIs are not a sandbox and should not be exposed to untrusted networks or users. + +`prism inspect` reads a bounded typed view of a DataModel path through the connected plugin. The Studio plugin is required for both commands, and automation is available only in Studio edit mode. + +## Compatibility and attribution + +Prism keeps Rojo-compatible `.project.json` formats, the `/api/rojo` compatibility route, existing sync routes, exec job routes such as `/api/exec/jobs`, the packaged `Rojo` model root, and compatibility-sensitive Studio IDs. The Rust package remains named `rojo`, and its public library remains `librojo`; `prism` is the primary executable and `rojo` is retained as a compatibility executable. + +Prism is derived from Rojo and retains its Mozilla Public License 2.0 attribution and licensing. See `LICENSE.txt` and the upstream history in `CHANGELOG.md`. diff --git a/design/prism/AUTOMATION_ARCHITECTURE.md b/design/prism/AUTOMATION_ARCHITECTURE.md new file mode 100644 index 00000000..7d9c2329 --- /dev/null +++ b/design/prism/AUTOMATION_ARCHITECTURE.md @@ -0,0 +1,785 @@ +# Prism automation architecture + +This document is a planning artifact. It records confirmed repository facts and proposes a small automation subsystem that can grow from the current `prism exec ` primitive without creating unrelated queues, endpoint families, poll loops, and result formats for every future command. + +External Roblox API notes were checked against Creator Hub pages on 2026-07-20: + +- `Selection` documents plugin access to `Get`, `Set`, `Add`, `Remove`, and `SelectionChanged`: +- `StudioCaptureService` documents plugin-security screenshot methods: +- `StudioScreenshotCapture` documents plugin-security `GetBuffer`, `GetErrors`, and `ScaleAsync`: +- `RunService` documents `Run` and `Stop` as plugin-security methods, plus `IsEdit`, `IsRunning`, `IsRunMode`, `IsStudio`, `IsServer`, and `RunState`: +- `StudioTestService` documents plugin-security `ExecutePlayModeAsync`, `ExecuteRunModeAsync`, and multiplayer test methods: +- `ChangeHistoryService` documents `TryBeginRecording` and `FinishRecording`, and says `SetWaypoint` is being replaced by recordings: +- `Instance.UniqueId` is documented as Roblox security, so it is not a plugin-accessible persistent identifier for this design: + +## 1. Current Prism architecture + +Confirmed facts: + +- CLI subcommands are declared in `src/cli/mod.rs`. `Options::run` dispatches `Subcommand::Exec` to `ExecCommand::run`. +- `src/cli/exec.rs` implements `rojo exec ` with `--address` and `--port`, reads UTF-8 source, strips one leading BOM, enforces `MAX_SOURCE_SIZE_BYTES`, checks `/api/rojo`, submits MessagePack to `/api/exec/jobs`, polls `/api/exec/jobs/{id}`, and renders terminal results and logs. +- `src/exec.rs` owns the current server-side exec queue. `ExecJobStore` uses `Mutex`, `HashMap`, and `VecDeque`, supports one claimed job at once, and tracks `Pending`, `Claimed`, `Succeeded`, `Failed`, and `TimedOut`. +- `src/serve_session.rs` stores one `ExecJobStore` per `ServeSession` as `exec_job_store: ExecJobStore` and exposes `exec_job_store(&self) -> &ExecJobStore`. +- `src/web/mod.rs` builds the raw Hyper server with `make_service_fn` and `service_fn`, sends `/api...` requests to `src/web/api.rs`, and handles non-API requests through the UI module. +- `src/web/api.rs` manually parses `/api/exec/jobs`, `/api/exec/jobs/next`, `/api/exec/jobs/{id}`, and `/api/exec/jobs/{id}/complete`. It rejects non-loopback peers using the same locality check as `/api/open`, cleans expired jobs on exec requests, and returns MessagePack API errors. +- `src/web/interface.rs` defines the wire structs and enums for exec, the sync protocol, WebSocket packets, binary model serialization, and the common `ErrorResponse`. +- `src/web/util.rs` defines MessagePack helpers using `rmp_serde` with human-readable struct maps and `application/msgpack`. +- `plugin/src/ApiContext.lua` owns plugin HTTP methods. Exec currently adds `claimNextExecJob()` and `completeExecJob(jobId, payload)` using the existing Promise and MessagePack wrappers. +- `plugin/src/ServeSession.lua` owns the plugin connection lifecycle. It creates `Exec.new`, starts it only after initial sync and WebSocket creation, and stops it from `__stopInternal` before disconnecting `ApiContext`. +- `plugin/src/Exec.lua` owns the production exec poller and executor. It claims at roughly 250 ms intervals only in edit mode, executes a fresh temporary `ModuleScript`, records one undo action with `ChangeHistoryService:TryBeginRecording` and `FinishRecording`, captures bounded logs through `rojoExec.print`/`warn`, encodes structured scalar/array/table results, retries completion, and treats timeouts as soft. +- `plugin/src/Types.lua` validates API payloads with `t`, including exec claim/status/result/log structs. +- `plugin/http/init.lua`, `plugin/http/Error.lua`, and `plugin/http/Response.lua` provide Promise-based Roblox HTTP wrappers. Non-2xx responses reject as `Http.Error`; this is why `ApiContext.completeExecJob` has a compatibility check for HTTP 409 text. + +Proposal: + +Prism should treat the current exec flow as the first implementation of a general automation pattern: CLI submits a typed job to the server, the connected plugin claims and runs it on the current Studio DataModel, the plugin posts a typed completion, and the CLI renders the result. The generalization should be narrow and typed, not a generic remote RPC system. + +## 2. Design goals + +- One session-scoped automation store, one claim loop in the plugin, one result envelope, and one CLI polling pattern for short request/response commands. +- Typed automation handlers for inspect, search, selection, camera, focus, screenshot, play/stop, doctor plugin checks, snapshot, diff, preview, and future playtesting. +- Preserve arbitrary `exec` as a trusted escape hatch and a development tool, but do not build ordinary product commands as string-generated Luau wrappers around exec. +- Support small MessagePack job responses, large/binary artifacts, long-running streams, and previewable DataModel modifications as separate transport modes under one automation namespace. +- Keep WebSocket live sync and `MessageQueue` behavior unchanged unless a future phase deliberately extends them. +- Keep all automation endpoints local-only unless the user later designs authentication. + +## 3. Non-goals + +- No public network RPC framework. +- No authentication in the immediate MVP beyond loopback/local peer checks. +- No multi-user Studio collaboration semantics in the first version. +- No promise that Preview Mode is a security sandbox. +- No attempt to use undocumented persistent Roblox identifiers. +- No routing of play-client versus play-server automation until playtesting is designed. +- No reuse of the live-sync WebSocket packet schema for unrelated automation events. + +## 4. Command classification + +Confirmed command list comes from the user request. Classifications below are proposals. + +| Command | Classification | Reasoning | +| --- | --- | --- | +| `exec` | Arbitrary exec wrapper and specialized automation job | Already implemented as trusted Luau source. It should remain special because it accepts arbitrary code and has source-size/undo semantics unlike typed commands. | +| `inspect` | Typed one-shot automation job | Needs Studio DataModel access and a structured request/result. Should not generate source. | +| `search` | Typed one-shot automation job | Pure DataModel traversal with filters and bounded results. | +| `selection` | Typed one-shot automation job | Documented `Selection:Get()` is plugin-accessible and returns current selection. | +| `select` | Typed one-shot automation job | Documented `Selection:Set()` is plugin-accessible; needs reference resolution and edit-mode behavior verified. | +| `camera` | Typed one-shot automation job | Reads `Workspace.CurrentCamera` properties. No source execution needed. | +| `focus` | Studio lifecycle/typed one-shot automation job | Mutates camera state to frame an object. Needs bounding box handling and edit-mode verification. | +| `screenshot` | Binary artifact job | Screenshot capture returns a buffer according to current docs. The CLI should download an artifact rather than receive a huge MessagePack completion. Requires Studio spike. | +| `play` | Studio lifecycle command | `RunService:Run` and `StudioTestService` play methods exist with plugin security, but actual installed-plugin behavior must be spiked. | +| `stop` | Studio lifecycle command | `RunService:Stop` and `StudioTestService:LeaveTest` exist with plugin/security indications, but behavior depends on mode. | +| `doctor` | CLI-only plus typed one-shot automation job | CLI can check server reachability/protocol. Plugin status and Studio mode require a plugin-side automation status handler. | +| `snapshot` | Typed one-shot automation job, possibly artifact job for large output | Needs bounded DataModel traversal and deterministic encoding. Large snapshots should use artifacts. | +| `diff` | CLI-only when comparing saved snapshots; typed one-shot when diffing live Studio | Diffing two local snapshot files is CLI-only. Diffing live Studio against a file or current server state needs automation. | +| `watch` | Persistent WebSocket stream | Live events must not use 250 ms HTTP polling. | +| `profile` | Typed one-shot automation job, unsupported parts require verification | Hierarchy/class/script counts are easy. Memory, script performance, and MicroProfiler data may be unavailable/private. | +| `preview` | Typed one-shot automation job with preview/apply protocol | Requires execution plus snapshot/diff/patch capture. Not a secure sandbox. | +| Automated playtesting | Unsupported without further Studio verification, later WebSocket/artifact/lifecycle system | Requires play/stop, input injection, observations, logs, screenshots, assertions, reports, and client/server routing. | + +Features that can be wrappers over `exec`: quick user-authored scripts, early prototypes of inspect/search, and manual experiments. Features that deserve dedicated handlers: inspect, search, selection/select, camera/focus, screenshot, play/stop, doctor status, snapshot/diff, watch, profile, preview/apply. Dedicated handlers give typed validation, bounded traversal, stable output, and clearer safety behavior. + +## 5. Shared automation job model + +Proposal: + +`ExecJobStore` should evolve into `AutomationJobStore`, but not by erasing all domain types into arbitrary maps. The generic store should own queue mechanics, deadlines, terminal retention, one active claim, capacity limits, local cleanup, and common state transitions. Job payloads should remain typed enums in Rust and Luau. + +Suggested Rust shape: + +```rust +pub struct AutomationJob { + pub id: Uuid, + pub kind: AutomationJobKind, + pub request: AutomationRequest, + pub state: AutomationJobState, + pub created_at: SystemTime, + pub claim_deadline: Instant, + pub execution_deadline: Option, + pub result: Option, + pub logs: Option>, + pub error: Option, +} + +pub enum AutomationRequest { + Exec(ExecRequest), + Inspect(InspectRequest), + Search(SearchRequest), + Selection(SelectionRequest), + Select(SelectRequest), + Camera(CameraRequest), + Focus(FocusRequest), + Screenshot(ScreenshotRequest), + Play(PlayRequest), + Stop(StopRequest), + Snapshot(SnapshotRequest), + Preview(PreviewRequest), +} +``` + +`AutomationJobState` should keep the existing lifecycle names: `pending`, `claimed`, `succeeded`, `failed`, `timedOut`, plus future `canceled` only when a real cancellation endpoint exists. `kind` should be an explicit enum such as `exec`, `inspect`, `search`, `screenshot`, not a free-form string. + +Arbitrary exec should remain a specialized job type: + +- It carries trusted source, `scriptName`, source limits, undo label, and privileged result/log semantics. +- It should continue to be visually/audibly branded as Prism exec. +- Typed handlers should not depend on `ModuleScript.Source` or `require` unless they are intentionally executing arbitrary source. + +## 6. HTTP and WebSocket layout + +Confirmed current layout: + +- `/api/rojo` returns server/session info. +- `/api/read/{ids}`, `/api/write`, `/api/serialize`, and `/api/ref-patch` support live sync and sync fallback. +- `/api/socket/{cursor}` is a WebSocket for server-to-plugin live-sync messages. +- `/api/exec/jobs...` is a local-only HTTP MessagePack job family. + +Proposal: + +Use one future endpoint family for typed one-shot jobs: + +- `POST /api/automation/jobs` +- `GET /api/automation/jobs/next` +- `GET /api/automation/jobs/{id}` +- `POST /api/automation/jobs/{id}/complete` +- `POST /api/automation/jobs/{id}/cancel` + +Keep `/api/exec/jobs...` during transition as a compatibility alias or thin wrapper around `AutomationJobKind::Exec`. New features should not add `/api/inspect/jobs`, `/api/search/jobs`, etc. + +Use separate transport for artifacts: + +- `POST /api/automation/jobs/{id}/artifacts` for plugin uploads. +- `GET /api/automation/artifacts/{artifactId}` for CLI downloads. +- `DELETE /api/automation/artifacts/{artifactId}` is optional; server cleanup by age and count is enough for MVP. + +Use separate WebSocket stream endpoints for long-running subscriptions: + +- `POST /api/automation/streams` to create a watch/profile/playtest stream request. +- `GET /api/automation/streams/{streamId}/socket` or `GET /api/automation/socket/{streamId}` for CLI event consumption. +- The plugin can either claim stream jobs over the automation job queue and then post events, or connect to a plugin-side automation socket later. MVP should prefer plugin HTTP event posts to the server and CLI WebSocket downloads from the server. That avoids requiring Studio to maintain a second outgoing WebSocket before the shape is proven. + +Do not reuse `MessageQueue` or `SocketPacketType::Messages` for automation. The current WebSocket is specifically live-sync server-to-plugin patch delivery. + +## 7. Plugin session identity + +Confirmed current state: + +- `src/serve_session.rs` has a server-side `SessionId`, returned by `/api/rojo`. +- Existing read/write/serialize/ref-patch requests include `sessionId` to detect server changes. +- Exec routes do not include a plugin-session identifier. +- The server does not currently track connected Studio plugin sessions as first-class records. +- `plugin/src/App/init.lua` uses a Studio-side `__Rojo_SessionLock` in `ServerStorage` for Team Create sync ownership, but this is not an HTTP server session registry. +- Multiple WebSocket clients can technically connect to `/api/socket/{cursor}` because `src/web/api.rs` spawns a handler per upgrade. Exec prevents concurrent work by allowing only one claimed job, not by identifying the claimant. + +Proposal for MVP: + +- Add a `pluginSessionId` generated by the plugin after `/api/rojo` succeeds and passed in automation claim/complete calls. +- The server should remember the active automation plugin session for the current `ServeSession` after first claim/status heartbeat. +- If no plugin is connected, one-shot jobs can remain pending until claim timeout. `doctor` should report this as "server reachable, plugin not claiming automation jobs". + +Future multi-session handling: + +- Store `AutomationStudioSession { pluginSessionId, serverSessionId, placeId, gameId, studioUserId?, connectedAt, lastSeenAt, mode, label }`. +- A job may have `targetSession: "default" | { pluginSessionId }`. +- MVP should reject multiple active automation sessions or require an explicit chooser later. Do not auto-broadcast arbitrary exec to every Studio window. + +## 8. Instance references + +Problem: + +Roblox Instances can be renamed, reparented, destroyed, duplicated, or recreated between commands. Documented `Instance.UniqueId` is Roblox security, so Prism should not rely on it. Full paths are human-friendly but not stable across rename/reparent. Debug IDs are not a good protocol foundation unless a live spike proves plugin access and stability, and even then they should be treated as diagnostic, not persistent. + +MVP proposal: + +- Accept target syntax as escaped DataModel paths, plus aliases: + - `game` + - `/` or `DataModel` + - `workspace` or `Workspace` + - `selection` + - `@` for references returned by previous commands in the same plugin session +- Return references as: + +```json +{ + "kind": "instance", + "id": "pinst-00000042", + "path": "Workspace.Map.Model", + "className": "Model", + "name": "Model", + "ancestry": [ + { "name": "Workspace", "className": "Workspace" }, + { "name": "Map", "className": "Folder" }, + { "name": "Model", "className": "Model" } + ] +} +``` + +- The plugin owns a session-local weak registry from generated IDs to Instances. Returned references include path/class/name snapshots for human output and stale-reference diagnostics. +- On use, `@id` resolves through the registry and validates `Parent ~= nil`, `IsDescendantOf(game)`, class name if supplied, and optional ancestry hash. If stale, return a typed stale-reference error. +- Escaped paths should be deterministic and unambiguous: split on `.`, allow quoted segments for names containing `.`, `\`, control characters, or leading/trailing whitespace, for example `Workspace."Map.With.Dot"."Part \"A\""`. The exact grammar should be specified before implementation and tested. + +Later evolution: + +- Add reference leases with `expiresAt` and a CLI-visible `sessionId`. +- Add `refreshReference` and `resolvePath` handlers. +- If Roblox exposes a documented, plugin-accessible persistent identifier later, layer it in as an optional validation field, not as the only reference. + +## 9. Roblox value encoding + +Confirmed exec value schema: + +- Rust `src/web/interface.rs` exposes tagged `ExecValue` variants: `nil`, `string`, `number`, `boolean`, `array`, and `table`. +- Plugin `plugin/src/Exec.lua` rejects NaN/infinity, cycles, sparse arrays, mixed array/dictionary tables, non-string dictionary keys, and unsupported values. It sorts table keys deterministically. + +Proposal: + +Create `AutomationValue` as a superset of `ExecValue`: + +- Scalars: `nil`, `string`, finite `number`, `boolean`. +- Structured: dense `array`, sorted string-key `table`. +- Roblox primitives: `vector2`, `vector3`, `cframe`, `color3`, `udim`, `udim2`, `rect`, `enumItem`, `brickColor`, `numberRange`, `numberSequence`, `colorSequence`, `physicalProperties`. +- Instance references: `{ kind = "instanceRef", value = InstanceReference }`. +- Binary artifacts are never embedded as `AutomationValue`; use artifact IDs. + +Keep encoding small and explicit. Values that cannot be represented safely should return a typed `unsupportedValue` error with a path such as `properties.CFrame` or `result.items[3]`. + +## 10. Inspect and search + +`prism inspect ` proposal: + +Request: + +```json +{ + "kind": "inspect", + "target": "Workspace.Map", + "depth": 1, + "includeProperties": "default", + "includeChildren": true, + "maxChildren": 200 +} +``` + +Response: + +```json +{ + "reference": { "kind": "instance", "id": "pinst-42", "path": "Workspace.Map", "className": "Model" }, + "name": "Map", + "className": "Model", + "path": "Workspace.Map", + "properties": {}, + "attributes": {}, + "tags": [], + "children": [] +} +``` + +Design choices: + +- Target syntax should use the shared escaped path/reference grammar. +- Defaults: depth `1`, max children `200`, max total instances `2_000`, max properties `default`. +- `--json` should print deterministic JSON. Human output can be compact but should never be the only format. +- `--depth`, `--children`, and `--properties` map directly to request fields. +- Use a property allowlist per class for MVP. Avoid expensive or sensitive properties by default. +- Script source privacy: do not return `Source` from `Script`, `LocalScript`, or `ModuleScript` unless an explicit future flag is designed. +- Non-serializable Roblox datatypes return a placeholder error per property rather than failing the entire inspect. +- Sort attributes, tags, property keys, and children by current child order with deterministic tie behavior. + +`prism search ` proposal: + +- Avoid a full query language at first. +- Support flags: `--name`, `--class`, `--tag`, `--attribute key[=value]`, `--ancestor `, `--case-sensitive`, `--limit`. +- Bare `` should search name substring case-insensitively. +- Return an array of instance references with name, className, path, selected matched fields, and a truncated preview. + +## 11. Selection + +Confirmed external fact: + +- Roblox documents `Selection:Get()` and `Selection:Set()` as usable by plugins and the command line. + +Proposal: + +- `prism selection` submits `{ kind = "selectionGet" }` and returns the selected instances as references in selection order. +- `prism select ` resolves each target and submits `{ kind = "selectionSet", references = [...] }`. +- `prism select --clear` submits an empty set. +- In edit mode, selection changes should not be wrapped in `ChangeHistoryService` by default unless a live spike shows they are undoable and useful. Treat selection as Studio UI state, not DataModel mutation. + +Studio verification required: + +- Confirm installed plugin access to `Selection:Set()` in edit mode. +- Confirm behavior in Play Solo and whether changing selection during play should be rejected or allowed as a UI-only operation. +- Confirm how selection behaves for destroyed/stale references. + +## 12. Camera and focus + +Confirmed external facts: + +- Roblox documents `Camera.CFrame`, `Focus`, `FieldOfView`, `ViewportSize`, `GetRenderCFrame`, and `ZoomToExtents`. +- `Workspace.CurrentCamera` is the current camera object in normal camera documentation. + +Proposal: + +- `prism camera` returns current camera CFrame, Focus, FieldOfView, FieldOfViewMode, CameraType, CameraSubject reference if serializable, and ViewportSize. +- `prism focus ` resolves an Instance and frames it: + - `BasePart`: use `CFrame` and `Size`. + - `Model`: use `Model:GetBoundingBox()`, reject empty models with no geometric descendants. + - `Attachment`: frame around `WorldPosition` with a small default radius. + - `selection`: frame union of selected supported objects. +- Mutate `Workspace.CurrentCamera` in edit mode by setting camera type/scriptable behavior only if needed and reversible enough for user expectations. + +Studio verification required: + +- Confirm installed plugin can reliably read and set `Workspace.CurrentCamera` in edit mode. +- Confirm whether `Camera:ZoomToExtents` works from plugin context. +- Confirm whether camera changes should be excluded from undo and snapshot diffs. + +## 13. Screenshot feasibility and artifacts + +Confirmed external facts: + +- Current Creator Hub documents `StudioCaptureService:CanCaptureScreenshot()`, `RequestScreenshotPermissionAsync()`, and `CaptureScreenshot(options)` with plugin security. +- `StudioScreenshotCapture` exposes `GetBuffer(): buffer`, read-only `BufferFormat`, `BufferStatus`, `OriginalSize`, `Position`, `Resolution`, `UICaptureMode`, `GetErrors()`, and `ScaleAsync()`, all with plugin-security access where listed. +- `Capture.FilePathString` is Roblox script/security restricted on the separate in-experience `Capture` class, so local file-path handoff should not be assumed. + +Proposal: + +- Treat `screenshot` as a binary artifact job, not a normal completion payload. +- Plugin captures a screenshot, validates `BufferStatus`, reads `GetBuffer()`, and uploads the bytes to the server as an artifact associated with the job. +- Completion returns: + +```json +{ + "kind": "screenshot", + "artifactId": "...", + "contentType": "image/png", + "width": 1920, + "height": 1080, + "format": "png", + "byteLength": 1234567 +} +``` + +Artifact transfer options: + +- Best MVP: plugin uploads artifact bytes to `POST /api/automation/jobs/{id}/artifacts`, CLI downloads `GET /api/automation/artifacts/{artifactId}` as raw bytes. +- Acceptable fallback: MessagePack `serde_bytes`/Luau buffer chunks if Roblox `RequestAsync` cannot send raw binary safely. +- Avoid base64 unless Roblox HTTP string-body constraints make binary unsafe; base64 adds size overhead and extra copies. +- Avoid local path handoff because plugin-accessible file paths are not established. + +Recommended limits: + +- Default max screenshot artifact: 16 MiB. +- Default max artifact retention: 5 minutes or 16 artifacts. +- Optional downscale in plugin before upload if requested size exceeds max. + +Studio verification required: + +- Confirm installed plugin can call `RequestScreenshotPermissionAsync` and how often permission prompts. +- Confirm `CaptureScreenshot` captures the 3D viewport, plugin widgets, game UI, or some combination. +- Confirm edit mode versus Play Solo differences. +- Confirm buffer format and whether `GetBuffer()` is PNG/JPEG bytes or raw pixels for each `BufferFormat`. +- Confirm whether user permission is required and how denial is reported. + +## 14. Play and stop feasibility + +Confirmed external facts: + +- Roblox documents `RunService:Run()` and `RunService:Stop()` with plugin security. +- Roblox documents `StudioTestService:ExecutePlayModeAsync`, `ExecuteRunModeAsync`, `ExecuteMultiplayerTestAsync`, `CanLeaveTest`, and `LeaveTest`; several have plugin security. +- Existing Prism plugin code uses `RunService:IsEdit`, `IsRunning`, `IsStudio`, and `IsServer`, and already has Play Solo auto-connect logic in `plugin/src/App/init.lua`. + +Proposal: + +- `prism play` should first be a Studio lifecycle command with `{ kind = "play", mode = "playSolo" | "run" }`. +- `prism stop` should stop the active Studio test/run session if Prism started it or if a safe, explicit `--force` is later designed. +- The automation subsystem should record a `studioMode` in plugin status so CLI can explain when play/stop is unavailable. + +Studio verification required: + +- Confirm installed plugin can start Play Solo through `StudioTestService` or `RunService` without internal APIs. +- Confirm whether `RunService:Run()` starts Run mode, not Play Solo with a player. +- Confirm how to stop a play session from the edit-mode plugin versus a play-mode auto-connected plugin. +- Confirm whether `StudioTestService` methods require special beta flags or user permissions. + +## 15. Doctor + +Proposal: + +CLI-only checks: + +- Server reachable at address/port. +- `/api/rojo` returns MessagePack with expected protocol version. +- Host/origin/local-only behavior is sane for loopback. +- Server version, project name, address, port, expected place IDs, blocked place IDs. +- Exec/automation submission route exists. +- Queue capacity and stale jobs if the server exposes status. + +Plugin-status checks: + +- Plugin connected and claiming automation. +- Plugin session ID and active Studio mode. +- HTTP requests enabled in Studio. Current `plugin/http/Error.lua` maps disabled HTTP to a clear Prism error. +- Duplicate plugin/session detection. +- Exec availability and automation handler versions. +- Mismatched plugin/server version or protocol. +- Current DataModel place/game IDs. + +Implementation proposal: + +- Add `{ kind = "doctor" }` automation job returning a typed health object. +- Later add `GET /api/automation/status` for server-only state that does not need plugin execution. + +## 16. Snapshot and diff + +Snapshot proposal: + +- A bounded DataModel snapshot should include session ID, root reference, traversal limits, deterministic node IDs, name, className, escaped path, attributes, tags, allowlisted properties, child order, and diagnostics for skipped values. +- Exclude script `Source` by default. Include script metadata only unless explicitly requested. +- Exclude or summarize terrain and binary content for MVP. +- Use deterministic hashes per node and for the whole snapshot. +- Large snapshots should be artifacts, not normal job result payloads. + +Diff proposal: + +- `+ instance`: created instance with class/name/parent/order and properties. +- `- instance`: removed instance reference plus last known snapshot. +- `~ property`: changed allowlisted property. +- `~ attribute`: changed attribute. +- `~ tag`: added/removed tag. +- `> reparent`: parent changed. +- `> reorder`: sibling order changed. + +Storage recommendation: + +- CLI memory for immediate `snapshot | diff` pipelines. +- Disk for user-requested `.prism-snapshot.msgpack` or JSON snapshot files. +- Server state only for temporary preview IDs and artifacts. +- Plugin state only for session-local instance references and active watch subscriptions. + +## 17. Watch streams + +Proposal: + +`prism watch ` should use a stream, not HTTP polling. + +Potential events: + +- `descendantAdded` +- `descendantRemoved` +- `propertyChanged` +- `attributeChanged` +- `tagChanged` +- `ancestryChanged` +- `selectionChanged` + +Transport: + +- CLI opens a WebSocket to the server for a stream ID. +- Plugin subscribes to Roblox events for the target and posts batches to the server, or in a later phase opens a plugin automation WebSocket. +- Server forwards ordered batches to CLI and applies backpressure limits. + +Requirements: + +- Subscription IDs and explicit unsubscribe. +- Disconnect cleanup on CLI disconnect, plugin disconnect, and server shutdown. +- Event coalescing over short windows, for example 50-100 ms. +- Event count/byte limits with a `droppedEvents` marker. +- Initial snapshot option before live events. +- Ordering by plugin-side sequence number. +- No interference with `/api/socket/{cursor}` live-sync packets or `MessageQueue`. + +## 18. Profile feasibility + +Proposal: + +Call the first version `profile` only if the output is honest about scope. + +Likely supported through plugin DataModel traversal: + +- Instance counts. +- Class counts. +- Script counts by class. +- Approximate hierarchy depth and child fanout. +- Counts of tagged/attributed instances. +- Counts of oversized strings or selected expensive properties if included. + +Requires Studio verification or may be unsupported/private: + +- Memory categories. +- Physics metrics. +- Script performance. +- MicroProfiler data. +- Render stats and GPU timings. + +Do not call hierarchy counts a full profiler. Use output labels such as `hierarchySummary` and `unavailableMetrics`. + +## 19. Preview-mode threat model + +Preview Mode is not a secure sandbox. It is a workflow for previewing and reverting many ordinary DataModel edits from trusted automation. + +Potentially reversible DataModel effects: + +- Creating/destroying Instances. +- Property changes. +- Attributes. +- Tags. +- Parenting and reparenting. +- Sibling order. +- Many script source changes, subject to source access and snapshot limits. +- Some terrain changes only if explicit terrain snapshot/patch support is built. + +Potentially non-reversible or external effects: + +- HTTP requests. +- DataStore/Open Cloud actions. +- Marketplace, publishing, or asset generation actions. +- Plugin settings. +- Selection changes. +- Camera changes. +- External logging/analytics. +- Detached tasks that keep running after preview. +- User prompts. +- Play-mode effects. +- Filesystem-like plugin capabilities, if any are exposed. +- Side effects outside `ChangeHistoryService`. + +Approach comparison: + +- Begin recording, execute, capture diff, cancel recording: attractive but risky. `ChangeHistoryService` is for undo history, not security, and cancellation semantics need live proof for all mutation classes. +- Clone a bounded subtree and execute against the clone: safer for subtree-local edits but many scripts expect services such as `workspace`, `Lighting`, or `Selection`; transparent redirection is hard. +- Clone the whole DataModel into a temporary DataModel: not feasible with normal plugin APIs. +- Record mutations through an injected API: safest for deterministic apply, but not compatible with arbitrary Luau that mutates normal Roblox objects directly. +- Execute normally and derive a patch from before/after snapshots: feasible for trusted scripts and typed handlers; misses external effects and unsupported properties. +- Use Roblox undo as rollback: useful as a cleanup mechanism, not a security boundary. +- Re-run the script during apply: unsafe and non-deterministic because the script may depend on time, selection, random values, HTTP, or mutated state. +- Apply a captured deterministic patch instead of re-running: preferred when patch coverage is sufficient. + +Recommended MVP: + +- Name it Preview Mode, not sandbox. +- Run trusted source once in edit mode. +- Take a bounded before snapshot. +- Execute with a `ChangeHistoryService` recording. +- Take a bounded after snapshot. +- Compute a structured patch. +- Roll back live changes using Roblox undo or an explicit inverse patch, with live Studio verification deciding which is more reliable. +- Store the captured patch and before-hash in server state under a preview ID. +- `preview apply ` applies the captured patch, not the original source. +- Reject apply if the referenced DataModel subtree changed since preview. +- Expire previews quickly. + +## 20. Preview/apply protocol + +Proposal: + +Preview job request: + +```json +{ + "kind": "preview", + "scriptName": "destructive-edit.lua", + "source": "...", + "target": "game", + "snapshotOptions": { + "maxInstances": 10000, + "propertySet": "default" + } +} +``` + +Preview result: + +```json +{ + "kind": "preview", + "previewId": "...", + "baseHash": "...", + "patchArtifactId": "...", + "summary": { + "added": 3, + "removed": 0, + "propertiesChanged": 5, + "attributesChanged": 1, + "tagsChanged": 0, + "reparented": 0, + "reordered": 0 + }, + "rollback": { + "ok": true, + "method": "undo" + } +} +``` + +Apply request: + +```json +{ + "kind": "previewApply", + "previewId": "...", + "expectedBaseHash": "..." +} +``` + +Apply result: + +```json +{ + "kind": "previewApply", + "applied": true, + "undoLabel": "Prism Preview Apply: destructive-edit.lua" +} +``` + +## 21. Undo and rollback limitations + +Confirmed facts: + +- Current live sync uses `ChangeHistoryService:TryBeginRecording("Prism: Patch ...")` and `FinishRecording(...Commit)` in `plugin/src/ServeSession.lua`. +- Current exec uses `TryBeginRecording("Prism Exec: ")` and commits the recording in `plugin/src/Exec.lua`. +- Current code does not use `SetWaypoint` for exec. Creator Hub marks `SetWaypoint` as headed toward deprecation in favor of recordings. + +Proposal: + +- Typed mutating automation jobs should use `TryBeginRecording`/`FinishRecording`, not `SetWaypoint`. +- Read-only jobs should not open a recording. +- Runtime failures after partial mutation should commit one undoable action when the user asked for real execution. +- Preview should isolate user experience by rolling back, but must disclose that external effects are not rollback-protected. +- The controller, not user code, owns `FinishRecording`. + +## 22. Automated playtesting prerequisites + +Do not deeply design playtesting yet. Minimum prerequisites: + +- Verified `play` and `stop`. +- Route execution to edit plugin, play server, and play client contexts. +- Stable plugin/session identity across edit/play transition. +- Input injection feasibility. +- Camera/screenshot support. +- UI inspection support. +- Log collection from relevant contexts. +- Assertion result schema. +- Artifact/report storage. +- Timeouts and cleanup for hung tests. +- Clear distinction between local Studio automation and publish/live-service effects. + +## 23. Security and local-only boundaries + +Confirmed current security boundaries: + +- `src/web/mod.rs` rejects cross-origin requests before API routing. +- Exec routes in `src/web/api.rs` reject non-loopback peers using `reject_non_local`, matching `/api/open`. +- There is no authentication on exec. +- Exec is trusted-source only. + +Proposal: + +- Keep all `/api/automation/...` endpoints local-only for MVP. +- Keep source execution and mutating jobs opt-in from the CLI. +- Do not expose arbitrary automation to non-loopback peers without a separate authentication design. +- Include protocol and handler versions in automation status so the CLI can fail closed on mismatches. + +## 24. Exact likely file plan + +Rust CLI: + +- Modify `src/cli/mod.rs` for new subcommands. +- Create or modify `src/cli/automation.rs` for shared submit/poll/render helpers. +- Create `src/cli/inspect.rs`, `src/cli/search.rs`, `src/cli/selection.rs`, `src/cli/select.rs`, `src/cli/camera.rs`, `src/cli/focus.rs`, `src/cli/screenshot.rs`, `src/cli/play.rs`, `src/cli/stop.rs`, `src/cli/doctor.rs`, `src/cli/snapshot.rs`, `src/cli/diff.rs`, `src/cli/watch.rs`, `src/cli/profile.rs`, and `src/cli/preview.rs` as phases justify. +- Keep `src/cli/exec.rs`, but route its HTTP job operations through shared automation helpers after the protocol migration. + +Rust server/protocol: + +- Create `src/automation.rs` for `AutomationJobStore`, job states, errors, cleanup, artifact registry, and common limits. +- Modify `src/serve_session.rs` to replace or wrap `exec_job_store` with `automation_job_store`. +- Modify `src/web/interface.rs` for `AutomationRequest`, `AutomationResult`, values, references, artifacts, status, and stream messages. +- Modify `src/web/api.rs` for `/api/automation/jobs...`, artifact routes, status routes, and stream setup. +- Keep `src/web/util.rs` MessagePack helpers. +- Keep `src/exec.rs` during migration or reduce it to exec-specific request/value helpers once automation owns queue mechanics. + +Studio plugin: + +- Create `plugin/src/Automation.lua` for the shared poller/dispatcher. +- Create `plugin/src/AutomationHandlers/*.lua` for inspect, search, selection, camera, screenshot, etc. +- Create `plugin/src/AutomationValue.lua` and `plugin/src/InstanceReferences.lua`. +- Modify `plugin/src/ApiContext.lua` to add generic automation claim/complete/artifact methods. +- Modify `plugin/src/ServeSession.lua` to start one automation poller after initial sync/WebSocket setup and stop it during disconnect. +- Modify `plugin/src/Types.lua` for automation validators. +- Keep `plugin/src/Exec.lua`, but make it an automation handler instead of its own poll loop. + +Tests: + +- Extend `tests/tests/serve.rs` and `tests/rojo_test/serve_util.rs` for generic automation HTTP routes and artifacts. +- Add Rust unit tests in `src/automation.rs`. +- Add plugin TestEZ specs for `Automation.lua`, `AutomationValue.lua`, `InstanceReferences.lua`, and each handler. + +Documentation: + +- Keep this file: `design/prism/AUTOMATION_ARCHITECTURE.md`. +- Later add command-specific design docs only for large uncertain areas such as screenshot, playtesting, and Preview Mode. + +## 25. Testing strategy + +Rust: + +- Generic store: submit, claim, one active claim, complete success/failure/timeout/cancel, duplicate claim, duplicate completion, unknown job, capacity, size, cleanup, artifact retention. +- API routing: exact route matching, `/next` before `{id}`, malformed IDs, local-only rejection, MessagePack decode errors, source/payload/artifact limits. +- CLI: clap parsing, file/target validation, server check, status rendering, JSON output, HTTP error decoding, artifact download, timeouts. +- WebSocket streams: stream creation, event ordering, backpressure, disconnect cleanup, no interference with live-sync socket tests. + +Plugin: + +- One automation poller starts once from `ServeSession`. +- No polling before connection or after disconnect. +- No duplicate claim while executing/completing. +- Handler dispatch validates kind. +- Instance reference creation/resolution/stale detection. +- Value encoding rejects unsupported and cyclic values. +- Each typed handler returns deterministic payloads. +- Watch subscriptions coalesce events and clean up. +- Screenshot/play/preview specs use injected dependencies; live Studio spikes verify actual APIs. + +Manual Studio: + +- Inspect Workspace and selected Instances. +- Search by name/class/tag/attribute. +- Selection get/set/clear. +- Camera read and focus on BasePart, Model, Attachment, and selection. +- Screenshot permission, viewport capture, output image validity, Play Solo behavior. +- Play/stop lifecycle behavior. +- Preview mutation, rollback, apply, stale apply rejection. +- Disconnect/reconnect recovery. + +## 26. Open Studio verification questions + +- Can installed plugins use `StudioCaptureService` screenshot APIs without beta flags or special permissions? +- What exactly is contained in `StudioScreenshotCapture:GetBuffer()` for each buffer format? +- Does screenshot capture include only the 3D viewport, plugin widgets, CoreGui/game UI, or different modes controlled by `UICaptureMode`? +- Can installed plugins start Play Solo reliably through `StudioTestService:ExecutePlayModeAsync`, or only Run mode through `RunService:Run()`? +- Can an edit-mode plugin stop a play session it started after execution context changes? +- How does the current Prism auto-connect playtest path interact with an automation plugin session identity? +- Are `Selection:Set` changes acceptable in Play Solo, or should selection be edit-mode only? +- Can `Workspace.CurrentCamera` be set and focused reliably in edit mode from an installed plugin? +- Which property reads are expensive or restricted for inspect/snapshot across common services? +- Can undo cancellation reliably roll back all mutation classes needed for Preview Mode? +- How should Terrain be snapshotted, diffed, and rolled back if included? +- What memory/performance metrics are accessible to an installed plugin without private APIs? + +## 27. Recommended implementation phases + +1. Automation status and identity: add plugin session IDs, server automation status, and `doctor` checks without moving exec. +2. Generic `AutomationJobStore`: extract queue mechanics from `ExecJobStore`, keep `/api/exec/jobs...` compatible. +3. Generic automation HTTP routes: add `/api/automation/jobs...` and make exec an `AutomationRequest::Exec` adapter. +4. Plugin automation poller: replace the exec-specific poller with one dispatcher while preserving exec behavior. +5. Shared values and instance references: implement `AutomationValue` and session-local Instance refs. +6. Read-only typed commands: inspect, search, selection, camera. +7. Mutating typed commands: select and focus with clear undo/Studio-mode rules. +8. Screenshot spike and artifact transport. +9. Snapshot and diff. +10. Watch streams over a separate automation stream transport. +11. Play/stop feasibility spike, then lifecycle commands if verified. +12. Preview Mode MVP with before/after snapshots, rollback, stored patch, and apply without rerunning source. +13. Profile hierarchy summary, with honest unavailable metrics. +14. Automated playtesting design once play/stop, screenshots, streams, logs, and assertions exist. diff --git a/plugin/src/App/Components/Header.lua b/plugin/src/App/Components/Header.lua index 505dafc3..4e8569de 100644 --- a/plugin/src/App/Components/Header.lua +++ b/plugin/src/App/Components/Header.lua @@ -53,6 +53,7 @@ local function Header(props) Image = props.icon or Assets.Images.Logo, ImageColor3 = Color3.new(1, 1, 1), ImageTransparency = props.transparency, + ScaleType = Enum.ScaleType.Fit, Size = UDim2.fromOffset(27, 27), Position = UDim2.new(0, 0, 0.5, 0), AnchorPoint = Vector2.new(0, 0.5), diff --git a/plugin/src/App/Components/Notifications/FullscreenNotification.lua b/plugin/src/App/Components/Notifications/FullscreenNotification.lua index ef0b12a2..cc1568e1 100644 --- a/plugin/src/App/Components/Notifications/FullscreenNotification.lua +++ b/plugin/src/App/Components/Notifications/FullscreenNotification.lua @@ -116,6 +116,7 @@ function FullscreenNotification:render() Image = Assets.Images.Logo, ImageColor3 = theme.Header.LogoColor, BackgroundTransparency = 1, + ScaleType = Enum.ScaleType.Fit, Size = UDim2.fromOffset(27, 27), LayoutOrder = 1, }), diff --git a/plugin/src/App/Components/Notifications/Notification.lua b/plugin/src/App/Components/Notifications/Notification.lua index 1d9464c9..b4ff3b55 100644 --- a/plugin/src/App/Components/Notifications/Notification.lua +++ b/plugin/src/App/Components/Notifications/Notification.lua @@ -156,6 +156,7 @@ function Notification:render() Logo = e("ImageLabel", { ImageTransparency = transparency, Image = Assets.Images.PluginButton, + ScaleType = Enum.ScaleType.Fit, BackgroundTransparency = 1, Size = UDim2.fromOffset(logoSize, logoSize), Position = UDim2.new(0, 0, 0, 0), diff --git a/plugin/src/App/StatusPages/Confirming.lua b/plugin/src/App/StatusPages/Confirming.lua index 71a02485..c3699ac6 100644 --- a/plugin/src/App/StatusPages/Confirming.lua +++ b/plugin/src/App/StatusPages/Confirming.lua @@ -45,6 +45,7 @@ function ConfirmingPage:render() ImageColor3 = Color3.new(1, 1, 1), ImageTransparency = self.props.transparency, BackgroundTransparency = 1, + ScaleType = Enum.ScaleType.Fit, Size = UDim2.fromOffset(24, 24), Position = UDim2.new(0, 0, 0.5, 0), AnchorPoint = Vector2.new(0, 0.5), diff --git a/plugin/src/App/StatusPages/Connecting.lua b/plugin/src/App/StatusPages/Connecting.lua index 4d8e87b3..590ad752 100644 --- a/plugin/src/App/StatusPages/Connecting.lua +++ b/plugin/src/App/StatusPages/Connecting.lua @@ -27,6 +27,7 @@ function ConnectingPage:render() Image = Assets.Images.Logo, ImageColor3 = Color3.new(1, 1, 1), ImageTransparency = self.props.transparency, + ScaleType = Enum.ScaleType.Fit, BackgroundTransparency = 1, Size = UDim2.fromOffset(30, 30), Position = UDim2.new(0.5, -20, 0, 12), diff --git a/plugin/src/App/StatusPages/Settings/init.lua b/plugin/src/App/StatusPages/Settings/init.lua index 5c7be807..ebbb0e95 100644 --- a/plugin/src/App/StatusPages/Settings/init.lua +++ b/plugin/src/App/StatusPages/Settings/init.lua @@ -72,6 +72,7 @@ local function Navbar(props) ImageColor3 = theme.Header.LogoColor, ImageTransparency = props.transparency, BackgroundTransparency = 1, + ScaleType = Enum.ScaleType.Fit, Size = UDim2.fromOffset(24, 24), Position = UDim2.new(1, -4, 0.5, 0), AnchorPoint = Vector2.new(1, 0.5), diff --git a/plugin/src/Assets.lua b/plugin/src/Assets.lua index 22632676..91ae5137 100644 --- a/plugin/src/Assets.lua +++ b/plugin/src/Assets.lua @@ -66,7 +66,7 @@ local Assets = { }, }, Sounds = { - Notification = "rbxassetid://203785492", + Notification = "rbxassetid://98797174600699", }, StartSession = "", SessionActive = "", diff --git a/plugin/src/Branding.lua b/plugin/src/Branding.lua index f9b3e62e..eb7a5d33 100644 --- a/plugin/src/Branding.lua +++ b/plugin/src/Branding.lua @@ -1,9 +1,9 @@ local Branding = { Name = "Prism", Tagline = "Studio automation for Roblox", - FullLogoAssetId = "rbxassetid://78583834238790", - IconAssetId = "rbxassetid://84145747248222", - WarningAssetId = "rbxassetid://130567248372259", + FullLogoAssetId = "rbxassetid://126486056510599", + IconAssetId = "rbxassetid://132751330860535", + WarningAssetId = "rbxassetid://104859841226668", -- Retain the original field for consumers that already use it as the compact icon. AssetId = "rbxassetid://84145747248222", DockWidgetTitlePrefix = "Prism ", diff --git a/scripts/watch-build-plugin.sh b/scripts/watch-build-plugin.sh index c21397f2..788fa857 100644 --- a/scripts/watch-build-plugin.sh +++ b/scripts/watch-build-plugin.sh @@ -1 +1 @@ -rojo build plugin.project.json --plugin Rojo.rbxm --watch \ No newline at end of file +rojo build plugin.project.json --plugin Prism.rbxm --watch diff --git a/src/bin/rojo.rs b/src/bin/rojo.rs new file mode 100644 index 00000000..6480680e --- /dev/null +++ b/src/bin/rojo.rs @@ -0,0 +1,3 @@ +fn main() { + librojo::cli::run(); +} diff --git a/src/cli/exec.rs b/src/cli/exec.rs index ae660076..f48c46ec 100644 --- a/src/cli/exec.rs +++ b/src/cli/exec.rs @@ -472,7 +472,7 @@ mod test { #[test] fn clap_parses_exec_file() { - let options = parse(&["rojo", "exec", "file.lua"]).unwrap(); + let options = parse(&["prism", "exec", "file.lua"]).unwrap(); let Subcommand::Exec(command) = options.subcommand else { panic!("expected exec command"); }; @@ -482,7 +482,7 @@ mod test { #[test] fn clap_uses_default_address_and_port() { - let options = parse(&["rojo", "exec", "file.lua"]).unwrap(); + let options = parse(&["prism", "exec", "file.lua"]).unwrap(); let Subcommand::Exec(command) = options.subcommand else { panic!("expected exec command"); }; @@ -494,7 +494,7 @@ mod test { #[test] fn clap_accepts_custom_address_and_port() { let options = parse(&[ - "rojo", + "prism", "exec", "file.lua", "--address", @@ -513,12 +513,12 @@ mod test { #[test] fn clap_rejects_missing_file() { - assert!(parse(&["rojo", "exec"]).is_err()); + assert!(parse(&["prism", "exec"]).is_err()); } #[test] fn clap_rejects_inline_source() { - assert!(parse(&["rojo", "exec", "-e", "return true"]).is_err()); + assert!(parse(&["prism", "exec", "-e", "return true"]).is_err()); } #[test] diff --git a/src/cli/inspect.rs b/src/cli/inspect.rs index e6a9a41b..f03ba96b 100644 --- a/src/cli/inspect.rs +++ b/src/cli/inspect.rs @@ -28,6 +28,7 @@ const DEFAULT_MAX_INSTANCES: u32 = 2_000; const POLL_INTERVAL: Duration = Duration::from_millis(250); const LOCAL_TIMEOUT: Duration = Duration::from_secs(50); +/// Inspect a DataModel path through a connected Prism Studio plugin. #[derive(Debug, Parser)] pub struct InspectCommand { /// DataModel path to inspect. @@ -407,7 +408,7 @@ mod tests { #[test] fn parses_command_defaults_and_custom_values() { - let options = Options::try_parse_from(["rojo", "inspect", "Workspace"]).unwrap(); + let options = Options::try_parse_from(["prism", "inspect", "Workspace"]).unwrap(); let crate::cli::Subcommand::Inspect(command) = options.subcommand else { panic!("expected inspect command") }; @@ -419,7 +420,7 @@ mod tests { assert!(command.tags); let options = Options::try_parse_from([ - "rojo", + "prism", "inspect", "Workspace", "--depth", @@ -445,9 +446,11 @@ mod tests { #[test] fn validates_depth_and_child_limits() { - assert!(Options::try_parse_from(["rojo", "inspect", "Workspace", "--depth", "9"]).is_err()); assert!( - Options::try_parse_from(["rojo", "inspect", "Workspace", "--max-children", "0"]) + Options::try_parse_from(["prism", "inspect", "Workspace", "--depth", "9"]).is_err() + ); + assert!( + Options::try_parse_from(["prism", "inspect", "Workspace", "--max-children", "0"]) .is_err() ); } @@ -490,7 +493,7 @@ mod tests { #[test] fn rejects_missing_target() { - assert!(Options::try_parse_from(["rojo", "inspect"]).is_err()); + assert!(Options::try_parse_from(["prism", "inspect"]).is_err()); } fn sample_result() -> InspectResult { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 60837654..bf08a84a 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,4 +1,4 @@ -//! Defines Rojo's CLI through clap types. +//! Defines Prism's CLI through clap types. mod automation; mod build; @@ -13,9 +13,12 @@ mod sourcemap; mod syncback; mod upload; -use std::{borrow::Cow, env, path::Path, str::FromStr}; +use std::{borrow::Cow, env, panic, path::Path, process, str::FromStr}; use anyhow::Context; +use backtrace::Backtrace; +#[cfg(test)] +use clap::CommandFactory; use clap::Parser; use thiserror::Error; @@ -31,9 +34,13 @@ pub use self::sourcemap::SourcemapCommand; pub use self::syncback::SyncbackCommand; pub use self::upload::UploadCommand; -/// Command line options that Rojo accepts, defined using the clap crate. +/// Command line options that Prism accepts, defined using the clap crate. #[derive(Debug, Parser)] -#[clap(name = "Rojo", version, about)] +#[clap( + name = "Prism", + version, + about = "Prism developer tooling for Roblox, derived from Rojo" +)] pub struct Options { #[clap(flatten)] pub global: GlobalOptions, @@ -43,6 +50,72 @@ pub struct Options { pub subcommand: Subcommand, } +pub fn run() { + #[cfg(feature = "profile-with-tracy")] + profiling::tracy_client::Client::start(); + + panic::set_hook(Box::new(|panic_info| { + let message = match panic_info.payload().downcast_ref::<&str>() { + Some(&message) => message.to_string(), + None => match panic_info.payload().downcast_ref::() { + Some(message) => message.clone(), + None => "".to_string(), + }, + }; + + log::error!( + "Prism crashed! You are running Prism {}.", + env!("CARGO_PKG_VERSION") + ); + log::error!("This is probably a Prism bug."); + log::error!(""); + log::error!("Please report this Prism build through its distribution channel."); + log::error!(""); + log::error!("Details: {}", message); + + if let Some(location) = panic_info.location() { + log::error!("in file {} on line {}", location.file(), location.line()); + } + + let should_backtrace = env::var("RUST_BACKTRACE") + .map(|var| var == "1") + .unwrap_or(false); + + if should_backtrace { + eprintln!("{:?}", Backtrace::new()); + } else { + eprintln!( + "note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace." + ); + } + + process::exit(1); + })); + + let options = Options::parse(); + + let log_filter = match options.global.verbosity { + 0 => "info", + 1 => "info,librojo=debug", + 2 => "info,librojo=trace", + _ => "trace", + }; + + let log_env = env_logger::Env::default().default_filter_or(log_filter); + + env_logger::Builder::from_env(log_env) + .format_module_path(false) + .format_timestamp(None) + .format_indent(Some(8)) + .write_style(options.global.color.into()) + .init(); + + if let Err(err) = options.run() { + log::error!("{:?}", err); + process::exit(1); + } +} + impl Options { pub fn run(self) -> anyhow::Result<()> { match self.subcommand { @@ -141,8 +214,26 @@ pub(super) fn resolve_path(path: &Path) -> anyhow::Result> { } else { let current_dir = env::current_dir().context( "Could not determine the current working directory. \ - It may have been deleted, or Rojo may not have permission to access it.", + It may have been deleted, or Prism may not have permission to access it.", )?; Ok(Cow::Owned(current_dir.join(path))) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn help_and_version_are_prism_branded() { + let command = Options::command(); + assert_eq!(command.get_name(), "Prism"); + assert_eq!(command.get_version(), Some(env!("CARGO_PKG_VERSION"))); + + let mut help = Vec::new(); + Options::command().write_long_help(&mut help).unwrap(); + let help = String::from_utf8(help).unwrap(); + assert!(help.contains("Prism developer tooling for Roblox")); + assert!(help.contains("USAGE:\n Prism")); + } +} diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs index af7025ca..b9f99619 100644 --- a/src/cli/plugin.rs +++ b/src/cli/plugin.rs @@ -1,33 +1,40 @@ use std::{ fs::{self, File}, - io::BufWriter, + io::{BufReader, BufWriter, Write}, + path::{Path, PathBuf}, }; +use anyhow::Context; use clap::Parser; use memofs::{InMemoryFs, Vfs, VfsSnapshot}; +use rbx_dom_weak::types::Variant; use roblox_install::RobloxStudio; use crate::serve_session::ServeSession; static PLUGIN_BINCODE: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/plugin.bincode")); -static PLUGIN_FILE_NAME: &str = "RojoManagedPlugin.rbxm"; +const PLUGIN_FILE_NAME: &str = "PrismManagedPlugin.rbxm"; +const LEGACY_PLUGIN_FILE_NAME: &str = "RojoManagedPlugin.rbxm"; +const PRISM_ASSET_ID: &str = "rbxassetid://84145747248222"; -/// Install Rojo's plugin. +/// Manage Prism's local Roblox Studio plugin. #[derive(Debug, Parser)] pub struct PluginCommand { #[clap(subcommand)] subcommand: PluginSubcommand, } -/// Manages Rojo's Roblox Studio plugin. +/// Manage Prism's local Roblox Studio plugin. #[derive(Debug, Parser)] pub enum PluginSubcommand { - /// Install the plugin in Roblox Studio's plugins folder. If the plugin is - /// already installed, installing it again will overwrite the current plugin - /// file. + /// Install or update Prism's managed plugin in Roblox Studio's Plugins + /// folder. Install, - /// Removes the plugin if it is installed. + /// List local Prism and Rojo-like plugin files without changing them. + List, + + /// Remove Prism's managed plugin if it is installed. Uninstall, } @@ -41,6 +48,7 @@ impl PluginSubcommand { pub fn run(self) -> anyhow::Result<()> { match self { PluginSubcommand::Install => install_plugin(), + PluginSubcommand::List => list_plugins(), PluginSubcommand::Uninstall => uninstall_plugin(), } } @@ -48,7 +56,7 @@ impl PluginSubcommand { fn initialize_plugin() -> anyhow::Result { let plugin_snapshot: VfsSnapshot = bincode::deserialize(PLUGIN_BINCODE) - .expect("Rojo's plugin was not properly packed into Rojo's binary"); + .expect("Prism's plugin was not properly packed into the Prism binary"); let mut in_memory_fs = InMemoryFs::new(); in_memory_fs.load_snapshot("/plugin", plugin_snapshot)?; @@ -57,46 +65,339 @@ fn initialize_plugin() -> anyhow::Result { Ok(ServeSession::new(vfs, "/plugin")?) } +fn managed_plugin_path(plugins_folder: &Path) -> PathBuf { + plugins_folder.join(PLUGIN_FILE_NAME) +} + fn install_plugin() -> anyhow::Result<()> { let studio = RobloxStudio::locate()?; + let plugins_folder = studio.plugins_path(); - let plugins_folder_path = studio.plugins_path(); - - if !plugins_folder_path.exists() { + if !plugins_folder.exists() { log::debug!("Creating Roblox Studio plugins folder"); - fs::create_dir(plugins_folder_path)?; + fs::create_dir_all(plugins_folder)?; } - let plugin_path = plugins_folder_path.join(PLUGIN_FILE_NAME); + for plugin in inspect_local_plugins(plugins_folder)? { + match plugin.kind { + LocalPluginKind::LegacyPrismManaged => { + log::info!( + "Removing known Prism-owned legacy plugin {}", + plugin.path.display() + ); + fs::remove_file(&plugin.path).with_context(|| { + format!( + "Could not remove known Prism-owned legacy plugin '{}'.", + plugin.path.display() + ) + })?; + } + LocalPluginKind::RojoManagedUnknown => log::warn!( + "Found {}. Prism cannot reliably determine its owner, so it was not changed. Remove or disable it manually if it is this fork's old managed plugin.", + plugin.path.display() + ), + LocalPluginKind::PrismManaged + | LocalPluginKind::PrismLocalBuild + | LocalPluginKind::PossiblePrismLocalBuild => { + if plugin.path.file_name().is_some_and(|name| name != PLUGIN_FILE_NAME) { + log::warn!( + "Found another local Prism plugin at {}. Disable or remove duplicates manually if Studio loads both.", + plugin.path.display() + ); + } + } + LocalPluginKind::RojoLike => {} + } + } + + let plugin_path = managed_plugin_path(plugins_folder); log::debug!("Writing plugin to {}", plugin_path.display()); - let mut file = BufWriter::new(File::create(plugin_path)?); - + let mut file = BufWriter::new(File::create(&plugin_path)?); let session = initialize_plugin()?; let tree = session.tree(); let root_id = tree.get_root_id(); - rbx_binary::to_writer(&mut file, tree.inner(), &[root_id])?; + file.flush()?; + println!("Installed Prism Studio plugin at {}", plugin_path.display()); + println!( + "Marketplace-installed plugins are managed separately in Studio; disable any duplicate Rojo or Prism plugin there." + ); Ok(()) } +fn list_plugins() -> anyhow::Result<()> { + let studio = RobloxStudio::locate()?; + let plugins_folder = studio.plugins_path(); + let plugins = inspect_local_plugins(plugins_folder)?; + + println!("{}", render_local_plugin_report(plugins_folder, &plugins)); + Ok(()) +} + +fn render_local_plugin_report(plugins_folder: &Path, plugins: &[LocalPlugin]) -> String { + let mut lines = vec![format!( + "Local Roblox plugins in {}:", + plugins_folder.display() + )]; + if plugins.is_empty() { + lines.push(" No known Prism or Rojo-like local plugin files found.".to_owned()); + } else { + for plugin in plugins { + lines.push(format!( + " [{}] {}", + plugin.kind.label(), + plugin.path.display() + )); + } + + let prism_count = plugins + .iter() + .filter(|plugin| plugin.kind.is_prism()) + .count(); + if prism_count > 1 { + lines.push(format!( + " Warning: {prism_count} likely Prism local plugins were found; Studio may load duplicates." + )); + } + } + lines.push( + "Marketplace-installed plugins do not necessarily appear here; disable duplicate Rojo or Prism plugins through Studio's plugin manager." + .to_owned(), + ); + lines.join("\n") +} + fn uninstall_plugin() -> anyhow::Result<()> { let studio = RobloxStudio::locate()?; - - let plugin_path = studio.plugins_path().join(PLUGIN_FILE_NAME); + let plugin_path = managed_plugin_path(studio.plugins_path()); if plugin_path.exists() { - log::debug!("Removing existing plugin from {}", plugin_path.display()); - fs::remove_file(plugin_path)?; + fs::remove_file(&plugin_path)?; + println!("Removed Prism Studio plugin at {}", plugin_path.display()); } else { - log::debug!("Plugin not installed at {}", plugin_path.display()); + println!( + "Prism Studio plugin is not installed at {}", + plugin_path.display() + ); } Ok(()) } -#[test] -fn plugin_initialize() { - let _ = initialize_plugin().unwrap(); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LocalPluginKind { + PrismManaged, + LegacyPrismManaged, + PrismLocalBuild, + PossiblePrismLocalBuild, + RojoManagedUnknown, + RojoLike, +} + +impl LocalPluginKind { + fn label(self) -> &'static str { + match self { + Self::PrismManaged => "Prism managed plugin", + Self::LegacyPrismManaged => "legacy Prism-managed plugin (old Rojo filename)", + Self::PrismLocalBuild => "known Prism local build", + Self::PossiblePrismLocalBuild => "possible Prism local build", + Self::RojoManagedUnknown => "Rojo managed plugin (ownership ambiguous)", + Self::RojoLike => "Rojo-like local plugin (not managed by Prism)", + } + } + + fn is_prism(self) -> bool { + matches!( + self, + Self::PrismManaged + | Self::LegacyPrismManaged + | Self::PrismLocalBuild + | Self::PossiblePrismLocalBuild + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LocalPlugin { + path: PathBuf, + kind: LocalPluginKind, +} + +fn inspect_local_plugins(plugins_folder: &Path) -> anyhow::Result> { + if !plugins_folder.exists() { + return Ok(Vec::new()); + } + + let mut plugins = Vec::new(); + for entry in fs::read_dir(plugins_folder).with_context(|| { + format!( + "Could not inspect Roblox Studio's Plugins folder '{}'.", + plugins_folder.display() + ) + })? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + + let path = entry.path(); + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + let lower_name = file_name.to_ascii_lowercase(); + let kind = if file_name.eq_ignore_ascii_case(PLUGIN_FILE_NAME) { + Some(LocalPluginKind::PrismManaged) + } else if file_name.eq_ignore_ascii_case(LEGACY_PLUGIN_FILE_NAME) { + Some(if is_prism_owned_legacy_plugin(&path).unwrap_or(false) { + LocalPluginKind::LegacyPrismManaged + } else { + LocalPluginKind::RojoManagedUnknown + }) + } else if file_name.eq_ignore_ascii_case("PrismPlugin.rbxm") { + Some(LocalPluginKind::PrismLocalBuild) + } else if ["rojoexecplugin.rbxm", "rojoexecspike.rbxm"].contains(&lower_name.as_str()) { + Some(LocalPluginKind::RojoLike) + } else if (lower_name.ends_with(".rbxm") || lower_name.ends_with(".rbxmx")) + && lower_name.contains("prism") + { + Some(LocalPluginKind::PossiblePrismLocalBuild) + } else { + None + }; + + if let Some(kind) = kind { + plugins.push(LocalPlugin { path, kind }); + } + } + plugins.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(plugins) +} + +fn is_prism_owned_legacy_plugin(path: &Path) -> anyhow::Result { + let file = BufReader::new( + File::open(path) + .with_context(|| format!("Could not open legacy plugin '{}'.", path.display()))?, + ); + let dom = rbx_binary::from_reader(file) + .with_context(|| format!("Could not inspect legacy plugin '{}'.", path.display()))?; + let mut has_prism_asset = false; + let mut has_prism_identity = false; + + for instance in dom.descendants() { + for value in instance.properties.values() { + if let Variant::String(value) = value { + has_prism_asset |= value.contains(PRISM_ASSET_ID); + has_prism_identity |= value.contains("local pluginName = \"Prism ") + || value.contains("\"name\": \"Prism\""); + } + } + } + + Ok(has_prism_asset && has_prism_identity) +} + +#[cfg(test)] +mod tests { + use super::*; + use rbx_dom_weak::{InstanceBuilder, WeakDom}; + + #[test] + fn plugin_initialize_has_no_exec_spike_modules() { + let session = initialize_plugin().unwrap(); + let tree = session.tree(); + let names = tree + .descendants(tree.get_root_id()) + .map(|instance| instance.name().to_owned()) + .collect::>(); + + assert!(!names.iter().any(|name| name.contains("ExecSpike"))); + } + + #[test] + fn managed_plugin_output_path_is_prism_branded() { + let path = managed_plugin_path(Path::new( + r"C:\Users\Developer\AppData\Local\Roblox\Plugins", + )); + assert_eq!(path.file_name().unwrap(), PLUGIN_FILE_NAME); + assert_eq!(PLUGIN_FILE_NAME, "PrismManagedPlugin.rbxm"); + } + + #[test] + fn diagnostics_are_deterministic_and_distinguish_known_names() { + let directory = tempfile::tempdir().unwrap(); + for name in [ + "z-prism-copy.rbxm", + "RojoExecSpike.rbxm", + "PrismPlugin.rbxm", + "PrismManagedPlugin.rbxm", + "unrelated.rbxm", + ] { + File::create(directory.path().join(name)).unwrap(); + } + + let plugins = inspect_local_plugins(directory.path()).unwrap(); + let names = plugins + .iter() + .map(|plugin| { + plugin + .path + .file_name() + .unwrap() + .to_string_lossy() + .into_owned() + }) + .collect::>(); + assert_eq!( + names, + [ + "PrismManagedPlugin.rbxm", + "PrismPlugin.rbxm", + "RojoExecSpike.rbxm", + "z-prism-copy.rbxm", + ] + ); + assert_eq!(plugins[0].kind, LocalPluginKind::PrismManaged); + assert_eq!(plugins[1].kind, LocalPluginKind::PrismLocalBuild); + assert_eq!(plugins[2].kind, LocalPluginKind::RojoLike); + assert_eq!(plugins[3].kind, LocalPluginKind::PossiblePrismLocalBuild); + + let report = render_local_plugin_report(directory.path(), &plugins); + assert!(report.contains("[Prism managed plugin]")); + assert!(report.contains("[Rojo-like local plugin (not managed by Prism)]")); + assert!(report.contains("Warning: 3 likely Prism local plugins")); + assert!(report.contains("Marketplace-installed plugins")); + } + + #[test] + fn recognizes_only_signed_legacy_prism_plugins() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(LEGACY_PLUGIN_FILE_NAME); + let dom = WeakDom::new( + InstanceBuilder::new("Folder") + .with_child( + InstanceBuilder::new("ModuleScript") + .with_name("Assets") + .with_property("Source", format!("Logo = {PRISM_ASSET_ID}")), + ) + .with_child( + InstanceBuilder::new("ModuleScript") + .with_name("App") + .with_property("Source", "local pluginName = \"Prism \""), + ), + ); + let mut file = BufWriter::new(File::create(&path).unwrap()); + rbx_binary::to_writer(&mut file, &dom, dom.root().children()).unwrap(); + drop(file); + + assert!(is_prism_owned_legacy_plugin(&path).unwrap()); + + let unsigned_path = directory.path().join("unsigned.rbxm"); + let unsigned = WeakDom::new(InstanceBuilder::new("Folder")); + let mut file = BufWriter::new(File::create(&unsigned_path).unwrap()); + rbx_binary::to_writer(&mut file, &unsigned, unsigned.root().children()).unwrap(); + drop(file); + assert!(!is_prism_owned_legacy_plugin(&unsigned_path).unwrap()); + } } diff --git a/src/cli/serve.rs b/src/cli/serve.rs index b22beb69..4dd6b548 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -1,10 +1,12 @@ use std::{ + fs, io::{self, Write}, net::{IpAddr, Ipv4Addr}, - path::PathBuf, + path::{Path, PathBuf}, sync::Arc, }; +use anyhow::{bail, Context}; use clap::Parser; use memofs::Vfs; use termcolor::{BufferWriter, Color, ColorChoice, ColorSpec, WriteColor}; @@ -16,12 +18,12 @@ use super::{resolve_path, GlobalOptions}; const DEFAULT_BIND_ADDRESS: Ipv4Addr = Ipv4Addr::new(127, 0, 0, 1); const DEFAULT_PORT: u16 = 34872; -/// Expose a Rojo project to the Rojo Studio plugin. +/// Expose a Rojo-compatible project to the Prism Studio plugin. #[derive(Debug, Parser)] pub struct ServeCommand { - /// Path to the project to serve. Defaults to the current directory. - #[clap(default_value = "")] - pub project: PathBuf, + /// Path to the project to serve. If omitted, Prism discovers a project in + /// the current directory. + pub project: Option, /// The IP address to listen on. Defaults to `127.0.0.1`. #[clap(long)] @@ -43,7 +45,10 @@ pub struct ServeCommand { impl ServeCommand { pub fn run(self, global: GlobalOptions) -> anyhow::Result<()> { - let project_path = resolve_path(&self.project)?; + let (project_path, auto_discovered) = resolve_project_path(self.project.as_deref())?; + if auto_discovered { + log::info!("Auto-discovered Prism project {}", project_path.display()); + } let vfs = Vfs::new_default()?; @@ -90,7 +95,7 @@ fn show_start_message(bind_address: IpAddr, port: u16, color: ColorChoice) -> io bind_address.to_string() }; - writeln!(&mut buffer, "Rojo server listening:")?; + writeln!(&mut buffer, "Prism server listening:")?; write!(&mut buffer, " Address: ")?; buffer.set_color(&green)?; @@ -135,3 +140,183 @@ fn show_start_message(bind_address: IpAddr, port: u16, color: ColorChoice) -> io Ok(()) } + +fn resolve_project_path(explicit: Option<&Path>) -> anyhow::Result<(PathBuf, bool)> { + if let Some(explicit) = explicit { + return Ok((resolve_path(explicit)?.into_owned(), false)); + } + + let current_dir = std::env::current_dir().context( + "Could not determine the current working directory for Prism project discovery.", + )?; + discover_project_in(¤t_dir).map(|path| (path, true)) +} + +fn discover_project_in(current_dir: &Path) -> anyhow::Result { + let default_project = current_dir.join("default.project.json"); + if default_project.exists() { + return Ok(default_project); + } + + let entries = fs::read_dir(current_dir).with_context(|| { + format!( + "Could not inspect '{}' for Prism project files.", + current_dir.display() + ) + })?; + let mut candidates = Vec::new(); + for entry in entries { + let entry = entry.with_context(|| { + format!( + "Could not inspect an entry in '{}' during Prism project discovery.", + current_dir.display() + ) + })?; + if entry + .file_type() + .with_context(|| format!("Could not inspect '{}'.", entry.path().display()))? + .is_file() + && entry + .file_name() + .to_str() + .is_some_and(|name| name.ends_with(".project.json")) + { + candidates.push(entry.path()); + } + } + candidates.sort(); + + match candidates.as_slice() { + [project] => Ok(project.clone()), + [] => bail!( + "Prism could not find a project in '{}'. Create default.project.json or pass an explicit project path.", + current_dir.display() + ), + _ => { + let candidates = candidates + .iter() + .map(|path| { + format!( + " {}", + path.file_name() + .expect("project candidate has a file name") + .to_string_lossy() + ) + }) + .collect::>() + .join("\n"); + bail!( + "Prism found multiple project files in '{}':\n{}\nPass the project path explicitly.", + current_dir.display(), + candidates + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_project(path: &Path) { + fs::write(path, r#"{"name":"Test","tree":{"$className":"DataModel"}}"#).unwrap(); + } + + #[test] + fn explicit_path_is_used_without_discovery() { + let directory = tempfile::tempdir().unwrap(); + write_project(&directory.path().join("other.project.json")); + let explicit = directory.path().join("chosen.project.json"); + + let (actual, auto_discovered) = resolve_project_path(Some(&explicit)).unwrap(); + + assert_eq!(actual, explicit); + assert!(!auto_discovered); + } + + #[test] + fn explicit_directory_is_used_unchanged() { + let directory = tempfile::tempdir().unwrap(); + let (actual, auto_discovered) = resolve_project_path(Some(directory.path())).unwrap(); + + assert_eq!(actual, directory.path()); + assert!(!auto_discovered); + } + + #[test] + fn default_project_has_priority() { + let directory = tempfile::tempdir().unwrap(); + write_project(&directory.path().join("z.project.json")); + write_project(&directory.path().join("default.project.json")); + + assert_eq!( + discover_project_in(directory.path()).unwrap(), + directory.path().join("default.project.json") + ); + } + + #[test] + fn exactly_one_alternate_project_is_discovered() { + let directory = tempfile::tempdir().unwrap(); + write_project(&directory.path().join("game.project.json")); + + assert_eq!( + discover_project_in(directory.path()).unwrap(), + directory.path().join("game.project.json") + ); + } + + #[test] + fn no_project_has_a_helpful_error() { + let directory = tempfile::tempdir().unwrap(); + let error = discover_project_in(directory.path()) + .unwrap_err() + .to_string(); + + assert!(error.contains("could not find a project")); + assert!(error.contains("default.project.json")); + assert!(error.contains("explicit project path")); + } + + #[test] + fn multiple_projects_are_sorted_and_require_an_explicit_path() { + let directory = tempfile::tempdir().unwrap(); + for name in ["z.project.json", "a.project.json", "middle.project.json"] { + write_project(&directory.path().join(name)); + } + + let error = discover_project_in(directory.path()) + .unwrap_err() + .to_string(); + assert!(error.contains("multiple project files")); + assert!(error.contains("Pass the project path explicitly")); + assert!(error.find("a.project.json").unwrap() < error.find("middle.project.json").unwrap()); + assert!(error.find("middle.project.json").unwrap() < error.find("z.project.json").unwrap()); + } + + #[test] + fn malformed_discovered_project_is_reported_by_session_loading() { + let directory = tempfile::tempdir().unwrap(); + let project = directory.path().join("broken.project.json"); + fs::write(&project, "{not json").unwrap(); + let project = discover_project_in(directory.path()).unwrap(); + + let error = match ServeSession::new(Vfs::new_default().unwrap(), project) { + Ok(_) => panic!("malformed project unexpectedly loaded"), + Err(error) => error.to_string(), + }; + assert!(!error.is_empty()); + } + + #[test] + fn unreadable_explicit_project_is_reported_by_session_loading() { + let directory = tempfile::tempdir().unwrap(); + let missing = directory.path().join("missing.project.json"); + + let error = match ServeSession::new(Vfs::new_default().unwrap(), missing) { + Ok(_) => panic!("missing project unexpectedly loaded"), + Err(error) => error.to_string(), + }; + assert!(!error.is_empty()); + } +} diff --git a/src/main.rs b/src/main.rs index 357c27cb..6480680e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,81 +1,3 @@ -use std::{env, panic, process}; - -use backtrace::Backtrace; -use clap::Parser; - -use librojo::cli::Options; - fn main() { - #[cfg(feature = "profile-with-tracy")] - profiling::tracy_client::Client::start(); - - panic::set_hook(Box::new(|panic_info| { - // PanicInfo's payload is usually a &'static str or String. - // See: https://doc.rust-lang.org/beta/std/panic/struct.PanicInfo.html#method.payload - let message = match panic_info.payload().downcast_ref::<&str>() { - Some(&message) => message.to_string(), - None => match panic_info.payload().downcast_ref::() { - Some(message) => message.clone(), - None => "".to_string(), - }, - }; - - log::error!( - "Rojo crashed! You are running Rojo {}.", - env!("CARGO_PKG_VERSION") - ); - log::error!("This is probably a Rojo bug."); - log::error!(""); - log::error!( - "Please consider filing an issue: {}/issues", - env!("CARGO_PKG_REPOSITORY") - ); - log::error!(""); - log::error!("Details: {}", message); - - if let Some(location) = panic_info.location() { - log::error!("in file {} on line {}", location.file(), location.line()); - } - - // When using the backtrace crate, we need to check the RUST_BACKTRACE - // environment variable ourselves. Once we switch to the (currently - // unstable) std::backtrace module, we won't need to do this anymore. - let should_backtrace = env::var("RUST_BACKTRACE") - .map(|var| var == "1") - .unwrap_or(false); - - if should_backtrace { - eprintln!("{:?}", Backtrace::new()); - } else { - eprintln!( - "note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace." - ); - } - - process::exit(1); - })); - - let options = Options::parse(); - - let log_filter = match options.global.verbosity { - 0 => "info", - 1 => "info,librojo=debug", - 2 => "info,librojo=trace", - _ => "trace", - }; - - let log_env = env_logger::Env::default().default_filter_or(log_filter); - - env_logger::Builder::from_env(log_env) - .format_module_path(false) - .format_timestamp(None) - // Indent following lines equal to the log level label, like `[ERROR] ` - .format_indent(Some(8)) - .write_style(options.global.color.into()) - .init(); - - if let Err(err) = options.run() { - log::error!("{:?}", err); - process::exit(1); - } + librojo::cli::run(); } diff --git a/tests/rojo_test/io_util.rs b/tests/rojo_test/io_util.rs index c18e457d..62e0d10f 100644 --- a/tests/rojo_test/io_util.rs +++ b/tests/rojo_test/io_util.rs @@ -8,6 +8,7 @@ use std::{ use walkdir::WalkDir; pub static ROJO_PATH: &str = env!("CARGO_BIN_EXE_rojo"); +pub static PRISM_PATH: &str = env!("CARGO_BIN_EXE_prism"); pub static BUILD_TESTS_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/rojo-test/build-tests"); pub static SERVE_TESTS_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/rojo-test/serve-tests"); pub static SYNCBACK_TESTS_PATH: &str = diff --git a/tests/tests/cli.rs b/tests/tests/cli.rs new file mode 100644 index 00000000..2f263cfe --- /dev/null +++ b/tests/tests/cli.rs @@ -0,0 +1,26 @@ +use std::process::Command; + +use crate::rojo_test::io_util::PRISM_PATH; + +#[test] +fn prism_binary_help_is_branded_and_lists_product_commands() { + let output = Command::new(PRISM_PATH).arg("--help").output().unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("Prism developer tooling for Roblox")); + for command in ["serve", "exec", "inspect", "plugin"] { + assert!(stdout.contains(command), "help did not list {command}"); + } +} + +#[test] +fn prism_binary_version_is_branded() { + let output = Command::new(PRISM_PATH).arg("--version").output().unwrap(); + + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout).unwrap().trim(), + format!("Prism {}", env!("CARGO_PKG_VERSION")) + ); +} diff --git a/tests/tests/mod.rs b/tests/tests/mod.rs index bc710016..81a693ec 100644 --- a/tests/tests/mod.rs +++ b/tests/tests/mod.rs @@ -1,3 +1,4 @@ mod build; +mod cli; mod serve; mod syncback;