diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml index 1a346b8c..4a283ecb 100644 --- a/.github/workflows/cli-e2e.yml +++ b/.github/workflows/cli-e2e.yml @@ -10,7 +10,7 @@ on: - "package-lock.json" - ".github/workflows/cli-e2e.yml" push: - branches: [ main, next ] + branches: [main] paths: - "cli/**" - "src-lua/**" diff --git a/.gitignore b/.gitignore index 1621a575..f2ef53e3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ docs/_site/ playwright-report/ test-results/ .env +builds/ +vendor/ +feather.build.json \ No newline at end of file diff --git a/.husky/commit-msg b/.husky/commit-msg index e89aef78..16c5abd4 100644 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -5,7 +5,7 @@ message_file="$1" subject="$(sed -n '1p' "$message_file")" case "$subject" in - ci:*|cli:*|app:*|lua:*|tauri:*|feather:*|docs:*) + ci:*|cli:*|package:*|plugin:*|app:*|lua:*|tauri:*|feather:*|docs:*) exit 0 ;; esac @@ -16,6 +16,8 @@ cat <<'EOF' Commit message must start with one of: ci: cli: + package: + plugin: app: lua: tauri: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f5c6512..5bba21d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,50 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v1.0.0] - 2026-05-17 - The one with platform builds + +### Added + +- **Build pipeline** — `feather build` now supports `.love`, web, Android, iOS, Windows, macOS, Linux, and SteamOS targets. + - Added mobile release build support for Android and iOS. + - Added desktop runtime packaging for Windows, macOS, Linux, and SteamOS. + - Added web build output and web run support. + - Added vendor management with `feather build vendor add/list` for build templates and runtimes. +- **Upload pipeline** — added `feather upload` support for publishing build artifacts, including itch.io workflows. +- **Run targets** — `feather run` can now launch desktop, web, Android, and iOS development flows. + - Added mobile run cache support to speed up repeated Android/iOS iteration. + - Added Android/iOS device and simulator launch helpers. +- **CLI doctor expansion** — `feather doctor` now includes deeper environment, project, security, production, vendor, and build-target checks. + - Added `--production`, security-focused checks, and build-target diagnostics. + - Added structured JSON diagnostics used by the desktop app. +- **Desktop CLI awareness** — Settings now detects the Feather CLI, reports CLI version/path/source, checks Node/npm, and runs read-only project doctor/vendor summaries. + - Added CLI path override and project directory selection. + - Added CLI/Desktop version mismatch warnings. + - Added docs links and copyable fix commands for missing setup. +- **Safe editor launch** — replaced shell-based source opening with a dedicated Tauri command that validates editor paths and project-relative file locations before launching VS Code. +- **First-run guidance** — added no-session setup prompts for installing the CLI, opening docs, connecting a LÖVE project, and copying `feather run`. +- **Package catalog growth** — added packages including baton, beehive, cargo, g3d, knife, love-dialogue, lovebpm, lua-state-machine, shove, SYSL-text, and tiny-ecs. +- **Custom package installs** — added `feather package add` workflows for custom packages, branch/commit-based sources, submodules, and provenance metadata. +- **CLI tooling and tests** — added package helper scripts and a broader CLI command test suite for build, doctor, package, plugin, run, runtime, and upload commands. + +### Changed + +- Refactored CLI command structure, shared UI components, output formatting, and interactive workflows for init, package, plugin, remove, and update commands. +- Hardened package, plugin, and filesystem mutation paths with path safety checks, provenance tracking, trust validation, redaction, and more auditable output. +- Improved lockfile handling for package installs and audits. +- Improved package registry generation and switched package source references toward pinned commit hashes. +- Improved documentation for installation, usage, assets, debugger, recommendations, CLI workflows, and minimal examples. +- Refined desktop settings into tabbed sections with CLI, assets, editor, connection, and security controls. +- Improved light/dark theming, plugin sidebar states, plugin button-style inputs, observability controls, and empty states. +- Updated Lua runtime/plugin internals, including safer `auto.lua` loading, plugin manager improvements, and debug overlay support. + +### Fixed + +- Fixed custom package add behavior and terminal input cleanup. +- Fixed iOS build behavior and error messages. +- Removed Lua `goto` usage that could trigger runtime errors. +- Removed frontend dependency on arbitrary shell execution for opening source locations. + ## [v0.10.0] - 2026-05-13 - The one with the internal package manager ### Added @@ -353,6 +397,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - LuaRocks package. - GitHub Actions CI. +[v1.0.0]: https://github.com/Kyonru/feather/compare/v0.10.0...v1.0.0 [v0.10.0]: https://github.com/Kyonru/feather/compare/v0.9.3...v0.10.0 [v0.9.3]: https://github.com/Kyonru/feather/compare/v0.9.2...v0.9.3 [v0.9.2]: https://github.com/Kyonru/feather/compare/v0.9.0...v0.9.2 diff --git a/README.md b/README.md index 8de98619..1c6f94ec 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Feather — CLI, Debugging & Inspection Tool for Löve2D +# Feather — CLI Debugging, Inspection, and Builds for LÖVE Feather is a CLI for debugging, inspecting, and manage packages for [LÖVE](https://love2d.org) games. @@ -19,10 +19,10 @@ The goal is to make the day-to-day loop of writing and testing a LÖVE game fast - **Console / REPL** — Execute Lua in the running game (opt-in, requires an `apiKey`). - **Plugin system** — 18+ built-in plugins (collision debug, animation inspector, audio debug, particle editor, and more). Plugins define their UI in Lua; the desktop app renders it automatically. - **Multi-session** — Connect multiple games at the same time. -- **Mobile debugging** — Auto-detected LAN IP for connecting phones, tablets, and Steam Deck. +- **Mobile and platform builds** — CLI-managed web, Android, iOS, Windows, macOS, Linux, and SteamOS workflows. - **Screenshots & GIF capture** — Built-in capture plugin. - **Log file viewer** — Open `.featherlog` files for offline inspection. -- **CLI** — No Lua changes needed to run and debug love2d games. +- **CLI** — No Lua changes needed to run, debug, build, or clean up love2d games. - **Package Manager** — Install packages from a curated list of popular love2D packages. --- @@ -33,60 +33,65 @@ The goal is to make the day-to-day loop of writing and testing a LÖVE game fast --- -## Setup +## Quick Start -Install the CLI, then initialize your project: +Install the Feather desktop app and CLI: + +1. Download the desktop app from [Releases](https://github.com/Kyonru/feather/releases). +2. Install the CLI: ```sh npm install -g @kyonru/feather -feather init --mode cli ``` -Run your game with Feather loaded: +Initialize your project, open the Feather app, then run the game: ```sh +feather init path/to/my-game feather run path/to/my-game -# or -USE_DEBUGGER=1 love path/to/my-game ``` -For games running on external devices (iOS, Android, Steam Deck, another machine), embed the runtime directly: +Feather is injected by the CLI for dev runs and debug builds, so your game code does not need a manual `require` for any target. + +Optional vendor setup for web, mobile, and packaged desktop workflows: ```sh -feather init path/to/my-game # copies the Lua runtime into your project -USE_DEBUGGER=1 love path/to/my-game +feather build vendor add web --dir path/to/my-game +feather run path/to/my-game --target web + +feather build vendor add android --dir path/to/my-game +feather run path/to/my-game --target android + +feather build vendor add ios --dir path/to/my-game +feather run path/to/my-game --target ios ``` -`feather init` creates a `feather.config.lua` in your project: +For all build vendors, including desktop packaging runtimes: -```lua -return { - sessionName = "My Game", - -- For phones, tablets, Steam Deck, or another computer: - -- host = "192.168.1.50", - -- include = { "console" }, - -- exclude = { "hump.signal" }, -} +```sh +feather build vendor add all --dir path/to/my-game ``` -All generated game-side code is guarded so it only runs when `USE_DEBUGGER` is set: +Build release artifacts from the same CLI flow: -```lua -function love.update(dt) - if DEBUGGER then - DEBUGGER:update(dt) - end -end +```sh +feather build love --dir path/to/my-game +feather build android --dir path/to/my-game --release +feather build ios --dir path/to/my-game --release +feather build windows --dir path/to/my-game +feather build macos --dir path/to/my-game +feather build linux --dir path/to/my-game +feather build steamos --dir path/to/my-game ``` -To strip Feather from a project before shipping: +For more commands and options: ```sh -feather remove --dry-run -feather remove --yes +feather --help +feather run --help ``` -Then download the desktop app from the [releases page](https://github.com/Kyonru/feather/releases). +See the [CLI docs](docs/cli.md) for `feather run`, `feather doctor`, `feather build`, and `feather upload`. --- diff --git a/ROADMAP.md b/ROADMAP.md index 079e01ed..7579fc02 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -60,7 +60,7 @@ Turn Feather into a platform instead of only a debugger. --- -## 1.0.0 — Trust Release +## Beyond v1 — Trust Release Focus: Production-ready release with security, trust, and community adoption. @@ -69,13 +69,9 @@ Production-ready release with security, trust, and community adoption. - [ ] Security pass - [ ] Remote debugging encryption - - [ ] Command validation - - [ ] File/folder restrictions - - [ ] OS script protections ### Platform Readiness -- [ ] Add developer signature to avoid installation warnings - [ ] Add privacy policy - [ ] Improve crash recovery - [ ] Final compatibility validation @@ -114,23 +110,3 @@ Production-ready release with security, trust, and community adoption. - [ ] Optimize screenshot/memory handling --- - -## Ideas - -- Being able to generate (maybe from a couple of templates \ pre made docker images) android and ios projects where lua code can be embedded, similar to how expo for react native works. (explore how to make hot reloading possible, but not needed for first iteration). - - `feather build android` - - `feather build ios` - - `feather build steamos` - - `feather build web` -- Ideally when building android and ios, the project should use cache from previous build to reduce the amount of time it takes to see lua code in mobile. -- Step by step setup guide for Steam deck (what legally is allowed). -- Release command, takes care of removing feather if integrated, and prepare builds with release flavors - - `feather release android` -- Interactive itch.io \ steam releases (also non-interactive) - - `feather upload itch.io` -- feather needs to be able to read from a secret file and inject them to builds if possible. - - Doctor should say what's missing to be able to use these commands (sdks, where to get them, etc). - - maybe a `feather.yml` to configure the cli -- Easy integration with CLI - -Goals: Being as close as possible to expo diff --git a/cli/README.md b/cli/README.md index 4f6c6e1d..171b2f88 100644 --- a/cli/README.md +++ b/cli/README.md @@ -13,10 +13,13 @@ The Feather CLI lets you run and debug LÖVE games **without modifying your game ```bash feather run feather run path/to/my-game +feather run path/to/my-game --target web +feather run path/to/my-game --target android +feather run path/to/my-game --target ios ``` > [!IMPORTANT] -> `feather run` is best for local desktop development. For mobile, handhelds, Steam Deck, or a second computer where the CLI is not launching the game process, use `feather init --mode auto` so the game carries the embedded Feather library. +> `feather run` launches desktop games directly. For web dev loops it builds and serves a local love.js artifact. For Android and iOS dev loops it builds the configured native template, installs the artifact, and launches it on a connected device or simulator. --- @@ -74,9 +77,20 @@ feather run . # run game in current directory feather run path/to/my-game # run from an explicit path feather run . --session-name "RPG" # custom name in the desktop session tab feather run . --no-plugins # feather core only, no plugins +feather run . --no-debugger # launch without Feather injection feather run . --love /usr/bin/love # override love2d binary feather run . --plugins-dir ./my-plugins # use a custom plugins directory feather run . -- --level dev # pass args through to the game +feather run . --target web # build love.js output and serve it locally +feather run . --target web --web-port 3000 +feather run . --target web --no-debugger # serve raw source without Feather embed +feather run . --target android # build, install, adb reverse, and launch Android +feather run . --target android --device emulator-5554 +feather run . --target android --verbose # show Gradle/ADB commands and output +feather run . --target android --no-cache # force a fresh native workspace +feather run . --target android --no-debugger +feather run . --target ios # build, install, and launch on the booted simulator +feather run . --target ios --device ``` When `game-path` is omitted in an interactive terminal, Feather opens an Ink workflow that asks for the game path, session name, config path, whether plugins should be disabled, and optional advanced paths/arguments. Scripts should pass `game-path` explicitly. @@ -88,12 +102,27 @@ When `game-path` is omitted in an interactive terminal, Feather opens an Ink wor | `--love ` | Path to the love2d binary. Defaults to auto-detect (see [Binary detection](#binary-detection)). | | `--session-name ` | Custom session name shown in the Feather desktop app. | | `--no-plugins` | Load feather core only — no plugins registered. | +| `--no-debugger` | Run without Feather debugger injection. Desktop runs the game directly; mobile skips connection setup and builds raw source. | +| `--disable-debugger` | Alias for `--no-debugger`. | | `--config ` | Explicit path to a `feather.config.lua` file. | | `--feather-path ` | Use a local feather install instead of the CLI's bundled copy. | | `--plugins-dir ` | Use a custom plugins directory instead of the CLI's bundled plugins. | +| `--target ` | Run target: `desktop`, `web`, `android`, or `ios`. Defaults to `desktop`. | +| `--device ` | Android device serial or iOS simulator UDID. iOS defaults to `booted`. | +| `--build-config ` | Path to `feather.build.json` for web/mobile run. | +| `--out-dir ` | Build output directory for web/mobile run. | +| `--clean` | Remove the output directory before the web/mobile build. | +| `--no-cache` | Disable Android/iOS dev native build cache for this run. | +| `--verbose` | Show web/mobile build steps plus Android/iOS install and launch commands. | +| `--no-adb-reverse` | Skip Android `adb reverse` setup. | +| `--port ` | Port used for Android `adb reverse`; defaults to `feather.config.lua` `port` or `4004`. | +| `--web-host ` | Host used by the web dev server. Defaults to `127.0.0.1`. | +| `--web-port ` | Port used by the web dev server. Defaults to `8000`; use `0` for an OS-assigned port. | Use `--` to separate Feather CLI options from arguments intended for the LÖVE game. Everything after `--` is passed to `love` after the generated shim path. +Web and mobile run are dev-only in V1 and do not forward game arguments. By default they embed the bundled Feather runtime, bundled plugins, and the selected `feather.config.lua` into the temporary `.love` archive before serving or installing. Web requires a configured `targets.web.loveJsDir`. Android requires `adb`, a configured `targets.android.loveAndroidDir`, and USB debugging or an emulator. iOS requires macOS, Xcode, a configured `targets.ios.loveIosDir`, and a booted simulator. + **Project config file:** If a `feather.config.lua` exists in the game directory, it is read automatically and merged into the feather setup. See [feather.config.lua](#featherconfiglua). @@ -282,6 +311,10 @@ feather doctor path/to/my-game feather doctor . --install-dir lib/feather feather doctor . --host 127.0.0.1 --port 4004 feather doctor . --json +feather doctor . --production +feather doctor . --security --json +feather doctor . --build-target web +feather doctor . --upload-target itch ``` Doctor checks: @@ -290,6 +323,9 @@ Doctor checks: - `main.lua`, `feather.config.lua`, and managed init metadata - embedded runtime files for auto/manual setups - installed plugin manifests +- missing, unknown, malformed, or development-only plugins +- package lockfile integrity, version drift, and source provenance +- build/upload dependencies when `--build-target` or `--upload-target` is provided - `USE_DEBUGGER` guards and `FEATHER-INIT` markers - risky settings such as hot reload, screenshot capture, and Console API keys - Feather desktop WebSocket reachability @@ -297,6 +333,12 @@ Doctor checks: > [!TIP] > `feather doctor --json` is useful in CI or pre-release scripts. It exits with a nonzero status only when it finds blockers. +Use `--production` as a release gate. It fails on production-dangerous settings such as `__DANGEROUS_INSECURE_CONNECTION__ = true`, Console with a weak or missing `apiKey`, hot reload, broad hot reload allowlists, debugger/screenshot/disk persistence settings, wildcard or LAN-facing hosts with weak auth, and unmanaged embedded Feather runtime. + +Use `--security --json` when automation needs a security-focused report without environment noise. It emits JSON only, filters checks to security-relevant groups, and includes config posture, network exposure, runtime management, plugin trust, and package provenance. + +Sensitive values such as `apiKey`, tokens, secrets, and passwords are redacted from human output, JSON output, compact errors, and `FEATHER_DEBUG=1` stack output. + **Example output:** ``` @@ -318,6 +360,205 @@ Doctor passed with 1 warning. --- +### `feather build ` + +Build a LÖVE game into local artifacts. Supported targets are `love`, `web`, `android`, `ios`, `windows`, `macos`, `linux`, and `steamos`. Android and iOS default to development builds from local native template checkouts; `--release` produces signed/store-oriented mobile artifacts without embedding Feather's debugger runtime. + +```bash +feather build love --dir path/to/my-game +feather build web --dir path/to/my-game +feather build vendor add web --dir path/to/my-game +feather build vendor add mobile --dir path/to/my-game +feather build vendor add desktop --dir path/to/my-game +feather build vendor add all --dir path/to/my-game +feather build android --dir path/to/my-game +feather build android --dir path/to/my-game --verbose +feather build android --dir path/to/my-game --no-cache +feather build android --dir path/to/my-game --runtime-config path/to/feather.config.lua +feather build android --dir path/to/my-game --no-debugger +feather build android --dir path/to/my-game --release +feather build ios --dir path/to/my-game +feather build ios --dir path/to/my-game --verbose +feather build ios --dir path/to/my-game --release +feather build windows --dir path/to/my-game +feather build macos --dir path/to/my-game +feather build linux --dir path/to/my-game +feather build steamos --dir path/to/my-game --json +feather build web --dry-run +feather build web --allow-unsafe +``` + +Builds read `feather.build.json` from the project root. `love` builds can run without target-specific vendors. Web builds need a local love.js player directory, mobile builds need local LÖVE native template paths, desktop builds need local LÖVE runtime vendors, and uploads need store metadata. + +To fetch build vendors locally: + +```bash +feather build vendor add web +feather build vendor add mobile +feather build vendor add desktop +feather build vendor add all --json +feather build vendor add android --ref 11.5 +feather build vendor add ios --ref 11.5 --json +feather build vendor list +``` + +`build vendor add` installs local build vendors into `vendor/` and updates `feather.build.json` by default. Web fetches `2dengine/love.js` into `vendor/love.js`. Android fetches `love2d/love-android` with submodules. iOS fetches `love2d/love` and installs the matching `love--apple-libraries.zip` into the Xcode tree. Desktop vendors download official LÖVE runtimes for Windows, macOS, and Linux; SteamOS reuses the Linux runtime unless configured separately. Mobile and desktop versions come from `loveVersion` or `--ref`, falling back to `11.5`; web defaults to the love.js `main` branch unless `--web-ref` or `--ref` is passed. + +```json +{ + "name": "My Game", + "version": "1.0.0", + "productId": "com.example.mygame", + "company": "Example Studio", + "website": "https://example.com", + "sourceDir": ".", + "outDir": "builds", + "exclude": ["screenshots/**", "tmp/**"], + "targets": { + "web": { + "loveJsDir": "vendor/love.js" + }, + "android": { + "loveAndroidDir": "vendor/love-android", + "displayName": "My Game", + "orientation": "landscape", + "recordAudio": false, + "versionCode": 1, + "versionName": "1.0.0", + "release": { + "bundleTask": "bundleEmbedNoRecordRelease", + "apkTask": "assembleEmbedNoRecordRelease", + "keystorePath": "signing/release.keystore", + "keyAlias": "release", + "storePasswordEnv": "ANDROID_STORE_PASSWORD", + "keyPasswordEnv": "ANDROID_KEY_PASSWORD" + } + }, + "ios": { + "loveIosDir": "vendor/love-ios", + "bundleIdentifier": "com.example.mygame", + "displayName": "My Game", + "scheme": "love-ios", + "sdk": "iphonesimulator", + "teamId": "ABCDE12345", + "release": { + "exportMethod": "app-store-connect", + "signingStyle": "manual", + "provisioningProfileSpecifier": "My Game App Store", + "teamId": "ABCDE12345" + } + }, + "windows": { + "loveRuntimeDir": "vendor/love-windows" + }, + "macos": { + "loveRuntimeDir": "vendor/love-macos" + }, + "linux": { + "loveRuntimeDir": "vendor/love-linux" + } + }, + "upload": { + "itch": { + "project": "my-user/my-game", + "channels": { + "web": "html5", + "linux": "linux" + } + } + } +} +``` + +Build behavior: + +- creates a deterministic `.love` archive from the staged project +- excludes `.git`, `node_modules`, `.featherlog`, build output, and Feather runtime/config files by default +- runs a production safety preflight unless `--allow-unsafe` is passed +- writes `feather-build-manifest.json` in the output directory +- packages `web` by copying the configured love.js player, adding `game.love`, patching the page title/game URL, and creating an HTML zip +- packages `android` by copying a configured love-android checkout, embedding `game.love`, patching obvious app metadata, running Gradle, and copying the APK +- packages `ios` on macOS by copying a configured LÖVE iOS source tree, embedding `game.love`, running `xcodebuild`, and copying the `.app` +- embeds Feather runtime/config into Android/iOS dev builds by default; use `--no-debugger` to build raw source, and note that `--release` never auto-embeds Feather +- caches Android/iOS dev native workspaces under `/.feather-cache` so Gradle/Xcode incremental state survives between builds +- `--release` on Android produces `.aab` and `.apk` artifacts; signing passwords are read from environment variables named in config +- `--release` on iOS produces `.xcarchive` and `.ipa` artifacts through `xcodebuild archive` and `-exportArchive` +- `--verbose` on Android/iOS shows staging steps, native workspace paths, Gradle/Xcode commands, and captured native tool output; JSON output stays decoration-free +- packages Windows as a fused runtime zip and NSIS installer, macOS as `.app.zip` and `.dmg`, and Linux/SteamOS as `.AppImage` plus `.tar.gz` +- `steamos` uses the Linux runtime vendor by default with SteamOS artifact naming + +**Options:** + +| Option | Description | +| ------------------- | ----------------------------------------------------------------- | +| `--dir ` | Project directory (default: current directory). | +| `--config ` | Path to `feather.build.json`. | +| `--out-dir ` | Build output directory override. | +| `--name ` | Product name override. | +| `--version ` | Product version override. | +| `--clean` | Remove the output directory before building. | +| `--dry-run` | Show planned files/artifacts without writing them. | +| `--json` | Print machine-readable output only. | +| `--allow-unsafe` | Skip the production safety preflight for intentional dev builds. | +| `--release` | Build Android/iOS release artifacts instead of dev artifacts. | +| `--no-cache` | Disable Android/iOS dev native build cache for this build. | +| `--no-debugger` | Build Android/iOS dev artifacts without embedding Feather. | +| `--runtime-config` | Path to `feather.config.lua` for Android/iOS dev embedding. | +| `--verbose` | Show Android/iOS native build commands and tool output. | + +Run `feather doctor --build-target ` to see missing local dependencies and exact setup guidance before building. Use `feather doctor --build-target all` to scan every platform in one pass. + +Mobile build notes: + +- Android builds expect `targets.android.loveAndroidDir` to point at a local love-android checkout with `gradlew`. +- iOS builds expect `targets.ios.loveIosDir` to point at a local LÖVE iOS source tree with `platform/xcode/love.xcodeproj`. +- `feather build vendor add mobile` fetches those template checkouts, but it does not install Android SDK, JDK, Xcode, or signing assets. +- Desktop builds expect `targets.windows.loveRuntimeDir`, `targets.macos.loveRuntimeDir`, and `targets.linux.loveRuntimeDir` to point at local runtime vendors. `feather build vendor add desktop` creates those directories. +- Dev Android/iOS builds reuse cached copied native templates by default. Use `--no-cache` for a fresh temporary workspace, or `--clean` to remove both artifacts and cached state in the output directory. +- Release Android/iOS builds use fresh native workspaces by default for reproducibility. +- `feather doctor --build-target android --release` validates product id, Gradle wrapper, JDK, Android SDK, and signing env setup. +- `feather doctor --build-target ios --release` validates bundle id, macOS/Xcode setup, template path, export options, and signing hints. +- Play Console and App Store upload are not included in this pass. + +--- + +### `feather upload ` + +Upload a built artifact. V1 supports Itch through `butler`; Steam is registered but returns a planned-support error. + +```bash +feather upload itch web --dir path/to/my-game +feather upload itch web --channel html5 --if-changed +feather upload itch web --dry-run --json +feather upload steam linux +``` + +`feather upload itch` reads `feather-build-manifest.json`, chooses the artifact for the requested build target, and runs: + +```bash +butler push : --userversion +``` + +The Itch project and default channels come from `feather.build.json`. Use `--channel` or `--user-version` to override them in CI. + +**Options:** + +| Option | Description | +| -------------------------- | --------------------------------------------------- | +| `--dir ` | Project directory (default: current directory). | +| `--config ` | Path to `feather.build.json`. | +| `--build-dir ` | Directory containing `feather-build-manifest.json`. | +| `--channel ` | Upload channel override. | +| `--user-version ` | Store-facing version override. | +| `--dry-run` | Show the upload command without running it. | +| `--if-changed` | Pass `--if-changed` to supported uploaders. | +| `--hidden` | Pass `--hidden` to supported uploaders. | +| `--json` | Print machine-readable output only. | + +Run `feather doctor --upload-target itch` to check for `butler`, Itch project config, and CI auth hints. Use `BUTLER_API_KEY` in CI or `butler login` locally. + +--- + ### `feather update [dir]` Update the Feather core library in a project. @@ -432,10 +673,19 @@ return { -- Connect to a remote desktop app (e.g. on another machine) -- host = "192.168.1.42", + + -- Small in-game badge shown while Feather is loaded. + debugOverlay = { + enabled = true, + visible = true, + hideKey = "f12", + touchToggle = true, + corner = "top-right", + }, } ``` -All `feather.auto.setup()` options are supported. Command-line flags (`--session-name`, etc.) take precedence over the config file. +All `feather.auto.setup()` options are supported. Command-line flags (`--session-name`, etc.) take precedence over the config file. The debug overlay is visible by default when Feather is active; press `F12` or double-tap the top-right corner to temporarily hide/show it. --- diff --git a/cli/package.json b/cli/package.json index 64498782..6b9e7fe7 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@kyonru/feather", - "version": "0.10.0", + "version": "1.0.0", "description": "CLI for Feather — run and debug Love2D games without touching your game code", "license": "EPL-2.0", "keywords": [ @@ -35,8 +35,12 @@ "bundle:lua": "bash ../scripts/bundle-lua.sh", "prepack": "npm run bundle:lua && npm run build", "typecheck": "tsc --noEmit", - "test": "node --test test/package.test.mjs", - "test:e2e": "npm run build && node --test test/package.test.mjs" + "test": "node --test test/commands/*.test.mjs", + "test:e2e": "npm run build && node --test test/commands/*.test.mjs", + "package:add": "tsx --tsconfig scripts/tsconfig.json scripts/add-package.tsx", + "package:add-url": "tsx --tsconfig scripts/tsconfig.json scripts/add-package-url.tsx", + "package:update": "tsx --tsconfig scripts/tsconfig.json scripts/update-package.tsx", + "package:remove": "tsx --tsconfig scripts/tsconfig.json scripts/remove-package.tsx" }, "dependencies": { "chalk": "5.3.0", @@ -47,6 +51,7 @@ }, "devDependencies": { "@types/node": "24.2.0", + "tsx": "4.21.0", "typescript": "6.0.3" }, "engines": { diff --git a/cli/scripts/add-package-url.tsx b/cli/scripts/add-package-url.tsx new file mode 100644 index 00000000..21c5d1c8 --- /dev/null +++ b/cli/scripts/add-package-url.tsx @@ -0,0 +1,481 @@ +#!/usr/bin/env node +/** + * Interactive wizard to add a new package from direct file URLs. + * Use this when there is no GitHub repo with tags (e.g. single-file libraries + * hosted elsewhere, or repos without versioned releases). + * + * Usage (from repo root): + * npm run package:add-url + */ + +import { render, Text, Box, useApp, useInput } from 'ink'; +import { useState, useEffect } from 'react'; +import { existsSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + root, + packagesDir, + TextInputStep, + SelectStep, + AutoStep, + SubpackagesStep, + YesNoStep, + ReviewStep, + Header, + Hint, + Spinner, +} from './wizard-shared.js'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { fileNameFromUrl } from '../src/lib/url.js'; + +interface UrlFile { + name: string; + url: string; + sha256: string; + target: string; +} + +interface FormData { + id: string; + trust: 'verified' | 'known'; + description: string; + tags: string[]; + homepage: string; + license: string; + require: string; + example: string; + subpackages?: Record; +} + +type Step = + | 'id' + | 'trust' + | 'description' + | 'pkg-tags' + | 'homepage' + | 'license' + | 'file-url' + | 'file-fetch' + | 'file-target' + | 'file-more' + | 'require' + | 'subpkgs' + | 'review' + | 'write' + | 'done' + | 'error'; + +const TITLE = 'feather package:add-url'; +const TOTAL = 10; + +function buildPackageJson( + data: FormData, + urlFiles: UrlFile[], +): object { + const obj: Record = { + type: 'love2d-library', + trust: data.trust, + description: data.description, + tags: data.tags, + homepage: data.homepage || undefined, + license: data.license || undefined, + source: { type: 'url' }, + install: { + files: urlFiles.map((f) => ({ name: f.name, url: f.url, sha256: f.sha256, target: f.target })), + }, + require: data.require, + example: data.example, + }; + if (data.subpackages && Object.keys(data.subpackages).length > 0) { + obj.subpackages = data.subpackages; + } + return obj; +} + +function FileFetchStep({ + url, + onDone, + onError, +}: { + url: string; + onDone: (sha256: string) => void; + onError: (msg: string) => void; +}) { + const [status, setStatus] = useState<'running' | 'done'>('running'); + const [sha, setSha] = useState(''); + + useEffect(() => { + const run = async () => { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); + const buf = await res.arrayBuffer(); + const hash = createHash('sha256').update(Buffer.from(buf)).digest('hex'); + setSha(hash); + setStatus('done'); + onDone(hash); + }; + run().catch((err: Error) => onError(err.message)); + }, []); + + return ( + + {status === 'running' ? ( + + ) : ( + ✔ sha256: {sha.slice(0, 16)}… + )} + + ); +} + +function FileMoreStep({ + stepNum, + urlFiles, + onYes, + onNo, +}: { + stepNum: number; + urlFiles: UrlFile[]; + onYes: () => void; + onNo: () => void; +}) { + useInput((input, key) => { + if (input === 'y' || input === 'Y') onYes(); + else if (input === 'n' || input === 'N' || key.return || key.escape) onNo(); + }); + + return ( + +
+ {' '}Add another file? + + {urlFiles.map((f) => ( + + {' ✔ '} + {f.name} + {' '} + → {f.target} + + ))} + + + {' y = add another · n/Enter = done'} + + + ); +} + +function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { + useEffect(() => { + const t = setTimeout(onExit, 300); + return () => clearTimeout(t); + }, [onExit]); + + return ( + + + ✔ Done! + + {' '}packages/{id}.json written + {' '}Registry regenerated + + Commit packages/{id}.json and cli/src/generated/registry.json + + + ); +} + +function Wizard() { + const { exit } = useApp(); + const [step, setStep] = useState('id'); + const [data, setData] = useState>({}); + const [urlFiles, setUrlFiles] = useState([]); + const [currentUrl, setCurrentUrl] = useState(''); + const [currentSha, setCurrentSha] = useState(''); + const [errorMsg, setErrorMsg] = useState(''); + const [outputJson, setOutputJson] = useState(''); + + const handleError = (msg: string) => { + setErrorMsg(msg); + setStep('error'); + }; + + if (step === 'id') { + return ( + { + if (!v) return 'Required'; + if (!/^[a-z0-9][a-z0-9.-]*$/.test(v)) return 'Use only a-z, 0-9, dots, hyphens'; + if (existsSync(join(packagesDir, `${v}.json`))) + return `packages/${v}.json already exists — use package:update to edit it`; + return null; + }} + onSubmit={(id) => { + setData((d) => ({ ...d, id })); + setStep('trust'); + }} + /> + ); + } + + if (step === 'trust') { + return ( + { + setData((d) => ({ ...d, trust: trust as FormData['trust'] })); + setStep('description'); + }} + /> + ); + } + + if (step === 'description') { + return ( + (v ? null : 'Required')} + onSubmit={(description) => { + setData((d) => ({ ...d, description })); + setStep('pkg-tags'); + }} + /> + ); + } + + if (step === 'pkg-tags') { + return ( + (v ? null : 'Required')} + onSubmit={(tagStr) => { + const tags = tagStr + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + setData((d) => ({ ...d, tags })); + setStep('homepage'); + }} + /> + ); + } + + if (step === 'homepage') { + return ( + { + setData((d) => ({ ...d, homepage })); + setStep('license'); + }} + /> + ); + } + + if (step === 'license') { + return ( + { + setData((d) => ({ ...d, license })); + setStep('file-url'); + }} + /> + ); + } + + if (step === 'file-url') { + const n = urlFiles.length; + return ( + { + if (!v) return 'Required'; + try { + new URL(v); + } catch { + return 'Must be a valid URL'; + } + return null; + }} + onSubmit={(url) => { + setCurrentUrl(url); + setStep('file-fetch'); + }} + /> + ); + } + + if (step === 'file-fetch') { + return ( + { + setCurrentSha(sha256); + setStep('file-target'); + }} + onError={handleError} + /> + ); + } + + if (step === 'file-target') { + const name = fileNameFromUrl(currentUrl); + const suggested = + urlFiles.length === 0 + ? `lib/${name}` + : `lib/${data.id}/${name}`; + return ( + { + if (!v) return 'Required'; + if (!v.endsWith('.lua')) return 'Must end in .lua'; + return null; + }} + onSubmit={(target) => { + setUrlFiles((fs) => [ + ...fs, + { name, url: currentUrl, sha256: currentSha, target }, + ]); + setStep('file-more'); + }} + /> + ); + } + + if (step === 'file-more') { + return ( + setStep('file-url')} + onNo={() => setStep('require')} + /> + ); + } + + if (step === 'require') { + const firstName = urlFiles[0]?.name ?? `${data.id}.lua`; + const suggested = `lib.${firstName.replace(/\.lua$/, '').replace(/\//g, '.')}`; + return ( + (v ? null : 'Required')} + onSubmit={(req) => { + const example = `local ${data.id!.replace(/[.-]/g, '_')} = require('${req}')`; + setData((d) => ({ ...d, require: req, example })); + setStep('subpkgs'); + }} + /> + ); + } + + if (step === 'subpkgs') { + return ( + f.name)} + onSubmit={(subpackages) => { + setData((d) => ({ ...d, subpackages })); + const full = { ...data, subpackages } as FormData; + setOutputJson(JSON.stringify(buildPackageJson(full, urlFiles), null, 2)); + setStep('review'); + }} + /> + ); + } + + if (step === 'review') { + return ( + setStep('write')} + onAbort={() => { + setErrorMsg('Aborted.'); + setStep('error'); + }} + /> + ); + } + + if (step === 'write') { + return ( + { + writeFileSync(join(packagesDir, `${data.id}.json`), outputJson + '\n', 'utf8'); + const result = spawnSync(process.execPath, [join(root, 'scripts', 'generate-registry.mjs')], { + cwd: root, + stdio: 'pipe', + encoding: 'utf8', + }); + if (result.status !== 0) throw new Error(result.stderr || 'generate-registry.mjs failed'); + setStep('done'); + }} + onError={handleError} + /> + ); + } + + if (step === 'done') return ; + + return ( + + + ✖ Error + + {errorMsg} + + ); +} + +const { waitUntilExit } = render(, { alternateScreen: true }); +await waitUntilExit(); diff --git a/cli/scripts/add-package.tsx b/cli/scripts/add-package.tsx new file mode 100644 index 00000000..c0808e64 --- /dev/null +++ b/cli/scripts/add-package.tsx @@ -0,0 +1,403 @@ +#!/usr/bin/env node +/** + * Interactive wizard to add a new package to the Feather catalog. + * + * Usage (from repo root): + * npm run package:add + */ + +import { render, Text, Box, useApp } from 'ink'; +import { useState, useEffect } from 'react'; +import { existsSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { + root, + packagesDir, + type FormData, + type FileEntry, + TextInputStep, + SelectStep, + MultiSelectStep, + AutoStep, + TargetsStep, + SubpackagesStep, + ReviewStep, + ChecksumStep, + fetchRepoMeta, + fetchCommitSha, + fetchLuaFiles, + buildPackageJson, +} from './wizard-shared.js'; + +type Step = + | 'id' + | 'repo' + | 'fetch-tags' + | 'tag' + | 'resolve-commit' + | 'trust' + | 'description' + | 'pkg-tags' + | 'fetch-files' + | 'files' + | 'targets' + | 'require' + | 'subpkgs' + | 'fetch-checksums' + | 'review' + | 'write' + | 'done' + | 'error'; + +const TITLE = 'feather package:add'; +const TOTAL = 12; + +function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { + useEffect(() => { + const t = setTimeout(onExit, 300); + return () => clearTimeout(t); + }, [onExit]); + + return ( + + + ✔ Done! + + + {' '}packages/{id}.json written + + {' '}Registry regenerated + + Commit packages/{id}.json and cli/src/generated/registry.json + + + Then push — GitHub Actions will publish to the packages branch. + + + ); +} + +function Wizard() { + const { exit } = useApp(); + const [step, setStep] = useState('id'); + const [data, setData] = useState>({}); + const [fetchedTags, setFetchedTags] = useState([]); + const [fetchedLabels, setFetchedLabels] = useState([]); + const [fetchedFiles, setFetchedFiles] = useState([]); + const [errorMsg, setErrorMsg] = useState(''); + const [outputJson, setOutputJson] = useState(''); + + const handleError = (msg: string) => { + setErrorMsg(msg); + setStep('error'); + }; + + if (step === 'id') { + return ( + { + if (!v) return 'Required'; + if (!/^[a-z0-9][a-z0-9.-]*$/.test(v)) return 'Use only a-z, 0-9, dots, hyphens'; + if (existsSync(join(packagesDir, `${v}.json`))) + return `packages/${v}.json already exists — use package:update to edit it`; + return null; + }} + onSubmit={(id) => { + setData((d) => ({ ...d, id })); + setStep('repo'); + }} + /> + ); + } + + if (step === 'repo') { + return ( + { + if (!v) return 'Required'; + if (!/^[^/]+\/[^/]+$/.test(v)) return 'Must be owner/repo format'; + return null; + }} + onSubmit={(repo) => { + setData((d) => ({ ...d, repo, homepage: `https://github.com/${repo}` })); + setStep('fetch-tags'); + }} + /> + ); + } + + if (step === 'fetch-tags') { + return ( + { + const { tags, branches, defaultBranch, license } = await fetchRepoMeta(data.repo!); + if (tags.length === 0 && branches.length === 0) throw new Error('No tags or branches found.'); + const orderedBranches = [defaultBranch, ...branches.filter((b) => b !== defaultBranch)]; + const values = [...tags, ...orderedBranches]; + const labels = [...tags.map((t) => t), ...orderedBranches.map((b) => `⎇ ${b}`)]; + setFetchedTags(values); + setFetchedLabels(labels); + setData((d) => ({ ...d, license })); + setStep('tag'); + }} + onError={handleError} + /> + ); + } + + if (step === 'tag') { + return ( + { + setData((d) => ({ ...d, tag })); + setStep('resolve-commit'); + }} + /> + ); + } + + if (step === 'resolve-commit') { + return ( + { + const commitSha = await fetchCommitSha(data.repo!, data.tag!); + setData((d) => ({ + ...d, + commitSha, + baseUrl: `https://raw.githubusercontent.com/${d.repo}/${commitSha}/`, + })); + setStep('trust'); + }} + onError={handleError} + /> + ); + } + + if (step === 'trust') { + return ( + { + setData((d) => ({ ...d, trust: trust as FormData['trust'] })); + setStep('description'); + }} + /> + ); + } + + if (step === 'description') { + return ( + (v ? null : 'Required')} + onSubmit={(description) => { + setData((d) => ({ ...d, description })); + setStep('pkg-tags'); + }} + /> + ); + } + + if (step === 'pkg-tags') { + return ( + (v ? null : 'Required')} + onSubmit={(tagStr) => { + const tags = tagStr + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + setData((d) => ({ ...d, tags })); + setStep('fetch-files'); + }} + /> + ); + } + + if (step === 'fetch-files') { + return ( + { + const files = await fetchLuaFiles(data.repo!, data.tag!); + if (files.length === 0) throw new Error('No .lua files found at this tag.'); + setFetchedFiles(files); + setStep('files'); + }} + onError={handleError} + /> + ); + } + + if (step === 'files') { + return ( + { + setData((d) => ({ ...d, selectedFiles })); + setStep('targets'); + }} + /> + ); + } + + if (step === 'targets') { + return ( + { + setData((d) => ({ ...d, targetMap })); + setStep('require'); + }} + /> + ); + } + + if (step === 'require') { + const firstTarget = Object.values(data.targetMap ?? {})[0] ?? `lib/${data.id}.lua`; + const suggested = firstTarget.replace(/\.lua$/, '').replace(/\//g, '.'); + return ( + (v ? null : 'Required')} + onSubmit={(req) => { + const example = `local ${data.id!.replace(/[.-]/g, '_')} = require('${req}')`; + setData((d) => ({ ...d, require: req, example })); + setStep('subpkgs'); + }} + /> + ); + } + + if (step === 'subpkgs') { + return ( + { + setData((d) => ({ ...d, subpackages })); + setStep('fetch-checksums'); + }} + /> + ); + } + + if (step === 'fetch-checksums') { + const fileList = (data.selectedFiles ?? []).map((name) => ({ name, url: data.baseUrl! + name })); + return ( + { + const files: FileEntry[] = (data.selectedFiles ?? []).map((name) => ({ + name, + target: data.targetMap![name]!, + sha256: checksums[name]!, + })); + const fullData = { ...data, files } as FormData; + setData(fullData); + setOutputJson(JSON.stringify(buildPackageJson(fullData), null, 2)); + setStep('review'); + }} + onError={handleError} + /> + ); + } + + if (step === 'review') { + return ( + setStep('write')} + onAbort={() => { + setErrorMsg('Aborted.'); + setStep('error'); + }} + /> + ); + } + + if (step === 'write') { + return ( + { + writeFileSync(join(packagesDir, `${data.id}.json`), outputJson + '\n', 'utf8'); + const result = spawnSync(process.execPath, [join(root, 'scripts', 'generate-registry.mjs')], { + cwd: root, + stdio: 'pipe', + encoding: 'utf8', + }); + if (result.status !== 0) throw new Error(result.stderr || 'generate-registry.mjs failed'); + setStep('done'); + }} + onError={handleError} + /> + ); + } + + if (step === 'done') return ; + + return ( + + + ✖ Error + + {errorMsg} + + ); +} + +const { waitUntilExit } = render(, { alternateScreen: true }); +await waitUntilExit(); diff --git a/cli/scripts/remove-package.tsx b/cli/scripts/remove-package.tsx new file mode 100644 index 00000000..cfedf6f9 --- /dev/null +++ b/cli/scripts/remove-package.tsx @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * Interactive wizard to remove a package from the Feather catalog. + * + * Usage (from repo root): + * npm run package:remove + */ + +import { render, Text, Box, useInput, useApp } from 'ink'; +import { useState, useEffect } from 'react'; +import { readdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { root, packagesDir, SelectStep, AutoStep } from './wizard-shared.js'; + +const TITLE = 'feather package:remove'; + +function ConfirmStep({ id, onConfirm, onAbort }: { id: string; onConfirm: () => void; onAbort: () => void }) { + useInput((input, key) => { + if (input === 'y' || input === 'Y' || key.return) onConfirm(); + if (input === 'n' || input === 'N' || key.escape) onAbort(); + }); + + return ( + + {' '}{TITLE} + + {' Remove '} + {id} + {' from the catalog?'} + + + {' This deletes packages/'}{id}{'.json and regenerates the registry.'} + + + {' y/Enter to confirm · n/Esc to abort'} + + + ); +} + +function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { + useEffect(() => { + const t = setTimeout(onExit, 300); + return () => clearTimeout(t); + }, [onExit]); + + return ( + + ✔ Done! + {' '}packages/{id}.json removed + {' '}Registry regenerated + + Commit the deletions and cli/src/generated/registry.json + + + ); +} + +type Step = 'pick' | 'confirm' | 'remove' | 'done' | 'error'; + +function Wizard() { + const { exit } = useApp(); + const [step, setStep] = useState('pick'); + const [id, setId] = useState(''); + const [errorMsg, setErrorMsg] = useState(''); + + const handleError = (msg: string) => { setErrorMsg(msg); setStep('error'); }; + + if (step === 'pick') { + const ids = readdirSync(packagesDir) + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace(/\.json$/, '')) + .sort(); + + return ( + { setId(picked); setStep('confirm'); }} + /> + ); + } + + if (step === 'confirm') { + return ( + setStep('remove')} + onAbort={() => { setErrorMsg('Aborted.'); setStep('error'); }} + /> + ); + } + + if (step === 'remove') { + return ( + { + rmSync(join(packagesDir, `${id}.json`)); + const result = spawnSync(process.execPath, [join(root, 'scripts', 'generate-registry.mjs')], { + cwd: root, stdio: 'pipe', encoding: 'utf8', + }); + if (result.status !== 0) throw new Error(result.stderr || 'generate-registry.mjs failed'); + setStep('done'); + }} + onError={handleError} + /> + ); + } + + if (step === 'done') return ; + + return ( + + ✖ Error + {errorMsg} + + ); +} + +const { waitUntilExit } = render(); +await waitUntilExit(); diff --git a/cli/scripts/tsconfig.json b/cli/scripts/tsconfig.json new file mode 100644 index 00000000..69aec6df --- /dev/null +++ b/cli/scripts/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["."] +} diff --git a/cli/scripts/update-package.tsx b/cli/scripts/update-package.tsx new file mode 100644 index 00000000..6f0bcc76 --- /dev/null +++ b/cli/scripts/update-package.tsx @@ -0,0 +1,474 @@ +#!/usr/bin/env node +/** + * Interactive wizard to update an existing package in the Feather catalog. + * + * Usage (from repo root): + * npm run package:update + * + * Loads packages/.json, pre-fills every field so you can change only + * what's wrong, re-computes checksums, and overwrites the file. + */ + +import { render, Text, Box, useApp } from 'ink'; +import { useState, useEffect } from 'react'; +import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { + root, + packagesDir, + type FormData, + type FileEntry, + TextInputStep, + SelectStep, + MultiSelectStep, + AutoStep, + TargetsStep, + SubpackagesStep, + ReviewStep, + ChecksumStep, + fetchRepoMeta, + fetchCommitSha, + fetchLuaFiles, + buildPackageJson, +} from './wizard-shared.js'; + +type Step = + | 'pick' + | 'repo' + | 'fetch-tags' + | 'tag' + | 'resolve-commit' + | 'trust' + | 'description' + | 'pkg-tags' + | 'fetch-files' + | 'files' + | 'targets' + | 'require' + | 'subpkgs' + | 'fetch-checksums' + | 'review' + | 'write' + | 'done' + | 'error'; + +interface RawPackage { + trust?: string; + description?: string; + tags?: string[]; + homepage?: string; + license?: string; + source?: { repo?: string; tag?: string; commitSha?: string; baseUrl?: string }; + install?: { files?: Array<{ name: string; sha256: string; target: string }> }; + require?: string; + example?: string; + subpackages?: Record; +} + +const TITLE = 'feather package:update'; +const TOTAL = 12; + +function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { + useEffect(() => { + const t = setTimeout(onExit, 300); + return () => clearTimeout(t); + }, [onExit]); + + return ( + + + ✔ Done! + + + {' '}packages/{id}.json updated + + {' '}Registry regenerated + + Commit packages/{id}.json and cli/src/generated/registry.json + + + ); +} + +function Wizard() { + const { exit } = useApp(); + const [step, setStep] = useState('pick'); + const [data, setData] = useState>({}); + const [fetchedTags, setFetchedTags] = useState([]); + const [fetchedFiles, setFetchedFiles] = useState([]); + const [errorMsg, setErrorMsg] = useState(''); + const [outputJson, setOutputJson] = useState(''); + + // pre-selection state from the loaded package + const [fetchedLabels, setFetchedLabels] = useState([]); + const [initialTagIndex, setInitialTagIndex] = useState(0); + const [initialFileSelected, setInitialFileSelected] = useState | undefined>(undefined); + const [initialTargets, setInitialTargets] = useState | undefined>(undefined); + const [initialSubpkgs, setInitialSubpkgs] = useState | undefined>(undefined); + + const handleError = (msg: string) => { + setErrorMsg(msg); + setStep('error'); + }; + + // Step 1: pick which package to update + if (step === 'pick') { + const ids = readdirSync(packagesDir) + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace(/\.json$/, '')) + .sort(); + + return ( + { + const raw: RawPackage = JSON.parse(readFileSync(join(packagesDir, `${id}.json`), 'utf8')); + const existingFiles = raw.install?.files ?? []; + setInitialTargets(Object.fromEntries(existingFiles.map((f) => [f.name, f.target]))); + setInitialSubpkgs(raw.subpackages ?? undefined); + setData({ + id, + repo: raw.source?.repo ?? '', + homepage: raw.homepage ?? `https://github.com/${raw.source?.repo ?? ''}`, + license: raw.license ?? '', + tag: raw.source?.tag ?? '', + commitSha: raw.source?.commitSha ?? '', + baseUrl: raw.source?.baseUrl ?? '', + trust: (raw.trust as FormData['trust']) ?? 'known', + description: raw.description ?? '', + tags: raw.tags ?? [], + selectedFiles: existingFiles.map((f) => f.name), + targetMap: Object.fromEntries(existingFiles.map((f) => [f.name, f.target])), + require: raw.require ?? '', + example: raw.example ?? '', + subpackages: raw.subpackages, + }); + setStep('repo'); + }} + /> + ); + } + + // Step 2: repo (pre-filled) + if (step === 'repo') { + return ( + { + if (!v) return 'Required'; + if (!/^[^/]+\/[^/]+$/.test(v)) return 'Must be owner/repo format'; + return null; + }} + onSubmit={(repo) => { + setData((d) => ({ ...d, repo, homepage: `https://github.com/${repo}` })); + setStep('fetch-tags'); + }} + /> + ); + } + + // Step 3: fetch tags + license + if (step === 'fetch-tags') { + return ( + { + const { tags, branches, defaultBranch, license } = await fetchRepoMeta(data.repo!); + if (tags.length === 0 && branches.length === 0) throw new Error('No tags or branches found.'); + const orderedBranches = [defaultBranch, ...branches.filter((b) => b !== defaultBranch)]; + const values = [...tags, ...orderedBranches]; + const labels = [...tags.map((t) => t), ...orderedBranches.map((b) => `⎇ ${b}`)]; + setFetchedTags(values); + setFetchedLabels(labels); + // pre-position cursor at the currently pinned tag/branch + const idx = values.indexOf(data.tag ?? ''); + setInitialTagIndex(idx >= 0 ? idx : 0); + setData((d) => ({ ...d, license })); + setStep('tag'); + }} + onError={handleError} + /> + ); + } + + // Step 4: select tag or branch (cursor starts at current pin) + if (step === 'tag') { + return ( + { + setData((d) => ({ ...d, tag })); + setStep('resolve-commit'); + }} + /> + ); + } + + if (step === 'resolve-commit') { + return ( + { + const commitSha = await fetchCommitSha(data.repo!, data.tag!); + setData((d) => ({ + ...d, + commitSha, + baseUrl: `https://raw.githubusercontent.com/${d.repo}/${commitSha}/`, + })); + setStep('trust'); + }} + onError={handleError} + /> + ); + } + + // Step 5: trust level + if (step === 'trust') { + const currentIdx = ['verified', 'known'].indexOf(data.trust ?? 'known'); + return ( + = 0 ? currentIdx : 0} + onSelect={(trust) => { + setData((d) => ({ ...d, trust: trust as FormData['trust'] })); + setStep('description'); + }} + /> + ); + } + + // Step 6: description + if (step === 'description') { + return ( + (v ? null : 'Required')} + onSubmit={(description) => { + setData((d) => ({ ...d, description })); + setStep('pkg-tags'); + }} + /> + ); + } + + // Step 7: tags + if (step === 'pkg-tags') { + return ( + (v ? null : 'Required')} + onSubmit={(tagStr) => { + const tags = tagStr + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + setData((d) => ({ ...d, tags })); + setStep('fetch-files'); + }} + /> + ); + } + + // Step 8: fetch files for the (possibly new) tag + if (step === 'fetch-files') { + return ( + { + const files = await fetchLuaFiles(data.repo!, data.tag!); + if (files.length === 0) throw new Error('No .lua files found at this tag.'); + setFetchedFiles(files); + // pre-select files that were already installed + const existing = new Set(data.selectedFiles ?? []); + setInitialFileSelected( + new Set( + files + .map((f, i) => ({ f, i })) + .filter(({ f }) => existing.has(f)) + .map(({ i }) => i), + ), + ); + setStep('files'); + }} + onError={handleError} + /> + ); + } + + // Step 9: multi-select files + if (step === 'files') { + return ( + { + setData((d) => ({ ...d, selectedFiles })); + setStep('targets'); + }} + /> + ); + } + + // Step 10: target paths (pre-filled from existing package) + if (step === 'targets') { + return ( + { + setData((d) => ({ ...d, targetMap })); + setStep('require'); + }} + /> + ); + } + + // Step 11: require path + if (step === 'require') { + return ( + (v ? null : 'Required')} + onSubmit={(req) => { + const example = `local ${data.id!.replace(/[.-]/g, '_')} = require('${req}')`; + setData((d) => ({ ...d, require: req, example })); + setStep('subpkgs'); + }} + /> + ); + } + + // Step 12: submodules + if (step === 'subpkgs') { + return ( + { + setData((d) => ({ ...d, subpackages })); + setStep('fetch-checksums'); + }} + /> + ); + } + + // Step 13: compute checksums (always fresh — tag may have changed) + if (step === 'fetch-checksums') { + const fileList = (data.selectedFiles ?? []).map((name) => ({ name, url: data.baseUrl! + name })); + return ( + { + const files: FileEntry[] = (data.selectedFiles ?? []).map((name) => ({ + name, + target: data.targetMap![name]!, + sha256: checksums[name]!, + })); + const fullData = { ...data, files } as FormData; + setData(fullData); + setOutputJson(JSON.stringify(buildPackageJson(fullData), null, 2)); + setStep('review'); + }} + onError={handleError} + /> + ); + } + + // Step 14: review + if (step === 'review') { + return ( + setStep('write')} + onAbort={() => { + setErrorMsg('Aborted.'); + setStep('error'); + }} + /> + ); + } + + // Step 14: write + regenerate + if (step === 'write') { + return ( + { + writeFileSync(join(packagesDir, `${data.id}.json`), outputJson + '\n', 'utf8'); + const result = spawnSync(process.execPath, [join(root, 'scripts', 'generate-registry.mjs')], { + cwd: root, + stdio: 'pipe', + encoding: 'utf8', + }); + if (result.status !== 0) throw new Error(result.stderr || 'generate-registry.mjs failed'); + setStep('done'); + }} + onError={handleError} + /> + ); + } + + if (step === 'done') return ; + + return ( + + + ✖ Error + + {errorMsg} + + ); +} + +const { waitUntilExit } = render(, { alternateScreen: true }); +await waitUntilExit(); diff --git a/cli/scripts/wizard-shared.tsx b/cli/scripts/wizard-shared.tsx new file mode 100644 index 00000000..6cb52f26 --- /dev/null +++ b/cli/scripts/wizard-shared.tsx @@ -0,0 +1,451 @@ +/** + * Shared primitives for add-package and update-package wizards. + */ + +import { Text, Box, useInput } from 'ink'; +import { useState, useEffect, type ReactNode } from 'react'; +import { createHash } from 'node:crypto'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { useTextInput } from '../src/hooks/use-text-input.js'; +import { + CursorText, + Spinner, + Header, + Hint, + TextInputStep, + SelectStep, + MultiSelectStep, + AutoStep, + TargetsStep, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore +} from '../src/ui/components.js'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { GH_HEADERS, fetchCommitSha, fetchLuaFiles } from '../src/lib/github.js'; + +export { CursorText, Spinner, Header, Hint, TextInputStep, SelectStep, MultiSelectStep, AutoStep, TargetsStep }; +export { GH_HEADERS, fetchCommitSha, fetchLuaFiles }; + +export const __dirname = dirname(fileURLToPath(import.meta.url)); +export const root = resolve(__dirname, '../..'); +export const packagesDir = join(root, 'packages'); + +export interface FileEntry { + name: string; + url?: string; + target: string; + sha256: string; +} + +export interface FormData { + id: string; + repo: string; + homepage: string; + license: string; + tag: string; + commitSha: string; + baseUrl: string; + trust: 'verified' | 'known'; + description: string; + tags: string[]; + selectedFiles: string[]; + targetMap: Record; + require: string; + example: string; + files: FileEntry[]; + subpackages?: Record; +} + +export type InkKey = Parameters[0]>[1]; + +interface ReviewStepProps { + stepNum: number; + total: number; + json: string; + onConfirm: () => void; + onAbort: () => void; + title?: string; +} + +export function ReviewStep({ stepNum, total, json, onConfirm, onAbort, title }: ReviewStepProps) { + useInput((input, key) => { + if (key.return || input === 'y' || input === 'Y') onConfirm(); + if (input === 'n' || input === 'N' || key.escape) onAbort(); + }); + + return ( + +
+ {' '}Review generated package + + {json.split('\n').map((line, i) => ( + + {' '} + {line} + + ))} + + + {' '}y/Enter to write · n/Esc to abort + + + ); +} + +interface ChecksumStepProps { + files: Array<{ name: string; url: string }>; + onDone: (checksums: Record) => void; + onError: (msg: string) => void; +} + +export function ChecksumStep({ files, onDone, onError }: ChecksumStepProps) { + const [done, setDone] = useState>({}); + const [current, setCurrent] = useState(files[0]?.name ?? ''); + + useEffect(() => { + const run = async () => { + const results: Record = {}; + for (const f of files) { + setCurrent(f.name); + const res = await fetch(f.url); + if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${f.url}`); + const buf = await res.arrayBuffer(); + const hash = createHash('sha256').update(Buffer.from(buf)).digest('hex'); + results[f.name] = hash; + setDone((d) => ({ ...d, [f.name]: hash })); + } + onDone(results); + }; + run().catch((err: Error) => onError(err.message)); + }, []); + + return ( + + Computing SHA-256 checksums… + {files.map((f) => { + const sha = done[f.name]; + const isActive = f.name === current && !sha; + return ( + + {sha ? ( + + {' ✔ '} + {f.name} + + ) : isActive ? ( + + {' '} + + + ) : ( + + {' ○ '} + {f.name} + + )} + + ); + })} + + ); +} + +interface YesNoStepProps { + stepNum: number; + total: number; + title?: string; + label: string; + hint?: string; + children?: ReactNode; + onYes: () => void; + onNo: () => void; +} + +export function YesNoStep({ stepNum, total, title, label, hint, children, onYes, onNo }: YesNoStepProps) { + useInput((input, key) => { + if (input === 'y' || input === 'Y') onYes(); + else if (input === 'n' || input === 'N' || key.return || key.escape) onNo(); + }); + + return ( + +
+ + {' '} + {label} + + {hint && {hint}} + {children} + + {' y = yes · n/Enter = no'} + + + ); +} + +type SubPkgPhase = 'ask' | 'id' | 'files' | 'require' | 'add-more'; + +interface SubpackagesStepProps { + stepNum: number; + total: number; + title?: string; + selectedFiles: string[]; + initialSubpackages?: Record; + onSubmit: (subpackages: Record) => void; +} + +export function SubpackagesStep({ + stepNum, + total, + title, + selectedFiles, + initialSubpackages, + onSubmit, +}: SubpackagesStepProps) { + const hasInitial = Object.keys(initialSubpackages ?? {}).length > 0; + const [phase, setPhase] = useState(hasInitial ? 'add-more' : 'ask'); + const [accumulated, setAccumulated] = useState>( + initialSubpackages ?? {}, + ); + const [currentId, setCurrentId] = useState(''); + const [currentFiles, setCurrentFiles] = useState([]); + const idInput = useTextInput(''); + const reqInput = useTextInput(''); + const [fileCursor, setFileCursor] = useState(0); + const [fileSelected, setFileSelected] = useState>(new Set()); + const [idError, setIdError] = useState(null); + const [reqError, setReqError] = useState(null); + + useInput((input, key) => { + if (phase === 'ask' || phase === 'add-more') { + if (input === 'y' || input === 'Y') { + idInput.reset(''); + setIdError(null); + setPhase('id'); + } else if (input === 'n' || input === 'N' || key.return || key.escape) { + onSubmit(accumulated); + } + } else if (phase === 'id') { + if (key.return) { + const val = idInput.value.trim(); + if (!val) { + setIdError('Required'); + return; + } + setCurrentId(val); + setFileSelected(new Set()); + setFileCursor(0); + setPhase('files'); + return; + } + if (idInput.handleKey(input, key)) setIdError(null); + } else if (phase === 'files') { + if (key.upArrow) setFileCursor((c) => Math.max(0, c - 1)); + if (key.downArrow) setFileCursor((c) => Math.min(selectedFiles.length - 1, c + 1)); + if (input === ' ') { + setFileSelected((s) => { + const next = new Set(s); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + next.has(fileCursor) ? next.delete(fileCursor) : next.add(fileCursor); + return next; + }); + } + if (key.return) { + const chosen = selectedFiles.filter((_, i) => fileSelected.has(i)); + if (chosen.length === 0) return; + setCurrentFiles(chosen); + const suggested = chosen[0]!.replace(/\.lua$/, '').replace(/\//g, '.'); + reqInput.reset(suggested); + setReqError(null); + setPhase('require'); + } + } else if (phase === 'require') { + if (key.return) { + const val = reqInput.value.trim(); + if (!val) { + setReqError('Required'); + return; + } + setAccumulated((a) => ({ ...a, [currentId]: { files: currentFiles, require: val } })); + setPhase('add-more'); + return; + } + if (reqInput.handleKey(input, key)) setReqError(null); + } + }); + + const accKeys = Object.keys(accumulated); + + if (phase === 'ask') { + return ( + +
+ {' '}Define submodules? + Like hump.camera, hump.timer — named subsets of the package files + + {' y = define submodules · n/Enter = skip'} + + + ); + } + + if (phase === 'add-more') { + return ( + +
+ {' '}Add another submodule? + + {accKeys.map((k) => ( + + {' ✔ '} + {k}{' '} + + ({accumulated[k]!.files.length} file{accumulated[k]!.files.length === 1 ? '' : 's'}) + + + ))} + + + {' y = add another · n/Enter = done'} + + + ); + } + + if (phase === 'id') { + return ( + +
+ {' '}Submodule ID + e.g. hump.camera, hump.timer + + {' '} + + + {idError && ( + + + {' ✖ '} + {idError} + + + )} + + {' ←→ move · Backspace/Delete edit · Enter confirm'} + + + ); + } + + if (phase === 'files') { + return ( + +
+ + {' '}Files for {currentId} + + Select which files belong to this submodule + + {selectedFiles.map((f, i) => ( + + + {' '} + {i === fileCursor ? '❯ ' : ' '} + {fileSelected.has(i) ? '◉ ' : '○ '} + {f} + + + ))} + + + {' ↑↓ navigate · Space toggle · Enter confirm'} + + + ); + } + + return ( + +
+ + {' '}Require path for {currentId} + + e.g. lib.hump.camera + + {' '} + + + {reqError && ( + + + {' ✖ '} + {reqError} + + + )} + + {' ←→ move · Backspace/Delete edit · Enter confirm'} + + + ); +} + +export interface RepoMeta { + tags: string[]; + branches: string[]; + defaultBranch: string; + license: string; +} + +export async function fetchRepoMeta(repo: string): Promise { + const [tagsRes, repoRes, branchesRes] = await Promise.all([ + fetch(`https://api.github.com/repos/${repo}/tags?per_page=20`, { headers: GH_HEADERS }), + fetch(`https://api.github.com/repos/${repo}`, { headers: GH_HEADERS }), + fetch(`https://api.github.com/repos/${repo}/branches?per_page=30`, { headers: GH_HEADERS }), + ]); + if (!tagsRes.ok) throw new Error(`GitHub API ${tagsRes.status} for ${repo}/tags`); + if (!repoRes.ok) throw new Error(`GitHub API ${repoRes.status} for ${repo}`); + if (!branchesRes.ok) throw new Error(`GitHub API ${branchesRes.status} for ${repo}/branches`); + const [tagsData, repoData, branchesData] = await Promise.all([ + tagsRes.json() as Promise>, + repoRes.json() as Promise<{ license?: { spdx_id?: string }; default_branch?: string }>, + branchesRes.json() as Promise>, + ]); + return { + tags: tagsData.map((t) => t.name), + branches: branchesData.map((b) => b.name), + defaultBranch: repoData.default_branch ?? 'main', + license: repoData.license?.spdx_id ?? 'unknown', + }; +} + +export function buildPackageJson(data: FormData): object { + const obj: Record = { + type: 'love2d-library', + trust: data.trust, + description: data.description, + tags: data.tags, + homepage: data.homepage, + license: data.license, + source: { + repo: data.repo, + tag: data.tag, + commitSha: data.commitSha, + baseUrl: `https://raw.githubusercontent.com/${data.repo}/${data.commitSha}/`, + }, + install: { + files: data.files.map((f) => ({ name: f.name, sha256: f.sha256, target: f.target })), + }, + require: data.require, + example: data.example, + }; + if (data.subpackages && Object.keys(data.subpackages).length > 0) { + obj.subpackages = data.subpackages; + } + return obj; +} diff --git a/cli/src/commands/build-vendor.ts b/cli/src/commands/build-vendor.ts new file mode 100644 index 00000000..800d4fa2 --- /dev/null +++ b/cli/src/commands/build-vendor.ts @@ -0,0 +1,126 @@ +import { fail } from '../lib/command.js'; +import { + createSpinner, + printBlank, + printJson, + printKeyValues, + printStatus, + printTable, + style, +} from '../lib/output.js'; +import { + addBuildVendors, + buildVendorTargets, + isBuildVendorTarget, + listBuildVendors, + type BuildVendorTargetInput, +} from '../lib/build/vendor.js'; + +export type BuildVendorCommandOptions = { + dir?: string; + config?: string; + vendorDir?: string; + ref?: string; + webRef?: string; + androidRef?: string; + iosRef?: string; + force?: boolean; + dryRun?: boolean; + json?: boolean; + configUpdate?: boolean; +}; + +export type BuildVendorListCommandOptions = { + dir?: string; + config?: string; + vendorDir?: string; + json?: boolean; +}; + +export async function buildVendorAddCommand(targetValues: string[], opts: BuildVendorCommandOptions = {}): Promise { + const invalid = targetValues.find((target) => !isBuildVendorTarget(target)); + if (invalid) { + fail(`Unknown build vendor target: ${invalid}`, { details: [`Available: ${buildVendorTargets.join(', ')}`] }); + } + const targets = targetValues as BuildVendorTargetInput[]; + const spinner = opts.json || opts.dryRun ? null : createSpinner('Fetching build vendors…').start(); + try { + const result = await addBuildVendors(targets, { + projectDir: opts.dir, + configPath: opts.config, + vendorDir: opts.vendorDir, + ref: opts.ref, + webRef: opts.webRef, + androidRef: opts.androidRef, + iosRef: opts.iosRef, + force: opts.force, + dryRun: opts.dryRun, + updateConfig: opts.configUpdate, + }); + + if (opts.json) { + printJson(result); + return; + } + + if (opts.dryRun) { + printStatus('info', 'Build vendor plan'); + } else { + spinner?.succeed('Build vendors ready'); + } + printBlank(); + printKeyValues([ + ['Project', result.projectDir], + ['Config', result.configPath], + ['LÖVE', result.loveVersion], + ]); + printBlank(); + printTable({ + columns: [ + { key: 'target', label: 'Target' }, + { key: 'ref', label: 'Ref' }, + { key: 'path', label: 'Path' }, + { key: 'config', label: 'Config' }, + ], + rows: result.vendors.map((vendor) => ({ + target: vendor.target, + ref: vendor.ref, + path: vendor.relativePath, + config: vendor.configUpdated ? 'updated' : opts.dryRun && opts.configUpdate !== false ? 'planned' : 'unchanged', + })), + }); + } catch (err) { + spinner?.fail((err as Error).message); + fail((err as Error).message, { silent: Boolean(spinner) }); + } +} + +export function buildVendorListCommand(opts: BuildVendorListCommandOptions = {}): void { + const result = listBuildVendors({ + projectDir: opts.dir, + configPath: opts.config, + vendorDir: opts.vendorDir, + }); + + if (opts.json) { + printJson(result); + return; + } + + printStatus('info', `Build vendors for ${style.heading(result.projectDir)}`); + printBlank(); + printTable({ + columns: [ + { key: 'target', label: 'Target' }, + { key: 'configured', label: 'Configured' }, + { key: 'status', label: 'Status' }, + { key: 'path', label: 'Path' }, + ], + rows: result.vendors.map((vendor) => ({ + target: vendor.target, + configured: vendor.configured ? 'yes' : 'no', + status: vendor.valid ? 'ready' : vendor.exists ? 'incomplete' : 'missing', + path: vendor.configuredPath ?? vendor.relativePath, + })), + }); +} diff --git a/cli/src/commands/build.ts b/cli/src/commands/build.ts new file mode 100644 index 00000000..cab23324 --- /dev/null +++ b/cli/src/commands/build.ts @@ -0,0 +1,103 @@ +import { fail } from '../lib/command.js'; +import { + printBlank, + printJson, + printKeyValues, + printMuted, + printStatus, + printTable, + createSpinner, + style, +} from '../lib/output.js'; +import { + buildTargets, + isBuildTarget, + type BuildTarget, +} from '../lib/build/config.js'; +import { describeArtifact, runBuild } from '../lib/build/build.js'; + +export type BuildCommandOptions = { + dir?: string; + config?: string; + outDir?: string; + name?: string; + version?: string; + clean?: boolean; + dryRun?: boolean; + json?: boolean; + allowUnsafe?: boolean; + release?: boolean; + noCache?: boolean; + debugger?: boolean; + runtimeConfig?: string; + verbose?: boolean; +}; + +export async function buildCommand(targetValue: string, opts: BuildCommandOptions = {}): Promise { + if (!isBuildTarget(targetValue)) { + fail(`Unknown build target: ${targetValue}`, { details: [`Available: ${buildTargets.join(', ')}`] }); + } + const target: BuildTarget = targetValue; + const verbose = Boolean(opts.verbose && !opts.json && !opts.dryRun); + const spinner = opts.json || opts.dryRun || verbose ? null : createSpinner(`Building ${target}…`).start(); + if (verbose) { + printStatus('info', `Building ${style.heading(target)} in verbose mode`); + } + const result = runBuild({ + target, + projectDir: opts.dir, + configPath: opts.config, + outDir: opts.outDir, + name: opts.name, + version: opts.version, + clean: opts.clean, + dryRun: opts.dryRun, + allowUnsafe: opts.allowUnsafe, + release: opts.release, + noCache: opts.noCache, + debugger: opts.debugger, + runtimeConfigPath: opts.runtimeConfig, + verbose, + log: verbose ? printMuted : undefined, + }); + + if (!result.ok) { + spinner?.fail(result.error); + fail(result.error, { silent: Boolean(spinner) }); + } + + if (opts.json) { + printJson(result); + return; + } + + if (result.dryRun) { + printStatus('info', `Build plan for ${style.heading(result.target)}`); + } else { + spinner?.succeed(`Built ${result.target}`); + } + + printBlank(); + printKeyValues([ + ['Project', result.projectDir], + ['Output', result.outDir], + ['Name', result.name], + ['Version', result.version], + ['Files', result.files.length], + ]); + printBlank(); + printTable({ + columns: [ + { key: 'type', label: 'Type' }, + { key: 'path', label: 'Path' }, + ], + rows: result.artifacts.map((artifact) => ({ + type: artifact.type, + path: result.dryRun ? artifact.path : describeArtifact(artifact), + })), + }); + if (result.manifestPath && !result.dryRun) { + printBlank(); + printMuted(`Manifest: ${result.manifestPath}`); + } +} diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index 2abe0aeb..db789bdf 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -1,408 +1,2 @@ -import { existsSync, readFileSync, readdirSync } from "node:fs"; -import { join, resolve } from "node:path"; -import { createConnection } from "node:net"; -import { spawnSync } from "node:child_process"; -import chalk from "chalk"; -import { findLoveBinary, getLoveVersion } from "../lib/love.js"; -import { loadConfig } from "../lib/config.js"; -import { normalizeInstallDir } from "../lib/install.js"; +export * from './doctor/index.js'; -type Severity = "pass" | "warn" | "fail" | "info"; - -type DoctorCheck = { - group: string; - label: string; - severity: Severity; - detail?: string; - fix?: string; -}; - -export type DoctorOptions = { - installDir?: string; - host?: string; - port?: number; - json?: boolean; -}; - -const severityOrder: Record = { - fail: 0, - warn: 1, - info: 2, - pass: 3, -}; - -function add( - checks: DoctorCheck[], - group: string, - label: string, - severity: Severity, - detail?: string, - fix?: string, -): void { - checks.push({ group, label, severity, detail, fix }); -} - -function icon(severity: Severity): string { - if (severity === "pass") return chalk.green("✔"); - if (severity === "warn") return chalk.yellow("!"); - if (severity === "fail") return chalk.red("✖"); - return chalk.cyan("i"); -} - -function colorLabel(severity: Severity, label: string): string { - if (severity === "pass") return chalk.white(label); - if (severity === "warn") return chalk.yellow(label); - if (severity === "fail") return chalk.red(label); - return chalk.white(label); -} - -function portReachable(port: number, host = "127.0.0.1", timeout = 1000): Promise { - return new Promise((resolve) => { - const sock = createConnection({ port, host }); - const timer = setTimeout(() => { - sock.destroy(); - resolve(false); - }, timeout); - sock.once("connect", () => { - clearTimeout(timer); - sock.destroy(); - resolve(true); - }); - sock.once("error", () => { - clearTimeout(timer); - resolve(false); - }); - }); -} - -function commandVersion(command: string, args: string[]): string | null { - const result = spawnSync(command, args, { encoding: "utf8" }); - if (result.error || result.status !== 0) return null; - return (result.stdout || result.stderr).trim().split("\n")[0] ?? null; -} - -function readIfExists(path: string): string | null { - if (!existsSync(path)) return null; - return readFileSync(path, "utf8"); -} - -function uncommentedLua(src: string): string { - return src - .split("\n") - .filter((line) => !line.trimStart().startsWith("--")) - .join("\n"); -} - -function parseManagedValue(src: string, key: string): string | null { - return src.match(new RegExp(`^--\\s*${key}:\\s*(.+)$`, "m"))?.[1]?.trim() ?? null; -} - -function luaBoolEnabled(src: string, key: string): boolean { - return new RegExp(`${key}\\s*=\\s*true\\b`).test(src); -} - -function hasConfigArrayValue(src: string, key: string, value: string): boolean { - const match = src.match(new RegExp(`${key}\\s*=\\s*\\{([\\s\\S]*?)\\}`)); - return match ? new RegExp(`["']${value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`).test(match[1]) : false; -} - -function isWeakApiKey(value: unknown): boolean { - return typeof value !== "string" || value.trim().length < 24 || value === "change-me" || value === "dev"; -} - -function findInstalledPluginDirs(root: string): string[] { - if (!existsSync(root)) return []; - - const found: string[] = []; - for (const entry of readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const dir = join(root, entry.name); - if (existsSync(join(dir, "manifest.lua"))) { - found.push(dir); - } else { - found.push(...findInstalledPluginDirs(dir)); - } - } - return found; -} - -function readPluginId(pluginDir: string): string | null { - const src = readIfExists(join(pluginDir, "manifest.lua")); - return src?.match(/id\s*=\s*"([^"]+)"/)?.[1] ?? null; -} - -function renderReport(checks: DoctorCheck[], projectDir: string): void { - console.log(chalk.bold("\nFeather doctor\n")); - console.log(chalk.dim(`Project: ${projectDir}\n`)); - - const groups = [...new Set(checks.map((check) => check.group))]; - for (const group of groups) { - console.log(chalk.bold(group)); - for (const check of checks.filter((item) => item.group === group).sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity])) { - console.log(` ${icon(check.severity)} ${colorLabel(check.severity, check.label)}${check.detail ? chalk.dim(` ${check.detail}`) : ""}`); - if (check.fix) console.log(chalk.dim(` → ${check.fix}`)); - } - console.log(); - } - - const failures = checks.filter((check) => check.severity === "fail"); - const warnings = checks.filter((check) => check.severity === "warn"); - const passed = checks.filter((check) => check.severity === "pass"); - - if (failures.length > 0) { - console.log(chalk.red.bold(`Doctor found ${failures.length} blocker${failures.length === 1 ? "" : "s"}.`)); - } else if (warnings.length > 0) { - console.log(chalk.yellow.bold(`Doctor passed with ${warnings.length} warning${warnings.length === 1 ? "" : "s"}.`)); - } else { - console.log(chalk.green.bold("Doctor found no problems.")); - } - console.log(chalk.dim(`${passed.length} passed, ${warnings.length} warnings, ${failures.length} failures.\n`)); -} - -export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}): Promise { - const projectDir = gamePath ? resolve(gamePath) : process.cwd(); - const installDir = normalizeInstallDir(opts.installDir ?? "feather"); - const checks: DoctorCheck[] = []; - - const nodeVersion = process.versions.node; - const [major] = nodeVersion.split(".").map(Number); - add( - checks, - "Environment", - "Node.js", - major >= 18 ? "pass" : "fail", - `v${nodeVersion}`, - major >= 18 ? undefined : "Install Node.js 18 or newer.", - ); - - const npmVersion = commandVersion("npm", ["--version"]); - add( - checks, - "Environment", - "npm", - npmVersion ? "pass" : "warn", - npmVersion ? `v${npmVersion}` : "not found", - npmVersion ? undefined : "Install npm if you want to use npm run feather or global CLI installs.", - ); - - let lovePath: string | null = null; - try { - lovePath = findLoveBinary(); - const ver = getLoveVersion(lovePath); - add(checks, "Environment", "LÖVE binary", "pass", `${lovePath} (${ver})`); - } catch { - add( - checks, - "Environment", - "LÖVE binary", - "fail", - "not found", - "Install from https://love2d.org or set LOVE_BIN to the executable path.", - ); - } - - add(checks, "Environment", "Platform", "info", `${process.platform} ${process.arch}`); - - const hasProjectDir = existsSync(projectDir); - add( - checks, - "Project", - "Project directory", - hasProjectDir ? "pass" : "fail", - projectDir, - hasProjectDir ? undefined : "Pass the game directory to `feather doctor `.", - ); - - const mainPath = join(projectDir, "main.lua"); - const mainSource = readIfExists(mainPath); - const hasMain = mainSource !== null; - add( - checks, - "Project", - "main.lua", - hasMain ? "pass" : "fail", - hasMain ? mainPath : "missing", - hasMain ? undefined : "Run doctor from a LÖVE project root or pass the project directory.", - ); - - const configPath = join(projectDir, "feather.config.lua"); - const configSource = readIfExists(configPath); - let config: ReturnType = null; - if (configSource) { - try { - config = loadConfig(projectDir); - add(checks, "Project", "feather.config.lua", "pass", configPath); - } catch (err) { - add(checks, "Project", "feather.config.lua", "fail", (err as Error).message); - } - } else { - add(checks, "Project", "feather.config.lua", "warn", "missing", "Run `feather init` to create a shared config."); - } - - const managedMode = configSource ? parseManagedValue(configSource, "mode") : null; - const managedInstallDir = configSource ? parseManagedValue(configSource, "installDir") : null; - const effectiveInstallDir = normalizeInstallDir(opts.installDir ?? managedInstallDir ?? installDir); - if (managedMode) { - add(checks, "Project", "Managed init metadata", "pass", `mode=${managedMode}, installDir=${managedInstallDir ?? effectiveInstallDir}`); - } else if (configSource) { - add(checks, "Project", "Managed init metadata", "info", "not present", "New `feather init` configs include metadata for `feather remove`."); - } - - const runtimeDir = join(projectDir, effectiveInstallDir); - const hasRuntime = existsSync(join(runtimeDir, "init.lua")); - const configOnlyMode = managedMode === "cli"; - add( - checks, - "Runtime", - "Embedded Feather runtime", - hasRuntime ? "pass" : configOnlyMode ? "info" : "warn", - hasRuntime ? runtimeDir : configOnlyMode ? "not needed for cli mode" : "missing", - hasRuntime || configOnlyMode ? undefined : "Run `feather init --mode auto` for embedded/device workflows.", - ); - - if (hasRuntime) { - for (const file of ["auto.lua", "lib/ws.lua", "plugin_manager.lua"]) { - const path = join(runtimeDir, file); - add( - checks, - "Runtime", - file, - existsSync(path) ? "pass" : "fail", - existsSync(path) ? undefined : "missing", - existsSync(path) ? undefined : `Reinstall runtime with \`feather init --mode auto --install-dir ${effectiveInstallDir}\`.`, - ); - } - - const initSource = readIfExists(join(runtimeDir, "init.lua")); - const runtimeVersion = initSource?.match(/FEATHER_VERSION_NAME\s*=\s*"([^"]+)"/)?.[1] ?? "unknown"; - add(checks, "Runtime", "Runtime version", runtimeVersion === "unknown" ? "warn" : "pass", runtimeVersion); - } - - const runtimePluginRoot = join(runtimeDir, "plugins"); - const sourceTreePluginRoot = join(projectDir, "plugins"); - const pluginRoot = existsSync(runtimePluginRoot) ? runtimePluginRoot : sourceTreePluginRoot; - const pluginDirs = findInstalledPluginDirs(pluginRoot); - if (hasRuntime) { - add( - checks, - "Plugins", - "Plugin directory", - existsSync(pluginRoot) ? "pass" : "info", - existsSync(pluginRoot) ? pluginRoot : "not installed", - existsSync(pluginRoot) ? undefined : "Core-only installs are valid; run `feather plugin` to manage plugins.", - ); - add(checks, "Plugins", "Installed plugins", pluginDirs.length > 0 ? "pass" : "info", `${pluginDirs.length}`); - const malformed = pluginDirs.filter((dir) => !readPluginId(dir)); - if (malformed.length > 0) { - add(checks, "Plugins", "Plugin manifests", "warn", `${malformed.length} missing id`, "Reinstall affected plugins with `feather plugin update`."); - } else if (pluginDirs.length > 0) { - add(checks, "Plugins", "Plugin manifests", "pass", "all installed plugins declare an id"); - } - } - - if (mainSource) { - const hasUseDebugger = mainSource.includes("USE_DEBUGGER"); - const hasInitMarkers = mainSource.includes("FEATHER-INIT-BEGIN") && mainSource.includes("FEATHER-INIT-END"); - add( - checks, - "Safety", - "USE_DEBUGGER guard", - hasUseDebugger || configOnlyMode ? "pass" : "warn", - hasUseDebugger ? "present" : configOnlyMode ? "not needed for cli mode" : "not found", - hasUseDebugger || configOnlyMode ? undefined : "Guard Feather imports so production builds can skip debugger code.", - ); - add( - checks, - "Safety", - "FEATHER-INIT markers", - hasInitMarkers || configOnlyMode ? "pass" : "info", - hasInitMarkers ? "present" : "not present", - hasInitMarkers || configOnlyMode ? undefined : "`feather remove` works best when init markers are present.", - ); - } - - if (configSource) { - const activeConfigSource = uncommentedLua(configSource); - const captureScreenshot = luaBoolEnabled(activeConfigSource, "captureScreenshot"); - add( - checks, - "Safety", - "captureScreenshot", - captureScreenshot ? "warn" : "pass", - captureScreenshot ? "enabled" : "disabled", - captureScreenshot ? "Enable only when you need visual error context; it can affect performance." : undefined, - ); - - const hotReloadEnabled = /hotReload\s*=\s*\{[\s\S]*?enabled\s*=\s*true/.test(activeConfigSource); - const hotReloadPluginIncluded = hasConfigArrayValue(activeConfigSource, "include", "hot-reload") || pluginDirs.some((dir) => readPluginId(dir) === "hot-reload"); - const persistToDisk = /hotReload\s*=\s*\{[\s\S]*?persistToDisk\s*=\s*true/.test(activeConfigSource); - const broadHotReload = /allow\s*=\s*\{[\s\S]*["'][^"']+\.\*["']/.test(activeConfigSource); - add( - checks, - "Safety", - "Hot reload", - hotReloadEnabled ? "warn" : "pass", - hotReloadEnabled ? "enabled" : "disabled", - hotReloadEnabled ? "Hot reload is development-only remote code execution; keep allowlists narrow and never ship with it on." : undefined, - ); - if (hotReloadEnabled && broadHotReload) { - add(checks, "Safety", "Hot reload allowlist", "warn", "contains wildcard", "Prefer exact module names while editing."); - } - if (hotReloadEnabled && !hotReloadPluginIncluded) { - add( - checks, - "Safety", - "Hot reload plugin", - "warn", - "not included", - "Install and include the opt-in `hot-reload` plugin, or remove debugger.hotReload.", - ); - } - if (hotReloadEnabled && persistToDisk) { - add(checks, "Safety", "Hot reload persistence", "warn", "persistToDisk=true", "Persisted patches survive app restarts until restored or cleared."); - } - - const consoleIncluded = hasConfigArrayValue(activeConfigSource, "include", "console") || pluginDirs.some((dir) => readPluginId(dir) === "console"); - if (consoleIncluded) { - const apiKey = config?.apiKey; - add( - checks, - "Safety", - "Console API key", - isWeakApiKey(apiKey) ? "warn" : "pass", - isWeakApiKey(apiKey) ? "missing or weak" : "configured", - isWeakApiKey(apiKey) ? "Set a strong per-session or config API key when using Console." : undefined, - ); - } - } - - const configuredPort = opts.port ?? (typeof config?.port === "number" ? config.port : 4004); - const configuredHost = opts.host ?? "127.0.0.1"; - const mode = typeof config?.mode === "string" ? config.mode : "socket"; - if (mode === "disk") { - add(checks, "Connectivity", "WebSocket mode", "info", "disk mode", "Desktop WebSocket connectivity is not used in disk mode."); - } else { - const desktopUp = await portReachable(configuredPort, configuredHost); - add( - checks, - "Connectivity", - `Feather desktop (${configuredHost}:${configuredPort})`, - desktopUp ? "pass" : "warn", - desktopUp ? "reachable" : "not reachable", - desktopUp ? undefined : "Start the Feather desktop app, or pass --host/--port if checking a custom endpoint.", - ); - } - - const failures = checks.filter((check) => check.severity === "fail"); - const warnings = checks.filter((check) => check.severity === "warn"); - - if (opts.json) { - console.log(JSON.stringify({ projectDir, installDir: effectiveInstallDir, failures: failures.length, warnings: warnings.length, checks }, null, 2)); - } else { - renderReport(checks, projectDir); - } - - if (failures.length > 0) { - process.exitCode = 1; - } -} diff --git a/cli/src/commands/doctor/checks.ts b/cli/src/commands/doctor/checks.ts new file mode 100644 index 00000000..f2a9f635 --- /dev/null +++ b/cli/src/commands/doctor/checks.ts @@ -0,0 +1,141 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { createConnection } from 'node:net'; +import { spawnSync } from 'node:child_process'; +import { basename } from 'node:path'; +import { readPluginManifest } from '../../lib/plugin-utils.js'; + +export type Severity = 'pass' | 'warn' | 'fail' | 'info'; + +export type DoctorCheck = { + group: string; + label: string; + severity: Severity; + detail?: string; + fix?: string; +}; + +export type DoctorOptions = { + installDir?: string; + host?: string; + port?: number; + json?: boolean; + production?: boolean; + security?: boolean; + buildTarget?: string; + uploadTarget?: string; + release?: boolean; +}; + +export const severityOrder: Record = { + fail: 0, + warn: 1, + info: 2, + pass: 3, +}; + +export function add( + checks: DoctorCheck[], + group: string, + label: string, + severity: Severity, + detail?: string, + fix?: string, +): void { + checks.push({ group, label, severity, detail, fix }); +} + +export function portReachable(port: number, host = '127.0.0.1', timeout = 1000): Promise { + return new Promise((resolve) => { + const sock = createConnection({ port, host }); + const timer = setTimeout(() => { + sock.destroy(); + resolve(false); + }, timeout); + sock.once('connect', () => { + clearTimeout(timer); + sock.destroy(); + resolve(true); + }); + sock.once('error', () => { + clearTimeout(timer); + resolve(false); + }); + }); +} + +export function commandVersion(command: string, args: string[]): string | null { + const result = spawnSync(command, args, { encoding: 'utf8' }); + if (result.error || result.status !== 0) return null; + return (result.stdout || result.stderr).trim().split('\n')[0] ?? null; +} + +export function readIfExists(path: string): string | null { + if (!existsSync(path)) return null; + return readFileSync(path, 'utf8'); +} + +export function uncommentedLua(src: string): string { + return src + .split('\n') + .filter((line) => !line.trimStart().startsWith('--')) + .join('\n'); +} + +export function luaBoolEnabled(src: string, key: string): boolean { + return new RegExp(`${key}\\s*=\\s*true\\b`).test(src); +} + +export function luaStringValue(src: string, key: string): string | null { + const match = src.match(new RegExp(`${key}\\s*=\\s*["']([^"']*)["']`)); + return match?.[1] ?? null; +} + +export function hasConfigArrayValue(src: string, key: string, value: string): boolean { + const match = src.match(new RegExp(`${key}\\s*=\\s*\\{([\\s\\S]*?)\\}`)); + return match ? new RegExp(`["']${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`).test(match[1]) : false; +} + +export function configArrayValues(src: string, key: string): string[] { + const match = src.match(new RegExp(`${key}\\s*=\\s*\\{([\\s\\S]*?)\\}`)); + if (!match) return []; + return [...match[1].matchAll(/["']([^"']+)["']/g)].map((item) => item[1]); +} + +export function isWeakApiKey(value: unknown): boolean { + return typeof value !== 'string' || value.trim().length < 24 || value === 'change-me' || value === 'dev'; +} + +export function isLoopbackHost(host: string): boolean { + const normalized = host.trim().toLowerCase(); + return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1'; +} + +export function isWildcardHost(host: string): boolean { + const normalized = host.trim().toLowerCase(); + return normalized === '0.0.0.0' || normalized === '::'; +} + +export function isLanHost(host: string): boolean { + const normalized = host.trim().toLowerCase(); + if (isLoopbackHost(normalized) || isWildcardHost(normalized)) return false; + if (/^10\./.test(normalized)) return true; + if (/^192\.168\./.test(normalized)) return true; + const match = normalized.match(/^172\.(\d+)\./); + return match ? Number(match[1]) >= 16 && Number(match[1]) <= 31 : false; +} + +export function shellArg(value: string): string { + if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) return value; + return `'${value.replaceAll("'", "'\\''")}'`; +} + +export function buildPluginIndex(pluginDirs: string[]): Map { + const plugins = new Map(); + for (const dir of pluginDirs) { + const manifest = readPluginManifest(dir); + if (manifest?.id) { + plugins.set(manifest.id, { dir, name: manifest.name || basename(dir), version: manifest.version || 'unknown' }); + } + } + return plugins; +} diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts new file mode 100644 index 00000000..35f4284c --- /dev/null +++ b/cli/src/commands/doctor/index.ts @@ -0,0 +1,1108 @@ +import { existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { findLoveBinary, getLoveVersion } from '../../lib/love.js'; +import { loadConfig } from '../../lib/config.js'; +import { normalizeInstallDir } from '../../lib/install.js'; +import { fail } from '../../lib/command.js'; +import { printJson } from '../../lib/output.js'; +import { findSymlinkEscapes } from '../../lib/path-safety.js'; +import { + buildTargets, + isBuildTarget, + isSupportedBuildTarget, + isUploadTarget, + loadBuildConfig, + outDirWritableDetail, + supportedBuildTargets, + type SupportedBuildTarget, + uploadTargets, +} from '../../lib/build/config.js'; +import { androidProductId, iosBundleIdentifier, validateBuildConfigForTarget } from '../../lib/build/validation.js'; +import { auditLockfile } from '../../lib/package/audit.js'; +import { readLockfile } from '../../lib/package/lockfile.js'; +import { lockfileEntrySourceSummary, lockfileUrlFindings } from '../../lib/package/provenance.js'; +import { loadRegistry } from '../../lib/package/registry.js'; +import { classifyPluginTrust, dangerousPluginIds, parseManagedValue, findInstalledPluginDirs, pluginTrustLabel, readPluginManifest, type PluginTrust } from '../../lib/plugin-utils.js'; +import { pluginCatalog } from '../../generated/plugin-catalog.js'; +import { + add, + buildPluginIndex, + commandVersion, + configArrayValues, + hasConfigArrayValue, + isLanHost, + isLoopbackHost, + isWildcardHost, + isWeakApiKey, + luaBoolEnabled, + luaStringValue, + portReachable, + readIfExists, + shellArg, + uncommentedLua, + type DoctorCheck, + type DoctorOptions, +} from './checks.js'; +import { renderReport } from './report.js'; +import { buildSecurityReport, securityChecks, type SecurityPackageReport, type SecurityPluginReport } from './security.js'; + +export type { DoctorOptions } from './checks.js'; + +export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}): Promise { + const projectDir = gamePath ? resolve(gamePath) : process.cwd(); + const installDir = normalizeInstallDir(opts.installDir ?? 'feather'); + const checks: DoctorCheck[] = []; + const productionSeverity = (unsafe: boolean): DoctorCheck['severity'] => unsafe ? (opts.production ? 'fail' : 'warn') : 'pass'; + let symlinkEscapeCount = 0; + let consoleIncluded = false; + let weakApiKey = true; + let insecureConnection = false; + let appIdMissing = true; + let captureScreenshot = false; + let hotReloadEnabled = false; + let broadHotReload = false; + let hotReloadPersistence = false; + let debuggerEnabled = false; + let writeToDisk = false; + let weakNetworkAuth = true; + let networkExposure: 'loopback' | 'wildcard' | 'lan' | 'non-loopback' = 'loopback'; + const missingIncludedPlugins: string[] = []; + const unknownIncludedPlugins: string[] = []; + const installedPluginReports: SecurityPluginReport[] = []; + const packageReports: SecurityPackageReport[] = []; + let packageProvenanceFindings: ReturnType = []; + + const nodeVersion = process.versions.node; + const [major] = nodeVersion.split('.').map(Number); + add( + checks, + 'Environment', + 'Node.js', + major >= 18 ? 'pass' : 'fail', + `v${nodeVersion}`, + major >= 18 ? undefined : 'Install Node.js 18 or newer.', + ); + + const npmVersion = commandVersion('npm', ['--version']); + add( + checks, + 'Environment', + 'npm', + npmVersion ? 'pass' : 'warn', + npmVersion ? `v${npmVersion}` : 'not found', + npmVersion ? undefined : 'Install npm if you want to use npm run feather or global CLI installs.', + ); + + try { + const lovePath = findLoveBinary(); + const ver = getLoveVersion(lovePath); + add(checks, 'Environment', 'LÖVE binary', 'pass', `${lovePath} (${ver})`); + } catch { + add( + checks, + 'Environment', + 'LÖVE binary', + 'fail', + 'not found', + 'Install from https://love2d.org or set LOVE_BIN to the executable path.', + ); + } + + add(checks, 'Environment', 'Platform', 'info', `${process.platform} ${process.arch}`); + + if (opts.buildTarget) { + if (!isDoctorBuildTarget(opts.buildTarget)) { + add( + checks, + 'Build', + 'Build target', + 'fail', + opts.buildTarget, + `Use one of: ${doctorBuildTargets.join(', ')}.`, + ); + } else { + add( + checks, + 'Build', + 'Build target', + opts.buildTarget === 'all' || isSupportedBuildTarget(opts.buildTarget) ? 'pass' : 'warn', + opts.buildTarget, + opts.buildTarget === 'all' + ? `checking ${supportedBuildTargets.join(', ')}` + : isSupportedBuildTarget(opts.buildTarget) + ? undefined + : `${opts.buildTarget} support is registered but not enabled in this build.`, + ); + } + } + + if (opts.uploadTarget) { + if (!isUploadTarget(opts.uploadTarget)) { + add( + checks, + 'Upload', + 'Upload target', + 'fail', + opts.uploadTarget, + `Use one of: ${uploadTargets.join(', ')}.`, + ); + } else { + add( + checks, + 'Upload', + 'Upload target', + opts.uploadTarget === 'itch' ? 'pass' : 'warn', + opts.uploadTarget, + opts.uploadTarget === 'itch' ? undefined : 'Steam upload support is planned; use itch for now.', + ); + } + } + + const hasProjectDir = existsSync(projectDir); + add( + checks, + 'Project', + 'Project directory', + hasProjectDir ? 'pass' : 'fail', + projectDir, + hasProjectDir ? undefined : 'Pass the game directory to `feather doctor `.', + ); + + if (hasProjectDir && (opts.buildTarget || opts.uploadTarget)) { + try { + const buildConfig = loadBuildConfig({ projectDir }); + const configExists = existsSync(buildConfig.configPath); + add( + checks, + 'Build', + 'feather.build.json', + configExists ? 'pass' : 'warn', + configExists ? buildConfig.configPath : 'missing', + configExists ? undefined : 'Create feather.build.json to share local and CI build settings.', + ); + const writable = outDirWritableDetail(buildConfig.outDir); + add( + checks, + 'Build', + 'Build output directory', + writable.ok ? 'pass' : 'fail', + writable.detail, + writable.ok ? undefined : 'Choose a writable outDir in feather.build.json or pass --out-dir.', + ); + + if (opts.buildTarget && isDoctorBuildTarget(opts.buildTarget)) { + const fromAll = opts.buildTarget === 'all'; + for (const target of expandDoctorBuildTargets(opts.buildTarget)) { + addBuildTargetChecks(checks, projectDir, buildConfig, target, { + fromAll, + release: Boolean(opts.release), + }); + } + } + + if (opts.uploadTarget === 'itch') { + const itchProject = buildConfig.upload.itch?.project; + add( + checks, + 'Upload', + 'Itch project', + itchProject ? 'pass' : 'fail', + itchProject ?? 'missing', + 'Set upload.itch.project in feather.build.json, for example "user/game".', + ); + const butler = commandVersion('butler', ['--version']); + add( + checks, + 'Upload', + 'butler', + butler ? 'pass' : 'fail', + butler ? butler : 'not found', + butler ? undefined : 'Install butler from https://itch.io/docs/butler/ and make sure it is on PATH.', + ); + add( + checks, + 'Upload', + 'BUTLER_API_KEY', + process.env.BUTLER_API_KEY ? 'pass' : 'warn', + process.env.BUTLER_API_KEY ? 'configured' : 'missing', + process.env.BUTLER_API_KEY ? undefined : 'Set BUTLER_API_KEY in CI or run `butler login` locally.', + ); + } + } catch (err) { + add(checks, 'Build', 'feather.build.json', 'fail', (err as Error).message, 'Fix feather.build.json before building or uploading.'); + } + } + + const mainPath = join(projectDir, 'main.lua'); + const mainSource = readIfExists(mainPath); + const hasMain = mainSource !== null; + add( + checks, + 'Project', + 'main.lua', + hasMain ? 'pass' : 'fail', + hasMain ? mainPath : 'missing', + hasMain ? undefined : 'Run doctor from a LÖVE project root or pass the project directory.', + ); + + const configPath = join(projectDir, 'feather.config.lua'); + const configSource = readIfExists(configPath); + const activeConfigSource = configSource ? uncommentedLua(configSource) : ''; + const includedPluginIds = configSource ? configArrayValues(activeConfigSource, 'include') : []; + const excludedPluginIds = new Set(configSource ? configArrayValues(activeConfigSource, 'exclude') : []); + const activeIncludedPluginIds = [...new Set(includedPluginIds.filter((id) => !excludedPluginIds.has(id)))].sort(); + let config: ReturnType = null; + if (configSource) { + try { + config = loadConfig(projectDir); + add(checks, 'Project', 'feather.config.lua', 'pass', configPath); + } catch (err) { + add(checks, 'Project', 'feather.config.lua', 'fail', (err as Error).message); + } + } else { + add(checks, 'Project', 'feather.config.lua', 'warn', 'missing', 'Run `feather init` to create a shared config.'); + } + + const managedMode = configSource ? parseManagedValue(configSource, 'mode') : null; + const managedInstallDir = configSource ? parseManagedValue(configSource, 'installDir') : null; + const effectiveInstallDir = normalizeInstallDir(opts.installDir ?? managedInstallDir ?? installDir); + const mode = typeof config?.mode === 'string' ? config.mode : 'socket'; + if (managedMode) { + add(checks, 'Project', 'Managed init metadata', 'pass', `mode=${managedMode}, installDir=${managedInstallDir ?? effectiveInstallDir}`); + } else if (configSource) { + add(checks, 'Project', 'Managed init metadata', 'info', 'not present', 'New `feather init` configs include metadata for `feather remove`.'); + } + + const runtimeDir = join(projectDir, effectiveInstallDir); + const hasRuntime = existsSync(join(runtimeDir, 'init.lua')); + const configOnlyMode = managedMode === 'cli'; + add( + checks, + 'Runtime', + 'Embedded Feather runtime', + hasRuntime ? 'pass' : configOnlyMode ? 'info' : 'warn', + hasRuntime ? runtimeDir : configOnlyMode ? 'not needed for cli mode' : 'missing', + hasRuntime || configOnlyMode ? undefined : 'Run `feather init --mode auto` for embedded/device workflows.', + ); + + if (hasRuntime) { + for (const file of ['auto.lua', 'lib/ws.lua', 'plugin_manager.lua']) { + const path = join(runtimeDir, file); + add( + checks, + 'Runtime', + file, + existsSync(path) ? 'pass' : 'fail', + existsSync(path) ? undefined : 'missing', + existsSync(path) ? undefined : `Reinstall runtime with \`feather init --mode auto --install-dir ${effectiveInstallDir}\`.`, + ); + } + + const initSource = readIfExists(join(runtimeDir, 'init.lua')); + const runtimeVersion = initSource?.match(/FEATHER_VERSION_NAME\s*=\s*"([^"]+)"/)?.[1] ?? 'unknown'; + add(checks, 'Runtime', 'Runtime version', runtimeVersion === 'unknown' ? 'warn' : 'pass', runtimeVersion); + } + + const runtimePluginRoot = join(runtimeDir, 'plugins'); + const sourceTreePluginRoot = join(projectDir, 'plugins'); + const pluginRoot = existsSync(runtimePluginRoot) ? runtimePluginRoot : sourceTreePluginRoot; + let pluginRootUnsafe = false; + if (hasProjectDir) { + const symlinkEscapes = findSymlinkEscapes(projectDir, [runtimeDir, runtimePluginRoot, sourceTreePluginRoot]); + symlinkEscapeCount = symlinkEscapes.length; + pluginRootUnsafe = symlinkEscapes.some((escape) => resolve(pluginRoot).startsWith(resolve(escape.path))); + const seenEscapes = new Set(); + for (const escape of symlinkEscapes) { + const key = `${escape.path}\0${escape.target}`; + if (seenEscapes.has(key)) continue; + seenEscapes.add(key); + add( + checks, + 'Safety', + 'Symlink escape', + 'warn', + `${escape.path} → ${escape.target}`, + 'Remove or replace symlinks that point outside the project before running update/remove or shipping.', + ); + } + } + const pluginDirs = pluginRootUnsafe ? [] : findInstalledPluginDirs(pluginRoot); + const installedPlugins = buildPluginIndex(pluginDirs); + const knownPluginIds = new Set(pluginCatalog.map((plugin) => plugin.id)); + const pluginCatalogById = new Map(pluginCatalog.map((plugin) => [plugin.id, plugin])); + for (const dir of pluginDirs) { + const manifest = readPluginManifest(dir); + const trust: PluginTrust = classifyPluginTrust(manifest, manifest?.id ? pluginCatalogById.get(manifest.id) : null); + installedPluginReports.push({ + id: manifest?.id || dir.split(/[\\/]/).pop() || 'unknown', + trust, + dangerous: Boolean(manifest?.id && dangerousPluginIds.has(manifest.id)), + malformed: trust === 'malformed', + }); + } + const projectDirArg = shellArg(projectDir); + const installDirArg = shellArg(effectiveInstallDir); + if (hasRuntime) { + add( + checks, + 'Plugins', + 'Plugin directory', + existsSync(pluginRoot) ? 'pass' : 'info', + existsSync(pluginRoot) ? pluginRoot : 'not installed', + existsSync(pluginRoot) ? undefined : 'Core-only installs are valid; run `feather plugin` to manage plugins.', + ); + add(checks, 'Plugins', 'Installed plugins', pluginDirs.length > 0 ? 'pass' : 'info', `${pluginDirs.length}`); + const malformed = pluginDirs.filter((dir) => { + const manifest = readPluginManifest(dir); + return !manifest?.id || !manifest.version; + }); + const unknownInstalled = pluginDirs + .map((dir) => ({ dir, manifest: readPluginManifest(dir) })) + .filter((plugin) => plugin.manifest?.id && !knownPluginIds.has(plugin.manifest.id)); + if (malformed.length > 0) { + add( + checks, + 'Plugins', + 'Plugin manifests', + 'warn', + `${malformed.length} missing id`, + `Run \`feather plugin update --dir ${projectDirArg} --install-dir ${installDirArg} --yes\`.`, + ); + } else if (pluginDirs.length > 0) { + add(checks, 'Plugins', 'Plugin manifests', 'pass', 'all installed plugins declare an id'); + } + for (const { dir, manifest } of unknownInstalled) { + add( + checks, + 'Plugins', + `Plugin ${manifest?.id}`, + 'warn', + `installed with ${pluginTrustLabel(classifyPluginTrust(manifest, null))} trust`, + `Review ${dir} and remove it with \`feather plugin remove ${manifest?.id} --dir ${projectDirArg} --install-dir ${installDirArg} --yes\` if it should not ship.`, + ); + } + for (const dir of pluginDirs) { + const manifest = readPluginManifest(dir); + if (!manifest?.id || !knownPluginIds.has(manifest.id)) continue; + const trust = classifyPluginTrust(manifest, pluginCatalogById.get(manifest.id)); + if (dangerousPluginIds.has(manifest.id)) { + add( + checks, + 'Plugins', + `Plugin ${manifest.id} trust`, + 'warn', + `${pluginTrustLabel(trust)}; development-only`, + `Remove before shipping with \`feather plugin remove ${manifest.id} --dir ${projectDirArg} --install-dir ${installDirArg} --yes\`.`, + ); + } else { + add(checks, 'Plugins', `Plugin ${manifest.id} trust`, trust === 'bundled-opt-in' ? 'info' : 'pass', pluginTrustLabel(trust)); + } + } + } + + for (const id of activeIncludedPluginIds) { + if (!knownPluginIds.has(id)) { + unknownIncludedPlugins.push(id); + add( + checks, + 'Plugins', + `Plugin ${id}`, + 'warn', + 'included but unknown', + `Remove or correct "${id}" in feather.config.lua include.`, + ); + continue; + } + + if (!installedPlugins.has(id)) { + missingIncludedPlugins.push(id); + add( + checks, + 'Plugins', + `Plugin ${id}`, + 'warn', + 'included but not installed', + `Run \`feather plugin install ${id} --dir ${projectDirArg} --install-dir ${installDirArg}\`.`, + ); + } + } + + if (mainSource) { + const hasUseDebugger = mainSource.includes('USE_DEBUGGER'); + const hasInitMarkers = mainSource.includes('FEATHER-INIT-BEGIN') && mainSource.includes('FEATHER-INIT-END'); + add( + checks, + 'Safety', + 'USE_DEBUGGER guard', + hasUseDebugger || configOnlyMode ? 'pass' : 'warn', + hasUseDebugger ? 'present' : configOnlyMode ? 'not needed for cli mode' : 'not found', + hasUseDebugger || configOnlyMode ? undefined : 'Guard Feather imports so production builds can skip debugger code.', + ); + add( + checks, + 'Safety', + 'FEATHER-INIT markers', + hasInitMarkers || configOnlyMode ? 'pass' : 'info', + hasInitMarkers ? 'present' : 'not present', + hasInitMarkers || configOnlyMode ? undefined : '`feather remove` works best when init markers are present.', + ); + } + + if (configSource) { + insecureConnection = luaBoolEnabled(activeConfigSource, '__DANGEROUS_INSECURE_CONNECTION__'); + add( + checks, + 'Safety', + '__DANGEROUS_INSECURE_CONNECTION__', + productionSeverity(insecureConnection), + insecureConnection ? 'enabled' : 'disabled', + insecureConnection ? 'Set appId in feather.config.lua and remove __DANGEROUS_INSECURE_CONNECTION__ before sharing or shipping.' : undefined, + ); + + const appId = typeof config?.appId === 'string' ? config.appId.trim() : ''; + appIdMissing = mode !== 'disk' && !appId; + add( + checks, + 'Safety', + 'Desktop App ID', + appIdMissing ? 'fail' : 'pass', + appIdMissing ? 'missing' : mode === 'disk' ? 'not needed for disk mode' : 'configured', + appIdMissing ? 'Set appId in feather.config.lua before shipping socket/network builds.' : undefined, + ); + + captureScreenshot = luaBoolEnabled(activeConfigSource, 'captureScreenshot'); + add( + checks, + 'Safety', + 'captureScreenshot', + productionSeverity(captureScreenshot), + captureScreenshot ? 'enabled' : 'disabled', + captureScreenshot ? 'Set captureScreenshot = false in feather.config.lua unless you need visual error context.' : undefined, + ); + + hotReloadEnabled = /hotReload\s*=\s*\{[\s\S]*?enabled\s*=\s*true/.test(activeConfigSource); + const hotReloadPluginIncluded = hasConfigArrayValue(activeConfigSource, 'include', 'hot-reload') || pluginDirs.some((dir) => readPluginManifest(dir)?.id === 'hot-reload'); + hotReloadPersistence = /hotReload\s*=\s*\{[\s\S]*?persistToDisk\s*=\s*true/.test(activeConfigSource); + broadHotReload = /allow\s*=\s*\{[\s\S]*["'][^"']+\.\*["']/.test(activeConfigSource); + add( + checks, + 'Safety', + 'Hot reload', + productionSeverity(hotReloadEnabled), + hotReloadEnabled ? 'enabled' : 'disabled', + hotReloadEnabled ? 'Set debugger.hotReload.enabled = false in feather.config.lua before shipping.' : undefined, + ); + if (hotReloadEnabled && broadHotReload) { + add(checks, 'Safety', 'Hot reload allowlist', opts.production ? 'fail' : 'warn', 'contains wildcard', 'Replace wildcard hot reload allow entries with exact module names in feather.config.lua.'); + } + if (hotReloadEnabled && !hotReloadPluginIncluded) { + add( + checks, + 'Safety', + 'Hot reload plugin', + 'warn', + 'not included', + `Run \`feather plugin install hot-reload --dir ${projectDirArg} --install-dir ${installDirArg}\`, or remove debugger.hotReload from feather.config.lua.`, + ); + } + if (hotReloadEnabled && hotReloadPersistence) { + add(checks, 'Safety', 'Hot reload persistence', opts.production ? 'fail' : 'warn', 'persistToDisk=true', 'Set debugger.hotReload.persistToDisk = false in feather.config.lua.'); + } + + consoleIncluded = hasConfigArrayValue(activeConfigSource, 'include', 'console') || pluginDirs.some((dir) => readPluginManifest(dir)?.id === 'console'); + if (consoleIncluded) { + const apiKey = config?.apiKey; + weakApiKey = isWeakApiKey(apiKey); + add( + checks, + 'Safety', + 'Console API key', + weakApiKey ? (opts.production ? 'fail' : 'warn') : 'pass', + weakApiKey ? 'missing or weak' : 'configured', + weakApiKey ? 'Set apiKey to a strong per-session secret in feather.config.lua when using Console.' : undefined, + ); + } + + debuggerEnabled = luaBoolEnabled(activeConfigSource, 'debugger') || /debugger\s*=\s*\{[\s\S]*?enabled\s*=\s*true/.test(activeConfigSource); + add( + checks, + 'Safety', + 'Step debugger', + productionSeverity(debuggerEnabled), + debuggerEnabled ? 'enabled' : 'disabled', + debuggerEnabled ? 'Set debugger = false in feather.config.lua unless this is a trusted development session.' : undefined, + ); + + writeToDisk = luaBoolEnabled(activeConfigSource, 'writeToDisk'); + add( + checks, + 'Safety', + 'Disk logging', + productionSeverity(writeToDisk), + writeToDisk ? 'enabled' : 'disabled', + writeToDisk ? 'Set writeToDisk = false in feather.config.lua before committing or sharing the project.' : undefined, + ); + } else if (mode !== 'disk') { + add( + checks, + 'Safety', + 'Desktop App ID', + 'fail', + 'missing', + 'Create feather.config.lua with a strong appId before shipping socket/network builds.', + ); + } + + const lockfilePath = join(projectDir, 'feather.lock.json'); + if (existsSync(lockfilePath)) { + try { + const lockfile = readLockfile(projectDir); + const auditResults = await auditLockfile(projectDir, lockfile); + const badPackages = new Set(auditResults.filter((result) => result.status !== 'verified').map((result) => result.id)); + const registry = await loadRegistry({ offline: true }); + for (const [id, entry] of Object.entries(lockfile.packages).sort(([a], [b]) => a.localeCompare(b))) { + packageReports.push({ + id, + version: entry.version, + trust: entry.trust, + source: lockfileEntrySourceSummary(id, entry), + }); + } + add( + checks, + 'Packages', + 'Package lockfile', + 'pass', + `${Object.keys(lockfile.packages).length} package(s)`, + undefined, + ); + add( + checks, + 'Packages', + 'Package file integrity', + badPackages.size === 0 ? 'pass' : 'warn', + badPackages.size === 0 ? 'all verified' : `${badPackages.size} package(s) need attention`, + badPackages.size === 0 ? undefined : `Run \`feather package install --dir ${projectDirArg}\` to restore missing or modified files.`, + ); + for (const id of [...badPackages].sort()) { + const packageResults = auditResults.filter((result) => result.id === id && result.status !== 'verified'); + const missing = packageResults.filter((result) => result.status === 'missing').length; + const modified = packageResults.filter((result) => result.status === 'modified').length; + const details = [ + missing > 0 ? `${missing} missing` : '', + modified > 0 ? `${modified} modified` : '', + ].filter(Boolean).join(', '); + add( + checks, + 'Packages', + `Package ${id} files`, + 'warn', + details, + `Run \`feather package install --dir ${projectDirArg}\`.`, + ); + } + + packageProvenanceFindings = lockfileUrlFindings(lockfile); + const provenanceByPackage = new Map(); + for (const finding of packageProvenanceFindings) { + provenanceByPackage.set(finding.id, [...(provenanceByPackage.get(finding.id) ?? []), finding]); + } + for (const [id, findings] of [...provenanceByPackage.entries()].sort(([a], [b]) => a.localeCompare(b))) { + const urls = [...new Set(findings.map((finding) => finding.url))].slice(0, 3).join(', '); + const targets = [...new Set(findings.map((finding) => finding.target))].slice(0, 3).join(', '); + add( + checks, + 'Packages', + `Package ${id} source`, + 'warn', + `${findings.length} untrusted URL(s): ${urls}${targets ? ` (${targets})` : ''}`, + `Review feather.lock.json; repair only if trusted with \`feather package install --dir ${projectDirArg} --allow-untrusted\`.`, + ); + } + + for (const [id, entry] of Object.entries(lockfile.packages).sort(([a], [b]) => a.localeCompare(b))) { + if (entry.trust === 'experimental') continue; + + const registryEntry = registry.packages[id]; + if (!registryEntry) { + add( + checks, + 'Packages', + `Package ${id} registry`, + 'warn', + 'not found in bundled registry', + `Run \`feather package remove ${id} --dir ${projectDirArg} --yes\`, or reinstall from a trusted source.`, + ); + continue; + } + + if (entry.version !== registryEntry.source.tag) { + add( + checks, + 'Packages', + `Package ${id} version`, + 'warn', + `${entry.version} → ${registryEntry.source.tag}`, + `Run \`feather package update ${id} --dir ${projectDirArg}\`.`, + ); + } + } + } catch (err) { + add(checks, 'Packages', 'Package lockfile', 'fail', (err as Error).message, 'Fix or delete feather.lock.json and reinstall packages.'); + } + } else { + add(checks, 'Packages', 'Package lockfile', 'info', 'not present', 'Run `feather package install ` to add project dependencies.'); + } + + const configuredPort = opts.port ?? (typeof config?.port === 'number' ? config.port : 4004); + const configuredHost = opts.host ?? (typeof config?.host === 'string' ? config.host : luaStringValue(activeConfigSource, 'host')) ?? '127.0.0.1'; + if (mode !== 'disk') { + const wildcardHost = isWildcardHost(configuredHost); + const lanHost = isLanHost(configuredHost); + const loopbackHost = isLoopbackHost(configuredHost); + networkExposure = wildcardHost ? 'wildcard' : lanHost ? 'lan' : loopbackHost ? 'loopback' : 'non-loopback'; + weakNetworkAuth = !configSource || luaBoolEnabled(activeConfigSource, '__DANGEROUS_INSECURE_CONNECTION__') || !config?.appId; + add( + checks, + 'Safety', + 'Network host exposure', + wildcardHost || (lanHost && weakNetworkAuth) || (!loopbackHost && opts.production && weakNetworkAuth) ? (opts.production ? 'fail' : 'warn') : 'pass', + wildcardHost ? configuredHost : lanHost ? `${configuredHost} (LAN)` : loopbackHost ? configuredHost : `${configuredHost} (non-loopback)`, + wildcardHost + ? 'Use host = "127.0.0.1" for local development, or configure a strong appId before exposing Feather on a network.' + : lanHost && weakNetworkAuth + ? 'Set a strong appId before using a LAN-facing Feather host.' + : !loopbackHost && weakNetworkAuth + ? 'Use a loopback host or set a strong appId before shipping.' + : undefined, + ); + } + + if ((opts.production || opts.security) && hasRuntime && (!configSource || !managedMode)) { + add( + checks, + 'Safety', + 'Managed runtime', + opts.production ? 'fail' : 'warn', + 'runtime present without managed init metadata', + 'Run `feather init` to regenerate managed metadata, or remove the embedded Feather runtime before shipping.', + ); + } + + if (mode === 'disk') { + add(checks, 'Connectivity', 'WebSocket mode', 'info', 'disk mode', 'Desktop WebSocket connectivity is not used in disk mode.'); + } else { + const desktopUp = await portReachable(configuredPort, configuredHost); + add( + checks, + 'Connectivity', + `Feather desktop (${configuredHost}:${configuredPort})`, + desktopUp ? 'pass' : 'warn', + desktopUp ? 'reachable' : 'not reachable', + desktopUp ? undefined : 'Start the Feather desktop app, or pass --host/--port if checking a custom endpoint.', + ); + } + + const displayedChecks = opts.security ? securityChecks(checks) : checks; + const failures = displayedChecks.filter((check) => check.severity === 'fail'); + const warnings = displayedChecks.filter((check) => check.severity === 'warn'); + const securityReport = opts.security + ? buildSecurityReport({ + checks, + mode, + installDir: effectiveInstallDir, + host: configuredHost, + port: configuredPort, + appIdMissing, + consoleIncluded, + weakApiKey, + insecureConnection, + captureScreenshot, + hotReloadEnabled, + broadHotReload, + hotReloadPersistence, + debuggerEnabled, + writeToDisk, + networkExposure, + weakNetworkAuth, + runtimeEmbedded: hasRuntime, + runtimeManaged: Boolean(managedMode), + symlinkEscapes: symlinkEscapeCount, + includedPlugins: activeIncludedPluginIds, + missingIncludedPlugins, + unknownIncludedPlugins, + installedPlugins: installedPluginReports, + packages: packageReports, + untrustedPackageSources: packageProvenanceFindings, + }) + : undefined; + + if (opts.json) { + printJson({ + projectDir, + installDir: effectiveInstallDir, + production: Boolean(opts.production), + security: Boolean(opts.security), + failures: failures.length, + warnings: warnings.length, + checks: displayedChecks, + ...(securityReport ? { report: securityReport } : {}), + }); + } else { + renderReport(displayedChecks, projectDir); + } + + if (failures.length > 0) { + fail('', { silent: true }); + } +} + +const doctorBuildTargets = [...buildTargets, 'all'] as const; +type DoctorBuildTarget = SupportedBuildTarget | 'all'; + +function isDoctorBuildTarget(value: string): value is DoctorBuildTarget { + return value === 'all' || isBuildTarget(value); +} + +function expandDoctorBuildTargets(target: DoctorBuildTarget): SupportedBuildTarget[] { + return target === 'all' ? [...supportedBuildTargets] : isSupportedBuildTarget(target) ? [target] : []; +} + +function addBuildTargetChecks( + checks: DoctorCheck[], + projectDir: string, + buildConfig: ReturnType, + target: SupportedBuildTarget, + options: { fromAll: boolean; release: boolean }, +): void { + const release = options.release && (!options.fromAll || target === 'android' || target === 'ios'); + const label = (singleTargetLabel: string, allTargetBase = singleTargetLabel) => + options.fromAll ? buildCheckLabel(target, allTargetBase, true) : singleTargetLabel; + const configIssues = validateBuildConfigForTarget(buildConfig, target, release); + + if (options.release && !options.fromAll && target !== 'android' && target !== 'ios') { + add( + checks, + 'Build', + 'Release mode', + 'fail', + target, + 'Release mode is currently supported only for android and ios builds.', + ); + } + + if (target === 'love') { + add( + checks, + 'Build', + label('LÖVE package'), + 'pass', + '.love archive', + 'Run it with LÖVE or build a platform target for a bundled runtime.', + ); + return; + } + + if (target === 'web') { + const loveJsDir = buildConfig.targets.web?.loveJsDir; + add( + checks, + 'Build', + label('love.js player'), + loveJsDir && existsSync(resolve(projectDir, loveJsDir)) ? 'pass' : 'fail', + loveJsDir ?? 'not configured', + `Run \`feather build vendor add web --dir ${projectDir}\` or set targets.web.loveJsDir in feather.build.json to a local love.js checkout or build output.`, + ); + return; + } + + if (target === 'android') { + addMobileConfigCheck(checks, target, configIssues, options.fromAll); + const androidConfig = buildConfig.targets.android ?? {}; + const loveAndroidDir = androidConfig.loveAndroidDir ? resolve(projectDir, androidConfig.loveAndroidDir) : ''; + const hasLoveAndroidDir = Boolean(loveAndroidDir && existsSync(loveAndroidDir)); + add( + checks, + 'Build', + label('love-android template'), + hasLoveAndroidDir ? 'pass' : 'fail', + androidConfig.loveAndroidDir ?? 'not configured', + hasLoveAndroidDir ? undefined : `Run \`feather build vendor add android --dir ${projectDir}\` or set targets.android.loveAndroidDir in feather.build.json.`, + ); + const gradleWrapper = hasLoveAndroidDir && (existsSync(join(loveAndroidDir, 'gradlew')) || existsSync(join(loveAndroidDir, 'gradlew.bat'))); + add( + checks, + 'Build', + label('Android Gradle wrapper'), + gradleWrapper ? 'pass' : 'fail', + gradleWrapper ? loveAndroidDir : 'not found', + 'Use a love-android checkout that includes gradlew, or restore the Gradle wrapper files.', + ); + const java = commandVersion('java', ['-version']); + add( + checks, + 'Build', + label('JDK'), + java ? 'pass' : 'fail', + java ?? 'not found', + 'Install a JDK compatible with the configured Android Gradle Plugin and make sure java is on PATH.', + ); + const androidSdk = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT; + add( + checks, + 'Build', + label('Android SDK'), + androidSdk ? 'pass' : 'fail', + androidSdk ?? 'ANDROID_HOME/ANDROID_SDK_ROOT missing', + 'Install Android SDK command-line tools and set ANDROID_HOME or ANDROID_SDK_ROOT.', + ); + const productId = androidProductId(buildConfig); + add( + checks, + 'Build', + label('Android product id'), + configIssues.some((issue) => issue.field === 'productId') ? 'fail' : 'pass', + productId, + configIssues.some((issue) => issue.field === 'productId') ? 'Set a valid productId or targets.android.productId in feather.build.json.' : undefined, + ); + add( + checks, + 'Build', + label('Android signing'), + release ? androidReleaseSigningSeverity(buildConfig) : 'warn', + release ? androidReleaseSigningDetail(buildConfig) : 'debug/dev build', + release + ? androidReleaseSigningFix(buildConfig) + : 'Use --release to check signed AAB/APK setup.', + ); + return; + } + + if (target === 'ios') { + addMobileConfigCheck(checks, target, configIssues, options.fromAll); + const iosConfig = buildConfig.targets.ios ?? {}; + const loveIosDir = iosConfig.loveIosDir ? resolve(projectDir, iosConfig.loveIosDir) : ''; + const hasLoveIosDir = Boolean(loveIosDir && existsSync(loveIosDir)); + add( + checks, + 'Build', + label('macOS host'), + process.platform === 'darwin' ? 'pass' : 'fail', + process.platform, + 'iOS builds require macOS with Xcode.', + ); + const xcodebuild = commandVersion('xcodebuild', ['-version']); + add( + checks, + 'Build', + label('xcodebuild'), + xcodebuild ? 'pass' : 'fail', + xcodebuild ?? 'not found', + 'Install Xcode and command line tools, then run `xcode-select --install` if needed.', + ); + add( + checks, + 'Build', + label('LÖVE iOS template', 'LÖVE template'), + hasLoveIosDir ? 'pass' : 'fail', + iosConfig.loveIosDir ?? 'not configured', + hasLoveIosDir ? undefined : `Run \`feather build vendor add ios --dir ${projectDir}\` or set targets.ios.loveIosDir in feather.build.json.`, + ); + const xcodeProject = hasLoveIosDir && existsSync(join(loveIosDir, 'platform', 'xcode', 'love.xcodeproj')); + add( + checks, + 'Build', + label('LÖVE iOS Xcode project', 'LÖVE Xcode project'), + xcodeProject ? 'pass' : 'fail', + xcodeProject ? join(loveIosDir, 'platform', 'xcode', 'love.xcodeproj') : 'not found', + 'Use a LÖVE iOS source tree that includes platform/xcode/love.xcodeproj.', + ); + const bundleId = iosBundleIdentifier(buildConfig); + add( + checks, + 'Build', + label('iOS bundle id'), + configIssues.some((issue) => issue.field === 'bundleIdentifier') ? 'fail' : 'pass', + bundleId, + configIssues.some((issue) => issue.field === 'bundleIdentifier') ? 'Set a valid productId, targets.ios.productId, or targets.ios.bundleIdentifier in feather.build.json.' : undefined, + ); + add( + checks, + 'Build', + label('iOS signing team'), + release + ? ((iosConfig.release?.teamId ?? iosConfig.teamId) ? 'pass' : 'warn') + : (iosConfig.teamId ? 'pass' : 'warn'), + release ? (iosConfig.release?.teamId ?? iosConfig.teamId ?? 'missing') : (iosConfig.teamId ?? 'missing'), + release + ? 'Set targets.ios.release.teamId or targets.ios.teamId if your export requires a team.' + : 'Set targets.ios.teamId for device builds; simulator debug builds can usually omit it.', + ); + if (release) { + add( + checks, + 'Build', + label('iOS export options'), + iosConfig.release?.exportOptionsPlist || iosConfig.release?.exportMethod ? 'pass' : 'warn', + iosConfig.release?.exportOptionsPlist ?? iosConfig.release?.exportMethod ?? 'generated development export options', + 'Set targets.ios.release.exportOptionsPlist or exportMethod for App Store/TestFlight exports.', + ); + } + return; + } + + addDesktopBuildChecks(checks, projectDir, buildConfig, target, { fromAll: options.fromAll }); +} + +function addMobileConfigCheck( + checks: DoctorCheck[], + target: 'android' | 'ios', + configIssues: ReturnType, + fromAll: boolean, +): void { + const label = target === 'android' ? 'Android config' : 'iOS config'; + add( + checks, + 'Build', + buildCheckLabel(target, label, fromAll), + configIssues.length === 0 ? 'pass' : 'fail', + configIssues.length === 0 ? 'valid' : configIssues.map((issue) => `${issue.field}: ${issue.message}`).join('; '), + configIssues.length === 0 ? undefined : 'Fix feather.build.json before running the build.', + ); +} + +function buildCheckLabel(target: SupportedBuildTarget, base: string, prefixed: boolean): string { + return prefixedCheckLabel(buildTargetLabel(target), base, prefixed); +} + +function buildTargetLabel(target: SupportedBuildTarget): string { + if (target === 'love') return 'LÖVE'; + if (target === 'ios') return 'iOS'; + if (target === 'macos') return 'macOS'; + if (target === 'steamos') return 'SteamOS'; + return target[0].toUpperCase() + target.slice(1); +} + +function androidReleaseSigningSeverity(buildConfig: ReturnType): DoctorCheck['severity'] { + const release = buildConfig.targets.android?.release; + if (!release) return 'warn'; + const signingConfigured = Boolean(release.keystorePath || release.keyAlias || release.storePasswordEnv || release.keyPasswordEnv); + if (!signingConfigured) return 'warn'; + const keystorePath = release.keystorePath ? resolve(buildConfig.projectDir, release.keystorePath) : ''; + const missingEnv = [ + release.storePasswordEnv && process.env[release.storePasswordEnv] ? '' : release.storePasswordEnv, + release.keyPasswordEnv && process.env[release.keyPasswordEnv] ? '' : release.keyPasswordEnv, + ].filter(Boolean); + return missingEnv.length === 0 && release.keystorePath && existsSync(keystorePath) && release.keyAlias ? 'pass' : 'fail'; +} + +const desktopBuildTargets = ['windows', 'macos', 'linux', 'steamos'] as const; +type DoctorDesktopBuildTarget = typeof desktopBuildTargets[number]; + +function isDoctorDesktopBuildTarget(target: string): target is DoctorDesktopBuildTarget { + return (desktopBuildTargets as readonly string[]).includes(target); +} + +function addDesktopBuildChecks( + checks: DoctorCheck[], + projectDir: string, + buildConfig: ReturnType, + target: string, + options: { fromAll?: boolean } = {}, +): void { + if (!isDoctorDesktopBuildTarget(target)) return; + const label = (base: string) => prefixedCheckLabel(desktopTargetLabel(target), base, Boolean(options.fromAll)); + + const configuredPath = target === 'steamos' + ? buildConfig.targets.steamos?.loveRuntimeDir ?? buildConfig.targets.linux?.loveRuntimeDir + : buildConfig.targets[target]?.loveRuntimeDir; + const runtimeDir = configuredPath ? resolve(projectDir, configuredPath) : ''; + const runtimeReady = Boolean(runtimeDir && desktopRuntimeReady(runtimeDir, target)); + const configField = target === 'steamos' + ? 'targets.steamos.loveRuntimeDir or targets.linux.loveRuntimeDir' + : `targets.${target}.loveRuntimeDir`; + add( + checks, + 'Build', + label(`${desktopTargetLabel(target)} LÖVE runtime`), + runtimeReady ? 'pass' : 'fail', + configuredPath ?? 'not configured', + runtimeReady ? undefined : `Run \`feather build vendor add ${target} --dir ${projectDir}\` or set ${configField} in feather.build.json.`, + ); + + if (target === 'windows') { + addToolCheck(checks, label('NSIS makensis'), 'makensis', ['/VERSION'], 'Install NSIS and make sure makensis is on PATH.'); + return; + } + if (target === 'macos') { + addToolCheck(checks, label('plutil'), 'plutil', ['-help'], 'Use macOS or install Xcode command line tools.'); + addToolCheck(checks, label('hdiutil'), 'hdiutil', ['help'], 'Use macOS for DMG packaging.'); + return; + } + + const appImageTool = runtimeDir ? join(runtimeDir, 'appimagetool.AppImage') : ''; + add( + checks, + 'Build', + label('appimagetool'), + appImageTool && existsSync(appImageTool) ? 'pass' : 'fail', + appImageTool || 'not found', + appImageTool && existsSync(appImageTool) ? undefined : `Run \`feather build vendor add ${target} --dir ${projectDir}\`.`, + ); + addToolCheck(checks, label('tar'), 'tar', ['--version'], 'Install tar or use a shell with standard archive tools available.'); +} + +function desktopRuntimeReady(runtimeDir: string, target: DoctorDesktopBuildTarget): boolean { + if (target === 'windows') return existsSync(join(runtimeDir, 'love.exe')); + if (target === 'macos') return existsSync(join(runtimeDir, 'love.app', 'Contents', 'Info.plist')); + return existsSync(join(runtimeDir, 'squashfs-root', 'bin', 'love')) && existsSync(join(runtimeDir, 'appimagetool.AppImage')); +} + +function desktopTargetLabel(target: DoctorDesktopBuildTarget): string { + if (target === 'macos') return 'macOS'; + if (target === 'steamos') return 'SteamOS'; + return target[0].toUpperCase() + target.slice(1); +} + +function prefixedCheckLabel(prefix: string, base: string, prefixed: boolean): string { + if (!prefixed || base === prefix || base.startsWith(`${prefix} `)) return base; + return `${prefix} ${base}`; +} + +function addToolCheck(checks: DoctorCheck[], label: string, command: string, args: string[], fix: string): void { + const version = commandVersion(command, args); + add( + checks, + 'Build', + label, + version ? 'pass' : 'fail', + version ?? 'not found', + version ? undefined : fix, + ); +} + +function androidReleaseSigningDetail(buildConfig: ReturnType): string { + const release = buildConfig.targets.android?.release; + if (!release) return 'not configured'; + const signingConfigured = Boolean(release.keystorePath || release.keyAlias || release.storePasswordEnv || release.keyPasswordEnv); + if (!signingConfigured) return 'not configured'; + const keystorePath = release.keystorePath ? resolve(buildConfig.projectDir, release.keystorePath) : ''; + const missing = [ + release.keystorePath ? '' : 'keystorePath', + release.keystorePath && !existsSync(keystorePath) ? 'keystore file' : '', + release.keyAlias ? '' : 'keyAlias', + release.storePasswordEnv ? '' : 'storePasswordEnv', + release.keyPasswordEnv ? '' : 'keyPasswordEnv', + release.storePasswordEnv && process.env[release.storePasswordEnv] ? '' : release.storePasswordEnv, + release.keyPasswordEnv && process.env[release.keyPasswordEnv] ? '' : release.keyPasswordEnv, + ].filter(Boolean); + return missing.length === 0 ? 'configured' : `missing ${missing.join(', ')}`; +} + +function androidReleaseSigningFix(buildConfig: ReturnType): string { + const release = buildConfig.targets.android?.release; + if (!release || !(release.keystorePath || release.keyAlias || release.storePasswordEnv || release.keyPasswordEnv)) { + return 'Set targets.android.release keystore fields to create signed release artifacts.'; + } + return 'Set Android signing config paths in feather.build.json and provide password environment variables in your shell or CI.'; +} diff --git a/cli/src/commands/doctor/report.ts b/cli/src/commands/doctor/report.ts new file mode 100644 index 00000000..b5dc4fc8 --- /dev/null +++ b/cli/src/commands/doctor/report.ts @@ -0,0 +1,43 @@ +import { icon as statusIcon, printBlank, printDanger, printHeading, printLine, printMuted, printSuccess, printWarning, style } from '../../lib/output.js'; +import { type DoctorCheck, type Severity, severityOrder } from './checks.js'; + +function icon(severity: Severity): string { + if (severity === 'pass') return statusIcon.success; + if (severity === 'warn') return statusIcon.warning; + if (severity === 'fail') return statusIcon.error; + return statusIcon.info; +} + +function colorLabel(severity: Severity, label: string): string { + if (severity === 'warn') return style.warning(label); + if (severity === 'fail') return style.danger(label); + return label; +} + +export function renderReport(checks: DoctorCheck[], projectDir: string): void { + printHeading('\nFeather doctor\n'); + printMuted(`Project: ${projectDir}\n`); + + const groups = [...new Set(checks.map((check) => check.group))]; + for (const group of groups) { + printHeading(group); + for (const check of checks.filter((item) => item.group === group).sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity])) { + printLine(` ${icon(check.severity)} ${colorLabel(check.severity, check.label)}${check.detail ? style.muted(` ${check.detail}`) : ''}`); + if (check.fix) printMuted(` → ${check.fix}`); + } + printBlank(); + } + + const failures = checks.filter((check) => check.severity === 'fail'); + const warnings = checks.filter((check) => check.severity === 'warn'); + const passed = checks.filter((check) => check.severity === 'pass'); + + if (failures.length > 0) { + printDanger(`Doctor found ${failures.length} blocker${failures.length === 1 ? '' : 's'}.`); + } else if (warnings.length > 0) { + printWarning(`Doctor passed with ${warnings.length} warning${warnings.length === 1 ? '' : 's'}.`); + } else { + printSuccess('Doctor found no problems.'); + } + printMuted(`${passed.length} passed, ${warnings.length} warnings, ${failures.length} failures.\n`); +} diff --git a/cli/src/commands/doctor/security.ts b/cli/src/commands/doctor/security.ts new file mode 100644 index 00000000..cc3b974d --- /dev/null +++ b/cli/src/commands/doctor/security.ts @@ -0,0 +1,118 @@ +import type { PluginTrust } from '../../lib/plugin-utils.js'; +import type { LockfileUrlFinding } from '../../lib/package/provenance.js'; +import { packageUrlSummary } from '../../lib/package/provenance.js'; +import type { DoctorCheck } from './checks.js'; + +export type SecurityPluginReport = { + id: string; + trust: PluginTrust; + dangerous: boolean; + malformed: boolean; +}; + +export type SecurityPackageReport = { + id: string; + version: string; + trust: string; + source: string; +}; + +export type SecurityReportInput = { + checks: DoctorCheck[]; + mode: string; + installDir: string; + host: string; + port: number; + appIdMissing: boolean; + consoleIncluded: boolean; + weakApiKey: boolean; + insecureConnection: boolean; + captureScreenshot: boolean; + hotReloadEnabled: boolean; + broadHotReload: boolean; + hotReloadPersistence: boolean; + debuggerEnabled: boolean; + writeToDisk: boolean; + networkExposure: 'loopback' | 'wildcard' | 'lan' | 'non-loopback'; + weakNetworkAuth: boolean; + runtimeEmbedded: boolean; + runtimeManaged: boolean; + symlinkEscapes: number; + includedPlugins: string[]; + missingIncludedPlugins: string[]; + unknownIncludedPlugins: string[]; + installedPlugins: SecurityPluginReport[]; + packages: SecurityPackageReport[]; + untrustedPackageSources: LockfileUrlFinding[]; +}; + +export function isSecurityCheck(check: DoctorCheck): boolean { + return ( + check.group === 'Safety' + || check.group === 'Plugins' + || check.group === 'Packages' + || check.group === 'Runtime' + || check.label === 'feather.config.lua' + ); +} + +export function securityChecks(checks: DoctorCheck[]): DoctorCheck[] { + return checks.filter(isSecurityCheck); +} + +export function buildSecurityReport(input: SecurityReportInput): Record { + const checks = securityChecks(input.checks); + const failures = checks.filter((check) => check.severity === 'fail').length; + const warnings = checks.filter((check) => check.severity === 'warn').length; + + return { + summary: { + failures, + warnings, + dangerousPlugins: input.installedPlugins.filter((plugin) => plugin.dangerous).length, + untrustedPackageSources: input.untrustedPackageSources.length, + }, + config: { + mode: input.mode, + appId: input.appIdMissing ? 'missing' : input.mode === 'disk' ? 'not-required' : 'configured', + apiKeyStatus: input.consoleIncluded ? (input.weakApiKey ? 'weak-or-missing' : 'configured') : 'not-required', + insecureConnection: input.insecureConnection, + consoleIncluded: input.consoleIncluded, + captureScreenshot: input.captureScreenshot, + hotReloadEnabled: input.hotReloadEnabled, + hotReloadWildcardAllowlist: input.broadHotReload, + hotReloadPersistence: input.hotReloadPersistence, + stepDebugger: input.debuggerEnabled, + writeToDisk: input.writeToDisk, + }, + network: { + host: input.host, + port: input.port, + exposure: input.networkExposure, + weakAuth: input.weakNetworkAuth, + }, + runtime: { + embedded: input.runtimeEmbedded, + managed: input.runtimeManaged, + installDir: input.installDir, + symlinkEscapes: input.symlinkEscapes, + }, + plugins: { + included: input.includedPlugins, + missingIncluded: input.missingIncludedPlugins, + unknownIncluded: input.unknownIncludedPlugins, + installed: input.installedPlugins, + }, + packages: { + total: input.packages.length, + experimental: input.packages.filter((pkg) => pkg.trust === 'experimental').length, + installed: input.packages, + untrustedSources: input.untrustedPackageSources.map((finding) => ({ + id: finding.id, + target: finding.target, + source: packageUrlSummary(finding.url), + reason: finding.reason, + })), + }, + }; +} diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index c5e722c0..4116310b 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -1,8 +1,5 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { basename, dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import chalk from 'chalk'; -import ora from 'ora'; +import { basename, join, resolve } from 'node:path'; import { fetchManifest, getLocalPluginIds, @@ -14,8 +11,12 @@ import { normalizeInstallDir, } from '../lib/install.js'; import { configTemplate, luaKey, luaValue } from '../lib/config.js'; -import { chooseInitMode, type InitMode, type InitSetup } from '../ui/init-mode.js'; +import { chooseInitMode, type InitMode, type InitSetup } from '../ui/init/index.js'; import { pluginCatalog } from '../generated/plugin-catalog.js'; +import { resolveLocalLuaRoot } from '../lib/paths.js'; +import { fail } from '../lib/command.js'; +import { createSpinner, icon, printLine, printMuted, printWarning, style } from '../lib/output.js'; +import { assertSafeProjectTarget } from '../lib/path-safety.js'; export interface InitOptions { branch?: string; @@ -29,7 +30,6 @@ export interface InitOptions { } const knownPlugins = pluginCatalog.map((plugin) => plugin.id); -const __dirname = dirname(fileURLToPath(import.meta.url)); const toLocalName = (id: string) => id @@ -75,26 +75,17 @@ function patchMainLuaForManual(mainPath: string): boolean { return true; } -function bundledLuaRoot(): string { - return resolve(__dirname, '../../lua'); -} - -function repoLuaRoot(): string | null { - const candidate = resolve(__dirname, '../../../src-lua'); - return existsSync(join(candidate, 'feather', 'init.lua')) ? candidate : null; -} - -function resolveLocalLuaRoot(opts: InitOptions): string { - if (opts.localSrc) return resolve(opts.localSrc); - return repoLuaRoot() ?? bundledLuaRoot(); -} - export async function initCommand(dir: string, opts: InitOptions): Promise { const target = resolve(dir); - if (!existsSync(join(target, 'main.lua'))) { - console.error(chalk.red(`No main.lua found in ${target}. Is this a love2d project?`)); - process.exit(1); + let mainPath: string; + try { + mainPath = assertSafeProjectTarget(target, 'main.lua', 'main.lua write target'); + } catch (err) { + fail((err as Error).message); + } + if (!existsSync(mainPath)) { + fail(`No main.lua found in ${target}. Is this a love2d project?`); } const setup: InitSetup = @@ -111,8 +102,14 @@ export async function initCommand(dir: string, opts: InitOptions): Promise }; const mode = setup.mode; const installDir = normalizeInstallDir(setup.installDir); + let installInitPath: string; + try { + installInitPath = assertSafeProjectTarget(target, join(installDir, 'init.lua'), 'Core install target'); + } catch (err) { + fail((err as Error).message); + } const pluginsDisabled = opts.noPlugins || setup.installPlugins === false; - const alreadyInstalled = existsSync(join(target, installDir, 'init.lua')); + const alreadyInstalled = existsSync(installInitPath); const useRemote = opts.remote === true || setup.source === 'remote'; if (mode === 'cli') { @@ -121,15 +118,15 @@ export async function initCommand(dir: string, opts: InitOptions): Promise installDir, source: useRemote ? `github:${setup.branch || opts.branch || 'main'}` : 'local', }); - console.log('\n' + chalk.bold('Done!') + ' Run this project through Feather CLI.\n'); - console.log(chalk.dim(` feather run ${dir}`)); - console.log(chalk.dim(' Use `--config ` if feather.config.lua lives elsewhere.\n')); + printLine(`\n${style.heading('Done!')} Run this project through Feather CLI.\n`); + printMuted(` feather run ${dir}`); + printMuted(' Use `--config ` if feather.config.lua lives elsewhere.\n'); return; } if (alreadyInstalled) { - console.log(chalk.yellow('Feather is already installed in this project.')); - console.log(chalk.dim('Run `feather update` to update to the latest version.')); + printWarning('Feather is already installed in this project.'); + printMuted('Run `feather update` to update to the latest version.'); } const branch = setup.branch || opts.branch || 'main'; @@ -138,17 +135,17 @@ export async function initCommand(dir: string, opts: InitOptions): Promise if (!alreadyInstalled) { if (useRemote) { - const spinner = ora('Fetching manifest…').start(); + const spinner = createSpinner('Fetching manifest…').start(); try { entries = await fetchManifest(branch); spinner.succeed(`Manifest loaded (${entries.length} files)`); } catch (err) { spinner.fail(`Could not fetch manifest: ${(err as Error).message}`); - process.exit(1); + fail((err as Error).message, { cause: err, silent: true }); } - const coreSpinner = ora('Installing feather core from GitHub…').start(); + const coreSpinner = createSpinner('Installing feather core from GitHub…').start(); try { await installCore(entries, target, branch, (f) => { coreSpinner.text = `Installing ${f}…`; @@ -156,14 +153,14 @@ export async function initCommand(dir: string, opts: InitOptions): Promise coreSpinner.succeed('Feather core installed'); } catch (err) { coreSpinner.fail(`Core install failed: ${(err as Error).message}`); - process.exit(1); + fail((err as Error).message, { cause: err, silent: true }); } if (!pluginsDisabled) { const excluded = new Set(setup.exclude); const pluginIds = (opts.plugins ?? getPluginIds(entries)).filter((id) => !excluded.has(id)); installedPluginIds = pluginIds; - const pluginSpinner = ora(`Installing ${pluginIds.length} plugins from GitHub…`).start(); + const pluginSpinner = createSpinner(`Installing ${pluginIds.length} plugins from GitHub…`).start(); let failed = 0; for (const id of pluginIds) { try { @@ -173,11 +170,11 @@ export async function initCommand(dir: string, opts: InitOptions): Promise failed++; } } - pluginSpinner.succeed(`Plugins installed${failed > 0 ? chalk.yellow(` (${failed} failed)`) : ''}`); + pluginSpinner.succeed(`Plugins installed${failed > 0 ? style.warning(` (${failed} failed)`) : ''}`); } } else { const sourceRoot = resolveLocalLuaRoot(opts); - const coreSpinner = ora(`Copying feather core from ${sourceRoot}…`).start(); + const coreSpinner = createSpinner(`Copying feather core from ${sourceRoot}…`).start(); try { installCoreFromLocal(sourceRoot, target, installDir, (f) => { coreSpinner.text = `Copying ${f}…`; @@ -185,14 +182,14 @@ export async function initCommand(dir: string, opts: InitOptions): Promise coreSpinner.succeed('Feather core installed'); } catch (err) { coreSpinner.fail(`Core install failed: ${(err as Error).message}`); - process.exit(1); + fail((err as Error).message, { cause: err, silent: true }); } if (!pluginsDisabled) { const excluded = new Set(setup.exclude); const pluginIds = (opts.plugins ?? getLocalPluginIds(sourceRoot)).filter((id) => !excluded.has(id)); installedPluginIds = pluginIds; - const pluginSpinner = ora(`Copying ${pluginIds.length} plugins…`).start(); + const pluginSpinner = createSpinner(`Copying ${pluginIds.length} plugins…`).start(); let failed = 0; for (const id of pluginIds) { try { @@ -202,19 +199,17 @@ export async function initCommand(dir: string, opts: InitOptions): Promise failed++; } } - pluginSpinner.succeed(`Plugins installed${failed > 0 ? chalk.yellow(` (${failed} failed)`) : ''}`); + pluginSpinner.succeed(`Plugins installed${failed > 0 ? style.warning(` (${failed} failed)`) : ''}`); } } } - const mainPath = join(target, 'main.lua'); - if (mode === 'auto') { const patched = patchMainLua(mainPath, installDir); if (patched) { - console.log(chalk.green('✔') + ' Patched main.lua with feather.auto require'); + printLine(`${icon.success} Patched main.lua with feather.auto require`); } else { - console.log(chalk.dim(' main.lua already references feather — skipped patch')); + printMuted(' main.lua already references feather — skipped patch'); } } else if (mode === 'manual') { const pluginIds = @@ -227,15 +222,15 @@ export async function initCommand(dir: string, opts: InitOptions): Promise const patched = patchMainLuaForManual(mainPath); if (created) { - console.log(chalk.green('✔') + ' Created feather.debugger.lua'); + printLine(`${icon.success} Created feather.debugger.lua`); } else { - console.log(chalk.dim(' feather.debugger.lua already exists — skipped')); + printMuted(' feather.debugger.lua already exists — skipped'); } if (patched) { - console.log(chalk.green('✔') + ' Patched main.lua with feather.debugger.lua loader'); + printLine(`${icon.success} Patched main.lua with feather.debugger.lua loader`); } else { - console.log(chalk.dim(' main.lua already references Feather — skipped patch')); + printMuted(' main.lua already references Feather — skipped patch'); } } @@ -246,12 +241,12 @@ export async function initCommand(dir: string, opts: InitOptions): Promise manualEntrypoint: mode === 'manual' ? 'feather.debugger.lua' : undefined, }); - console.log('\n' + chalk.bold('Done!') + ' Start the Feather desktop app, then run your game.\n'); + printLine(`\n${style.heading('Done!')} Start the Feather desktop app, then run your game.\n`); if (mode === 'manual') { - console.log(chalk.dim(' Manual setup lives in feather.debugger.lua and is loaded from main.lua.\n')); + printMuted(' Manual setup lives in feather.debugger.lua and is loaded from main.lua.\n'); } else { - console.log(chalk.dim(' Tip: use `feather run .` to inject without touching game code.\n')); + printMuted(' Tip: use `feather run .` to inject without touching game code.\n'); } } @@ -260,17 +255,17 @@ function writeConfig( config: Record = {}, context: { mode?: string; installDir?: string; source?: string; manualEntrypoint?: string } = {}, ): void { - const configPath = join(target, 'feather.config.lua'); + const configPath = assertSafeProjectTarget(target, 'feather.config.lua', 'Config write target'); if (!existsSync(configPath)) { writeFileSync(configPath, configTemplate(config, context)); - console.log(chalk.green('✔') + ' Created feather.config.lua'); + printLine(`${icon.success} Created feather.config.lua`); } else { - console.log(chalk.dim(' feather.config.lua already exists — skipped')); + printMuted(' feather.config.lua already exists — skipped'); } } function writeManualDebugger(target: string, config: Record, pluginIds: string[], installDir: string): boolean { - const manualPath = join(target, 'feather.debugger.lua'); + const manualPath = assertSafeProjectTarget(target, 'feather.debugger.lua', 'Manual debugger write target'); if (existsSync(manualPath)) return false; writeFileSync(manualPath, buildManualDebuggerFile(config, pluginIds, installDir)); return true; diff --git a/cli/src/commands/package.ts b/cli/src/commands/package.ts index 20c7774f..be79a465 100644 --- a/cli/src/commands/package.ts +++ b/cli/src/commands/package.ts @@ -1,531 +1,2 @@ -import { existsSync, rmSync } from 'node:fs'; -import { join, resolve } from 'node:path'; -import chalk from 'chalk'; -import ora from 'ora'; -import { loadRegistry } from '../lib/package/registry.js'; -import { readLockfile, writeLockfile, removeFromLockfile } from '../lib/package/lockfile.js'; -import { resolveMany, type ResolvedPackage } from '../lib/package/resolve.js'; -import { installFromUrl, restorePackage } from '../lib/package/install.js'; -import { auditLockfile } from '../lib/package/audit.js'; -import { showPackageBrowser } from '../ui/package-workflow.js'; -import { showInstallProgress } from '../ui/package-progress.js'; +export * from './package/index.js'; -function findProjectDir(cwd = process.cwd()): string { - if (existsSync(join(cwd, 'main.lua'))) return cwd; - return cwd; -} - -function trustBadge(trust: string): string { - if (trust === 'verified') return chalk.green('[verified]'); - if (trust === 'known') return chalk.yellow('[known]'); - return chalk.red('[experimental]'); -} - -export type PackageSearchOptions = { - offline?: boolean; - registryUrl?: string; -}; - -export async function packageSearchCommand(query: string | undefined, opts: PackageSearchOptions = {}): Promise { - const spinner = ora('Loading registry…').start(); - let registry; - try { - registry = await loadRegistry(opts); - spinner.stop(); - } catch (err) { - spinner.fail(`Failed to load registry: ${(err as Error).message}`); - process.exitCode = 1; - return; - } - - const q = query?.toLowerCase(); - const entries = Object.entries(registry.packages).filter(([, entry]) => !entry.parent); - - const matches = q - ? entries.filter( - ([id, entry]) => - id.includes(q) || entry.description.toLowerCase().includes(q) || entry.tags.some((t) => t.includes(q)), - ) - : entries; - - if (matches.length === 0) { - console.log(chalk.dim(`No packages found${q ? ` matching "${query}"` : ''}.`)); - return; - } - - const maxId = Math.max(...matches.map(([id]) => id.length)); - for (const [id, entry] of matches.sort(([a], [b]) => a.localeCompare(b))) { - const badge = trustBadge(entry.trust); - console.log(` ${chalk.bold(id.padEnd(maxId + 2))} ${badge} ${chalk.dim(entry.description)}`); - if (entry.subpackages?.length) { - console.log(chalk.dim(` ${''.padEnd(maxId + 2)} subpackages: ${entry.subpackages.join(', ')}`)); - } - } - console.log(chalk.dim(`\n${matches.length} package(s). Run \`feather package info \` for details.`)); -} - -export type PackageListOptions = { - installed?: boolean; - offline?: boolean; - refresh?: boolean; - dir?: string; - registryUrl?: string; -}; - -export async function packageListCommand(opts: PackageListOptions = {}): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - - if (opts.installed) { - const lockfile = readLockfile(projectDir); - const entries = Object.entries(lockfile.packages); - if (entries.length === 0) { - console.log(chalk.dim('No packages installed. Run `feather package install `.')); - return; - } - for (const [id, entry] of entries) { - console.log(` ${trustBadge(entry.trust)} ${chalk.bold(id)} @ ${entry.version}`); - } - console.log(chalk.dim(`\n${entries.length} package(s) installed.`)); - return; - } - - const spinner = ora('Loading registry…').start(); - let registry; - try { - registry = await loadRegistry({ offline: opts.offline, refresh: opts.refresh, registryUrl: opts.registryUrl }); - spinner.stop(); - } catch (err) { - spinner.fail(`Failed to load registry: ${(err as Error).message}`); - process.exitCode = 1; - return; - } - - const lockfile = readLockfile(projectDir); - - // Fall back to plain text when stdin is not a TTY (scripts, piped output) - if (!process.stdin.isTTY || !process.stdout.isTTY) { - const entries = Object.entries(registry.packages).filter(([, e]) => !e.parent); - for (const [id, entry] of entries.sort(([a], [b]) => a.localeCompare(b))) { - const installed = lockfile.packages[id]; - const installedLabel = installed ? chalk.cyan(` (installed ${installed.version})`) : ''; - console.log(` ${trustBadge(entry.trust)} ${chalk.bold(id)}${installedLabel} ${chalk.dim(entry.description)}`); - } - console.log(chalk.dim(`\n${entries.length} available.`)); - return; - } - - const result = await showPackageBrowser({ registry, lockfile }); - if (result.action === 'cancel') return; - - const { resolved, errors } = resolveMany([result.id], registry); - if (errors.length) { - for (const e of errors) console.log(chalk.red(` ✖ ${e}`)); - process.exitCode = 1; - return; - } - - if (result.action === 'remove') { - await packageRemoveCommand(result.id, { dir: opts.dir }); - return; - } - - const installResults = await showInstallProgress({ packages: resolved, lockfile, projectDir }); - if (installResults.every((r) => r.ok)) { - writeLockfile(projectDir, lockfile); - } else { - process.exitCode = 1; - } -} - -export type PackageInfoOptions = { - offline?: boolean; - dir?: string; - registryUrl?: string; -}; - -export async function packageInfoCommand(name: string, opts: PackageInfoOptions = {}): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - const lockfile = readLockfile(projectDir); - - const spinner = ora('Loading registry…').start(); - let registry; - try { - registry = await loadRegistry({ offline: opts.offline, registryUrl: opts.registryUrl }); - spinner.stop(); - } catch (err) { - spinner.fail(`Failed to load registry: ${(err as Error).message}`); - process.exitCode = 1; - return; - } - - const entry = registry.packages[name]; - if (!entry) { - console.log(chalk.red(`Package "${name}" not found.`)); - process.exitCode = 1; - return; - } - - const installed = lockfile.packages[name]; - console.log(); - console.log(`${chalk.bold(name)} ${trustBadge(entry.trust)}`); - console.log(chalk.dim(entry.description)); - console.log(); - console.log(` Source: ${chalk.cyan(`github.com/${entry.source.repo}`)}`); - console.log(` Version: ${entry.source.tag}`); - console.log(` Tags: ${entry.tags.join(', ') || '—'}`); - if (entry.homepage) console.log(` Docs: ${chalk.cyan(entry.homepage)}`); - if (installed) { - console.log(` Status: ${chalk.green('installed')} @ ${installed.version}`); - } - if (entry.subpackages?.length) { - console.log(` Modules: ${entry.subpackages.join(', ')}`); - } - console.log(); - console.log(' Files to install:'); - for (const f of entry.install.files) { - console.log(` ${chalk.dim(f.name)} → ${f.target}`); - } - console.log(); - console.log(' Usage:'); - console.log(` ${chalk.cyan(entry.example ?? `local lib = require("${entry.require}")`)}`); - console.log(); -} - -export type PackageInstallOptions = { - dryRun?: boolean; - allowUntrusted?: boolean; - target?: string; - fromUrl?: string; - dir?: string; - offline?: boolean; - yes?: boolean; - registryUrl?: string; -}; - -export async function packageInstallCommand(names: string[], opts: PackageInstallOptions = {}): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - - if (opts.fromUrl) { - if (!opts.allowUntrusted && !opts.yes) { - console.log(); - console.log(chalk.yellow('Installing from untrusted URL')); - console.log(` URL: ${opts.fromUrl}`); - if (!opts.target) { - console.log(chalk.red(' --target is required with --from-url')); - process.exitCode = 1; - return; - } - console.log(` Target: ${opts.target}`); - console.log(); - console.log(chalk.red(' This package has NOT been reviewed by the Feather team.')); - console.log(chalk.dim(' Use --allow-untrusted to confirm you trust this source.')); - process.exitCode = 1; - return; - } - - if (!opts.target) { - console.log(chalk.red('--target is required with --from-url')); - process.exitCode = 1; - return; - } - - const spinner = ora(`Fetching ${opts.fromUrl}…`).start(); - const lockfile = readLockfile(projectDir); - const result = await installFromUrl(lockfile, { - projectDir, - url: opts.fromUrl, - target: opts.target, - dryRun: opts.dryRun, - }); - - if (!result.ok) { - spinner.fail(result.error ?? 'Install failed'); - process.exitCode = 1; - return; - } - - if (opts.dryRun) { - spinner.stop(); - console.log(); - console.log(chalk.yellow('Dry run — no files written')); - console.log(` URL: ${opts.fromUrl}`); - console.log(` SHA-256: ${result.sha256}`); - console.log(` Size: ${result.size} bytes`); - console.log(` Target: ${result.target}`); - console.log(chalk.yellow(' Trust: experimental ⚠')); - return; - } - - spinner.succeed('Installed from URL (experimental)'); - console.log(); - console.log(` SHA-256: ${result.sha256}`); - console.log(` Target: ${result.target}`); - console.log(chalk.yellow(' Trust: experimental ⚠ — not reviewed by the Feather team')); - writeLockfile(projectDir, lockfile); - return; - } - - // No names → restore everything recorded in feather.lock.json - if (names.length === 0) { - const lockfile = readLockfile(projectDir); - const entries = Object.entries(lockfile.packages).filter(([, e]) => !e.parent); - - if (entries.length === 0) { - console.log(chalk.dim('feather.lock.json is empty. Run `feather package install ` to add packages.')); - return; - } - - console.log(); - const auditResults = await auditLockfile(projectDir, lockfile); - const broken = new Set(auditResults.filter((r) => r.status !== 'verified').map((r) => r.id)); - - if (broken.size === 0) { - console.log(chalk.green(`✔ All ${entries.length} package(s) already up to date.`)); - return; - } - - let failed = false; - for (const [id, entry] of entries) { - if (!broken.has(id)) { - console.log(chalk.dim(` ${id} @ ${entry.version} — up to date`)); - continue; - } - const spinner = ora(` ${id} @ ${entry.version}`).start(); - const result = await restorePackage(id, entry, { projectDir, dryRun: opts.dryRun }); - if (result.ok) { - spinner.succeed(` ${id} @ ${entry.version}`); - } else { - spinner.fail(` ${id}: ${result.error}`); - failed = true; - } - } - - console.log(); - if (failed) process.exitCode = 1; - return; - } - - const spinner = ora('Loading registry…').start(); - let registry; - try { - registry = await loadRegistry({ offline: opts.offline, registryUrl: opts.registryUrl }); - spinner.stop(); - } catch (err) { - spinner.fail(`Failed to load registry: ${(err as Error).message}`); - process.exitCode = 1; - return; - } - - const lockfile = readLockfile(projectDir); - const { resolved, errors } = resolveMany(names, registry); - - if (errors.length) { - for (const e of errors) console.log(chalk.red(` ✖ ${e}`)); - process.exitCode = 1; - return; - } - - // Check already installed (skip check when a version override is requested) - const toInstall = resolved.filter((pkg) => { - if (pkg.versionOverride) return true; - const existing = lockfile.packages[pkg.id]; - if (existing && existing.version === pkg.entry.source.tag) { - console.log(chalk.dim(` ${pkg.id} is already installed at ${existing.version}`)); - return false; - } - return true; - }); - - if (toInstall.length === 0) return; - - // Block experimental or version-overridden packages without --allow-untrusted - for (const pkg of toInstall) { - if (pkg.entry.trust === 'experimental' && !opts.allowUntrusted) { - console.log(chalk.red(` "${pkg.id}" requires --allow-untrusted (trust: experimental)`)); - process.exitCode = 1; - return; - } - if (pkg.versionOverride && !opts.allowUntrusted) { - console.log( - chalk.red( - ` "${pkg.id}@${pkg.versionOverride}" requires --allow-untrusted — this version has not been reviewed by Feather`, - ), - ); - process.exitCode = 1; - return; - } - } - - if (opts.dryRun) { - console.log(); - for (const pkg of toInstall) { - const displayVersion = pkg.versionOverride ?? pkg.entry.source.tag; - console.log(` ${chalk.bold(pkg.id)} ${trustBadge(pkg.versionOverride ? 'experimental' : pkg.entry.trust)}`); - console.log(` Source: github.com/${pkg.entry.source.repo} Version: ${displayVersion}`); - for (const f of pkg.files) { - console.log(` ${chalk.dim(f.name)} → ${f.target}`); - } - console.log(); - } - console.log(chalk.yellow('Dry run — no files written.')); - return; - } - - const results = await showInstallProgress({ packages: toInstall, lockfile, projectDir, targetOverride: opts.target }); - if (results.every((r) => r.ok)) { - writeLockfile(projectDir, lockfile); - } else { - process.exitCode = 1; - } -} - -export type PackageUpdateOptions = { - dryRun?: boolean; - dir?: string; - offline?: boolean; - registryUrl?: string; -}; - -export async function packageUpdateCommand(name: string | undefined, opts: PackageUpdateOptions = {}): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - const lockfile = readLockfile(projectDir); - - const installed = Object.entries(lockfile.packages); - if (installed.length === 0) { - console.log(chalk.dim('No packages installed.')); - return; - } - - const spinner = ora('Loading registry…').start(); - let registry; - try { - registry = await loadRegistry({ offline: opts.offline, registryUrl: opts.registryUrl }); - spinner.stop(); - } catch (err) { - spinner.fail(`Failed to load registry: ${(err as Error).message}`); - process.exitCode = 1; - return; - } - - const targets = name ? installed.filter(([id]) => id === name) : installed; - - if (name && targets.length === 0) { - console.log(chalk.red(`"${name}" is not installed.`)); - process.exitCode = 1; - return; - } - - const toUpdate: ResolvedPackage[] = []; - for (const [id, current] of targets) { - if (current.trust === 'experimental') { - console.log(chalk.dim(` Skipping "${id}" (experimental — re-install with --from-url to update)`)); - continue; - } - const entry = registry.packages[id]; - if (!entry) { - console.log(chalk.yellow(` "${id}" not found in registry — skipping`)); - continue; - } - if (entry.source.tag === current.version) { - console.log(chalk.dim(` ${id} is already up to date (${current.version})`)); - continue; - } - console.log(` ${chalk.bold(id)}: ${current.version} → ${entry.source.tag}`); - toUpdate.push({ id, entry, files: entry.install.files }); - } - - if (opts.dryRun || toUpdate.length === 0) return; - - const results = await showInstallProgress({ packages: toUpdate, lockfile, projectDir }); - if (results.every((r) => r.ok)) { - writeLockfile(projectDir, lockfile); - } else { - process.exitCode = 1; - } -} - -export type PackageRemoveOptions = { - dir?: string; - yes?: boolean; -}; - -export async function packageRemoveCommand(name: string, opts: PackageRemoveOptions = {}): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - const lockfile = readLockfile(projectDir); - - const entry = lockfile.packages[name]; - if (!entry) { - console.log(chalk.red(`"${name}" is not installed.`)); - process.exitCode = 1; - return; - } - - for (const file of entry.files) { - const abs = join(projectDir, file.target); - if (existsSync(abs)) { - rmSync(abs); - console.log(chalk.dim(` removed ${file.target}`)); - } - } - - removeFromLockfile(lockfile, name); - writeLockfile(projectDir, lockfile); - console.log(` ${chalk.bold(name)} removed.`); -} - -export type PackageAuditOptions = { - dir?: string; - json?: boolean; -}; - -export async function packageAuditCommand(opts: PackageAuditOptions = {}): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - const lockfile = readLockfile(projectDir); - - const entries = Object.values(lockfile.packages); - if (entries.length === 0) { - console.log(chalk.dim('No packages installed.')); - return; - } - - const spinner = ora('Auditing…').start(); - const results = await auditLockfile(projectDir, lockfile); - spinner.stop(); - - if (opts.json) { - console.log(JSON.stringify(results, null, 2)); - if (results.some((r) => r.status !== 'verified')) process.exitCode = 1; - return; - } - - console.log(chalk.bold(`\nAuditing ${entries.length} installed package(s)…\n`)); - - const maxId = Math.max(...results.map((r) => r.id.length)); - for (const r of results) { - if (r.status === 'verified') { - console.log(` ${chalk.green('✔')} ${r.id.padEnd(maxId + 2)} ${chalk.dim(r.target)} ${chalk.green('verified')}`); - } else if (r.status === 'missing') { - console.log( - ` ${chalk.yellow('!')} ${r.id.padEnd(maxId + 2)} ${chalk.dim(r.target)} ${chalk.yellow('missing')}`, - ); - } else { - console.log( - ` ${chalk.red('✖')} ${r.id.padEnd(maxId + 2)} ${chalk.dim(r.target)} ${chalk.red('MODIFIED ← SHA-256 mismatch')}`, - ); - } - } - - const bad = results.filter((r) => r.status !== 'verified'); - console.log(); - if (bad.length === 0) { - console.log(chalk.green.bold('All packages verified.')); - } else { - console.log( - chalk.red.bold( - `${bad.length} issue(s) found. Re-install affected packages with \`feather package install \`.`, - ), - ); - process.exitCode = 1; - } - console.log(); -} diff --git a/cli/src/commands/package/add.ts b/cli/src/commands/package/add.ts new file mode 100644 index 00000000..a3e3c783 --- /dev/null +++ b/cli/src/commands/package/add.ts @@ -0,0 +1,46 @@ +import { fail } from '../../lib/command.js'; +import { installPackageAddPlan, packageAddPlanFiles } from '../../lib/package/add-plan.js'; +import { readLockfile } from '../../lib/package/lockfile.js'; +import { createSpinner, printBlank, printKeyValues, printMuted, style } from '../../lib/output.js'; +import { showAddWizard } from '../../ui/package/index.js'; +import { ensurePackageAddInteractive, resolvePackageProjectDir } from './shared.js'; + +export type PackageAddOptions = { + dir?: string; +}; + +export async function packageAddCommand(opts: PackageAddOptions = {}): Promise { + if (!ensurePackageAddInteractive()) return; + + const projectDir = resolvePackageProjectDir(opts.dir); + const lockfile = readLockfile(projectDir); + const plan = await showAddWizard({ projectDir, lockfile }); + if (!plan) { + printMuted('Package add cancelled.'); + return; + } + + const spinner = createSpinner(`Installing ${plan.id}…`).start(); + const result = await installPackageAddPlan({ + plan, + projectDir, + lockfile, + onFileStart: (name) => { + spinner.text = `Installing ${name}…`; + }, + }); + + if (!result.ok) { + spinner.fail(result.error ?? 'Install failed'); + fail(result.error ?? 'Install failed', { silent: true }); + } + + spinner.succeed(`Installed ${plan.id}`); + printBlank(); + printKeyValues([ + ['Package', plan.id], + ['Files', packageAddPlanFiles(plan).map((file) => `${file.name} -> ${file.target}`).join(', ')], + ['Usage', `local ${plan.id.replace(/[.-]/g, '_')} = require('${plan.requirePath}')`], + ['Trust', style.warning('experimental; not reviewed by the Feather team')], + ]); +} diff --git a/cli/src/commands/package/audit.ts b/cli/src/commands/package/audit.ts new file mode 100644 index 00000000..5eebaf53 --- /dev/null +++ b/cli/src/commands/package/audit.ts @@ -0,0 +1,79 @@ +import { auditLockfile } from '../../lib/package/audit.js'; +import { readLockfile } from '../../lib/package/lockfile.js'; +import { fail } from '../../lib/command.js'; +import { + createSpinner, + icon, + printBlank, + printDanger, + printHeading, + printJson, + printMuted, + printStatus, + printTable, + style, +} from '../../lib/output.js'; +import { resolvePackageProjectDir } from './shared.js'; + +export type PackageAuditOptions = { + dir?: string; + json?: boolean; +}; + +export async function packageAuditCommand(opts: PackageAuditOptions = {}): Promise { + const projectDir = resolvePackageProjectDir(opts.dir); + const lockfile = readLockfile(projectDir); + + const entries = Object.values(lockfile.packages); + if (entries.length === 0) { + printMuted('No packages installed.'); + return; + } + + const spinner = createSpinner('Auditing…').start(); + const results = await auditLockfile(projectDir, lockfile); + spinner.stop(); + + if (opts.json) { + printJson(results); + if (results.some((r) => r.status !== 'verified')) fail('', { silent: true }); + return; + } + + printHeading(`\nAuditing ${entries.length} installed package(s)…\n`); + + printTable({ + columns: [ + { + key: 'status', + label: '', + color: (value, row) => + row.status === 'verified' ? style.success(value) : row.status === 'missing' ? style.warning(value) : style.danger(value), + }, + { key: 'id', label: 'PACKAGE', color: (value) => style.info(value) }, + { key: 'target', label: 'TARGET', color: (value) => style.muted(value) }, + { + key: 'message', + label: 'RESULT', + color: (value, row) => + row.status === 'verified' ? style.success(value) : row.status === 'missing' ? style.warning(value) : style.danger(value), + }, + ], + rows: results.map((result) => ({ + status: result.status === 'verified' ? icon.success : result.status === 'missing' ? icon.warning : icon.error, + id: result.id, + target: result.target, + message: result.status === 'modified' ? 'MODIFIED ← SHA-256 mismatch' : result.status, + })), + }); + + const bad = results.filter((r) => r.status !== 'verified'); + printBlank(); + if (bad.length === 0) { + printStatus('success', 'All packages verified.'); + } else { + printDanger(`${bad.length} issue(s) found. Re-install affected packages with \`feather package install \`.`); + fail('', { silent: true }); + } + printBlank(); +} diff --git a/cli/src/commands/package/index.ts b/cli/src/commands/package/index.ts new file mode 100644 index 00000000..80151b3f --- /dev/null +++ b/cli/src/commands/package/index.ts @@ -0,0 +1,9 @@ +export * from './add.js'; +export * from './audit.js'; +export * from './info.js'; +export * from './install.js'; +export * from './list.js'; +export * from './remove.js'; +export * from './search.js'; +export * from './update.js'; + diff --git a/cli/src/commands/package/info.ts b/cli/src/commands/package/info.ts new file mode 100644 index 00000000..01cf326b --- /dev/null +++ b/cli/src/commands/package/info.ts @@ -0,0 +1,50 @@ +import { readLockfile } from '../../lib/package/lockfile.js'; +import { fail } from '../../lib/command.js'; +import { printBlank, printLine, printMuted, style } from '../../lib/output.js'; +import { trustBadge } from '../../lib/trust.js'; +import { loadRegistryOrExit, resolvePackageProjectDir } from './shared.js'; + +export type PackageInfoOptions = { + offline?: boolean; + dir?: string; + registryUrl?: string; +}; + +export async function packageInfoCommand(name: string, opts: PackageInfoOptions = {}): Promise { + const projectDir = resolvePackageProjectDir(opts.dir); + const lockfile = readLockfile(projectDir); + + const registry = await loadRegistryOrExit({ offline: opts.offline, registryUrl: opts.registryUrl }); + if (!registry) return; + + const entry = registry.packages[name]; + if (!entry) { + fail(`Package "${name}" not found.`); + } + + const installed = lockfile.packages[name]; + printBlank(); + printLine(`${style.heading(name)} ${trustBadge(entry.trust)}`); + printMuted(entry.description); + printBlank(); + printLine(` Source: ${style.info(`github.com/${entry.source.repo}`)}`); + printLine(` Version: ${entry.source.tag}`); + printLine(` Tags: ${entry.tags.join(', ') || '—'}`); + if (entry.license) printLine(` License: ${entry.license}`); + if (entry.homepage) printLine(` Docs: ${style.info(entry.homepage)}`); + if (installed) { + printLine(` Status: ${style.success('installed')} @ ${installed.version}`); + } + if (entry.subpackages?.length) { + printLine(` Modules: ${entry.subpackages.join(', ')}`); + } + printBlank(); + printLine(' Files to install:'); + for (const f of entry.install.files) { + printLine(` ${style.muted(f.name)} → ${f.target}`); + } + printBlank(); + printLine(' Usage:'); + printLine(` ${style.info(entry.example ?? `local lib = require("${entry.require}")`)}`); + printBlank(); +} diff --git a/cli/src/commands/package/install.ts b/cli/src/commands/package/install.ts new file mode 100644 index 00000000..1c057877 --- /dev/null +++ b/cli/src/commands/package/install.ts @@ -0,0 +1,232 @@ +import { auditLockfile } from '../../lib/package/audit.js'; +import { installFromUrl, restorePackage } from '../../lib/package/install.js'; +import { readLockfile, writeLockfile } from '../../lib/package/lockfile.js'; +import { lockfileEntryRequiresUntrustedRepair, lockfileEntrySourceSummary } from '../../lib/package/provenance.js'; +import { resolveMany } from '../../lib/package/resolve.js'; +import { fail } from '../../lib/command.js'; +import { + createSpinner, + icon, + printBlank, + printDanger, + printKeyValues, + printLine, + printMuted, + printStatus, + printSuccess, + printWarning, + style, +} from '../../lib/output.js'; +import { trustBadge } from '../../lib/trust.js'; +import { confirmAction } from '../../ui/confirm.js'; +import { showInstallProgress } from '../../ui/package/index.js'; +import { loadRegistryOrExit, resolvePackageProjectDir } from './shared.js'; + +export type PackageInstallOptions = { + dryRun?: boolean; + allowUntrusted?: boolean; + target?: string; + fromUrl?: string; + dir?: string; + offline?: boolean; + yes?: boolean; + registryUrl?: string; +}; + +export async function packageInstallCommand(names: string[], opts: PackageInstallOptions = {}): Promise { + const projectDir = resolvePackageProjectDir(opts.dir); + + if (opts.fromUrl) { + if (!opts.target) { + printBlank(); + printStatus('error', '--target is required with --from-url'); + fail('', { silent: true }); + } + + if (!opts.allowUntrusted) { + printBlank(); + printWarning('Installing from untrusted URL'); + printKeyValues([ + ['URL', opts.fromUrl], + ['Target', opts.target], + ['Trust', 'experimental; not reviewed by the Feather team'], + ]); + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + printBlank(); + printDanger('Use --allow-untrusted to confirm this source in non-interactive mode.'); + fail('', { silent: true }); + } + + const confirmed = await confirmAction({ + title: 'feather package install', + label: 'Install this unreviewed URL?', + hint: 'Only continue if you trust the source and target path.', + danger: true, + rows: [`URL: ${opts.fromUrl}`, `Target: ${opts.target}`], + }); + if (!confirmed) { + printMuted('Install cancelled.'); + return; + } + } + + const spinner = createSpinner(`Fetching ${opts.fromUrl}…`).start(); + const lockfile = readLockfile(projectDir); + const result = await installFromUrl(lockfile, { + projectDir, + url: opts.fromUrl, + target: opts.target, + dryRun: opts.dryRun, + }); + + if (!result.ok) { + spinner.fail(result.error ?? 'Install failed'); + fail(result.error ?? 'Install failed', { silent: true }); + } + + if (opts.dryRun) { + spinner.stop(); + printBlank(); + printWarning('Dry run: no files written'); + printKeyValues([ + ['URL', opts.fromUrl], + ['SHA-256', result.sha256], + ['Size', `${result.size} bytes`], + ['Target', result.target], + ['Trust', 'experimental; not reviewed'], + ]); + return; + } + + spinner.succeed('Installed from URL (experimental)'); + printBlank(); + printKeyValues([ + ['SHA-256', result.sha256], + ['Target', result.target], + ['Trust', style.warning('experimental; not reviewed by the Feather team')], + ]); + writeLockfile(projectDir, lockfile); + return; + } + + if (names.length === 0) { + const lockfile = readLockfile(projectDir); + const entries = Object.entries(lockfile.packages).filter(([, e]) => !e.parent); + + if (entries.length === 0) { + printMuted('feather.lock.json is empty. Run `feather package install ` to add packages.'); + return; + } + + printBlank(); + const auditResults = await auditLockfile(projectDir, lockfile); + const broken = new Set(auditResults.filter((r) => r.status !== 'verified').map((r) => r.id)); + + if (broken.size === 0) { + printSuccess(`✔ All ${entries.length} package(s) already up to date.`); + return; + } + + const untrustedRepairs = entries.filter(([id, entry]) => broken.has(id) && lockfileEntryRequiresUntrustedRepair(id, entry)); + if (untrustedRepairs.length > 0 && !opts.allowUntrusted) { + printWarning('Repairing untrusted or experimental package sources'); + printKeyValues(untrustedRepairs.map(([id, entry]) => [ + id, + `${entry.version} (${lockfileEntrySourceSummary(id, entry)})`, + ])); + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + printBlank(); + printDanger('Use --allow-untrusted to repair these lockfile entries in non-interactive mode.'); + fail('', { silent: true }); + } + + const confirmed = await confirmAction({ + title: 'feather package install', + label: 'Repair these unreviewed package sources?', + hint: 'Only continue if you trust the lockfile sources and target paths.', + danger: true, + rows: untrustedRepairs.map(([id, entry]) => `${id}: ${lockfileEntrySourceSummary(id, entry)}`), + }); + if (!confirmed) { + printMuted('Install cancelled.'); + return; + } + } + + let failed = false; + for (const [id, entry] of entries) { + if (!broken.has(id)) { + printMuted(` ${id} @ ${entry.version} — up to date`); + continue; + } + const spinner = createSpinner(` ${id} @ ${entry.version}`).start(); + const result = await restorePackage(id, entry, { projectDir, dryRun: opts.dryRun }); + if (result.ok) { + spinner.succeed(` ${id} @ ${entry.version}`); + } else { + spinner.fail(` ${id}: ${result.error}`); + failed = true; + } + } + + printBlank(); + if (failed) fail('', { silent: true }); + return; + } + + const registry = await loadRegistryOrExit({ offline: opts.offline, registryUrl: opts.registryUrl }); + if (!registry) return; + + const lockfile = readLockfile(projectDir); + const { resolved, errors } = resolveMany(names, registry); + + if (errors.length) { + for (const e of errors) printLine(` ${icon.error} ${style.danger(e)}`); + fail('', { silent: true }); + } + + const toInstall = resolved.filter((pkg) => { + if (pkg.versionOverride) return true; + const existing = lockfile.packages[pkg.id]; + if (existing && existing.version === pkg.entry.source.tag) { + printMuted(` ${pkg.id} is already installed at ${existing.version}`); + return false; + } + return true; + }); + + if (toInstall.length === 0) return; + + for (const pkg of toInstall) { + if (pkg.entry.trust === 'experimental' && !opts.allowUntrusted) { + fail(`"${pkg.id}" requires --allow-untrusted (trust: experimental)`); + } + if (pkg.versionOverride && !opts.allowUntrusted) { + fail(`"${pkg.id}@${pkg.versionOverride}" requires --allow-untrusted — this version has not been reviewed by Feather`); + } + } + + if (opts.dryRun) { + printBlank(); + for (const pkg of toInstall) { + const displayVersion = pkg.versionOverride ?? pkg.entry.source.tag; + printLine(` ${style.heading(pkg.id)} ${trustBadge(pkg.versionOverride ? 'experimental' : pkg.entry.trust)}`); + printLine(` Source: github.com/${pkg.entry.source.repo} Version: ${displayVersion}`); + for (const f of pkg.files) { + printLine(` ${style.muted(f.name)} → ${f.target}`); + } + printBlank(); + } + printWarning('Dry run — no files written.'); + return; + } + + const results = await showInstallProgress({ packages: toInstall, lockfile, projectDir, targetOverride: opts.target }); + if (results.every((r) => r.ok)) { + writeLockfile(projectDir, lockfile); + } else { + fail('', { silent: true }); + } +} diff --git a/cli/src/commands/package/list.ts b/cli/src/commands/package/list.ts new file mode 100644 index 00000000..9fc0b966 --- /dev/null +++ b/cli/src/commands/package/list.ts @@ -0,0 +1,75 @@ +import { readLockfile, writeLockfile } from '../../lib/package/lockfile.js'; +import { resolveMany } from '../../lib/package/resolve.js'; +import { fail } from '../../lib/command.js'; +import { icon, printLine, printMuted, style } from '../../lib/output.js'; +import { trustBadge } from '../../lib/trust.js'; +import { showInstallProgress, showPackageBrowser } from '../../ui/package/index.js'; +import { packageRemoveCommand } from './remove.js'; +import { loadRegistryOrExit, resolvePackageProjectDir } from './shared.js'; + +export type PackageListOptions = { + installed?: boolean; + offline?: boolean; + refresh?: boolean; + dir?: string; + registryUrl?: string; +}; + +export async function packageListCommand(opts: PackageListOptions = {}): Promise { + const projectDir = resolvePackageProjectDir(opts.dir); + + if (opts.installed) { + const lockfile = readLockfile(projectDir); + const entries = Object.entries(lockfile.packages); + if (entries.length === 0) { + printMuted('No packages installed. Run `feather package install `.'); + return; + } + for (const [id, entry] of entries) { + printLine(` ${trustBadge(entry.trust)} ${style.heading(id)} @ ${entry.version}`); + } + printMuted(`\n${entries.length} package(s) installed.`); + return; + } + + const registry = await loadRegistryOrExit({ + offline: opts.offline, + refresh: opts.refresh, + registryUrl: opts.registryUrl, + }); + if (!registry) return; + + const lockfile = readLockfile(projectDir); + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + const entries = Object.entries(registry.packages).filter(([, e]) => !e.parent); + for (const [id, entry] of entries.sort(([a], [b]) => a.localeCompare(b))) { + const installed = lockfile.packages[id]; + const installedLabel = installed ? style.info(` (installed ${installed.version})`) : ''; + printLine(` ${trustBadge(entry.trust)} ${style.heading(id)}${installedLabel} ${style.muted(entry.description)}`); + } + printMuted(`\n${entries.length} available.`); + return; + } + + const result = await showPackageBrowser({ registry, lockfile }); + if (result.action === 'cancel') return; + + const { resolved, errors } = resolveMany([result.id], registry); + if (errors.length) { + for (const e of errors) printLine(` ${icon.error} ${style.danger(e)}`); + fail('', { silent: true }); + } + + if (result.action === 'remove') { + await packageRemoveCommand(result.id, { dir: opts.dir }); + return; + } + + const installResults = await showInstallProgress({ packages: resolved, lockfile, projectDir }); + if (installResults.every((r) => r.ok)) { + writeLockfile(projectDir, lockfile); + } else { + fail('', { silent: true }); + } +} diff --git a/cli/src/commands/package/remove.ts b/cli/src/commands/package/remove.ts new file mode 100644 index 00000000..2e159b48 --- /dev/null +++ b/cli/src/commands/package/remove.ts @@ -0,0 +1,62 @@ +import { existsSync, rmSync } from 'node:fs'; +import { readLockfile, removeFromLockfile, writeLockfile } from '../../lib/package/lockfile.js'; +import { fail } from '../../lib/command.js'; +import { icon, printLine, printMuted, style } from '../../lib/output.js'; +import { confirmAction } from '../../ui/confirm.js'; +import { resolvePackageProjectDir } from './shared.js'; +import { resolveProjectTarget } from '../../lib/package/target.js'; + +export type PackageRemoveOptions = { + dir?: string; + yes?: boolean; +}; + +export async function packageRemoveCommand(name: string, opts: PackageRemoveOptions = {}): Promise { + const projectDir = resolvePackageProjectDir(opts.dir); + const lockfile = readLockfile(projectDir); + + const entry = lockfile.packages[name]; + if (!entry) { + fail(`"${name}" is not installed.`); + } + + const existingFiles = entry.files.filter((file) => { + const abs = resolveProjectTarget(projectDir, file.target); + return abs !== null && existsSync(abs); + }); + if (!opts.yes && (!process.stdin.isTTY || !process.stdout.isTTY)) { + fail(`Refusing to remove "${name}" without --yes in non-interactive mode.`); + } + + if (!opts.yes) { + const confirmed = await confirmAction({ + title: 'feather package remove', + label: `Remove package "${name}"?`, + hint: 'This deletes installed files and updates feather.lock.json.', + danger: true, + rows: [ + ...existingFiles.map((file) => file.target), + 'feather.lock.json', + ], + }); + if (!confirmed) { + printMuted('Package remove cancelled.'); + return; + } + } + + for (const file of entry.files) { + const abs = resolveProjectTarget(projectDir, file.target); + if (!abs) { + fail(`Refusing to remove unsafe package target: ${file.target}`); + } + if (existsSync(abs)) { + rmSync(abs); + printMuted(` removed ${file.target}`); + } + } + + removeFromLockfile(lockfile, name); + writeLockfile(projectDir, lockfile); + printLine(` ${icon.success} ${style.heading(name)} removed.`); +} diff --git a/cli/src/commands/package/search.ts b/cli/src/commands/package/search.ts new file mode 100644 index 00000000..1f3ff1c2 --- /dev/null +++ b/cli/src/commands/package/search.ts @@ -0,0 +1,38 @@ +import { printLine, printMuted, style } from '../../lib/output.js'; +import { trustBadge } from '../../lib/trust.js'; +import { loadRegistryOrExit } from './shared.js'; + +export type PackageSearchOptions = { + offline?: boolean; + registryUrl?: string; +}; + +export async function packageSearchCommand(query: string | undefined, opts: PackageSearchOptions = {}): Promise { + const registry = await loadRegistryOrExit(opts); + if (!registry) return; + + const q = query?.toLowerCase(); + const entries = Object.entries(registry.packages).filter(([, entry]) => !entry.parent); + + const matches = q + ? entries.filter( + ([id, entry]) => + id.includes(q) || entry.description.toLowerCase().includes(q) || entry.tags.some((t) => t.includes(q)), + ) + : entries; + + if (matches.length === 0) { + printMuted(`No packages found${q ? ` matching "${query}"` : ''}.`); + return; + } + + const maxId = Math.max(...matches.map(([id]) => id.length)); + for (const [id, entry] of matches.sort(([a], [b]) => a.localeCompare(b))) { + const badge = trustBadge(entry.trust); + printLine(` ${style.heading(id.padEnd(maxId + 2))} ${badge} ${style.muted(entry.description)}`); + if (entry.subpackages?.length) { + printMuted(` ${''.padEnd(maxId + 2)} subpackages: ${entry.subpackages.join(', ')}`); + } + } + printMuted(`\n${matches.length} package(s). Run \`feather package info \` for details.`); +} diff --git a/cli/src/commands/package/shared.ts b/cli/src/commands/package/shared.ts new file mode 100644 index 00000000..1b29cd20 --- /dev/null +++ b/cli/src/commands/package/shared.ts @@ -0,0 +1,36 @@ +import { resolve } from 'node:path'; +import { findProjectDir } from '../../lib/paths.js'; +import { loadRegistry, type Registry, type RegistryLoadOptions } from '../../lib/package/registry.js'; +import { fail } from '../../lib/command.js'; +import { createSpinner, printMuted, printStatus } from '../../lib/output.js'; + +export function resolvePackageProjectDir(dir?: string): string { + return dir ? resolve(dir) : findProjectDir(); +} + +export async function loadRegistryOrExit( + opts: RegistryLoadOptions, + message = 'Loading registry…', +): Promise { + const spinner = createSpinner(message).start(); + try { + const registry = await loadRegistry(opts); + spinner.stop(); + return registry; + } catch (err) { + spinner.fail(`Failed to load registry: ${(err as Error).message}`); + fail((err as Error).message, { silent: true, cause: err }); + return null; + } +} + +export function ensurePackageAddInteractive(): boolean { + if (process.stdin.isTTY && process.stdout.isTTY && typeof process.stdin.setRawMode === 'function') { + return true; + } + + printStatus('error', '`feather package add` requires an interactive terminal.'); + printMuted('Run it from a real TTY, or use `feather package install --from-url --target --allow-untrusted` for scripts.'); + fail('', { silent: true }); + return false; +} diff --git a/cli/src/commands/package/update.ts b/cli/src/commands/package/update.ts new file mode 100644 index 00000000..d02d37e3 --- /dev/null +++ b/cli/src/commands/package/update.ts @@ -0,0 +1,61 @@ +import { readLockfile, writeLockfile } from '../../lib/package/lockfile.js'; +import type { ResolvedPackage } from '../../lib/package/resolve.js'; +import { fail } from '../../lib/command.js'; +import { printLine, printMuted, printWarning, style } from '../../lib/output.js'; +import { showInstallProgress } from '../../ui/package/index.js'; +import { loadRegistryOrExit, resolvePackageProjectDir } from './shared.js'; + +export type PackageUpdateOptions = { + dryRun?: boolean; + dir?: string; + offline?: boolean; + registryUrl?: string; +}; + +export async function packageUpdateCommand(name: string | undefined, opts: PackageUpdateOptions = {}): Promise { + const projectDir = resolvePackageProjectDir(opts.dir); + const lockfile = readLockfile(projectDir); + + const installed = Object.entries(lockfile.packages); + if (installed.length === 0) { + printMuted('No packages installed.'); + return; + } + + const registry = await loadRegistryOrExit({ offline: opts.offline, registryUrl: opts.registryUrl }); + if (!registry) return; + + const targets = name ? installed.filter(([id]) => id === name) : installed; + + if (name && targets.length === 0) { + fail(`"${name}" is not installed.`); + } + + const toUpdate: ResolvedPackage[] = []; + for (const [id, current] of targets) { + if (current.trust === 'experimental') { + printMuted(` Skipping "${id}" (experimental — re-install with --from-url to update)`); + continue; + } + const entry = registry.packages[id]; + if (!entry) { + printWarning(` "${id}" not found in registry — skipping`); + continue; + } + if (entry.source.tag === current.version) { + printMuted(` ${id} is already up to date (${current.version})`); + continue; + } + printLine(` ${style.heading(id)}: ${current.version} → ${entry.source.tag}`); + toUpdate.push({ id, entry, files: entry.install.files }); + } + + if (opts.dryRun || toUpdate.length === 0) return; + + const results = await showInstallProgress({ packages: toUpdate, lockfile, projectDir }); + if (results.every((r) => r.ok)) { + writeLockfile(projectDir, lockfile); + } else { + fail('', { silent: true }); + } +} diff --git a/cli/src/commands/plugin.ts b/cli/src/commands/plugin.ts index e696a272..cf1ca558 100644 --- a/cli/src/commands/plugin.ts +++ b/cli/src/commands/plugin.ts @@ -1,310 +1,2 @@ -import { existsSync, readFileSync, rmSync, readdirSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import chalk from "chalk"; -import ora from "ora"; -import { - fetchManifest, - getLocalPluginIds, - getPluginIds, - installPlugin, - installPluginsFromLocal, - normalizeInstallDir, -} from "../lib/install.js"; -import { choosePluginUpdateWorkflow, choosePluginWorkflow } from "../ui/plugin-workflow.js"; +export * from './plugin/index.js'; -const __dirname = dirname(fileURLToPath(import.meta.url)); - -function findProjectDir(cwd = process.cwd()): string { - if (existsSync(join(cwd, "feather", "init.lua"))) return cwd; - if (existsSync(join(cwd, "main.lua"))) return cwd; - return cwd; -} - -function pluginsDir(projectDir: string, installDir = "feather"): string { - return join(projectDir, normalizeInstallDir(installDir), "plugins"); -} - -function bundledLuaRoot(): string { - return resolve(__dirname, "../../lua"); -} - -function repoLuaRoot(): string | null { - const candidate = resolve(__dirname, "../../../src-lua"); - return existsSync(join(candidate, "feather", "init.lua")) ? candidate : null; -} - -function resolveLocalLuaRoot(opts: { localSrc?: string }): string { - if (opts.localSrc) return resolve(opts.localSrc); - return repoLuaRoot() ?? bundledLuaRoot(); -} - -function readManifest(pluginDir: string): Record | null { - // Try to extract id/name/version from manifest.lua via simple regex - const manifestPath = join(pluginDir, "manifest.lua"); - if (!existsSync(manifestPath)) return null; - const src = readFileSync(manifestPath, "utf8"); - const get = (key: string) => src.match(new RegExp(`${key}\\s*=\\s*"([^"]+)"`))?.[1] ?? ""; - return { id: get("id"), name: get("name"), version: get("version") }; -} - -function findInstalledPluginDirs(root: string): string[] { - const found: string[] = []; - for (const entry of readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const dir = join(root, entry.name); - if (existsSync(join(dir, "manifest.lua"))) { - found.push(dir); - } else { - found.push(...findInstalledPluginDirs(dir)); - } - } - return found; -} - -function getInstalledPluginIds(projectDir: string, installDir = "feather"): string[] { - const dirPath = pluginsDir(projectDir, installDir); - if (!existsSync(dirPath)) return []; - - return findInstalledPluginDirs(dirPath) - .map((dir) => readManifest(dir)?.id) - .filter((id): id is string => Boolean(id)) - .sort(); -} - -export async function pluginListCommand(dir?: string, installDir = "feather"): Promise { - const projectDir = dir ? resolve(dir) : findProjectDir(); - const dirPath = pluginsDir(projectDir, installDir); - - if (!existsSync(dirPath)) { - console.log(chalk.dim("No plugins directory found. Run `feather init` first.")); - return; - } - - const dirs = findInstalledPluginDirs(dirPath); - - if (dirs.length === 0) { - console.log(chalk.dim("No plugins installed.")); - return; - } - - console.log(chalk.bold(`\nInstalled plugins (${dirs.length})\n`)); - for (const dir of dirs) { - const meta = readManifest(dir); - if (meta) { - console.log(` ${chalk.cyan(meta.id.padEnd(24))} ${chalk.dim(meta.version.padEnd(8))} ${meta.name}`); - } else { - console.log(` ${chalk.cyan(dir.replace(dirPath, "").replace(/^[/\\]/, ""))}`); - } - } - console.log(); -} - -export async function pluginInstallCommand( - pluginId: string, - opts: { dir?: string; branch?: string; installDir?: string; remote?: boolean; localSrc?: string } -): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - const branch = opts.branch ?? "main"; - const installDir = opts.installDir ?? "feather"; - - if (!opts.remote) { - const sourceRoot = resolveLocalLuaRoot(opts); - const available = getLocalPluginIds(sourceRoot); - if (!available.includes(pluginId)) { - console.error(chalk.red(`Unknown plugin: ${pluginId}`)); - console.log(chalk.dim("Available: " + available.join(", "))); - process.exit(1); - } - - const spinner = ora(`Copying ${pluginId}…`).start(); - try { - installPluginsFromLocal([pluginId], sourceRoot, projectDir, installDir); - spinner.succeed(`Installed ${pluginId}`); - } catch (err) { - spinner.fail((err as Error).message); - process.exit(1); - } - return; - } - - const spinner = ora("Fetching manifest…").start(); - let entries: Awaited>; - try { - entries = await fetchManifest(branch); - spinner.succeed("Manifest loaded"); - } catch (err) { - spinner.fail((err as Error).message); - process.exit(1); - } - - const available = getPluginIds(entries); - if (!available.includes(pluginId)) { - console.error(chalk.red(`Unknown plugin: ${pluginId}`)); - console.log(chalk.dim("Available: " + available.join(", "))); - process.exit(1); - } - - const installSpinner = ora(`Installing ${pluginId}…`).start(); - try { - await installPlugin(pluginId, entries, projectDir, branch, undefined, installDir); - installSpinner.succeed(`Installed ${pluginId}`); - } catch (err) { - installSpinner.fail((err as Error).message); - process.exit(1); - } -} - -export async function pluginRemoveCommand(pluginId: string, opts: { dir?: string; installDir?: string }): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - const pluginDir = join(pluginsDir(projectDir, opts.installDir), pluginId.replace(/\./g, "/")); - - if (!existsSync(pluginDir)) { - console.error(chalk.red(`Plugin not found: ${pluginId}`)); - process.exit(1); - } - - rmSync(pluginDir, { recursive: true, force: true }); - console.log(chalk.green("✔") + ` Removed ${pluginId}`); -} - -export async function pluginUpdateCommand( - pluginId: string | undefined, - opts: { dir?: string; branch?: string; installDir?: string; remote?: boolean; localSrc?: string; yes?: boolean } -): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - const branch = opts.branch ?? "main"; - const installDir = opts.installDir ?? "feather"; - const dirPath = pluginsDir(projectDir, installDir); - - const hasExplicitSource = opts.remote === true || !!opts.localSrc || opts.yes === true; - if (!pluginId && process.stdin.isTTY && !hasExplicitSource) { - const installedIds = getInstalledPluginIds(projectDir, installDir); - if (installedIds.length === 0) { - console.log(chalk.dim("No plugins installed.")); - return; - } - - const result = await choosePluginUpdateWorkflow({ - installedIds, - defaultBranch: branch, - }); - - if (result.action === "cancel") return; - if (result.action !== "update" || result.pluginIds.length === 0) { - console.log(chalk.dim("No plugins selected.")); - return; - } - - for (const id of result.pluginIds) { - await pluginUpdateCommand(id, { - dir: projectDir, - branch: result.branch, - installDir, - remote: result.source === "remote", - localSrc: opts.localSrc, - yes: true, - }); - } - return; - } - - if (!opts.remote) { - const sourceRoot = resolveLocalLuaRoot(opts); - const available = getLocalPluginIds(sourceRoot); - const ids = pluginId ? [pluginId] : available.filter((id) => existsSync(join(dirPath, id.replace(/\./g, "/")))); - - for (const id of ids) { - const s = ora(`Updating ${id}…`).start(); - try { - installPluginsFromLocal([id], sourceRoot, projectDir, installDir); - s.succeed(`Updated ${id}`); - } catch (err) { - s.fail(`${id}: ${(err as Error).message}`); - } - } - return; - } - - const spinner = ora("Fetching manifest…").start(); - let entries: Awaited>; - try { - entries = await fetchManifest(branch); - spinner.succeed("Manifest loaded"); - } catch (err) { - spinner.fail((err as Error).message); - process.exit(1); - } - - const ids = pluginId ? [pluginId] : getPluginIds(entries).filter((id) => - existsSync(join(dirPath, id.replace(/\./g, "/"))) - ); - - for (const id of ids) { - const s = ora(`Updating ${id}…`).start(); - try { - await installPlugin(id, entries, projectDir, branch, undefined, installDir); - s.succeed(`Updated ${id}`); - } catch (err) { - s.fail(`${id}: ${(err as Error).message}`); - } - } -} - -export async function pluginWorkflowCommand(opts: { - dir?: string; - branch?: string; - installDir?: string; - remote?: boolean; - localSrc?: string; -}): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - const installDir = opts.installDir ?? "feather"; - const installedIds = getInstalledPluginIds(projectDir, installDir); - - const result = await choosePluginWorkflow({ - installedIds, - defaultBranch: opts.branch ?? "main", - }); - - if (result.action === "cancel") return; - if (result.action === "list") { - await pluginListCommand(projectDir, installDir); - return; - } - - if (result.pluginIds.length === 0) { - console.log(chalk.dim("No plugins selected.")); - return; - } - - if (result.action === "install") { - for (const id of result.pluginIds) { - await pluginInstallCommand(id, { - dir: projectDir, - branch: result.branch, - installDir, - remote: result.source === "remote", - localSrc: opts.localSrc, - }); - } - return; - } - - if (result.action === "update") { - for (const id of result.pluginIds) { - await pluginUpdateCommand(id, { - dir: projectDir, - branch: result.branch, - installDir, - remote: result.source === "remote", - localSrc: opts.localSrc, - }); - } - return; - } - - for (const id of result.pluginIds) { - await pluginRemoveCommand(id, { dir: projectDir, installDir }); - } -} diff --git a/cli/src/commands/plugin/index.ts b/cli/src/commands/plugin/index.ts new file mode 100644 index 00000000..0b166320 --- /dev/null +++ b/cli/src/commands/plugin/index.ts @@ -0,0 +1,6 @@ +export * from './install.js'; +export * from './list.js'; +export * from './remove.js'; +export * from './update.js'; +export * from './workflow.js'; + diff --git a/cli/src/commands/plugin/install.ts b/cli/src/commands/plugin/install.ts new file mode 100644 index 00000000..00dcd1f3 --- /dev/null +++ b/cli/src/commands/plugin/install.ts @@ -0,0 +1,70 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { + fetchManifest, + getLocalPluginIds, + getPluginIds, + installPlugin, + installPluginsFromLocal, +} from '../../lib/install.js'; +import { fail } from '../../lib/command.js'; +import { createSpinner } from '../../lib/output.js'; +import { resolveLocalLuaRoot } from '../../lib/paths.js'; +import { assertValidPluginId, pluginIdToSourceDir } from '../../lib/plugin-utils.js'; +import { type PluginSourceOptions, resolvePluginProjectDir, warnDangerousPlugin } from './shared.js'; + +export async function pluginInstallCommand(pluginId: string, opts: PluginSourceOptions): Promise { + const projectDir = resolvePluginProjectDir(opts.dir); + const branch = opts.branch ?? 'main'; + const installDir = opts.installDir ?? 'feather'; + try { + assertValidPluginId(pluginId); + } catch (err) { + fail((err as Error).message); + } + + if (!opts.remote) { + const sourceRoot = resolveLocalLuaRoot(opts); + const available = getLocalPluginIds(sourceRoot); + const sourceExists = existsSync(join(sourceRoot, 'plugins', pluginIdToSourceDir(pluginId))); + if (!available.includes(pluginId) && !sourceExists) { + fail(`Unknown plugin: ${pluginId}`, { details: ['Available: ' + available.join(', ')] }); + } + + const spinner = createSpinner(`Copying ${pluginId}…`).start(); + try { + installPluginsFromLocal([pluginId], sourceRoot, projectDir, installDir); + spinner.succeed(`Installed ${pluginId}`); + warnDangerousPlugin(pluginId); + } catch (err) { + spinner.fail((err as Error).message); + fail((err as Error).message, { cause: err, silent: true }); + } + return; + } + + const spinner = createSpinner('Fetching manifest…').start(); + let entries: Awaited>; + try { + entries = await fetchManifest(branch); + spinner.succeed('Manifest loaded'); + } catch (err) { + spinner.fail((err as Error).message); + fail((err as Error).message, { cause: err, silent: true }); + } + + const available = getPluginIds(entries); + if (!available.includes(pluginId)) { + fail(`Unknown plugin: ${pluginId}`, { details: ['Available: ' + available.join(', ')] }); + } + + const installSpinner = createSpinner(`Installing ${pluginId}…`).start(); + try { + await installPlugin(pluginId, entries, projectDir, branch, undefined, installDir); + installSpinner.succeed(`Installed ${pluginId}`); + warnDangerousPlugin(pluginId); + } catch (err) { + installSpinner.fail((err as Error).message); + fail((err as Error).message, { cause: err, silent: true }); + } +} diff --git a/cli/src/commands/plugin/list.ts b/cli/src/commands/plugin/list.ts new file mode 100644 index 00000000..73f4568c --- /dev/null +++ b/cli/src/commands/plugin/list.ts @@ -0,0 +1,41 @@ +import { existsSync } from 'node:fs'; +import { findInstalledPluginDirs, readPluginManifest } from '../../lib/plugin-utils.js'; +import { printBlank, printHeading, printMuted, printTable, style } from '../../lib/output.js'; +import { pluginsDir, resolvePluginProjectDir } from './shared.js'; + +export async function pluginListCommand(dir?: string, installDir = 'feather'): Promise { + const projectDir = resolvePluginProjectDir(dir); + const dirPath = pluginsDir(projectDir, installDir); + + if (!existsSync(dirPath)) { + printMuted('No plugins directory found. Run `feather init` first.'); + return; + } + + const dirs = findInstalledPluginDirs(dirPath); + + if (dirs.length === 0) { + printMuted('No plugins installed.'); + return; + } + + printHeading(`\nInstalled plugins (${dirs.length})\n`); + const rows = dirs.map((dir) => { + const meta = readPluginManifest(dir); + const fallbackId = dir.replace(dirPath, '').replace(/^[/\\]/, ''); + return { + id: meta?.id || fallbackId, + version: meta?.version ?? '', + name: meta?.name ?? '', + }; + }); + printTable({ + columns: [ + { key: 'id', label: 'ID', color: (value) => style.info(value) }, + { key: 'version', label: 'VERSION', color: (value) => style.muted(value) }, + { key: 'name', label: 'NAME' }, + ], + rows, + }); + printBlank(); +} diff --git a/cli/src/commands/plugin/remove.ts b/cli/src/commands/plugin/remove.ts new file mode 100644 index 00000000..712ed4e1 --- /dev/null +++ b/cli/src/commands/plugin/remove.ts @@ -0,0 +1,51 @@ +import { existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { fail } from '../../lib/command.js'; +import { normalizeInstallDir } from '../../lib/install.js'; +import { icon, printLine, printMuted } from '../../lib/output.js'; +import { assertSafeProjectTarget } from '../../lib/path-safety.js'; +import { pluginIdToSourceDir } from '../../lib/plugin-utils.js'; +import { confirmAction } from '../../ui/confirm.js'; +import { resolvePluginProjectDir } from './shared.js'; + +export async function pluginRemoveCommand( + pluginId: string, + opts: { dir?: string; installDir?: string; yes?: boolean }, +): Promise { + const projectDir = resolvePluginProjectDir(opts.dir); + let pluginDir: string; + try { + pluginDir = assertSafeProjectTarget( + projectDir, + join(normalizeInstallDir(opts.installDir), 'plugins', pluginIdToSourceDir(pluginId)), + 'Plugin remove target', + ); + } catch (err) { + fail((err as Error).message); + } + + if (!existsSync(pluginDir)) { + fail(`Plugin not found: ${pluginId}`); + } + + if (!opts.yes && (!process.stdin.isTTY || !process.stdout.isTTY)) { + fail(`Refusing to remove "${pluginId}" without --yes in non-interactive mode.`); + } + + if (!opts.yes) { + const confirmed = await confirmAction({ + title: 'feather plugin remove', + label: `Remove plugin "${pluginId}"?`, + hint: 'This recursively deletes the installed plugin directory.', + danger: true, + rows: [pluginDir], + }); + if (!confirmed) { + printMuted('Plugin remove cancelled.'); + return; + } + } + + rmSync(pluginDir, { recursive: true, force: true }); + printLine(`${icon.success} Removed ${pluginId}`); +} diff --git a/cli/src/commands/plugin/shared.ts b/cli/src/commands/plugin/shared.ts new file mode 100644 index 00000000..cda30f9c --- /dev/null +++ b/cli/src/commands/plugin/shared.ts @@ -0,0 +1,37 @@ +import { existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { normalizeInstallDir } from '../../lib/install.js'; +import { findProjectDir } from '../../lib/paths.js'; +import { dangerousPluginIds, findInstalledPluginDirs, readPluginManifest } from '../../lib/plugin-utils.js'; +import { printWarning } from '../../lib/output.js'; + +export type PluginSourceOptions = { + dir?: string; + branch?: string; + installDir?: string; + remote?: boolean; + localSrc?: string; +}; + +export function resolvePluginProjectDir(dir?: string): string { + return dir ? resolve(dir) : findProjectDir(); +} + +export function pluginsDir(projectDir: string, installDir = 'feather'): string { + return join(projectDir, normalizeInstallDir(installDir), 'plugins'); +} + +export function getInstalledPluginIds(projectDir: string, installDir = 'feather'): string[] { + const dirPath = pluginsDir(projectDir, installDir); + if (!existsSync(dirPath)) return []; + + return findInstalledPluginDirs(dirPath) + .map((dir) => readPluginManifest(dir)?.id) + .filter((id): id is string => Boolean(id)) + .sort(); +} + +export function warnDangerousPlugin(pluginId: string): void { + if (!dangerousPluginIds.has(pluginId)) return; + printWarning(`! ${pluginId} is development-only and can execute remote/debug commands. Do not ship it in production builds.`); +} diff --git a/cli/src/commands/plugin/update.ts b/cli/src/commands/plugin/update.ts new file mode 100644 index 00000000..8de8cc37 --- /dev/null +++ b/cli/src/commands/plugin/update.ts @@ -0,0 +1,112 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { + fetchManifest, + getLocalPluginIds, + getPluginIds, + installPlugin, + installPluginsFromLocal, +} from '../../lib/install.js'; +import { fail } from '../../lib/command.js'; +import { createSpinner, printMuted } from '../../lib/output.js'; +import { resolveLocalLuaRoot } from '../../lib/paths.js'; +import { assertValidPluginId, pluginIdToSourceDir } from '../../lib/plugin-utils.js'; +import { choosePluginUpdateWorkflow } from '../../ui/plugin-workflow.js'; +import { getInstalledPluginIds, pluginsDir, resolvePluginProjectDir, warnDangerousPlugin } from './shared.js'; + +export async function pluginUpdateCommand( + pluginId: string | undefined, + opts: { dir?: string; branch?: string; installDir?: string; remote?: boolean; localSrc?: string; yes?: boolean }, +): Promise { + const projectDir = resolvePluginProjectDir(opts.dir); + const branch = opts.branch ?? 'main'; + const installDir = opts.installDir ?? 'feather'; + const dirPath = pluginsDir(projectDir, installDir); + if (pluginId) { + try { + assertValidPluginId(pluginId); + } catch (err) { + fail((err as Error).message); + } + } + + const hasExplicitSource = opts.remote === true || !!opts.localSrc || opts.yes === true; + if (!pluginId && process.stdin.isTTY && !hasExplicitSource) { + const installedIds = getInstalledPluginIds(projectDir, installDir); + if (installedIds.length === 0) { + printMuted('No plugins installed.'); + return; + } + + const result = await choosePluginUpdateWorkflow({ + installedIds, + defaultBranch: branch, + }); + + if (result.action === 'cancel') return; + if (result.action !== 'update' || result.pluginIds.length === 0) { + printMuted('No plugins selected.'); + return; + } + + for (const id of result.pluginIds) { + await pluginUpdateCommand(id, { + dir: projectDir, + branch: result.branch, + installDir, + remote: result.source === 'remote', + localSrc: opts.localSrc, + yes: true, + }); + } + return; + } + + if (!opts.remote) { + const sourceRoot = resolveLocalLuaRoot(opts); + const available = getLocalPluginIds(sourceRoot); + const ids = pluginId ? [pluginId] : available.filter((id) => existsSync(join(dirPath, id.replace(/\./g, '/')))); + if (pluginId && !available.includes(pluginId) && !existsSync(join(sourceRoot, 'plugins', pluginIdToSourceDir(pluginId)))) { + fail(`Unknown plugin: ${pluginId}`, { details: ['Available: ' + available.join(', ')] }); + } + + for (const id of ids) { + const s = createSpinner(`Updating ${id}…`).start(); + try { + installPluginsFromLocal([id], sourceRoot, projectDir, installDir); + s.succeed(`Updated ${id}`); + warnDangerousPlugin(id); + } catch (err) { + s.fail(`${id}: ${(err as Error).message}`); + if (pluginId) fail((err as Error).message, { cause: err, silent: true }); + } + } + return; + } + + const spinner = createSpinner('Fetching manifest…').start(); + let entries: Awaited>; + try { + entries = await fetchManifest(branch); + spinner.succeed('Manifest loaded'); + } catch (err) { + spinner.fail((err as Error).message); + fail((err as Error).message, { cause: err, silent: true }); + } + + const ids = pluginId ? [pluginId] : getPluginIds(entries).filter((id) => + existsSync(join(dirPath, id.replace(/\./g, '/'))) + ); + + for (const id of ids) { + const s = createSpinner(`Updating ${id}…`).start(); + try { + await installPlugin(id, entries, projectDir, branch, undefined, installDir); + s.succeed(`Updated ${id}`); + warnDangerousPlugin(id); + } catch (err) { + s.fail(`${id}: ${(err as Error).message}`); + if (pluginId) fail((err as Error).message, { cause: err, silent: true }); + } + } +} diff --git a/cli/src/commands/plugin/workflow.ts b/cli/src/commands/plugin/workflow.ts new file mode 100644 index 00000000..e375673b --- /dev/null +++ b/cli/src/commands/plugin/workflow.ts @@ -0,0 +1,65 @@ +import { printMuted } from '../../lib/output.js'; +import { choosePluginWorkflow } from '../../ui/plugin-workflow.js'; +import { pluginInstallCommand } from './install.js'; +import { pluginListCommand } from './list.js'; +import { pluginRemoveCommand } from './remove.js'; +import { getInstalledPluginIds, resolvePluginProjectDir } from './shared.js'; +import { pluginUpdateCommand } from './update.js'; + +export async function pluginWorkflowCommand(opts: { + dir?: string; + branch?: string; + installDir?: string; + remote?: boolean; + localSrc?: string; +}): Promise { + const projectDir = resolvePluginProjectDir(opts.dir); + const installDir = opts.installDir ?? 'feather'; + const installedIds = getInstalledPluginIds(projectDir, installDir); + + const result = await choosePluginWorkflow({ + installedIds, + defaultBranch: opts.branch ?? 'main', + }); + + if (result.action === 'cancel') return; + if (result.action === 'list') { + await pluginListCommand(projectDir, installDir); + return; + } + + if (result.pluginIds.length === 0) { + printMuted('No plugins selected.'); + return; + } + + if (result.action === 'install') { + for (const id of result.pluginIds) { + await pluginInstallCommand(id, { + dir: projectDir, + branch: result.branch, + installDir, + remote: result.source === 'remote', + localSrc: opts.localSrc, + }); + } + return; + } + + if (result.action === 'update') { + for (const id of result.pluginIds) { + await pluginUpdateCommand(id, { + dir: projectDir, + branch: result.branch, + installDir, + remote: result.source === 'remote', + localSrc: opts.localSrc, + }); + } + return; + } + + for (const id of result.pluginIds) { + await pluginRemoveCommand(id, { dir: projectDir, installDir, yes: true }); + } +} diff --git a/cli/src/commands/remove.ts b/cli/src/commands/remove.ts index ad43efd9..b92ff41c 100644 --- a/cli/src/commands/remove.ts +++ b/cli/src/commands/remove.ts @@ -1,8 +1,11 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; -import chalk from "chalk"; import { normalizeInstallDir } from "../lib/install.js"; import { chooseRemoveWorkflow, type RemoveTarget } from "../ui/remove-workflow.js"; +import { parseManagedValue } from "../lib/plugin-utils.js"; +import { fail } from "../lib/command.js"; +import { icon, printLine, printMuted, style } from "../lib/output.js"; +import { assertSafeProjectTarget } from "../lib/path-safety.js"; export interface RemoveOptions { yes?: boolean; @@ -20,25 +23,29 @@ type RemoveContext = { configPath: string; mainPath: string; manualEntrypoint: string; + manualPath: string; + runtimePath: string; }; -function metadataValue(src: string, key: string): string | null { - return src.match(new RegExp(`^--\\s*${key}:\\s*(.+)$`, "m"))?.[1]?.trim() ?? null; -} - function resolveContext(dir: string, opts: RemoveOptions): RemoveContext { const projectDir = resolve(dir); - const configPath = join(projectDir, "feather.config.lua"); + const configPath = assertSafeProjectTarget(projectDir, "feather.config.lua", "Config remove target"); const configSrc = existsSync(configPath) ? readFileSync(configPath, "utf8") : ""; - const installDir = normalizeInstallDir(opts.installDir ?? metadataValue(configSrc, "installDir") ?? "feather"); - const manualEntrypoint = metadataValue(configSrc, "manualEntrypoint") ?? "feather.debugger.lua"; + const installDir = normalizeInstallDir(opts.installDir ?? parseManagedValue(configSrc, "installDir") ?? "feather"); + const manualEntrypoint = parseManagedValue(configSrc, "manualEntrypoint") ?? "feather.debugger.lua"; + const runtimePath = assertSafeProjectTarget(projectDir, installDir, "Runtime remove target"); + const manualPath = manualEntrypoint === "(none)" + ? join(projectDir, "(none)") + : assertSafeProjectTarget(projectDir, manualEntrypoint, "Manual entrypoint remove target"); return { projectDir, installDir, configPath, - mainPath: join(projectDir, "main.lua"), + mainPath: assertSafeProjectTarget(projectDir, "main.lua", "main.lua update target"), manualEntrypoint, + manualPath, + runtimePath, }; } @@ -60,29 +67,30 @@ function discoverTargets(context: RemoveContext, opts: RemoveOptions): RemoveTar path: context.mainPath, description: "Remove only the FEATHER-INIT require block and DEBUGGER:update hook.", defaultSelected: true, + dangerous: true, }); } } - const runtimePath = join(context.projectDir, context.installDir); - if (!opts.keepRuntime && existsSync(runtimePath)) { + if (!opts.keepRuntime && existsSync(context.runtimePath)) { targets.push({ id: "runtime", label: "Feather runtime", - path: runtimePath, + path: context.runtimePath, description: "Delete the installed Feather core and plugins directory.", defaultSelected: true, + dangerous: true, }); } - const manualPath = join(context.projectDir, context.manualEntrypoint); - if (!opts.keepManual && context.manualEntrypoint !== "(none)" && existsSync(manualPath)) { + if (!opts.keepManual && context.manualEntrypoint !== "(none)" && existsSync(context.manualPath)) { targets.push({ id: "manual", label: "Manual debugger entrypoint", - path: manualPath, + path: context.manualPath, description: "Delete the generated manual setup file.", defaultSelected: true, + dangerous: true, }); } @@ -96,6 +104,7 @@ function discoverTargets(context: RemoveContext, opts: RemoveOptions): RemoveTar ? "Delete the generated Feather config file." : "Delete feather.config.lua. This file does not contain managed metadata.", defaultSelected: configSrc.includes("FEATHER-MANAGED-BEGIN"), + dangerous: true, }); } @@ -112,16 +121,14 @@ function applyTarget(id: string, context: RemoveContext, dryRun: boolean): strin } if (id === "runtime") { - const runtimePath = join(context.projectDir, context.installDir); - if (!existsSync(runtimePath)) return null; - if (!dryRun) rmSync(runtimePath, { recursive: true, force: true }); + if (!existsSync(context.runtimePath)) return null; + if (!dryRun) rmSync(context.runtimePath, { recursive: true, force: true }); return `Removed ${context.installDir}/`; } if (id === "manual") { - const manualPath = join(context.projectDir, context.manualEntrypoint); - if (!existsSync(manualPath)) return null; - if (!dryRun) rmSync(manualPath, { force: true }); + if (!existsSync(context.manualPath)) return null; + if (!dryRun) rmSync(context.manualPath, { force: true }); return `Removed ${context.manualEntrypoint}`; } @@ -138,35 +145,38 @@ export async function removeCommand(dir: string, opts: RemoveOptions): Promise target.defaultSelected).map((target) => target.id); if (!opts.yes && process.stdin.isTTY) { const result = await chooseRemoveWorkflow(targets); if (result.cancelled) { - console.log(chalk.dim("Remove cancelled.")); + printMuted("Remove cancelled."); return; } targetIds = result.targetIds; } if (targetIds.length === 0) { - console.log(chalk.dim("No remove targets selected.")); + printMuted("No remove targets selected."); return; } for (const id of targetIds) { const message = applyTarget(id, context, opts.dryRun === true); if (message) { - console.log((opts.dryRun ? chalk.dim("dry-run ") : chalk.green("✔ ")) + message); + printLine((opts.dryRun ? style.muted("dry-run ") : `${icon.success} `) + message); } } } diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index 62dd28fa..f4aae6bb 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -1,32 +1,59 @@ import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; -import chalk from "chalk"; +import { existsSync, realpathSync } from "node:fs"; +import { basename, dirname, join, relative, resolve } from "node:path"; import { findLoveBinary } from "../lib/love.js"; import { createShim, shimEnv } from "../lib/shim.js"; import { loadConfig } from "../lib/config.js"; import { chooseRunWorkflow } from "../ui/run-workflow.js"; +import { fail } from "../lib/command.js"; +import { printInfo, printKeyValues, printMuted, printStatus } from "../lib/output.js"; +import { runMobile, type MobileRunTarget } from "../lib/run/mobile.js"; +import { runWeb } from "../lib/run/web.js"; +import { isPathInside } from "../lib/path-safety.js"; export interface RunOptions { love?: string; sessionName?: string; noPlugins?: boolean; + debugger?: boolean; config?: string; featherPath?: string; pluginsDir?: string; gameArgs?: string[]; + target?: "desktop" | "web" | MobileRunTarget; + device?: string; + buildConfig?: string; + outDir?: string; + clean?: boolean; + noCache?: boolean; + verbose?: boolean; + webHost?: string; + webPort?: number; + adbReverse?: boolean; + port?: number; } -export async function runCommand(gamePath: string | undefined, opts: RunOptions): Promise { +export async function runCommand(gamePath: string | undefined, opts: RunOptions): Promise { + const target = opts.target ?? "desktop"; + const debuggerEnabled = opts.debugger !== false; + if (!["desktop", "web", "android", "ios"].includes(target)) { + fail("Run target must be one of: desktop, web, android, ios."); + } + if (opts.port !== undefined && (!Number.isInteger(opts.port) || opts.port < 1 || opts.port > 65535)) { + fail("Port must be a number between 1 and 65535."); + } + if (opts.webPort !== undefined && (!Number.isInteger(opts.webPort) || opts.webPort < 0 || opts.webPort > 65535)) { + fail("Web port must be a number between 0 and 65535."); + } + if (!gamePath) { if (!process.stdin.isTTY) { - console.error(chalk.red("Game path is required. Use `feather run `.")); - process.exit(1); + fail("Game path is required. Use `feather run `."); } const result = await chooseRunWorkflow(); if (result.cancelled) { - console.log(chalk.dim("Run cancelled.")); + printMuted("Run cancelled."); return; } @@ -46,26 +73,128 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) const absGame = resolve(gamePath); if (!existsSync(absGame)) { - console.error(chalk.red(`Game path not found: ${absGame}`)); - process.exit(1); + fail(`Game path not found: ${absGame}`); } if (!existsSync(`${absGame}/main.lua`)) { - console.error(chalk.red(`No main.lua found in: ${absGame}`)); - process.exit(1); + fail(`No main.lua found in: ${absGame}`); + } + + const inferredConfig = inferConfigArg(opts.config, opts.gameArgs); + if (inferredConfig) { + opts = { + ...opts, + config: inferredConfig.config, + gameArgs: inferredConfig.gameArgs, + }; + } + + const userConfig = loadConfig(absGame, opts.config) ?? undefined; + + if (target === "web") { + if ((opts.gameArgs?.length ?? 0) > 0) { + fail("Web run does not support forwarded game arguments yet."); + } + const buildContext = resolveRunBuildContext(absGame, opts.buildConfig); + try { + printInfo("Feather run web"); + const result = await runWeb({ + projectDir: buildContext.projectDir, + configPath: buildContext.configPath, + sourceDir: buildContext.sourceDir, + outDir: opts.outDir, + clean: opts.clean, + debugger: debuggerEnabled, + runtimeConfigPath: opts.config, + noPlugins: opts.noPlugins, + featherOverride: opts.featherPath, + pluginsOverride: opts.pluginsDir, + verbose: opts.verbose, + host: opts.webHost, + port: opts.webPort, + }); + printStatus("success", "Serving web build"); + printKeyValues([ + ["Game", absGame], + ["HTML", result.htmlDir], + ["URL", result.url], + ["Debugger", result.debugger ? "enabled" : "disabled"], + ]); + await result.wait; + return; + } catch (err) { + fail((err as Error).message, { cause: err }); + } + } + + if (target === "android" || target === "ios") { + if ((opts.gameArgs?.length ?? 0) > 0) { + fail("Mobile run does not support forwarded game arguments yet."); + } + const buildContext = resolveRunBuildContext(absGame, opts.buildConfig); + try { + printInfo(`Feather run ${target}`); + const result = runMobile({ + target, + projectDir: buildContext.projectDir, + configPath: buildContext.configPath, + sourceDir: buildContext.sourceDir, + outDir: opts.outDir, + clean: opts.clean, + noCache: opts.noCache, + debugger: debuggerEnabled, + runtimeConfigPath: opts.config, + noPlugins: opts.noPlugins, + featherOverride: opts.featherPath, + pluginsOverride: opts.pluginsDir, + verbose: opts.verbose, + device: opts.device, + adbReverse: debuggerEnabled ? opts.adbReverse : false, + port: opts.port ?? (typeof userConfig?.port === "number" ? userConfig.port : undefined), + }); + printStatus("success", `Launched ${target}`); + printKeyValues([ + ["Game", absGame], + ["Artifact", result.artifact], + ["App ID", result.appId], + ["Device", result.device], + ["ADB reverse", result.adbReverse === undefined ? undefined : result.adbReverse ? `tcp:${result.port}` : "disabled"], + ]); + return; + } catch (err) { + fail((err as Error).message, { cause: err }); + } } let loveBin: string; try { loveBin = findLoveBinary(opts.love); } catch (err) { - console.error(chalk.red((err as Error).message)); - process.exit(1); + fail((err as Error).message, { cause: err }); } - const userConfig = loadConfig(absGame, opts.config) ?? undefined; const sessionName = opts.sessionName ?? (userConfig?.sessionName as string | undefined); + if (!debuggerEnabled) { + printInfo("Feather run"); + printKeyValues([ + ["Game", absGame], + ["Debugger", "disabled"], + ["Args", opts.gameArgs?.join(" ")], + ]); + + const result = spawnSync(loveBin, [absGame, ...(opts.gameArgs ?? [])], { + stdio: "inherit", + env: process.env, + }); + + if (result.error) { + fail(`Failed to launch love: ${result.error.message}`, { cause: result.error }); + } + + return result.status ?? 0; + } + const shim = createShim({ gamePath: absGame, sessionName, @@ -75,11 +204,12 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) userConfig: userConfig as Record | undefined, }); - console.log(chalk.dim(`[feather] shim → ${shim.dir}`)); - console.log(chalk.cyan(`[feather] running ${absGame}`)); - if (opts.gameArgs && opts.gameArgs.length > 0) { - console.log(chalk.dim(`[feather] args → ${opts.gameArgs.join(" ")}`)); - } + printInfo("Feather run"); + printKeyValues([ + ["Game", absGame], + ["Shim", shim.dir], + ["Args", opts.gameArgs?.join(" ")], + ]); const env = shimEnv(absGame, sessionName); @@ -91,9 +221,58 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) shim.cleanup(); if (result.error) { - console.error(chalk.red(`Failed to launch love: ${result.error.message}`)); - process.exit(1); + fail(`Failed to launch love: ${result.error.message}`, { cause: result.error }); } - process.exit(result.status ?? 0); + return result.status ?? 0; +} + +function resolveRunBuildContext(absGame: string, buildConfig: string | undefined): { + projectDir: string; + configPath?: string; + sourceDir?: string; +} { + if (buildConfig) { + const configPath = resolve(buildConfig); + const configDir = dirname(configPath); + const sourceDir = relativeInside(configDir, absGame); + if (sourceDir) { + return { + projectDir: realpathSync(configDir), + configPath, + sourceDir, + }; + } + return { projectDir: absGame, configPath }; + } + + if (existsSync(join(absGame, "feather.build.json"))) { + return { projectDir: absGame }; + } + + const cwd = resolve(process.cwd()); + const sourceDir = relativeInside(cwd, absGame); + if (existsSync(join(cwd, "feather.build.json")) && sourceDir) { + return { + projectDir: realpathSync(cwd), + sourceDir, + }; + } + + return { projectDir: absGame }; +} + +function relativeInside(root: string, target: string): string | null { + const rootReal = realpathSync(root); + const targetReal = realpathSync(target); + if (!isPathInside(rootReal, targetReal)) return null; + return relative(rootReal, targetReal) || "."; +} + +function inferConfigArg(config: string | undefined, gameArgs: string[] | undefined): { config: string; gameArgs: string[] } | null { + if (config || !gameArgs || gameArgs.length !== 1) return null; + const candidate = gameArgs[0]; + if (!["feather.config.lua", ".featherrc.lua"].includes(basename(candidate))) return null; + if (!existsSync(resolve(candidate))) return null; + return { config: candidate, gameArgs: [] }; } diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index 459921d8..c408f246 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -1,26 +1,11 @@ import { existsSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import chalk from "chalk"; -import ora from "ora"; +import { join, resolve } from "node:path"; import { fetchManifest, installCore, installCoreFromLocal, normalizeInstallDir } from "../lib/install.js"; import { chooseCoreUpdateWorkflow } from "../ui/update-workflow.js"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -function bundledLuaRoot(): string { - return resolve(__dirname, "../../lua"); -} - -function repoLuaRoot(): string | null { - const candidate = resolve(__dirname, "../../../src-lua"); - return existsSync(join(candidate, "feather", "init.lua")) ? candidate : null; -} - -function resolveLocalLuaRoot(opts: { localSrc?: string }): string { - if (opts.localSrc) return resolve(opts.localSrc); - return repoLuaRoot() ?? bundledLuaRoot(); -} +import { resolveLocalLuaRoot } from "../lib/paths.js"; +import { fail } from "../lib/command.js"; +import { createSpinner, printLine, printMuted, style } from "../lib/output.js"; +import { assertSafeProjectTarget } from "../lib/path-safety.js"; export async function updateCommand( dir: string, @@ -28,10 +13,15 @@ export async function updateCommand( ): Promise { const target = resolve(dir); const installDir = normalizeInstallDir(opts.installDir); + let installedInit: string; + try { + installedInit = assertSafeProjectTarget(target, join(installDir, "init.lua"), "Core update target"); + } catch (err) { + fail((err as Error).message); + } - if (!existsSync(join(target, installDir, "init.lua"))) { - console.error(chalk.red(`Feather is not installed in ${target}. Run \`feather init\` first.`)); - process.exit(1); + if (!existsSync(installedInit)) { + fail(`Feather is not installed in ${target}. Run \`feather init\` first.`); } let branch = opts.branch ?? "main"; @@ -41,7 +31,7 @@ export async function updateCommand( if (!hasExplicitSource && process.stdin.isTTY) { const result = await chooseCoreUpdateWorkflow(branch); if (result.cancelled) { - console.log(chalk.dim("Update cancelled.")); + printMuted("Update cancelled."); return; } useRemote = result.source === "remote"; @@ -50,7 +40,7 @@ export async function updateCommand( if (!useRemote) { const sourceRoot = resolveLocalLuaRoot(opts); - const spinner = ora("Updating feather core from local copy…").start(); + const spinner = createSpinner("Updating feather core from local copy…").start(); try { installCoreFromLocal(sourceRoot, target, installDir, (file) => { spinner.text = `Updating ${file}…`; @@ -58,14 +48,14 @@ export async function updateCommand( spinner.succeed("Feather core updated"); } catch (err) { spinner.fail((err as Error).message); - process.exit(1); + fail((err as Error).message, { cause: err, silent: true }); } - console.log(chalk.bold("\nDone!") + " Feather core is up to date.\n"); + printLine(`\n${style.heading("Done!")} Feather core is up to date.\n`); return; } - const spinner = ora("Fetching manifest…").start(); + const spinner = createSpinner("Fetching manifest…").start(); let entries: Awaited>; try { @@ -73,10 +63,10 @@ export async function updateCommand( spinner.succeed(`Manifest loaded (${entries.length} files)`); } catch (err) { spinner.fail((err as Error).message); - process.exit(1); + fail((err as Error).message, { cause: err, silent: true }); } - const updateSpinner = ora("Updating feather core…").start(); + const updateSpinner = createSpinner("Updating feather core…").start(); try { await installCore(entries, target, branch, (f) => { updateSpinner.text = `Updating ${f}…`; @@ -84,8 +74,8 @@ export async function updateCommand( updateSpinner.succeed("Feather core updated"); } catch (err) { updateSpinner.fail((err as Error).message); - process.exit(1); + fail((err as Error).message, { cause: err, silent: true }); } - console.log(chalk.bold("\nDone!") + " Feather core is up to date.\n"); + printLine(`\n${style.heading("Done!")} Feather core is up to date.\n`); } diff --git a/cli/src/commands/upload.ts b/cli/src/commands/upload.ts new file mode 100644 index 00000000..4d8ab08c --- /dev/null +++ b/cli/src/commands/upload.ts @@ -0,0 +1,210 @@ +import { fail } from '../lib/command.js'; +import { + createSpinner, + printBlank, + printDanger, + printJson, + printKeyValues, + printMuted, + printStatus, + printWarning, + style, +} from '../lib/output.js'; +import { buildTargets, isBuildTarget, isUploadTarget, uploadTargets, type BuildTarget, type UploadTarget } from '../lib/build/config.js'; +import { runBuild } from '../lib/build/build.js'; +import { runUpload } from '../lib/build/upload.js'; +import { chooseUploadWorkflow, type UploadWorkflowResult } from '../ui/upload-workflow.js'; +import type { UploadSafetyResult } from '../lib/build/upload-safety.js'; + +export type UploadCommandOptions = { + dir?: string; + config?: string; + buildDir?: string; + project?: string; + channel?: string; + userVersion?: string; + dryRun?: boolean; + ifChanged?: boolean; + hidden?: boolean; + json?: boolean; + yes?: boolean; + build?: boolean; + outDir?: string; + release?: boolean; + allowUnsafe?: boolean; + clean?: boolean; + noCache?: boolean; + verbose?: boolean; + allowFeatherRuntime?: boolean; +}; + +type ResolvedUploadInput = { + targetValue: string; + buildTarget?: string; + opts: UploadCommandOptions; + confirmedUnsafe?: boolean; +}; + +function isInteractive(opts: UploadCommandOptions): boolean { + return Boolean(!opts.json && process.stdin.isTTY && process.stdout.isTTY); +} + +function printSafetyWarning(safety: UploadSafetyResult, warning: string): void { + printBlank(); + printDanger('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); + printDanger('! UPLOAD SAFETY WARNING !'); + printDanger('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); + printWarning(warning); + printKeyValues([ + ['Status', safety.status], + ['Artifact', safety.artifact], + ['Detected', safety.detectedFiles.length ? safety.detectedFiles.join(', ') : safety.reason ?? 'unknown'], + ]); + printDanger('Review this upload before sharing it with players.'); +} + +async function resolveUploadInput( + targetValue: string | undefined, + buildTarget: string | undefined, + opts: UploadCommandOptions, +): Promise { + if (!targetValue && isInteractive(opts)) { + const result = await chooseUploadWorkflow({ + dir: opts.dir, + config: opts.config, + buildDir: opts.buildDir, + project: opts.project, + channel: opts.channel, + userVersion: opts.userVersion, + }); + if (result.cancelled) fail('Upload cancelled.'); + return { + targetValue: result.target, + buildTarget: result.buildTarget, + confirmedUnsafe: result.allowFeatherRuntime, + opts: { + ...opts, + dir: result.dir, + config: result.config, + buildDir: result.buildDir, + project: result.project, + channel: result.channel, + userVersion: result.userVersion, + dryRun: result.dryRun, + build: result.build, + allowFeatherRuntime: result.allowFeatherRuntime, + yes: result.confirmed, + }, + }; + } + + if (!targetValue) { + fail('Upload target is required in non-interactive mode. Example: feather upload itch web --dir path/to/game --yes'); + } + + return { targetValue, buildTarget, opts }; +} + +export async function uploadCommand(targetValue: string | undefined, buildTarget: string | undefined, opts: UploadCommandOptions = {}): Promise { + const resolved = await resolveUploadInput(targetValue, buildTarget, opts); + targetValue = resolved.targetValue; + buildTarget = resolved.buildTarget; + opts = resolved.opts; + + if (!isUploadTarget(targetValue)) { + fail(`Unknown upload target: ${targetValue}`, { details: [`Available: ${uploadTargets.join(', ')}`] }); + } + const target: UploadTarget = targetValue; + if (buildTarget && !isBuildTarget(buildTarget)) { + fail(`Unknown build target: ${buildTarget}`, { details: [`Available: ${buildTargets.join(', ')}`] }); + } + if (!opts.dryRun && !opts.yes && !isInteractive(opts)) { + fail('Refusing to upload without --yes in non-interactive mode.'); + } + if (opts.build) { + if (!buildTarget || !isBuildTarget(buildTarget)) { + fail('A build target is required with --build. Example: feather upload itch web --build'); + } + const verbose = Boolean(opts.verbose && !opts.json && !opts.dryRun); + const buildSpinner = opts.json || opts.dryRun || verbose ? null : createSpinner(`Building ${buildTarget}…`).start(); + const buildResult = runBuild({ + target: buildTarget as BuildTarget, + projectDir: opts.dir, + configPath: opts.config, + outDir: opts.outDir, + clean: opts.clean, + dryRun: false, + allowUnsafe: opts.allowUnsafe, + release: opts.release, + noCache: opts.noCache, + verbose, + log: verbose ? printMuted : undefined, + }); + if (!buildResult.ok) { + buildSpinner?.fail(buildResult.error); + fail(buildResult.error, { silent: Boolean(buildSpinner) }); + } + buildSpinner?.succeed(`Built ${buildTarget}`); + } + const spinner = opts.json || opts.dryRun ? null : createSpinner(`Uploading to ${target}…`).start(); + const result = runUpload({ + target, + buildTarget, + projectDir: opts.dir, + configPath: opts.config, + buildDir: opts.buildDir, + project: opts.project, + channel: opts.channel, + userVersion: opts.userVersion, + dryRun: opts.dryRun, + ifChanged: opts.ifChanged, + hidden: opts.hidden, + allowFeatherRuntime: opts.allowFeatherRuntime || resolved.confirmedUnsafe, + }); + + if (!result.ok) { + spinner?.fail(result.error); + if (opts.json) { + printJson(result); + process.exitCode = 1; + return; + } + if (result.safety) { + printBlank(); + printKeyValues([ + ['Safety', result.safety.status], + ['Artifact', result.safety.artifact], + ['Reason', result.safety.reason], + ['Detected', result.safety.detectedFiles.join(', ')], + ]); + } + fail(result.error, { silent: Boolean(spinner) }); + } + + if (opts.json) { + printJson(result); + return; + } + + if (result.dryRun) { + printStatus('info', `Upload plan for ${style.heading(result.target)}`); + } else { + spinner?.succeed(`Uploaded ${result.buildTarget} to ${result.project}:${result.channel}`); + } + printBlank(); + printKeyValues([ + ['Target', result.target], + ['Build', result.buildTarget], + ['Artifact', result.artifact], + ['Project', result.project], + ['Channel', result.channel], + ['Version', result.userVersion], + ]); + if (result.dryRun) { + printBlank(); + printMuted(`Command: ${result.command.join(' ')}`); + } + if (!result.dryRun && result.warning) { + printSafetyWarning(result.safety, result.warning); + } +} diff --git a/cli/src/generated/registry.json b/cli/src/generated/registry.json index d3db219b..4bb49f57 100644 --- a/cli/src/generated/registry.json +++ b/cli/src/generated/registry.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-05-12", + "updatedAt": "2026-05-16", "packages": { "anim8": { "type": "love2d-library", @@ -11,10 +11,12 @@ "sprites" ], "homepage": "https://github.com/kikito/anim8", + "license": "MIT", "source": { "repo": "kikito/anim8", "tag": "v2.3.1", - "baseUrl": "https://raw.githubusercontent.com/kikito/anim8/v2.3.1/" + "baseUrl": "https://raw.githubusercontent.com/kikito/anim8/c1c12ec45fde28ffd2d6967675adcb3c3e501fa7/", + "commitSha": "c1c12ec45fde28ffd2d6967675adcb3c3e501fa7" }, "install": { "files": [ @@ -28,6 +30,91 @@ "require": "lib.anim8", "example": "local anim8 = require('lib.anim8')" }, + "baton": { + "type": "love2d-library", + "trust": "verified", + "description": "An input library for LÖVE.", + "tags": [ + "input", + "controller", + "keyboard", + "mouse", + "touch" + ], + "homepage": "https://github.com/tesselode/baton", + "license": "MIT", + "source": { + "repo": "tesselode/baton", + "tag": "v1.0.2", + "baseUrl": "https://raw.githubusercontent.com/tesselode/baton/3defe19f8f0da1a457ba86095e6f6a3c7ee6a8e9/", + "commitSha": "3defe19f8f0da1a457ba86095e6f6a3c7ee6a8e9" + }, + "install": { + "files": [ + { + "name": "baton.lua", + "sha256": "5dee8e0984badef516d1a42f5996b8fae332363399940241fb82abeb5ae648f7", + "target": "lib/baton.lua" + } + ] + }, + "require": "lib.baton", + "example": "local baton = require('lib.baton')" + }, + "beehive": { + "type": "love2d-library", + "trust": "verified", + "description": "A behavior tree implementation in lua.", + "tags": [ + "ai", + "behavior", + "tree" + ], + "homepage": "https://github.com/drhayes/beehive.lua", + "license": "MIT", + "source": { + "repo": "drhayes/beehive.lua", + "tag": "main", + "baseUrl": "https://raw.githubusercontent.com/drhayes/beehive.lua/00877150688ce42fffbaeeb76d7a4cb87e88da60/", + "commitSha": "00877150688ce42fffbaeeb76d7a4cb87e88da60" + }, + "install": { + "files": [ + { + "name": "beehive/fail.lua", + "sha256": "8ffc086030857f311398ce2abc578f574c0303364e3f68a3e78e84fb00a9e41e", + "target": "lib/beehive/fail.lua" + }, + { + "name": "beehive/invert.lua", + "sha256": "cb11cc9564409a5419ad113155e357c968e8c6f38ec10cae55403e5ac488040e", + "target": "lib/beehive/invert.lua" + }, + { + "name": "beehive/parallel.lua", + "sha256": "fdeeadcf6e437a0d11bfcd4019a50aaac34cb969d72bfa10cf6cb69ee570fe4f", + "target": "lib/beehive/parallel.lua" + }, + { + "name": "beehive/repeat.lua", + "sha256": "fd4a75cfbba982ba4436716b12ca5a102e13b1bcf6f630a3c162ee7ad1afff38", + "target": "lib/beehive/repeat.lua" + }, + { + "name": "beehive/selector.lua", + "sha256": "0b46e7085065e89bd8488a0c1edaf335640c01c8d04b4fe302e8b02556f16b83", + "target": "lib/beehive/selector.lua" + }, + { + "name": "beehive/sequence.lua", + "sha256": "80ee98a5a1f8fd302bb2b0452b145051c4094cd4f776453eb0df7a10aef2ae15", + "target": "lib/beehive/sequence.lua" + } + ] + }, + "require": "lib.beehive.fail", + "example": "local beehive = require('lib.beehive.fail')" + }, "bump": { "type": "love2d-library", "trust": "verified", @@ -37,10 +124,12 @@ "physics" ], "homepage": "https://github.com/kikito/bump.lua", + "license": "MIT", "source": { "repo": "kikito/bump.lua", "tag": "v3.1.7", - "baseUrl": "https://raw.githubusercontent.com/kikito/bump.lua/v3.1.7/" + "baseUrl": "https://raw.githubusercontent.com/kikito/bump.lua/72d6b13dfa525564f6e1a533d71eaa8235a5e96c/", + "commitSha": "72d6b13dfa525564f6e1a533d71eaa8235a5e96c" }, "install": { "files": [ @@ -54,6 +143,35 @@ "require": "lib.bump", "example": "local bump = require('lib.bump')" }, + "cargo": { + "type": "love2d-library", + "trust": "verified", + "description": "LÖVE asset manager", + "tags": [ + "asset", + "manager", + "loader" + ], + "homepage": "https://github.com/bjornbytes/cargo", + "license": "MIT", + "source": { + "repo": "bjornbytes/cargo", + "tag": "v0.1.1", + "baseUrl": "https://raw.githubusercontent.com/bjornbytes/cargo/8014bc0ad7d4639c112e5d00fac6c9ac68b0d696/", + "commitSha": "8014bc0ad7d4639c112e5d00fac6c9ac68b0d696" + }, + "install": { + "files": [ + { + "name": "cargo.lua", + "sha256": "50749991281b9052e8f449c55d5bda0c5855f73c9264adb3f42077cf3ff0ab96", + "target": "lib/cargo.lua" + } + ] + }, + "require": "lib.cargo", + "example": "local cargo = require('lib.cargo')" + }, "classic": { "type": "love2d-library", "trust": "verified", @@ -63,10 +181,12 @@ "utilities" ], "homepage": "https://github.com/rxi/classic", + "license": "MIT", "source": { "repo": "rxi/classic", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/rxi/classic/master/" + "baseUrl": "https://raw.githubusercontent.com/rxi/classic/e5610756c98ac2f8facd7ab90c94e1a097ecd2c6/", + "commitSha": "e5610756c98ac2f8facd7ab90c94e1a097ecd2c6" }, "install": { "files": [ @@ -89,10 +209,12 @@ "tweening" ], "homepage": "https://github.com/rxi/flux", + "license": "MIT", "source": { "repo": "rxi/flux", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/rxi/flux/master/" + "baseUrl": "https://raw.githubusercontent.com/rxi/flux/bb330231b87eabf84fbd68322f13a6320db30a41/", + "commitSha": "bb330231b87eabf84fbd68322f13a6320db30a41" }, "install": { "files": [ @@ -106,6 +228,64 @@ "require": "lib.flux", "example": "local flux = require('lib.flux')" }, + "g3d": { + "type": "love2d-library", + "trust": "verified", + "description": "Simple and easy 3D engine for LÖVE.", + "tags": [ + "3d", + "engine" + ], + "homepage": "https://github.com/groverburger/g3d", + "license": "MIT", + "source": { + "repo": "groverburger/g3d", + "tag": "master", + "commitSha": "639120acd754dc5c34402e41eb1687b1a5a3ffa8", + "baseUrl": "https://raw.githubusercontent.com/groverburger/g3d/639120acd754dc5c34402e41eb1687b1a5a3ffa8/" + }, + "install": { + "files": [ + { + "name": "g3d/camera.lua", + "sha256": "4aa73b340ef09168ff22574bc2e9f5ea8c0f6f4561f38215ed9d0ae268897dc6", + "target": "lib/g3d/camera.lua" + }, + { + "name": "g3d/collisions.lua", + "sha256": "f93b0806e75f570a41e35d1a45978bf3217e7ca970d442ef9b18dea8aeef0850", + "target": "lib/g3d/collisions.lua" + }, + { + "name": "g3d/init.lua", + "sha256": "02f5c867264b8ca1d4501a4b7537aba35600d013950bc3d05e9b3b372e6a4dc6", + "target": "lib/g3d/init.lua" + }, + { + "name": "g3d/matrices.lua", + "sha256": "c6579f4c87032bfd0562a679ce261e51c6c42e4a6a69e05102de087e15625f9a", + "target": "lib/g3d/matrices.lua" + }, + { + "name": "g3d/model.lua", + "sha256": "c52054e0d5db5688417712449606bfd1eaedcdc85a5a4fc3eebf482f2d38360b", + "target": "lib/g3d/model.lua" + }, + { + "name": "g3d/objloader.lua", + "sha256": "208002fe5edd9ef05ebd710f0a07701f2fbadfc3cf74059cd701832f195c633e", + "target": "lib/g3d/objloader.lua" + }, + { + "name": "g3d/vectors.lua", + "sha256": "4d23c3334fa1e6b43fbf7bf4fe00969480ee6a4c82a7007f87b3e10df42c411e", + "target": "lib/g3d/vectors.lua" + } + ] + }, + "require": "lib.g3d", + "example": "local g3d = require('lib.g3d')" + }, "hump.camera": { "parent": "hump", "type": "love2d-library", @@ -119,10 +299,12 @@ "math" ], "homepage": "https://github.com/vrld/hump", + "license": "MIT", "source": { "repo": "vrld/hump", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/08937cc0ecf72d1a964a8de6cd552c5e136bf0d4/", + "commitSha": "08937cc0ecf72d1a964a8de6cd552c5e136bf0d4" }, "install": { "files": [ @@ -149,10 +331,12 @@ "math" ], "homepage": "https://github.com/vrld/hump", + "license": "MIT", "source": { "repo": "vrld/hump", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/08937cc0ecf72d1a964a8de6cd552c5e136bf0d4/", + "commitSha": "08937cc0ecf72d1a964a8de6cd552c5e136bf0d4" }, "install": { "files": [ @@ -179,10 +363,12 @@ "math" ], "homepage": "https://github.com/vrld/hump", + "license": "MIT", "source": { "repo": "vrld/hump", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/08937cc0ecf72d1a964a8de6cd552c5e136bf0d4/", + "commitSha": "08937cc0ecf72d1a964a8de6cd552c5e136bf0d4" }, "install": { "files": [ @@ -209,10 +395,12 @@ "math" ], "homepage": "https://github.com/vrld/hump", + "license": "MIT", "source": { "repo": "vrld/hump", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/08937cc0ecf72d1a964a8de6cd552c5e136bf0d4/", + "commitSha": "08937cc0ecf72d1a964a8de6cd552c5e136bf0d4" }, "install": { "files": [ @@ -239,10 +427,12 @@ "math" ], "homepage": "https://github.com/vrld/hump", + "license": "MIT", "source": { "repo": "vrld/hump", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/08937cc0ecf72d1a964a8de6cd552c5e136bf0d4/", + "commitSha": "08937cc0ecf72d1a964a8de6cd552c5e136bf0d4" }, "install": { "files": [ @@ -269,10 +459,12 @@ "math" ], "homepage": "https://github.com/vrld/hump", + "license": "MIT", "source": { "repo": "vrld/hump", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/08937cc0ecf72d1a964a8de6cd552c5e136bf0d4/", + "commitSha": "08937cc0ecf72d1a964a8de6cd552c5e136bf0d4" }, "install": { "files": [ @@ -298,10 +490,12 @@ "math" ], "homepage": "https://github.com/vrld/hump", + "license": "MIT", "source": { "repo": "vrld/hump", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/08937cc0ecf72d1a964a8de6cd552c5e136bf0d4/", + "commitSha": "08937cc0ecf72d1a964a8de6cd552c5e136bf0d4" }, "install": { "files": [ @@ -357,10 +551,12 @@ "utilities" ], "homepage": "https://github.com/kikito/inspect.lua", + "license": "MIT", "source": { "repo": "kikito/inspect.lua", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/kikito/inspect.lua/master/" + "baseUrl": "https://raw.githubusercontent.com/kikito/inspect.lua/a8ca3120dfec48801036eaeff9335ab7a096dd24/", + "commitSha": "a8ca3120dfec48801036eaeff9335ab7a096dd24" }, "install": { "files": [ @@ -374,6 +570,764 @@ "require": "lib.inspect", "example": "local inspect = require('lib.inspect')" }, + "knife.base": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — base module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" + }, + "install": { + "files": [ + { + "name": "knife/base.lua", + "sha256": "9cdc89da1a37296aa0f57c1f28c4123be6052604ca2b60306a98e926666e2fd1", + "target": "lib/knife/base.lua" + } + ] + }, + "require": "lib.knife.base", + "example": "local base = require('lib.knife.base')" + }, + "knife.behavior": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — behavior module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" + }, + "install": { + "files": [ + { + "name": "knife/behavior.lua", + "sha256": "9ee69bf0b7966061e75716811727138772dc766ba8d03ed6a5d98df3491967e4", + "target": "lib/knife/behavior.lua" + } + ] + }, + "require": "lib.knife.behavior", + "example": "local behavior = require('lib.knife.behavior')" + }, + "knife.bind": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — bind module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" + }, + "install": { + "files": [ + { + "name": "knife/bind.lua", + "sha256": "01923de7538a127d4bdc23ec6dccea9c791928e88dcd61dd4cd7cc97287c639a", + "target": "lib/knife/bind.lua" + } + ] + }, + "require": "lib.knife.bind", + "example": "local bind = require('lib.knife.bind')" + }, + "knife.chain": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — chain module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" + }, + "install": { + "files": [ + { + "name": "knife/chain.lua", + "sha256": "70143d8618f7be18025da8cc073714fe1c3c3a2d173c2d04f415e9d0d85bd964", + "target": "lib/knife/chain.lua" + } + ] + }, + "require": "lib.knife.chain", + "example": "local chain = require('lib.knife.chain')" + }, + "knife.convoke": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — convoke module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" + }, + "install": { + "files": [ + { + "name": "knife/convoke.lua", + "sha256": "76c1862861999b8523cb060c964fded1986dbcd04c08610e9547bddcef751776", + "target": "lib/knife/convoke.lua" + } + ] + }, + "require": "lib.knife.convoke", + "example": "local convoke = require('lib.knife.convoke')" + }, + "knife.event": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — event module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" + }, + "install": { + "files": [ + { + "name": "knife/event.lua", + "sha256": "962e2f6b47fc6a81cdc07d28d5fef6cd40132dcb28fd3d34c834616cd15be74f", + "target": "lib/knife/event.lua" + } + ] + }, + "require": "lib.knife.event", + "example": "local event = require('lib.knife.event')" + }, + "knife.gun": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — gun module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" + }, + "install": { + "files": [ + { + "name": "knife/gun.lua", + "sha256": "9f0754e9a11fddb7c376b4e92d9d583a1720dc038e080957a598c43093a4064b", + "target": "lib/knife/gun.lua" + } + ] + }, + "require": "lib.knife.gun", + "example": "local gun = require('lib.knife.gun')" + }, + "knife.memoize": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — memoize module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" + }, + "install": { + "files": [ + { + "name": "knife/memoize.lua", + "sha256": "45c857737cf3b3c4e7bd3994c882a142761153932ba4695b57aa4eb9198ccfd0", + "target": "lib/knife/memoize.lua" + } + ] + }, + "require": "lib.knife.memoize", + "example": "local memoize = require('lib.knife.memoize')" + }, + "knife.serialize": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — serialize module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" + }, + "install": { + "files": [ + { + "name": "knife/serialize.lua", + "sha256": "472901fa35c502af4bfddc2ee0cc723dc91fb5ffbd06ddde22fa512bd94c4870", + "target": "lib/knife/serialize.lua" + } + ] + }, + "require": "lib.knife.serialize", + "example": "local serialize = require('lib.knife.serialize')" + }, + "knife.system": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — system module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" + }, + "install": { + "files": [ + { + "name": "knife/system.lua", + "sha256": "bc9d8981679bdb752f4e1b2031f42b6e08a40f411981f23f6f148588edb23ea2", + "target": "lib/knife/system.lua" + } + ] + }, + "require": "lib.knife.system", + "example": "local system = require('lib.knife.system')" + }, + "knife.test": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — test module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" + }, + "install": { + "files": [ + { + "name": "knife/test.lua", + "sha256": "581c6c679cf5b711068880b36c242da9a4648c1525a9d615bbaeed0f79b4dbcd", + "target": "lib/knife/test.lua" + } + ] + }, + "require": "lib.knife.test", + "example": "local test = require('lib.knife.test')" + }, + "knife.timer": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — timer module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" + }, + "install": { + "files": [ + { + "name": "knife/timer.lua", + "sha256": "6c0a1f59404f7c1e3612afc80fb443b543546a5aa14a1d200a679e8684b5aaac", + "target": "lib/knife/timer.lua" + } + ] + }, + "require": "lib.knife.timer", + "example": "local timer = require('lib.knife.timer')" + }, + "knife": { + "type": "love2d-library", + "trust": "known", + "description": "A collection of useful micro-modules for Lua.", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" + }, + "install": { + "files": [ + { + "name": "knife/base.lua", + "sha256": "9cdc89da1a37296aa0f57c1f28c4123be6052604ca2b60306a98e926666e2fd1", + "target": "lib/knife/base.lua" + }, + { + "name": "knife/behavior.lua", + "sha256": "9ee69bf0b7966061e75716811727138772dc766ba8d03ed6a5d98df3491967e4", + "target": "lib/knife/behavior.lua" + }, + { + "name": "knife/bind.lua", + "sha256": "01923de7538a127d4bdc23ec6dccea9c791928e88dcd61dd4cd7cc97287c639a", + "target": "lib/knife/bind.lua" + }, + { + "name": "knife/chain.lua", + "sha256": "70143d8618f7be18025da8cc073714fe1c3c3a2d173c2d04f415e9d0d85bd964", + "target": "lib/knife/chain.lua" + }, + { + "name": "knife/convoke.lua", + "sha256": "76c1862861999b8523cb060c964fded1986dbcd04c08610e9547bddcef751776", + "target": "lib/knife/convoke.lua" + }, + { + "name": "knife/event.lua", + "sha256": "962e2f6b47fc6a81cdc07d28d5fef6cd40132dcb28fd3d34c834616cd15be74f", + "target": "lib/knife/event.lua" + }, + { + "name": "knife/gun.lua", + "sha256": "9f0754e9a11fddb7c376b4e92d9d583a1720dc038e080957a598c43093a4064b", + "target": "lib/knife/gun.lua" + }, + { + "name": "knife/memoize.lua", + "sha256": "45c857737cf3b3c4e7bd3994c882a142761153932ba4695b57aa4eb9198ccfd0", + "target": "lib/knife/memoize.lua" + }, + { + "name": "knife/serialize.lua", + "sha256": "472901fa35c502af4bfddc2ee0cc723dc91fb5ffbd06ddde22fa512bd94c4870", + "target": "lib/knife/serialize.lua" + }, + { + "name": "knife/system.lua", + "sha256": "bc9d8981679bdb752f4e1b2031f42b6e08a40f411981f23f6f148588edb23ea2", + "target": "lib/knife/system.lua" + }, + { + "name": "knife/test.lua", + "sha256": "581c6c679cf5b711068880b36c242da9a4648c1525a9d615bbaeed0f79b4dbcd", + "target": "lib/knife/test.lua" + }, + { + "name": "knife/timer.lua", + "sha256": "6c0a1f59404f7c1e3612afc80fb443b543546a5aa14a1d200a679e8684b5aaac", + "target": "lib/knife/timer.lua" + } + ] + }, + "subpackages": [ + "knife.base", + "knife.behavior", + "knife.bind", + "knife.chain", + "knife.convoke", + "knife.event", + "knife.gun", + "knife.memoize", + "knife.serialize", + "knife.system", + "knife.test", + "knife.timer" + ], + "require": "lib.knife.base", + "example": "local knife = require('lib.knife.base')" + }, + "love-dialogue": { + "type": "love2d-library", + "trust": "verified", + "description": "Dialogue based Game Engine for Love2d with custom scripting language for dialogues", + "tags": [ + "text", + "dialogue", + "system", + "tag", + "talk" + ], + "homepage": "https://github.com/Miisan-png/Love-Dialogue", + "license": "unknown", + "source": { + "repo": "Miisan-png/Love-Dialogue", + "tag": "main", + "baseUrl": "https://raw.githubusercontent.com/Miisan-png/Love-Dialogue/1d1cbd1f6730fbaf73fab18a2d33ee3525abfb75/", + "commitSha": "1d1cbd1f6730fbaf73fab18a2d33ee3525abfb75" + }, + "install": { + "files": [ + { + "name": "LoveDialogue/9patch.lua", + "sha256": "dd882490cab2a6850b4b5f95a3fc1845b090a37773fcd4347d5bb496efe2839b", + "target": "lib/love-dialogue/9patch.lua" + }, + { + "name": "LoveDialogue/DialogueConstants.lua", + "sha256": "9335210bfd61d9e39fcfdbd7ca6c7e55f5897565848260dffd3080e62ee9d0c5", + "target": "lib/love-dialogue/DialogueConstants.lua" + }, + { + "name": "LoveDialogue/Logic.lua", + "sha256": "85701aa89d7892f5a55095b70a992036a241504e3470caeb58caae7f886b434f", + "target": "lib/love-dialogue/Logic.lua" + }, + { + "name": "LoveDialogue/LoveCharacter.lua", + "sha256": "b606202a72f9b9c349bcee793bd6bfc7a36cc64059480a5a4071f81ba442d7d6", + "target": "lib/love-dialogue/LoveCharacter.lua" + }, + { + "name": "LoveDialogue/LoveDialogue.lua", + "sha256": "9bea894af34bca1db4f04e94c38fd8800a36093bac8730f5f32c5b963a9b7043", + "target": "lib/love-dialogue/LoveDialogue.lua" + }, + { + "name": "LoveDialogue/LoveDialogueParser.lua", + "sha256": "35548075414928e077b9d8136186444fbcbfb11c89c8b92eb92487e1cc78bddc", + "target": "lib/love-dialogue/LoveDialogueParser.lua" + }, + { + "name": "LoveDialogue/PluginManager.lua", + "sha256": "c66684888259a4eea25546369ffe9c8540f778b4e16fa5adad2a5fe3cda1dc8c", + "target": "lib/love-dialogue/PluginManager.lua" + }, + { + "name": "LoveDialogue/ResourceManager.lua", + "sha256": "5366b1bdb8a033fc1c2bfd787c326f1031aa9e285ff73c8c4695ac8e97dd32bb", + "target": "lib/love-dialogue/ResourceManager.lua" + }, + { + "name": "LoveDialogue/TextEffects.lua", + "sha256": "399aacf0ff2e8f8615692ef86a184f5e2b730f0ab1fc5961e24ee8a8564f11d6", + "target": "lib/love-dialogue/TextEffects.lua" + }, + { + "name": "LoveDialogue/ThemeParser.lua", + "sha256": "1303f4057eb47c9606c66560207308b7cc6b2ca12d4bcb98ee9c023d29e03af9", + "target": "lib/love-dialogue/ThemeParser.lua" + }, + { + "name": "LoveDialogue/Transition.lua", + "sha256": "296a3c8f33d184387487d46e6f5b22205a565293cdbb2226eaab6052e2ef5d84", + "target": "lib/love-dialogue/Transition.lua" + }, + { + "name": "LoveDialogue/Tween.lua", + "sha256": "2d5be30a9a4d3132373505c3ebde532ffe3009db2e7f8b9ce45680f01f5fef0f", + "target": "lib/love-dialogue/Tween.lua" + }, + { + "name": "LoveDialogue/init.lua", + "sha256": "23f6c1bf3ac8e9d9d99443caa750acee158a415242208e93f8e4f21893b5df79", + "target": "lib/love-dialogue/init.lua" + }, + { + "name": "LoveDialogue/plugins/DebugPlugin.lua", + "sha256": "5dc1d9f7a01d9f2e92aff9647ba43742cebb4095c17a4a48a2d698b9c638cebd", + "target": "lib/love-dialogue/plugins/DebugPlugin.lua" + } + ] + }, + "require": "lib.love-dialogue", + "example": "local love_dialogue = require('lib.love-dialogue')" + }, + "lovebpm": { + "type": "love2d-library", + "trust": "verified", + "description": "A LÖVE library for syncing events to the BPM of an audio track", + "tags": [ + "music", + "sound", + "audio", + "track", + "bpm", + "events" + ], + "homepage": "https://github.com/rxi/lovebpm", + "license": "MIT", + "source": { + "repo": "rxi/lovebpm", + "tag": "master", + "commitSha": "22f8537083818ebfc2a0788fbec3e991894371f9", + "baseUrl": "https://raw.githubusercontent.com/rxi/lovebpm/22f8537083818ebfc2a0788fbec3e991894371f9/" + }, + "install": { + "files": [ + { + "name": "lovebpm.lua", + "sha256": "fe3bb0fa97f79c0ff8e32810a16473bbe7677b1d21b753aeec1e35ac8f86b00c", + "target": "lib/lovebpm.lua" + } + ] + }, + "require": "lib.lovebpm", + "example": "local lovebpm = require('lib.lovebpm')" + }, + "lua-state-machine": { + "type": "love2d-library", + "trust": "verified", + "description": "A finite state machine lua micro framework", + "tags": [ + "state", + "machine", + "finite" + ], + "homepage": "https://github.com/kyleconroy/lua-state-machine", + "license": "MIT", + "source": { + "repo": "kyleconroy/lua-state-machine", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/kyleconroy/lua-state-machine/96fc10ee123479cf2e5fb8ffe9cdde2fd6df0877/", + "commitSha": "96fc10ee123479cf2e5fb8ffe9cdde2fd6df0877" + }, + "install": { + "files": [ + { + "name": "statemachine.lua", + "sha256": "95affa1ad7a9e2a4629cce4b321fbf49cf521cce6e9be5e92f035f7e64f9275c", + "target": "lib/statemachine.lua" + } + ] + }, + "require": "lib.statemachine", + "example": "local lua_state_machine = require('lib.statemachine')" + }, "lume": { "type": "love2d-library", "trust": "verified", @@ -383,10 +1337,12 @@ "math" ], "homepage": "https://github.com/rxi/lume", + "license": "MIT", "source": { "repo": "rxi/lume", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/rxi/lume/master/" + "baseUrl": "https://raw.githubusercontent.com/rxi/lume/98847e7812cf28d3d64b289b03fad71dc704547d/", + "commitSha": "98847e7812cf28d3d64b289b03fad71dc704547d" }, "install": { "files": [ @@ -409,10 +1365,12 @@ "utilities" ], "homepage": "https://github.com/kikito/middleclass", + "license": "MIT", "source": { "repo": "kikito/middleclass", "tag": "v4.1.1", - "baseUrl": "https://raw.githubusercontent.com/kikito/middleclass/v4.1.1/" + "baseUrl": "https://raw.githubusercontent.com/kikito/middleclass/dc0fb3612da5d41866a2f06076a4ecea5614ef78/", + "commitSha": "dc0fb3612da5d41866a2f06076a4ecea5614ef78" }, "install": { "files": [ @@ -435,10 +1393,12 @@ "resolution" ], "homepage": "https://github.com/Ulydev/push", + "license": "MIT", "source": { "repo": "Ulydev/push", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/Ulydev/push/master/" + "baseUrl": "https://raw.githubusercontent.com/Ulydev/push/9c165816a14c868339c3cd0d22eed65c313c8bf8/", + "commitSha": "9c165816a14c868339c3cd0d22eed65c313c8bf8" }, "install": { "files": [ @@ -452,31 +1412,92 @@ "require": "lib.push", "example": "local push = require('lib.push')" }, + "shove": { + "type": "love2d-library", + "trust": "verified", + "description": "A powerful resolution-handler and rendering library for LÖVE 📐", + "tags": [ + "resolution", + "gamedev", + "performance", + "lua", + "rendering", + "aspect-ratio", + "game-development", + "love2d", + "layers", + "pixel-perfect", + "scaling", + "love2d-framework" + ], + "homepage": "https://github.com/oval-tutu/shove", + "license": "MIT", + "source": { + "repo": "oval-tutu/shove", + "tag": "1.0.6", + "baseUrl": "https://raw.githubusercontent.com/oval-tutu/shove/ee1dedbca78ca988589edd4c5314d7e662223973/", + "commitSha": "ee1dedbca78ca988589edd4c5314d7e662223973" + }, + "install": { + "files": [ + { + "name": "shove-profiler.lua", + "sha256": "8c2c78a0d4dac5afaa3e487648c1e12fad632bd8971dd21424da765ac0135e88", + "target": "lib/shove/profiler.lua" + }, + { + "name": "shove.lua", + "sha256": "151b3bb65aa33114189bd7a2abe23b61acb58bd0530e4a3a92531ee0502e4903", + "target": "lib/shove/init.lua" + } + ] + }, + "require": "lib.shove", + "example": "local shove = require('lib.shove')" + }, "sti": { "type": "love2d-library", - "trust": "known", + "trust": "verified", "description": "Simple Tiled Implementation — loads and renders Tiled maps (.tmx) in LÖVE.", "tags": [ + "sprites", "maps", - "tiled", - "tilemaps" + "tiles", + "tiled" ], "homepage": "https://github.com/karai17/Simple-Tiled-Implementation", + "license": "NOASSERTION", "source": { "repo": "karai17/Simple-Tiled-Implementation", - "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/karai17/Simple-Tiled-Implementation/master/" + "tag": "v1.2.3.0", + "baseUrl": "https://raw.githubusercontent.com/karai17/Simple-Tiled-Implementation/69cdf8c7565f518c03967d0243cb54d1ee31da4e/", + "commitSha": "69cdf8c7565f518c03967d0243cb54d1ee31da4e" }, "install": { "files": [ + { + "name": "sti/graphics.lua", + "sha256": "c77ed8b4220b2e7c37fdf56612365ce3520ea8183deee92e2f337691f7d20b14", + "target": "lib/sti/graphics.lua" + }, { "name": "sti/init.lua", - "sha256": "b7542ac156b3c2ee24d256ce260f73dc01ab2d1245c4eb4b14c92fcdf487c814", + "sha256": "b65f9cc816bd666bf1ba42b4f50ee14d5fa83f22b3f121eb0b0f07c14ecb8df7", "target": "lib/sti/init.lua" }, + { + "name": "sti/plugins/box2d.lua", + "sha256": "5206a9528eeaf19a12b9512f2b8ca2fa9e2af7e3a634eb084b4dd9c0ac070205", + "target": "lib/sti/plugins/box2d.lua" + }, + { + "name": "sti/plugins/bump.lua", + "sha256": "0c2bc5c882aeab0e2dc5ad1019a83aab72ccfe25ef6d9b105b8cb924d2feaab5", + "target": "lib/sti/plugins/bump.lua" + }, { "name": "sti/utils.lua", - "sha256": "ba8abf5a3f5680c6a020234afc248c4fb8945cda993d4cd82b292510e4dab17c", + "sha256": "6993cfde776ec0d1dae4ee53cec8df36fe37801dcf6858f51e443fe65f4ad4c9", "target": "lib/sti/utils.lua" } ] @@ -484,31 +1505,86 @@ "require": "lib.sti", "example": "local sti = require('lib.sti')" }, - "windfield": { + "sysl-text": { "type": "love2d-library", - "trust": "known", - "description": "A physics module for LÖVE that makes Box2D easier to use. Wraps love.physics with a simpler API.", + "trust": "verified", + "description": "Text rendering with tag support", "tags": [ - "physics", - "box2d" + "text", + "draw", + "love2d", + "textboxes", + "sprite-text" ], - "homepage": "https://github.com/SSYGEN/windfield", + "homepage": "https://github.com/sysl-dev/SYSL-Text", + "license": "MIT", "source": { - "repo": "SSYGEN/windfield", + "repo": "sysl-dev/SYSL-Text", + "tag": "v2.2", + "baseUrl": "https://raw.githubusercontent.com/sysl-dev/SYSL-Text/6c9b95047cb8650344e90224cd56bd00c5132d97/", + "commitSha": "6c9b95047cb8650344e90224cd56bd00c5132d97" + }, + "install": { + "files": [ + { + "name": "example/library/slog-frame.lua", + "sha256": "d37119014d865d4e829a2757d8ec2974c3991afdcbcafe558decafb651066c5e", + "target": "lib/sysl-text/slog-frame.lua" + }, + { + "name": "example/library/slog-icon.lua", + "sha256": "bf48f58f25930d20a367361ef5decceb4e8e377aed27093e3ea9f5a2c34dfc1a", + "target": "lib/sysl-text/slog-icon.lua" + }, + { + "name": "example/library/slog-pixel.lua", + "sha256": "c95586e30a45ea9b5b49dfca1089c61c9ec1a59981e9526febdc56ae5354e925", + "target": "lib/sysl-text/slog-pixel.lua" + }, + { + "name": "example/library/slog-text.lua", + "sha256": "10134b45499d925de5b177ae13874fd026dfc476f1eae5ba1b64874a49ec4ad9", + "target": "lib/sysl-text/slog-text.lua" + } + ] + }, + "require": "lib.sysl-text.slog-text", + "example": "local sysl_text = require('lib.sysl-text.slog-text')" + }, + "tiny-ecs": { + "type": "love2d-library", + "trust": "verified", + "description": "ECS for Lua", + "tags": [ + "ecs", + "entity", + "component", + "system" + ], + "homepage": "https://github.com/bakpakin/tiny-ecs", + "license": "MIT", + "source": { + "repo": "bakpakin/tiny-ecs", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/SSYGEN/windfield/master/" + "commitSha": "821914795db365d73dcb973b4a54741423650dc9", + "baseUrl": "https://raw.githubusercontent.com/bakpakin/tiny-ecs/821914795db365d73dcb973b4a54741423650dc9/" }, "install": { "files": [ { - "name": "windfield/init.lua", - "sha256": "687c789dceeba3ebb6f5b152b94e9c085268695ed8cc578780d24ec1696f0388", - "target": "lib/windfield/init.lua" + "name": "init.lua", + "sha256": "73461b847b513ac10aeb0c65366de52940fd736d2c7026d06c0425ea78b2b175", + "target": "lib/tiny-ecs/init.lua" + }, + { + "name": "tiny.lua", + "sha256": "a766b6a35949546746d041e9f4806a4ea068605b9657e2c7dedbc636c2cdf805", + "target": "lib/tiny-ecs/tiny.lua" } ] }, - "require": "lib.windfield", - "example": "local wf = require('lib.windfield')" + "require": "lib.tiny-ecs", + "example": "local tiny_ecs = require('lib.tiny-ecs')" } } } diff --git a/cli/src/hooks/use-text-input.tsx b/cli/src/hooks/use-text-input.tsx new file mode 100644 index 00000000..611e4884 --- /dev/null +++ b/cli/src/hooks/use-text-input.tsx @@ -0,0 +1,60 @@ +import { useInput } from 'ink'; +import { useEffect, useState } from 'react'; + +type InkKey = Parameters[0]>[1]; + +function useTextInput(initial: string) { + const [value, setValue] = useState(initial); + const [cursor, setCursor] = useState(initial.length); + + const reset = (next: string) => { + setValue(next); + setCursor(next.length); + }; + + useEffect(() => { + reset(initial); + }, [initial]); + + const handleKey = (input: string, key: InkKey) => { + if (key.leftArrow) { + setCursor((c) => Math.max(0, c - 1)); + return true; + } + if (key.rightArrow) { + setCursor((c) => Math.min(value.length, c + 1)); + return true; + } + if (key.backspace) { + if (cursor === 0) return true; + const pos = cursor; + setValue((v) => v.slice(0, pos - 1) + v.slice(pos)); + setCursor((c) => c - 1); + return true; + } + if (key.delete) { + const pos = cursor; + setValue((v) => v.slice(0, pos) + v.slice(pos + 1)); + return true; + } + if (!key.ctrl && !key.meta && input) { + const pos = cursor; + setValue((v) => v.slice(0, pos) + input + v.slice(pos)); + setCursor((c) => c + 1); + return true; + } + return false; + }; + + return { + value, + cursor, + reset, + handleKey, + before: value.slice(0, cursor), + at: value[cursor] ?? '', + after: value.slice(cursor + 1), + }; +} + +export { useTextInput }; diff --git a/cli/src/index.ts b/cli/src/index.ts index c983bdde..4b9e977a 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,10 +1,16 @@ #!/usr/bin/env node import { Command } from 'commander'; +import { readFileSync } from 'node:fs'; +import { runCliAction } from './lib/command.js'; import { runCommand } from './commands/run.js'; import { initCommand } from './commands/init.js'; import { removeCommand } from './commands/remove.js'; import { doctorCommand } from './commands/doctor.js'; import { updateCommand } from './commands/update.js'; +import { buildCommand } from './commands/build.js'; +import { buildVendorAddCommand, buildVendorListCommand } from './commands/build-vendor.js'; +import { buildTargets } from './lib/build/config.js'; +import { uploadCommand } from './commands/upload.js'; import { pluginListCommand, pluginInstallCommand, @@ -20,11 +26,13 @@ import { packageUpdateCommand, packageRemoveCommand, packageAuditCommand, + packageAddCommand, } from './commands/package.js'; -import type { InitMode } from './ui/init-mode.js'; +import type { InitMode } from './ui/init/index.js'; const program = new Command(); const initModes = new Set(['cli', 'auto', 'manual']); +const cliVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version as string; function parseInitMode(value: string): InitMode { if (!initModes.has(value)) { @@ -36,28 +44,53 @@ function parseInitMode(value: string): InitMode { program .name('feather') .description('Run and debug Love2D games with Feather — zero game-side changes required') - .version('0.7.0'); + .version(cliVersion); program .command('run [game-path] [game-args...]') .description('Inject Feather into a Love2D game and run it') + .option('--target ', 'Run target: desktop, web, android, or ios', 'desktop') + .option('--device ', 'Android device serial or iOS simulator UDID') + .option('--build-config ', 'Path to feather.build.json for web/mobile run') + .option('--out-dir ', 'Build output directory for web/mobile run') + .option('--clean', 'Remove the output directory before web/mobile build') + .option('--no-cache', 'Disable Android/iOS dev native build cache') + .option('--verbose', 'Show web/mobile build commands and native tool output') + .option('--web-host ', 'Host for web run static server', '127.0.0.1') + .option('--web-port ', 'Port for web run static server', (value) => Number(value), 8000) + .option('--no-adb-reverse', 'Skip adb reverse setup for Android mobile run') + .option('--port ', 'Feather desktop port for Android adb reverse', (value) => Number(value)) .option('--love ', 'Path to the love2d binary (overrides auto-detect)') .option('--session-name ', 'Custom session name shown in the desktop app') .option('--no-plugins', 'Disable plugin loading (feather core only)') + .option('--no-debugger', 'Run without Feather debugger injection') + .option('--disable-debugger', 'Alias for --no-debugger') .option('--config ', 'Path to feather.config.lua') + .option('--config-path ', 'Alias for --config') + .option('--configPath ', 'Alias for --config') .option('--feather-path ', 'Use a local feather install instead of the bundled one') .option('--plugins-dir ', 'Use a custom plugins directory instead of the bundled one') - .action(async (gamePath: string | undefined, gameArgs: string[], opts) => { - await runCommand(gamePath, { + .action((gamePath: string | undefined, gameArgs: string[], opts) => runCliAction(() => runCommand(gamePath, { love: opts.love as string | undefined, + target: opts.target as 'desktop' | 'web' | 'android' | 'ios' | undefined, + device: opts.device as string | undefined, + buildConfig: opts.buildConfig as string | undefined, + outDir: opts.outDir as string | undefined, + clean: opts.clean as boolean | undefined, + noCache: opts.cache === false, + verbose: opts.verbose as boolean | undefined, + webHost: opts.webHost as string | undefined, + webPort: opts.webPort as number | undefined, + adbReverse: opts.adbReverse as boolean | undefined, + port: opts.port as number | undefined, sessionName: opts.sessionName as string | undefined, noPlugins: opts.plugins === false, - config: opts.config as string | undefined, + debugger: opts.debugger !== false && !opts.disableDebugger, + config: (opts.config ?? opts.configPath) as string | undefined, featherPath: opts.featherPath as string | undefined, pluginsDir: opts.pluginsDir as string | undefined, gameArgs, - }); - }); + }))); program .command('init [dir]') @@ -70,8 +103,7 @@ program .option('--plugins ', 'Comma-separated list of plugins to install') .option('--mode ', 'Setup mode: cli, auto, or manual', parseInitMode) .option('-y, --yes', 'Skip confirmation prompts') - .action(async (dir: string | undefined, opts) => { - await initCommand(dir ?? '.', { + .action((dir: string | undefined, opts) => runCliAction(() => initCommand(dir ?? '.', { branch: opts.branch as string, remote: opts.remote as boolean | undefined, localSrc: opts.localSrc as string | undefined, @@ -83,8 +115,7 @@ program : undefined, mode: opts.mode as InitMode | undefined, yes: opts.yes as boolean, - }); - }); + }))); program .command('remove [dir]') @@ -96,8 +127,7 @@ program .option('--keep-manual', 'Keep feather.debugger.lua') .option('--keep-runtime', 'Keep installed Feather runtime/plugins') .option('-y, --yes', 'Skip interactive confirmation') - .action(async (dir: string | undefined, opts) => { - await removeCommand(dir ?? '.', { + .action((dir: string | undefined, opts) => runCliAction(() => removeCommand(dir ?? '.', { installDir: opts.installDir as string | undefined, dryRun: opts.dryRun as boolean | undefined, keepConfig: opts.keepConfig as boolean | undefined, @@ -105,24 +135,191 @@ program keepManual: opts.keepManual as boolean | undefined, keepRuntime: opts.keepRuntime as boolean | undefined, yes: opts.yes as boolean | undefined, - }); - }); + }))); program .command('doctor [dir]') .description('Check the environment and project setup') .option('--install-dir ', 'Feather install directory override') - .option('--host ', 'Host to check for the Feather desktop WebSocket', '127.0.0.1') + .option('--host ', 'Host to check for the Feather desktop WebSocket') .option('--port ', 'Port to check for the Feather desktop WebSocket', (value) => Number(value)) .option('--json', 'Print machine-readable diagnostics') - .action(async (dir: string | undefined, opts) => { - await doctorCommand(dir, { + .option('--production', 'Fail when project settings are unsafe for production builds') + .option('--security', 'Print security-focused diagnostics; use with --json for automation') + .option('--build-target ', 'Check dependencies for a build target') + .option('--upload-target ', 'Check dependencies for an upload target') + .option('--release', 'Include mobile release build checks with --build-target') + .action((dir: string | undefined, opts) => runCliAction(() => doctorCommand(dir, { installDir: opts.installDir as string | undefined, host: opts.host as string | undefined, port: opts.port as number | undefined, json: opts.json as boolean | undefined, - }); - }); + production: opts.production as boolean | undefined, + security: opts.security as boolean | undefined, + buildTarget: opts.buildTarget as string | undefined, + uploadTarget: opts.uploadTarget as string | undefined, + release: opts.release as boolean | undefined, + }))); + +const build = program + .command('build') + .description('Build a LÖVE game package, web bundle, mobile dev app, or desktop installer'); + +function addBuildTargetCommand(target: string): void { + build + .command(target) + .description(`Build a LÖVE game for ${target}`) + .option('--dir ', 'Project directory (default: current directory)') + .option('--config ', 'Path to feather.build.json') + .option('--build-config ', 'Alias for --config') + .option('--runtime-config ', 'Path to feather.config.lua for mobile debugger embedding') + .option('--config-path ', 'Alias for --runtime-config') + .option('--configPath ', 'Alias for --runtime-config') + .option('--out-dir ', 'Build output directory') + .option('--name ', 'Build product name') + .option('--version ', 'Build product version') + .option('--clean', 'Remove the output directory before building') + .option('--dry-run', 'Show the build plan without writing artifacts') + .option('--json', 'Output machine-readable JSON') + .option('--allow-unsafe', 'Allow production-unsafe Feather config during build') + .option('--release', 'Build signed/store-oriented mobile release artifacts') + .option('--no-cache', 'Disable Android/iOS dev native build cache') + .option('--no-debugger', 'Build mobile dev artifacts without Feather debugger embedding') + .option('--disable-debugger', 'Alias for --no-debugger') + .option('--verbose', 'Show Android/iOS build commands and native tool output') + .action((opts) => runCliAction(() => buildCommand(target, { + dir: opts.dir as string | undefined, + config: buildConfigOption(opts.config as string | undefined, opts.buildConfig as string | undefined), + runtimeConfig: runtimeConfigOption( + opts.config as string | undefined, + opts.runtimeConfig as string | undefined, + opts.configPath as string | undefined, + ), + outDir: opts.outDir as string | undefined, + name: opts.name as string | undefined, + version: opts.version as string | undefined, + clean: opts.clean as boolean | undefined, + dryRun: opts.dryRun as boolean | undefined, + json: opts.json as boolean | undefined, + allowUnsafe: opts.allowUnsafe as boolean | undefined, + release: opts.release as boolean | undefined, + noCache: opts.cache === false, + debugger: opts.debugger !== false && !opts.disableDebugger, + verbose: opts.verbose as boolean | undefined, + }))); +} + +function looksLikeRuntimeConfig(path: string | undefined): boolean { + return Boolean(path && (/\.lua$/i.test(path) || path.endsWith('.featherrc'))); +} + +function buildConfigOption(config: string | undefined, buildConfig: string | undefined): string | undefined { + if (buildConfig) return buildConfig; + return looksLikeRuntimeConfig(config) ? undefined : config; +} + +function runtimeConfigOption( + config: string | undefined, + runtimeConfig: string | undefined, + configPath: string | undefined, +): string | undefined { + return runtimeConfig ?? configPath ?? (looksLikeRuntimeConfig(config) ? config : undefined); +} + +for (const target of buildTargets) { + addBuildTargetCommand(target); +} + +const buildVendor = build + .command('vendor') + .description('Fetch and inspect local build vendor templates'); + +buildVendor + .command('add [targets...]') + .description('Fetch build vendors: web, android, ios, mobile, desktop, or all') + .allowUnknownOption() + .option('--dir ', 'Project directory (default: current directory)') + .option('--config ', 'Path to feather.build.json') + .option('--vendor-dir ', 'Vendor directory inside the project', 'vendor') + .option('--ref ', 'LÖVE version/tag/ref for all vendors') + .option('--web-ref ', 'love.js vendor branch/tag/ref override') + .option('--android-ref ', 'Android vendor branch/tag/ref override') + .option('--ios-ref ', 'iOS vendor branch/tag/ref override') + .option('--force', 'Replace existing vendor directories or conflicting config paths') + .option('--dry-run', 'Show planned vendor changes without writing files') + .option('--json', 'Output machine-readable JSON') + .addHelpText('after', '\n --no-config Do not update feather.build.json') + .action((targets: string[], opts) => runCliAction(() => buildVendorAddCommand(targets.filter((target) => target !== '--no-config'), { + dir: opts.dir as string | undefined, + config: opts.config as string | undefined, + vendorDir: opts.vendorDir as string | undefined, + ref: opts.ref as string | undefined, + webRef: opts.webRef as string | undefined, + androidRef: opts.androidRef as string | undefined, + iosRef: opts.iosRef as string | undefined, + force: opts.force as boolean | undefined, + dryRun: opts.dryRun as boolean | undefined, + json: opts.json as boolean | undefined, + configUpdate: !process.argv.includes('--no-config'), + }))); + +buildVendor + .command('list') + .description('List configured build vendors') + .option('--dir ', 'Project directory (default: current directory)') + .option('--config ', 'Path to feather.build.json') + .option('--vendor-dir ', 'Vendor directory inside the project', 'vendor') + .option('--json', 'Output machine-readable JSON') + .action((opts) => runCliAction(() => buildVendorListCommand({ + dir: opts.dir as string | undefined, + config: opts.config as string | undefined, + vendorDir: opts.vendorDir as string | undefined, + json: opts.json as boolean | undefined, + }))); + +program + .command('upload [target] [build-target]') + .description('Upload built artifacts to itch.io or registered store targets') + .option('--dir ', 'Project directory (default: current directory)') + .option('--config ', 'Path to feather.build.json') + .option('--build-dir ', 'Directory containing feather-build-manifest.json') + .option('--project ', 'Upload project override, for example user/game') + .option('--channel ', 'Upload channel override') + .option('--user-version ', 'Store-facing version override') + .option('--dry-run', 'Show the upload plan without running the uploader') + .option('--if-changed', 'Pass --if-changed to supported uploaders') + .option('--hidden', 'Pass --hidden to supported uploaders') + .option('--json', 'Output machine-readable JSON') + .option('-y, --yes', 'Skip upload confirmation in non-interactive mode') + .option('--build', 'Build the selected target before uploading') + .option('--out-dir ', 'Build output directory when used with --build') + .option('--release', 'Build signed/store-oriented mobile release artifacts when used with --build') + .option('--allow-unsafe', 'Allow production-unsafe Feather config during --build') + .option('--clean', 'Remove the output directory before --build') + .option('--no-cache', 'Disable Android/iOS dev native build cache during --build') + .option('--verbose', 'Show build command output when used with --build') + .option('--allow-feather-runtime', 'Allow uploading artifacts that include or may include Feather runtime files') + .action((target: string, buildTarget: string | undefined, opts) => runCliAction(() => uploadCommand(target, buildTarget, { + dir: opts.dir as string | undefined, + config: opts.config as string | undefined, + buildDir: opts.buildDir as string | undefined, + project: opts.project as string | undefined, + channel: opts.channel as string | undefined, + userVersion: opts.userVersion as string | undefined, + dryRun: opts.dryRun as boolean | undefined, + ifChanged: opts.ifChanged as boolean | undefined, + hidden: opts.hidden as boolean | undefined, + json: opts.json as boolean | undefined, + yes: opts.yes as boolean | undefined, + build: opts.build as boolean | undefined, + outDir: opts.outDir as string | undefined, + release: opts.release as boolean | undefined, + allowUnsafe: opts.allowUnsafe as boolean | undefined, + clean: opts.clean as boolean | undefined, + noCache: opts.cache === false, + verbose: opts.verbose as boolean | undefined, + allowFeatherRuntime: opts.allowFeatherRuntime as boolean | undefined, + }))); program .command('update [dir]') @@ -132,15 +329,13 @@ program .option('--local-src ', 'Copy Lua runtime from a local src-lua style directory') .option('--install-dir ', 'Feather install directory', 'feather') .option('-y, --yes', 'Skip interactive confirmation and use the selected/default source') - .action((dir: string | undefined, opts) => { - updateCommand(dir ?? '.', { + .action((dir: string | undefined, opts) => runCliAction(() => updateCommand(dir ?? '.', { branch: opts.branch as string, remote: opts.remote as boolean | undefined, localSrc: opts.localSrc as string | undefined, installDir: opts.installDir as string, yes: opts.yes as boolean | undefined, - }); - }); + }))); const plugin = program .command('plugin') @@ -150,15 +345,13 @@ const plugin = program .option('--branch ', 'GitHub branch to download from when using --remote', 'main') .option('--local-src ', 'Copy plugins from a local src-lua style directory') .option('--install-dir ', 'Feather install directory', 'feather') - .action(async (opts) => { - await pluginWorkflowCommand({ + .action((opts) => runCliAction(() => pluginWorkflowCommand({ dir: opts.dir as string | undefined, branch: opts.branch as string, installDir: opts.installDir as string, remote: opts.remote as boolean | undefined, localSrc: opts.localSrc as string | undefined, - }); - }); + }))); const pluginCommandOptions = (opts: Record) => ({ ...opts, ...plugin.opts() }); @@ -166,10 +359,10 @@ plugin .command('list [dir]') .description('List installed plugins') .option('--install-dir ', 'Feather install directory', 'feather') - .action(async (dir: string | undefined, opts) => { + .action((dir: string | undefined, opts) => runCliAction(() => { const merged = pluginCommandOptions(opts); - await pluginListCommand(dir ?? (merged.dir as string | undefined), merged.installDir as string); - }); + return pluginListCommand(dir ?? (merged.dir as string | undefined), merged.installDir as string); + })); plugin .command('install ') @@ -179,26 +372,31 @@ plugin .option('--branch ', 'GitHub branch to download from when using --remote', 'main') .option('--local-src ', 'Copy plugins from a local src-lua style directory') .option('--install-dir ', 'Feather install directory', 'feather') - .action(async (id: string, opts) => { + .action((id: string, opts) => runCliAction(() => { const merged = pluginCommandOptions(opts); - await pluginInstallCommand(id, { + return pluginInstallCommand(id, { dir: merged.dir as string | undefined, branch: merged.branch as string, installDir: merged.installDir as string, remote: merged.remote as boolean | undefined, localSrc: merged.localSrc as string | undefined, }); - }); + })); plugin .command('remove ') .description('Remove an installed plugin') .option('--dir ', 'Project directory (default: current directory)') .option('--install-dir ', 'Feather install directory', 'feather') - .action(async (id: string, opts) => { + .option('-y, --yes', 'Skip interactive confirmation') + .action((id: string, opts) => runCliAction(() => { const merged = pluginCommandOptions(opts); - await pluginRemoveCommand(id, { dir: merged.dir as string | undefined, installDir: merged.installDir as string }); - }); + return pluginRemoveCommand(id, { + dir: merged.dir as string | undefined, + installDir: merged.installDir as string, + yes: merged.yes as boolean | undefined, + }); + })); plugin .command('update [id]') @@ -209,9 +407,9 @@ plugin .option('--local-src ', 'Copy plugins from a local src-lua style directory') .option('--install-dir ', 'Feather install directory', 'feather') .option('-y, --yes', 'Skip interactive selection and update all installed plugins when no id is given') - .action(async (id: string | undefined, opts) => { + .action((id: string | undefined, opts) => runCliAction(() => { const merged = pluginCommandOptions(opts); - await pluginUpdateCommand(id, { + return pluginUpdateCommand(id, { dir: merged.dir as string | undefined, branch: merged.branch as string, installDir: merged.installDir as string, @@ -219,21 +417,25 @@ plugin localSrc: merged.localSrc as string | undefined, yes: merged.yes as boolean | undefined, }); - }); + })); const pkg = program.command('package').description('Install and manage LÖVE packages from the Feather catalog'); +pkg + .command('add') + .description('Interactively install a custom dependency from URL(s)') + .option('--dir ', 'Project directory') + .action((opts) => runCliAction(() => packageAddCommand({ dir: opts.dir as string | undefined }))); + pkg .command('search [query]') .description('Search the package catalog') .option('--offline', 'Use bundled registry snapshot') .option('--registry ', 'Override registry URL') - .action(async (query: string | undefined, opts) => { - await packageSearchCommand(query, { + .action((query: string | undefined, opts) => runCliAction(() => packageSearchCommand(query, { offline: opts.offline as boolean | undefined, registryUrl: opts.registry as string | undefined, - }); - }); + }))); pkg .command('list') @@ -243,15 +445,13 @@ pkg .option('--refresh', 'Force a fresh registry fetch ignoring cache') .option('--dir ', 'Project directory') .option('--registry ', 'Override registry URL') - .action(async (opts) => { - await packageListCommand({ + .action((opts) => runCliAction(() => packageListCommand({ installed: opts.installed as boolean | undefined, offline: opts.offline as boolean | undefined, refresh: opts.refresh as boolean | undefined, dir: opts.dir as string | undefined, registryUrl: opts.registry as string | undefined, - }); - }); + }))); pkg .command('info ') @@ -259,13 +459,11 @@ pkg .option('--offline', 'Use bundled registry snapshot') .option('--dir ', 'Project directory') .option('--registry ', 'Override registry URL') - .action(async (name: string, opts) => { - await packageInfoCommand(name, { + .action((name: string, opts) => runCliAction(() => packageInfoCommand(name, { offline: opts.offline as boolean | undefined, dir: opts.dir as string | undefined, registryUrl: opts.registry as string | undefined, - }); - }); + }))); pkg .command('install [names...]') @@ -278,8 +476,7 @@ pkg .option('--dir ', 'Project directory') .option('-y, --yes', 'Skip confirmation prompts') .option('--registry ', 'Override registry URL') - .action(async (names: string[], opts) => { - await packageInstallCommand(names, { + .action((names: string[], opts) => runCliAction(() => packageInstallCommand(names, { dryRun: opts.dryRun as boolean | undefined, allowUntrusted: opts.allowUntrusted as boolean | undefined, target: opts.target as string | undefined, @@ -288,8 +485,7 @@ pkg dir: opts.dir as string | undefined, yes: opts.yes as boolean | undefined, registryUrl: opts.registry as string | undefined, - }); - }); + }))); pkg .command('update [name]') @@ -298,37 +494,31 @@ pkg .option('--offline', 'Use bundled registry snapshot') .option('--dir ', 'Project directory') .option('--registry ', 'Override registry URL') - .action(async (name: string | undefined, opts) => { - await packageUpdateCommand(name, { + .action((name: string | undefined, opts) => runCliAction(() => packageUpdateCommand(name, { dryRun: opts.dryRun as boolean | undefined, offline: opts.offline as boolean | undefined, dir: opts.dir as string | undefined, registryUrl: opts.registry as string | undefined, - }); - }); + }))); pkg .command('remove ') .description('Remove an installed package') .option('--dir ', 'Project directory') .option('-y, --yes', 'Skip confirmation prompt') - .action(async (name: string, opts) => { - await packageRemoveCommand(name, { + .action((name: string, opts) => runCliAction(() => packageRemoveCommand(name, { dir: opts.dir as string | undefined, yes: opts.yes as boolean | undefined, - }); - }); + }))); pkg .command('audit') .description('Verify SHA-256 checksums of all installed packages') .option('--dir ', 'Project directory') .option('--json', 'Output machine-readable JSON') - .action(async (opts) => { - await packageAuditCommand({ + .action((opts) => runCliAction(() => packageAuditCommand({ dir: opts.dir as string | undefined, json: opts.json as boolean | undefined, - }); - }); + }))); program.parse(); diff --git a/cli/src/lib/build/android.ts b/cli/src/lib/build/android.ts new file mode 100644 index 00000000..0cfa062e --- /dev/null +++ b/cli/src/lib/build/android.ts @@ -0,0 +1,298 @@ +import { spawnSync } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { artifactBaseName, writeLoveArchive, type BuildArtifact } from './files.js'; +import type { ResolvedBuildConfig } from './config.js'; +import { + assignmentValue, + createNativeWorkspace, + escapeXml, + findFirstPath, + logNativeCommand, + logNativeOutput, + logNativeStep, + patchTextFile, + resolveWorkspacePath, + type NativeCacheInfo, + type NativeBuildLogger, +} from './native.js'; +import { androidProductId } from './validation.js'; + +export type AndroidBuildModeOptions = { + release?: boolean; + cache?: boolean; + debuggerSignature?: string; + verbose?: boolean; + log?: NativeBuildLogger; + onCache?: (cache: NativeCacheInfo) => void; +}; + +export function buildAndroid(config: ResolvedBuildConfig, stageDir: string, options: AndroidBuildModeOptions = {}): BuildArtifact[] { + const androidConfig = config.targets.android ?? {}; + const loveAndroidDir = androidConfig.loveAndroidDir ? resolve(config.projectDir, androidConfig.loveAndroidDir) : ''; + if (!androidConfig.loveAndroidDir) { + throw new Error('Android build requires targets.android.loveAndroidDir in feather.build.json.'); + } + if (!existsSync(loveAndroidDir)) { + throw new Error(`Android template not found at ${loveAndroidDir}. Run \`feather build vendor add android --dir ${config.projectDir}\` or update targets.android.loveAndroidDir in feather.build.json.`); + } + + const base = artifactBaseName(config); + const lovePath = writeLoveArchive(stageDir, config.outDir, base); + logNativeStep(options.log, `Created .love archive: ${lovePath}`); + const gradleTask = androidConfig.gradleTask + ?? (androidConfig.recordAudio ? 'assembleEmbedRecordDebug' : 'assembleEmbedNoRecordDebug'); + if (options.release || options.cache === false) { + logNativeStep(options.log, `Build cache: ${options.release ? 'disabled for release build' : 'disabled by --no-cache'}`); + } + logNativeStep(options.log, `Android template: ${loveAndroidDir}`); + const workspace = createNativeWorkspace('feather-android-', loveAndroidDir, 'love-android', { + enabled: !options.release && options.cache !== false, + target: 'android', + outDir: config.outDir, + log: options.log, + requiredPaths: [process.platform === 'win32' ? 'gradlew.bat' : 'gradlew', 'app/build.gradle'], + keyParts: { + productId: androidProductId(config), + displayName: androidConfig.displayName ?? config.name, + orientation: androidConfig.orientation ?? 'landscape', + recordAudio: Boolean(androidConfig.recordAudio), + versionCode: androidConfig.versionCode ?? 1, + versionName: androidConfig.versionName ?? config.version, + gradleTask, + artifactPath: androidConfig.artifactPath, + debuggerSignature: options.debuggerSignature, + }, + }); + options.onCache?.(workspace.cache); + logNativeStep(options.log, `Android workspace: ${workspace.dir}`); + + try { + const embeddedLovePath = join(workspace.dir, 'app', 'src', 'embed', 'assets', 'game.love'); + mkdirSync(dirname(embeddedLovePath), { recursive: true }); + cpSync(lovePath, embeddedLovePath, { force: true }); + logNativeStep(options.log, `Embedded game.love: ${embeddedLovePath}`); + + patchAndroidProject(config, workspace.dir); + patchAndroidEmbeddedGameLoader(workspace.dir); + logNativeStep(options.log, 'Patched Android metadata'); + + const gradleCommand = process.platform === 'win32' ? join(workspace.dir, 'gradlew.bat') : join(workspace.dir, 'gradlew'); + if (!existsSync(gradleCommand)) { + throw new Error('Android build requires a Gradle wrapper in targets.android.loveAndroidDir.'); + } + if (options.release) { + writeAndroidSigningProperties(config, workspace.dir); + const bundleTask = androidConfig.release?.bundleTask + ?? (androidConfig.recordAudio ? 'bundleEmbedRecordRelease' : 'bundleEmbedNoRecordRelease'); + const apkTask = androidConfig.release?.apkTask + ?? (androidConfig.recordAudio ? 'assembleEmbedRecordRelease' : 'assembleEmbedNoRecordRelease'); + runGradleTask(gradleCommand, workspace.dir, bundleTask, options.log); + runGradleTask(gradleCommand, workspace.dir, apkTask, options.log); + + const aabSource = androidConfig.release?.bundleArtifactPath + ? resolveWorkspacePath(workspace.dir, androidConfig.release.bundleArtifactPath, 'Android bundle artifact path') + : findFirstPath(join(workspace.dir, 'app', 'build', 'outputs'), (_path, entry) => entry.isFile() && entry.name.endsWith('.aab')); + const apkSource = androidConfig.release?.apkArtifactPath + ? resolveWorkspacePath(workspace.dir, androidConfig.release.apkArtifactPath, 'Android APK artifact path') + : findFirstPath(join(workspace.dir, 'app', 'build', 'outputs'), (_path, entry) => entry.isFile() && entry.name.endsWith('.apk')); + if (!aabSource || !existsSync(aabSource)) { + throw new Error('Android release completed but no AAB artifact was found. Set targets.android.release.bundleArtifactPath if your template writes elsewhere.'); + } + if (!apkSource || !existsSync(apkSource)) { + throw new Error('Android release completed but no APK artifact was found. Set targets.android.release.apkArtifactPath if your template writes elsewhere.'); + } + const aabPath = join(config.outDir, `${base}-android.aab`); + const apkPath = join(config.outDir, `${base}-android.apk`); + cpSync(aabSource, aabPath, { force: true }); + cpSync(apkSource, apkPath, { force: true }); + logNativeStep(options.log, `Copied AAB artifact: ${aabPath}`); + logNativeStep(options.log, `Copied APK artifact: ${apkPath}`); + return [ + { target: 'android', type: 'love', path: lovePath }, + { target: 'android', type: 'aab', path: aabPath }, + { target: 'android', type: 'apk', path: apkPath }, + ]; + } + + runGradleTask(gradleCommand, workspace.dir, gradleTask, options.log); + const apkPath = copyAndroidApkArtifact(config, workspace.dir, base); + logNativeStep(options.log, `Copied APK artifact: ${apkPath}`); + return [ + { target: 'android', type: 'love', path: lovePath }, + { target: 'android', type: 'apk', path: apkPath }, + ]; + } finally { + workspace.cleanup(); + } +} + +function patchAndroidEmbeddedGameLoader(workDir: string): void { + const gameActivityPath = join(workDir, 'love', 'src', 'main', 'java', 'org', 'love2d', 'android', 'GameActivity.java'); + patchTextFile(gameActivityPath, (source) => { + if (source.includes('Feather: forcing embedded game.love from assets')) return source; + return source.replace( + /embed\s*=\s*getResources\(\)\.getBoolean\(R\.bool\.embed\);/, + [ + 'embed = getResources().getBoolean(R.bool.embed);', + ' try {', + ' InputStream featherGameStream = getAssets().open("game.love");', + ' featherGameStream.close();', + ' if (!embed) {', + ' Log.d("GameActivity", "Feather: forcing embedded game.love from assets.");', + ' }', + ' embed = true;', + ' needToCopyGameInArchive = true;', + ' } catch (IOException ignored) {', + " // No embedded game.love asset; keep the template's default mode.", + ' }', + ].join('\n'), + ); + }); +} + +function runGradleTask(gradleCommand: string, workDir: string, task: string, log?: NativeBuildLogger): void { + logNativeCommand(log, gradleCommand, [task], workDir); + const streamOutput = Boolean(log); + const result = spawnSync(gradleCommand, [task], { + cwd: workDir, + encoding: streamOutput ? undefined : 'utf8', + shell: process.platform === 'win32', + stdio: streamOutput ? 'inherit' : 'pipe', + }); + if (!streamOutput) logNativeOutput(log, result.stdout, result.stderr); + if (result.error) throw new Error(`Gradle wrapper failed to start: ${result.error.message}`); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || `Gradle task ${task} failed with exit code ${result.status ?? 'unknown'}`).toString().trim()); + } +} + +function copyAndroidApkArtifact(config: ResolvedBuildConfig, workDir: string, base: string): string { + const androidConfig = config.targets.android ?? {}; + const apkSource = androidConfig.artifactPath + ? resolveWorkspacePath(workDir, androidConfig.artifactPath, 'Android artifact path') + : findFirstPath(join(workDir, 'app', 'build', 'outputs'), (_path, entry) => entry.isFile() && entry.name.endsWith('.apk')); + if (!apkSource || !existsSync(apkSource)) { + throw new Error('Android build completed but no APK artifact was found. Set targets.android.artifactPath if your template writes elsewhere.'); + } + const apkPath = join(config.outDir, `${base}-android.apk`); + cpSync(apkSource, apkPath, { force: true }); + return apkPath; +} + +function writeAndroidSigningProperties(config: ResolvedBuildConfig, workDir: string): void { + const release = config.targets.android?.release; + if (!release) return; + const signingConfigured = Boolean(release.keystorePath || release.keyAlias || release.storePasswordEnv || release.keyPasswordEnv); + if (!signingConfigured) return; + const required = [ + ['targets.android.release.keystorePath', release.keystorePath], + ['targets.android.release.keyAlias', release.keyAlias], + ['targets.android.release.storePasswordEnv', release.storePasswordEnv], + ['targets.android.release.keyPasswordEnv', release.keyPasswordEnv], + ] as const; + const missing = required.filter(([, value]) => !value).map(([field]) => field); + if (missing.length > 0) { + throw new Error(`Android release signing is incomplete. Missing: ${missing.join(', ')}`); + } + const storePassword = process.env[release.storePasswordEnv!]; + const keyPassword = process.env[release.keyPasswordEnv!]; + const missingEnv = [ + storePassword ? '' : release.storePasswordEnv!, + keyPassword ? '' : release.keyPasswordEnv!, + ].filter(Boolean); + if (missingEnv.length > 0) { + throw new Error(`Android release signing environment is incomplete. Missing: ${missingEnv.join(', ')}`); + } + const keystorePath = resolve(config.projectDir, release.keystorePath!); + if (!existsSync(keystorePath)) { + throw new Error(`Android release keystore was not found: ${keystorePath}`); + } + writeFileSync( + join(workDir, 'feather-signing.properties'), + [ + `storeFile=${keystorePath}`, + `keyAlias=${release.keyAlias}`, + `storePassword=${storePassword}`, + `keyPassword=${keyPassword}`, + '', + ].join('\n'), + ); +} + +export function patchAndroidProject(config: ResolvedBuildConfig, workDir: string): void { + const androidConfig = config.targets.android ?? {}; + const productId = androidProductId(config); + const versionName = androidConfig.versionName ?? config.version; + const versionCode = androidConfig.versionCode ?? 1; + const displayName = androidConfig.displayName ?? config.name; + const orientation = androidConfig.orientation ?? 'landscape'; + + patchTextFile(join(workDir, 'gradle.properties'), (source) => { + let next = source + .split('\n') + .filter((line) => !/^app\.name\s*=/.test(line) && !/^app\.name_byte_array\s*=/.test(line)) + .join('\n'); + next = setGradleProperty(next, 'app.name_byte_array', utf8ByteArray(displayName)); + next = setGradleProperty(next, 'app.application_id', productId); + next = setGradleProperty(next, 'app.orientation', orientation); + next = setGradleProperty(next, 'app.version_code', String(versionCode)); + next = setGradleProperty(next, 'app.version_name', versionName); + return next; + }); + + for (const file of ['app/build.gradle', 'app/build.gradle.kts', 'build.gradle', 'build.gradle.kts']) { + patchTextFile(join(workDir, file), (source) => source + .replace(/applicationId\s*(?:=)?\s*["'][^"']+["']/g, (match) => assignmentValue(match, 'applicationId', productId)) + .replace(/namespace\s*(?:=)?\s*["'][^"']+["']/g, (match) => assignmentValue(match, 'namespace', productId)) + .replace(/versionName\s*(?:=)?\s*["'][^"']+["']/g, (match) => assignmentValue(match, 'versionName', versionName)) + .replace(/versionCode\s*(?:=)?\s*\d+/g, (match) => assignmentValue(match, 'versionCode', String(versionCode), false))); + } + + for (const file of ['app/src/main/res/values/strings.xml', 'app/src/embed/res/values/strings.xml']) { + patchTextFile(join(workDir, file), (source) => source.replace( + /[\s\S]*?<\/string>/, + `${escapeXml(displayName)}`, + )); + } + + for (const file of ['app/src/main/AndroidManifest.xml', 'app/src/embed/AndroidManifest.xml']) { + patchTextFile(join(workDir, file), (source) => { + let next = source + .replace(/android:label=["'][^"']*["']/g, `android:label="${escapeXml(displayName)}"`) + .replace(/android:screenOrientation=["'][^"']*["']/g, `android:screenOrientation="${escapeXml(orientation)}"`); + if (!/android:label=/.test(next)) { + next = next.replace(/]*)>/, ``); + } + if (!/android:screenOrientation=/.test(next)) { + next = next.replace(/]*)>/, ``); + } + if (androidConfig.recordAudio) { + if (!/android\.permission\.RECORD_AUDIO/.test(next)) { + next = next.replace(/]*>/, (match) => `${match}\n `); + } + } else { + next = next + .split('\n') + .filter((line) => !line.includes('android.permission.RECORD_AUDIO')) + .join('\n'); + } + return next; + }); + } +} + +function setGradleProperty(source: string, key: string, value: string): string { + const line = `${key}=${value}`; + const pattern = new RegExp(`^${escapeRegExp(key)}=.*$`, 'm'); + if (pattern.test(source)) return source.replace(pattern, line); + return `${source.replace(/\s*$/, '')}\n${line}\n`; +} + +function utf8ByteArray(value: string): string { + return [...Buffer.from(value, 'utf8')].join(','); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/cli/src/lib/build/archive.ts b/cli/src/lib/build/archive.ts new file mode 100644 index 00000000..94f4d3c5 --- /dev/null +++ b/cli/src/lib/build/archive.ts @@ -0,0 +1,114 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; + +export type ZipEntry = { + name: string; + data: Uint8Array; +}; + +const DOS_EPOCH = new Date('1980-01-01T00:00:00Z'); +const CRC_TABLE = new Uint32Array(256); +for (let i = 0; i < 256; i += 1) { + let c = i; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + CRC_TABLE[i] = c >>> 0; +} + +function crc32(data: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of data) crc = CRC_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function dosTime(date = DOS_EPOCH): { time: number; date: number } { + const year = Math.max(1980, date.getUTCFullYear()); + return { + time: (date.getUTCHours() << 11) | (date.getUTCMinutes() << 5) | Math.floor(date.getUTCSeconds() / 2), + date: ((year - 1980) << 9) | ((date.getUTCMonth() + 1) << 5) | date.getUTCDate(), + }; +} + +function u16(value: number): Buffer { + const buf = Buffer.alloc(2); + buf.writeUInt16LE(value); + return buf; +} + +function u32(value: number): Buffer { + const buf = Buffer.alloc(4); + buf.writeUInt32LE(value >>> 0); + return buf; +} + +export function createZipBuffer(entries: ZipEntry[]): Buffer { + const files = entries + .map((entry) => ({ ...entry, name: entry.name.replace(/\\/g, '/').replace(/^\/+/, '') })) + .filter((entry) => entry.name && !entry.name.endsWith('/')) + .sort((a, b) => a.name.localeCompare(b.name)); + const { time, date } = dosTime(); + const localParts: Buffer[] = []; + const centralParts: Buffer[] = []; + let offset = 0; + + for (const file of files) { + const name = Buffer.from(file.name, 'utf8'); + const data = Buffer.from(file.data); + const crc = crc32(data); + const local = Buffer.concat([ + u32(0x04034b50), + u16(20), + u16(0x0800), + u16(0), + u16(time), + u16(date), + u32(crc), + u32(data.length), + u32(data.length), + u16(name.length), + u16(0), + name, + data, + ]); + localParts.push(local); + + centralParts.push(Buffer.concat([ + u32(0x02014b50), + u16(20), + u16(20), + u16(0x0800), + u16(0), + u16(time), + u16(date), + u32(crc), + u32(data.length), + u32(data.length), + u16(name.length), + u16(0), + u16(0), + u16(0), + u16(0), + u32(0), + u32(offset), + name, + ])); + offset += local.length; + } + + const central = Buffer.concat(centralParts); + const end = Buffer.concat([ + u32(0x06054b50), + u16(0), + u16(0), + u16(files.length), + u16(files.length), + u32(central.length), + u32(offset), + u16(0), + ]); + return Buffer.concat([...localParts, central, end]); +} + +export function writeZip(path: string, entries: ZipEntry[]): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, createZipBuffer(entries)); +} diff --git a/cli/src/lib/build/build.ts b/cli/src/lib/build/build.ts new file mode 100644 index 00000000..a66e8099 --- /dev/null +++ b/cli/src/lib/build/build.ts @@ -0,0 +1,278 @@ +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { assertNoSymlinkEscape } from '../path-safety.js'; +import { + artifactBaseName, + fileSize, + latestManifestPath, + listProjectFiles, + removePath, + stageProject, + writeJson, + type BuildArtifact, + type BuildManifest, +} from './files.js'; +import { + isSupportedBuildTarget, + loadBuildConfig, + type BuildTarget, + type LoadBuildConfigOptions, + type ResolvedBuildConfig, + type SupportedBuildTarget, +} from './config.js'; +import { buildWeb } from './web.js'; +import { buildAndroid } from './android.js'; +import { buildIos } from './ios.js'; +import { buildDesktop, buildLove } from './desktop.js'; +import { assertBuildConfigValidForTarget } from './validation.js'; +import type { NativeBuildLogger, NativeCacheInfo } from './native.js'; +import { embedMobileDebuggerStage } from './debug-stage.js'; + +export type BuildOptions = LoadBuildConfigOptions & { + target: BuildTarget; + clean?: boolean; + dryRun?: boolean; + allowUnsafe?: boolean; + release?: boolean; + noCache?: boolean; + debugger?: boolean; + embedDebugger?: boolean; + runtimeConfigPath?: string; + noPlugins?: boolean; + featherOverride?: string; + pluginsOverride?: string; + verbose?: boolean; + log?: NativeBuildLogger; +}; + +export type BuildResult = { + ok: true; + dryRun: boolean; + target: BuildTarget; + projectDir: string; + outDir: string; + name: string; + version: string; + artifacts: BuildArtifact[]; + files: string[]; + manifestPath?: string; + command?: string[]; + cache?: NativeCacheInfo; +} | { + ok: false; + error: string; +}; + +export function assertBuildTargetSupported(target: BuildTarget): asserts target is SupportedBuildTarget { + if (!isSupportedBuildTarget(target)) { + throw new Error(`Build target "${target}" is planned but not supported yet. Run \`feather doctor --build-target ${target}\` for setup guidance.`); + } +} + +export function assertProductionBuildSafe(config: ResolvedBuildConfig, allowUnsafe = false): void { + if (allowUnsafe) return; + const configPath = join(config.projectDir, 'feather.config.lua'); + if (!existsSync(configPath)) return; + const source = readFileSync(configPath, 'utf8'); + const socketMode = !/mode\s*=\s*["']disk["']/.test(source); + const hasAppId = /appId\s*=\s*["'][^"']+["']/.test(source); + const host = source.match(/host\s*=\s*["']([^"']+)["']/)?.[1] ?? '127.0.0.1'; + const unsafe = [ + /__DANGEROUS_INSECURE_CONNECTION__\s*=\s*true/.test(source) ? '__DANGEROUS_INSECURE_CONNECTION__ is enabled' : '', + socketMode && !hasAppId ? 'appId is missing for socket/network mode' : '', + host === '0.0.0.0' || host === '::' ? `network host is exposed (${host})` : '', + /include\s*=\s*\{[\s\S]*["']console["']/.test(source) ? 'console plugin is included' : '', + /hotReload\s*=\s*\{[\s\S]*enabled\s*=\s*true/.test(source) ? 'hot reload is enabled' : '', + /allow\s*=\s*\{[\s\S]*["'][^"']+\.\*["']/.test(source) ? 'hot reload allowlist contains a wildcard' : '', + /debugger\s*=\s*(true|\{[\s\S]*enabled\s*=\s*true)/.test(source) ? 'debugger is enabled' : '', + /captureScreenshot\s*=\s*true/.test(source) ? 'captureScreenshot is enabled' : '', + /writeToDisk\s*=\s*true/.test(source) ? 'writeToDisk is enabled' : '', + ].filter(Boolean); + if (unsafe.length > 0) { + throw new Error(`Production build preflight failed. Run \`feather doctor --production\` or pass --allow-unsafe.\n${unsafe.join('\n')}`); + } +} + +export function planBuild(options: BuildOptions): Omit, 'artifacts'> & { artifacts: BuildArtifact[] } { + const config = loadBuildConfig(options); + assertReleaseTargetSupported(options.target, Boolean(options.release)); + assertBuildConfigValidForTarget(config, options.target, Boolean(options.release)); + return { + ok: true, + dryRun: true, + target: options.target, + projectDir: config.projectDir, + outDir: config.outDir, + name: config.name, + version: config.version, + files: listProjectFiles(config), + artifacts: plannedArtifacts(config, options.target, Boolean(options.release)), + manifestPath: latestManifestPath(config.outDir), + }; +} + +export function runBuild(options: BuildOptions): BuildResult { + try { + assertBuildTargetSupported(options.target); + assertReleaseTargetSupported(options.target, Boolean(options.release)); + const config = loadBuildConfig(options); + assertBuildConfigValidForTarget(config, options.target, Boolean(options.release)); + assertProductionBuildSafe(config, options.allowUnsafe); + assertNoSymlinkEscape(config.projectDir, config.outDir, 'Build output directory'); + + if (options.dryRun) return planBuild(options); + const log = options.verbose ? options.log : undefined; + const mobileDevBuild = (options.target === 'android' || options.target === 'ios') && !options.release; + const debuggerEmbedBuild = options.embedDebugger === true || mobileDevBuild; + if (options.clean && mobileDevBuild && !options.noCache) { + log?.('Build cache: reset by --clean'); + } + if (options.clean) removePath(config.outDir); + mkdirSync(config.outDir, { recursive: true }); + + const staged = stageProject(config); + log?.(`Staged ${staged.files.length} files from ${config.sourceDir}`); + try { + let cache: NativeCacheInfo | undefined; + const debugStage = debuggerEmbedBuild + ? embedMobileDebuggerStage(config, staged.dir, { + enabled: options.debugger !== false, + runtimeConfigPath: options.runtimeConfigPath, + noPlugins: options.noPlugins, + featherOverride: options.featherOverride, + pluginsOverride: options.pluginsOverride, + }) + : undefined; + if (debugStage?.enabled) { + log?.(`Embedded Feather debugger runtime${debugStage.configPath ? ` with ${debugStage.configPath}` : ' with generated dev config'}`); + } else if (debuggerEmbedBuild) { + log?.('Feather debugger embedding: disabled'); + } + const artifacts = options.target === 'love' + ? buildLove(config, staged.dir) + : options.target === 'web' + ? buildWeb(config, staged.dir) + : options.target === 'android' + ? buildAndroid(config, staged.dir, { + release: Boolean(options.release), + cache: !options.noCache, + debuggerSignature: debugStage?.signature, + verbose: options.verbose, + log, + onCache: (info) => { cache = info; }, + }) + : options.target === 'ios' + ? buildIos(config, staged.dir, { + release: Boolean(options.release), + cache: !options.noCache, + debuggerSignature: debugStage?.signature, + verbose: options.verbose, + log, + onCache: (info) => { cache = info; }, + }) + : buildDesktop(config, options.target, staged.dir); + const manifest: BuildManifest = { + name: config.name, + version: config.version, + target: options.target, + createdAt: new Date().toISOString(), + artifacts, + }; + writeJson(latestManifestPath(config.outDir), manifest); + return { + ok: true, + dryRun: false, + target: options.target, + projectDir: config.projectDir, + outDir: config.outDir, + name: config.name, + version: config.version, + files: staged.files, + artifacts, + manifestPath: latestManifestPath(config.outDir), + cache, + }; + } finally { + staged.cleanup(); + } + } catch (err) { + return { ok: false, error: (err as Error).message }; + } +} + +function assertReleaseTargetSupported(target: BuildTarget, release: boolean): void { + if (release && target !== 'android' && target !== 'ios') { + throw new Error('Release mode is currently supported only for android and ios builds.'); + } +} + +function plannedArtifacts(config: ResolvedBuildConfig, target: BuildTarget, release = false): BuildArtifact[] { + const base = artifactBaseName(config); + if (target === 'love') { + return [{ target, type: 'love', path: join(config.outDir, `${base}.love`) }]; + } + if (target === 'web') { + return [ + { target, type: 'love', path: join(config.outDir, `${base}.love`) }, + { target, type: 'html', path: join(config.outDir, `${base}-html`) }, + { target, type: 'zip', path: join(config.outDir, `${base}-html.zip`) }, + ]; + } + if (target === 'android') { + if (release) { + return [ + { target, type: 'love', path: join(config.outDir, `${base}.love`) }, + { target, type: 'aab', path: join(config.outDir, `${base}-android.aab`) }, + { target, type: 'apk', path: join(config.outDir, `${base}-android.apk`) }, + ]; + } + return [ + { target, type: 'love', path: join(config.outDir, `${base}.love`) }, + { target, type: 'apk', path: join(config.outDir, `${base}-android.apk`) }, + ]; + } + if (target === 'ios') { + if (release) { + return [ + { target, type: 'love', path: join(config.outDir, `${base}.love`) }, + { target, type: 'xcarchive', path: join(config.outDir, `${base}-ios.xcarchive`) }, + { target, type: 'ipa', path: join(config.outDir, `${base}-ios.ipa`) }, + ]; + } + return [ + { target, type: 'love', path: join(config.outDir, `${base}.love`) }, + { target, type: 'app', path: join(config.outDir, `${base}-ios.app`) }, + ]; + } + if (target === 'windows') { + return [ + { target, type: 'love', path: join(config.outDir, `${base}.love`) }, + { target, type: 'zip', path: join(config.outDir, `${base}-windows.zip`) }, + { target, type: 'installer', path: join(config.outDir, `${base}-windows-installer.exe`) }, + ]; + } + if (target === 'macos') { + return [ + { target, type: 'love', path: join(config.outDir, `${base}.love`) }, + { target, type: 'zip', path: join(config.outDir, `${base}-macos.app.zip`) }, + { target, type: 'dmg', path: join(config.outDir, `${base}-macos.dmg`) }, + ]; + } + if (target === 'linux' || target === 'steamos') { + return [ + { target, type: 'love', path: join(config.outDir, `${base}.love`) }, + { target, type: 'appimage', path: join(config.outDir, `${base}-${target}.AppImage`) }, + { target, type: 'tar.gz', path: join(config.outDir, `${base}-${target}.tar.gz`) }, + ]; + } + return [ + { target, type: 'love', path: join(config.outDir, `${base}.love`) }, + ]; +} + +export function describeArtifact(artifact: BuildArtifact): string { + const size = existsSync(artifact.path) && artifact.type !== 'html' + ? ` (${fileSize(artifact.path)} bytes)` + : ''; + return `${artifact.type}: ${artifact.path}${size}`; +} diff --git a/cli/src/lib/build/config.ts b/cli/src/lib/build/config.ts new file mode 100644 index 00000000..c3a0a4ee --- /dev/null +++ b/cli/src/lib/build/config.ts @@ -0,0 +1,239 @@ +import { accessSync, constants, existsSync, readFileSync } from 'node:fs'; +import { basename, dirname, join, resolve } from 'node:path'; +import { assertNoSymlinkEscape, assertSafeRelativePath, isPathInside } from '../path-safety.js'; + +export const buildTargets = ['love', 'web', 'android', 'ios', 'windows', 'macos', 'linux', 'steamos'] as const; +export const supportedBuildTargets = ['love', 'web', 'android', 'ios', 'windows', 'macos', 'linux', 'steamos'] as const; +export const uploadTargets = ['itch', 'steam'] as const; + +export type BuildTarget = typeof buildTargets[number]; +export type SupportedBuildTarget = typeof supportedBuildTargets[number]; +export type UploadTarget = typeof uploadTargets[number]; + +export type AndroidBuildTargetConfig = { + productId?: string; + loveAndroidDir?: string; + displayName?: string; + orientation?: string; + recordAudio?: boolean; + versionCode?: number; + versionName?: string; + gradleTask?: string; + artifactPath?: string; + release?: { + bundleTask?: string; + apkTask?: string; + bundleArtifactPath?: string; + apkArtifactPath?: string; + keystorePath?: string; + keyAlias?: string; + storePasswordEnv?: string; + keyPasswordEnv?: string; + }; +}; + +export type IosBuildTargetConfig = { + productId?: string; + loveIosDir?: string; + bundleIdentifier?: string; + displayName?: string; + scheme?: string; + configuration?: string; + sdk?: string; + simulatorArch?: string; + deploymentTarget?: string; + derivedDataPath?: string; + teamId?: string; + release?: { + archivePath?: string; + exportPath?: string; + exportOptionsPlist?: string; + exportMethod?: string; + signingStyle?: string; + provisioningProfileSpecifier?: string; + teamId?: string; + configuration?: string; + sdk?: string; + }; +}; + +export type DesktopRuntimeBuildTargetConfig = { + loveRuntimeDir?: string; +}; + +export type FeatherBuildConfig = { + name?: string; + version?: string; + loveVersion?: string; + productId?: string; + description?: string; + company?: string; + website?: string; + copyright?: string; + sourceDir?: string; + outDir?: string; + include?: string[]; + exclude?: string[]; + icon?: string; + includeRuntime?: boolean; + targets?: { + web?: { + loveJsDir?: string; + title?: string; + outputName?: string; + }; + windows?: DesktopRuntimeBuildTargetConfig; + macos?: DesktopRuntimeBuildTargetConfig; + linux?: DesktopRuntimeBuildTargetConfig; + steamos?: DesktopRuntimeBuildTargetConfig; + android?: AndroidBuildTargetConfig; + ios?: IosBuildTargetConfig; + }; + upload?: { + itch?: { + project?: string; + channels?: Record; + }; + }; +}; + +export type ResolvedBuildConfig = { + configPath: string; + projectDir: string; + sourceDir: string; + outDir: string; + name: string; + version: string; + loveVersion?: string; + productId?: string; + description?: string; + company?: string; + website?: string; + copyright?: string; + include: string[]; + exclude: string[]; + icon?: string; + includeRuntime: boolean; + targets: NonNullable; + upload: NonNullable; +}; + +export type LoadBuildConfigOptions = { + projectDir?: string; + configPath?: string; + sourceDir?: string; + outDir?: string; + name?: string; + version?: string; +}; + +const DEFAULT_EXCLUDES = [ + '.git', + 'node_modules', + '.featherlog', + 'feather', + 'feather.config.lua', + 'feather.lock.json', + 'feather.build.json', +]; + +export function isBuildTarget(value: string): value is BuildTarget { + return (buildTargets as readonly string[]).includes(value); +} + +export function isSupportedBuildTarget(value: BuildTarget): value is SupportedBuildTarget { + return (supportedBuildTargets as readonly string[]).includes(value); +} + +export function isUploadTarget(value: string): value is UploadTarget { + return (uploadTargets as readonly string[]).includes(value); +} + +export function buildConfigPath(projectDir: string, configPath?: string): string { + return configPath ? resolve(projectDir, configPath) : join(projectDir, 'feather.build.json'); +} + +export function readBuildConfig(projectDir: string, configPath?: string): FeatherBuildConfig { + const path = buildConfigPath(projectDir, configPath); + if (!existsSync(path)) return {}; + try { + const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Config root must be an object.'); + } + return parsed as FeatherBuildConfig; + } catch (err) { + throw new Error(`Invalid feather.build.json: ${(err as Error).message}`); + } +} + +function stringArray(value: unknown, label: string): string[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + throw new Error(`${label} must be an array of strings.`); + } + return value; +} + +function projectPath(projectDir: string, value: string | undefined, fallback: string, label: string): string { + const relative = value?.trim() || fallback; + assertSafeRelativePath(relative, label); + const absolute = resolve(projectDir, relative); + assertNoSymlinkEscape(projectDir, absolute, label); + return absolute; +} + +export function loadBuildConfig(options: LoadBuildConfigOptions = {}): ResolvedBuildConfig { + const projectDir = resolve(options.projectDir ?? process.cwd()); + const raw = readBuildConfig(projectDir, options.configPath); + const configPath = buildConfigPath(projectDir, options.configPath); + const name = (options.name ?? raw.name ?? basename(projectDir)).trim(); + const version = (options.version ?? raw.version ?? '0.1.0').trim(); + if (!name) throw new Error('Build name must not be empty.'); + if (!version) throw new Error('Build version must not be empty.'); + + const sourceDir = projectPath(projectDir, options.sourceDir ?? raw.sourceDir, '.', 'Build source directory'); + const outDir = projectPath(projectDir, options.outDir ?? raw.outDir, 'builds', 'Build output directory'); + const outRel = isPathInside(projectDir, outDir) ? outDirRelative(projectDir, outDir) : ''; + const includeRuntime = Boolean(raw.includeRuntime); + const exclude = [ + ...DEFAULT_EXCLUDES.filter((item) => includeRuntime ? item !== 'feather' : true), + ...(outRel ? [outRel] : []), + ...stringArray(raw.exclude, 'exclude'), + ]; + + return { + configPath, + projectDir, + sourceDir, + outDir, + name, + version, + loveVersion: raw.loveVersion, + productId: raw.productId, + description: raw.description, + company: raw.company, + website: raw.website, + copyright: raw.copyright, + include: stringArray(raw.include, 'include'), + exclude, + icon: raw.icon, + includeRuntime, + targets: raw.targets ?? {}, + upload: raw.upload ?? {}, + }; +} + +export function outDirWritableDetail(outDir: string): { ok: boolean; detail: string } { + const target = existsSync(outDir) ? outDir : dirname(outDir); + try { + accessSync(target, constants.W_OK); + return { ok: true, detail: outDir }; + } catch { + return { ok: false, detail: `${target} is not writable` }; + } +} + +function outDirRelative(projectDir: string, outDir: string): string { + return resolve(outDir).slice(resolve(projectDir).length + 1).replace(/\\/g, '/'); +} diff --git a/cli/src/lib/build/debug-stage.ts b/cli/src/lib/build/debug-stage.ts new file mode 100644 index 00000000..fbc4d0fa --- /dev/null +++ b/cli/src/lib/build/debug-stage.ts @@ -0,0 +1,152 @@ +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import type { ResolvedBuildConfig } from './config.js'; +import { copyDirectory } from './files.js'; +import { featherRoot, pluginsRoot } from '../shim.js'; + +export type MobileDebugStageOptions = { + enabled?: boolean; + runtimeConfigPath?: string; + noPlugins?: boolean; + featherOverride?: string; + pluginsOverride?: string; +}; + +export type MobileDebugStageInfo = { + enabled: boolean; + configPath?: string; + generatedConfig: boolean; + signature: string; +}; + +export function embedMobileDebuggerStage( + config: ResolvedBuildConfig, + stageDir: string, + options: MobileDebugStageOptions = {}, +): MobileDebugStageInfo { + if (options.enabled === false) { + return signature({ enabled: false, generatedConfig: false }); + } + + const originalMain = join(stageDir, 'main.lua'); + if (!existsSync(originalMain)) { + throw new Error('Feather debugger embedding requires main.lua in the staged game.'); + } + const relocatedMain = join(stageDir, '.feather-main.lua'); + if (existsSync(relocatedMain)) { + throw new Error('Feather debugger embedding cannot use .feather-main.lua because that file already exists in the game.'); + } + + const featherDir = featherRoot(options.featherOverride); + if (!existsSync(join(featherDir, 'auto.lua'))) { + throw new Error(`Feather runtime not found at ${featherDir}.`); + } + + const runtimeConfig = resolveRuntimeConfig(config, options.runtimeConfigPath); + const generatedConfig = runtimeConfig + ? readFileSync(runtimeConfig, 'utf8') + : generatedMobileConfig(config.name); + + rmSync(join(stageDir, 'feather'), { recursive: true, force: true }); + rmSync(join(stageDir, 'plugins'), { recursive: true, force: true }); + renameSync(originalMain, relocatedMain); + copyDirectory(featherDir, join(stageDir, 'feather')); + + const pluginDir = options.pluginsOverride + ? resolve(options.pluginsOverride) + : pluginsRoot(featherDir, options.featherOverride); + + if (!options.noPlugins) { + if (existsSync(pluginDir)) { + copyDirectory(pluginDir, join(stageDir, 'plugins')); + } + } + + writeFileSync(join(stageDir, 'feather.config.lua'), generatedConfig); + writeFileSync(join(stageDir, 'main.lua'), mobileMainWrapper()); + + return signature({ + enabled: true, + configPath: runtimeConfig, + generatedConfig: !runtimeConfig, + configSource: generatedConfig, + noPlugins: Boolean(options.noPlugins), + featherDir, + pluginsDir: options.noPlugins ? undefined : pluginDir, + }); +} + +function resolveRuntimeConfig(config: ResolvedBuildConfig, override: string | undefined): string | undefined { + const candidates = override + ? [resolve(config.projectDir, override)] + : [ + join(config.sourceDir, 'feather.config.lua'), + join(config.sourceDir, '.featherrc.lua'), + join(config.projectDir, 'feather.config.lua'), + join(config.projectDir, '.featherrc.lua'), + ]; + return candidates.find((candidate) => existsSync(candidate)); +} + +function generatedMobileConfig(name: string): string { + return [ + '-- feather.config.lua', + '-- Generated only inside Feather dev build staging.', + '-- The source project is not modified.', + 'return {', + ` sessionName = ${JSON.stringify(name)},`, + ' debug = true,', + ' wrapPrint = true,', + ' autoRegisterErrorHandler = true,', + ' captureScreenshot = false,', + ' __DANGEROUS_INSECURE_CONNECTION__ = true,', + ' debugOverlay = {', + ' enabled = true,', + ' visible = true,', + ' hideKey = "f12",', + ' touchToggle = true,', + ' corner = "top-right",', + ' },', + '}', + '', + ].join('\n'); +} + +function mobileMainWrapper(): string { + return [ + '-- Feather debugger injector - generated in build staging only.', + 'FEATHER_PATH = "feather"', + 'FEATHER_PLUGIN_PATH = ""', + '', + 'local function loadFeatherConfig()', + ' local chunk, err = love.filesystem.load("feather.config.lua")', + ' if not chunk then error("[feather] Could not load feather.config.lua: " .. tostring(err), 2) end', + ' local ok, config = pcall(chunk)', + ' if not ok then error("[feather] Could not evaluate feather.config.lua: " .. tostring(config), 2) end', + ' if type(config) ~= "table" then config = {} end', + ' if config.debug == nil then config.debug = true end', + ' if config.wrapPrint == nil then config.wrapPrint = true end', + ' if config.autoRegisterErrorHandler == nil then config.autoRegisterErrorHandler = true end', + ' return config', + 'end', + '', + 'FEATHER_AUTO_CONFIG = loadFeatherConfig()', + 'require("feather.auto")', + '', + 'local gameMain, err = love.filesystem.load(".feather-main.lua")', + 'if not gameMain then error("[feather] Could not load original main.lua: " .. tostring(err), 2) end', + 'gameMain()', + '', + ].join('\n'); +} + +function signature(input: Record): MobileDebugStageInfo { + const hash = createHash('sha256').update(JSON.stringify(input)).digest('hex'); + return { + enabled: Boolean(input.enabled), + configPath: typeof input.configPath === 'string' ? input.configPath : undefined, + generatedConfig: Boolean(input.generatedConfig), + signature: hash, + }; +} diff --git a/cli/src/lib/build/desktop.ts b/cli/src/lib/build/desktop.ts new file mode 100644 index 00000000..1be4ec06 --- /dev/null +++ b/cli/src/lib/build/desktop.ts @@ -0,0 +1,233 @@ +import { spawnSync } from 'node:child_process'; +import { + appendFileSync, + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { + artifactBaseName, + buildSlug, + copyDirectory, + removePath, + writeDirectoryZip, + writeLoveArchive, + type BuildArtifact, +} from './files.js'; +import type { ResolvedBuildConfig, SupportedBuildTarget } from './config.js'; + +export type DesktopBuildTarget = Exclude; + +export function buildLove(config: ResolvedBuildConfig, stageDir: string): BuildArtifact[] { + const lovePath = writeLoveArchive(stageDir, config.outDir, artifactBaseName(config)); + return [{ target: 'love', type: 'love', path: lovePath }]; +} + +export function buildDesktop(config: ResolvedBuildConfig, target: DesktopBuildTarget, stageDir: string): BuildArtifact[] { + const base = artifactBaseName(config); + const lovePath = writeLoveArchive(stageDir, config.outDir, base); + if (target === 'windows') return buildWindows(config, lovePath, base); + if (target === 'macos') return buildMacos(config, lovePath, base); + return buildLinuxLike(config, target, lovePath, base); +} + +function buildWindows(config: ResolvedBuildConfig, lovePath: string, base: string): BuildArtifact[] { + const runtimeDir = desktopRuntimeDir(config, 'windows'); + const sourceExe = join(runtimeDir, 'love.exe'); + if (!existsSync(sourceExe)) { + throw new Error(`Windows LÖVE runtime is incomplete at ${runtimeDir}. Run \`feather build vendor add windows --dir ${config.projectDir}\`.`); + } + + const workDir = mkdtempSync(join(tmpdir(), 'feather-windows-')); + const appSlug = buildSlug(config.name); + const bundleDir = join(workDir, appSlug); + const zipPath = join(config.outDir, `${base}-windows.zip`); + const installerPath = join(config.outDir, `${base}-windows-installer.exe`); + try { + copyDirectory(runtimeDir, bundleDir); + const appExe = join(bundleDir, `${appSlug}.exe`); + renameSync(join(bundleDir, 'love.exe'), appExe); + appendFileSync(appExe, readFileSync(lovePath)); + writeDirectoryZip(bundleDir, zipPath); + writeWindowsInstallerScript(join(workDir, 'installer.nsi'), config, bundleDir, installerPath); + runTool('makensis', [join(workDir, 'installer.nsi')], workDir, 'makensis not found. Run `feather doctor --build-target windows`.'); + } finally { + removePath(workDir); + } + + return [ + { target: 'windows', type: 'love', path: lovePath }, + { target: 'windows', type: 'zip', path: zipPath }, + { target: 'windows', type: 'installer', path: installerPath }, + ]; +} + +function buildMacos(config: ResolvedBuildConfig, lovePath: string, base: string): BuildArtifact[] { + const runtimeDir = desktopRuntimeDir(config, 'macos'); + const runtimeApp = join(runtimeDir, 'love.app'); + if (!existsSync(runtimeApp)) { + throw new Error(`macOS LÖVE runtime is incomplete at ${runtimeDir}. Run \`feather build vendor add macos --dir ${config.projectDir}\`.`); + } + + const workDir = mkdtempSync(join(tmpdir(), 'feather-macos-')); + const appSlug = buildSlug(config.name); + const appBundle = join(workDir, `${appSlug}.app`); + const zipPath = join(config.outDir, `${base}-macos.app.zip`); + const dmgPath = join(config.outDir, `${base}-macos.dmg`); + try { + copyDirectory(runtimeApp, appBundle); + const resourcesDir = join(appBundle, 'Contents', 'Resources'); + mkdirSync(resourcesDir, { recursive: true }); + writeFileSync(join(resourcesDir, 'game.love'), readFileSync(lovePath)); + patchMacosPlist(config, join(appBundle, 'Contents', 'Info.plist'), appSlug); + const ditto = spawnSync('ditto', ['-c', '-k', '--keepParent', appBundle, zipPath], { encoding: 'utf8' }); + if (ditto.error) { + writeDirectoryZip(appBundle, zipPath); + } else if (ditto.status !== 0) { + throw new Error((ditto.stderr || ditto.stdout || 'ditto failed').trim()); + } + runTool('hdiutil', ['create', '-volname', appSlug, '-srcfolder', appBundle, '-ov', '-format', 'UDZO', dmgPath], workDir, 'hdiutil not found. Run `feather doctor --build-target macos`.'); + } finally { + removePath(workDir); + } + + return [ + { target: 'macos', type: 'love', path: lovePath }, + { target: 'macos', type: 'zip', path: zipPath }, + { target: 'macos', type: 'dmg', path: dmgPath }, + ]; +} + +function buildLinuxLike(config: ResolvedBuildConfig, target: 'linux' | 'steamos', lovePath: string, base: string): BuildArtifact[] { + const runtimeDir = desktopRuntimeDir(config, target); + const runtimeRoot = join(runtimeDir, 'squashfs-root'); + const appImageTool = join(runtimeDir, 'appimagetool.AppImage'); + if (!existsSync(join(runtimeRoot, 'bin', 'love')) || !existsSync(appImageTool)) { + throw new Error(`${target} LÖVE runtime is incomplete at ${runtimeDir}. Run \`feather build vendor add ${target} --dir ${config.projectDir}\`.`); + } + + const workDir = mkdtempSync(join(tmpdir(), `feather-${target}-`)); + const appSlug = buildSlug(config.name); + const appDir = join(workDir, 'squashfs-root'); + const appImagePath = join(config.outDir, `${base}-${target}.AppImage`); + const tarPath = join(config.outDir, `${base}-${target}.tar.gz`); + try { + copyDirectory(runtimeRoot, appDir); + patchLinuxRuntime(appDir, appSlug, config.description); + const appBinary = join(appDir, 'bin', appSlug); + appendFileSync(appBinary, readFileSync(lovePath)); + chmodSync(appBinary, 0o755); + chmodSync(appImageTool, 0o755); + runTool(appImageTool, [appDir, appImagePath], workDir, `appimagetool failed. Run \`feather doctor --build-target ${target}\`.`); + runTool('tar', ['-czf', tarPath, '-C', appDir, '.'], workDir, `tar not found. Run \`feather doctor --build-target ${target}\`.`); + } finally { + removePath(workDir); + } + + return [ + { target, type: 'love', path: lovePath }, + { target, type: 'appimage', path: appImagePath }, + { target, type: 'tar.gz', path: tarPath }, + ]; +} + +function desktopRuntimeDir(config: ResolvedBuildConfig, target: DesktopBuildTarget): string { + const configured = target === 'steamos' + ? config.targets.steamos?.loveRuntimeDir ?? config.targets.linux?.loveRuntimeDir + : config.targets[target]?.loveRuntimeDir; + if (!configured) { + const field = target === 'steamos' ? 'targets.steamos.loveRuntimeDir or targets.linux.loveRuntimeDir' : `targets.${target}.loveRuntimeDir`; + throw new Error(`${target} build requires ${field} in feather.build.json. Run \`feather build vendor add ${target} --dir ${config.projectDir}\`.`); + } + const runtimeDir = resolve(config.projectDir, configured); + if (!existsSync(runtimeDir)) { + throw new Error(`${target} LÖVE runtime not found at ${runtimeDir}. Run \`feather build vendor add ${target} --dir ${config.projectDir}\`.`); + } + return runtimeDir; +} + +function patchLinuxRuntime(appDir: string, appSlug: string, description: string | undefined): void { + const loveBin = join(appDir, 'bin', 'love'); + const appBin = join(appDir, 'bin', appSlug); + mkdirSync(dirname(appBin), { recursive: true }); + renameSync(loveBin, appBin); + const appRun = join(appDir, 'AppRun'); + if (existsSync(appRun)) { + writeFileSync(appRun, readFileSync(appRun, 'utf8').replace(/bin\/love/g, `bin/${appSlug}`)); + chmodSync(appRun, 0o755); + } + const desktop = [ + '[Desktop Entry]', + `Name=${appSlug}`, + `Comment=${description ?? appSlug}`, + 'Type=Application', + 'Categories=Game;', + `Exec=${appSlug} %f`, + `Icon=${appSlug}`, + 'Terminal=false', + 'NoDisplay=false', + '', + ].join('\n'); + writeFileSync(join(appDir, `${appSlug}.desktop`), desktop); + mkdirSync(join(appDir, 'share', 'applications'), { recursive: true }); + writeFileSync(join(appDir, 'share', 'applications', `${appSlug}.desktop`), desktop); +} + +function patchMacosPlist(config: ResolvedBuildConfig, plistPath: string, appSlug: string): void { + if (!existsSync(plistPath)) { + throw new Error(`macOS LÖVE runtime is missing Contents/Info.plist. Run \`feather build vendor add macos --dir ${config.projectDir} --force\`.`); + } + const bundleId = config.productId ?? `org.feather.${appSlug.replace(/[^a-z0-9]+/g, '.')}.macos`; + for (const [key, value] of [ + ['CFBundleIdentifier', bundleId], + ['CFBundleName', config.name], + ['CFBundleDisplayName', config.name], + ['CFBundleShortVersionString', config.version], + ['CFBundleVersion', config.version], + ['NSHumanReadableCopyright', config.copyright ?? ''], + ] as const) { + runTool('plutil', ['-replace', key, '-string', value, plistPath], dirname(plistPath), 'plutil not found. Run `feather doctor --build-target macos`.'); + } +} + +function writeWindowsInstallerScript(path: string, config: ResolvedBuildConfig, bundleDir: string, installerPath: string): void { + const appSlug = buildSlug(config.name); + const sourceGlob = `${bundleDir.replace(/\\/g, '/')}/*`; + const script = [ + `Name "${nsis(config.name)}"`, + `OutFile "${nsis(installerPath)}"`, + `InstallDir "$LOCALAPPDATA\\${nsis(config.company ?? 'Feather')}\\${nsis(config.name)}"`, + 'RequestExecutionLevel user', + 'Section', + ' SetOutPath "$INSTDIR"', + ` File /r "${nsis(sourceGlob)}"`, + ` CreateShortcut "$SMPROGRAMS\\${nsis(config.name)}.lnk" "$INSTDIR\\${nsis(appSlug)}.exe"`, + ' WriteUninstaller "$INSTDIR\\uninstall.exe"', + 'SectionEnd', + 'Section "Uninstall"', + ' Delete "$INSTDIR\\*.*"', + ' RMDir "$INSTDIR"', + 'SectionEnd', + '', + ].join('\n'); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, script); +} + +function nsis(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function runTool(command: string, args: string[], cwd: string, missingMessage: string): void { + const result = spawnSync(command, args, { cwd, encoding: 'utf8' }); + if (result.error) throw new Error(missingMessage); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || `${command} failed`).trim()); + } +} diff --git a/cli/src/lib/build/files.ts b/cli/src/lib/build/files.ts new file mode 100644 index 00000000..e32a1e27 --- /dev/null +++ b/cli/src/lib/build/files.ts @@ -0,0 +1,190 @@ +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, relative, resolve } from 'node:path'; +import type { ResolvedBuildConfig } from './config.js'; +import { writeZip, type ZipEntry } from './archive.js'; + +export type StagedProject = { + dir: string; + files: string[]; + cleanup: () => void; +}; + +export function removePath(path: string): void { + rmSync(path, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); +} + +function patternToRegExp(pattern: string): RegExp { + const normalized = pattern.replace(/\\/g, '/').replace(/^\/+/, ''); + const escaped = normalized + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*/g, '::DOUBLE_STAR::') + .replace(/\*/g, '[^/]*') + .replace(/::DOUBLE_STAR::/g, '.*'); + return new RegExp(`^${escaped}(?:/.*)?$`); +} + +function matcher(patterns: string[]): (path: string) => boolean { + const regexes = patterns.map(patternToRegExp); + return (path) => regexes.some((regex) => regex.test(path)); +} + +export function buildSlug(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') || 'game'; +} + +export function artifactBaseName(config: ResolvedBuildConfig): string { + return `${buildSlug(config.name)}-${config.version}`; +} + +export function listProjectFiles(config: ResolvedBuildConfig): string[] { + const includes = matcher(config.include); + const excludes = matcher(config.exclude); + const files: string[] = []; + + const visit = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const abs = join(dir, entry.name); + const rel = relative(config.sourceDir, abs).replace(/\\/g, '/'); + if (!rel || excludes(rel)) continue; + if (entry.isDirectory()) { + visit(abs); + } else if (entry.isFile() && (config.include.length === 0 || includes(rel))) { + files.push(rel); + } + } + }; + + visit(config.sourceDir); + return files.sort((a, b) => a.localeCompare(b)); +} + +export function stageProject(config: ResolvedBuildConfig): StagedProject { + const root = mkdtempSync(join(tmpdir(), 'feather-build-')); + const stageDir = join(root, 'game'); + const files = listProjectFiles(config); + mkdirSync(stageDir, { recursive: true }); + for (const file of files) { + const src = join(config.sourceDir, file); + const dest = join(stageDir, file); + mkdirSync(dirname(dest), { recursive: true }); + cpSync(src, dest, { force: true }); + } + return { + dir: stageDir, + files, + cleanup: () => removePath(root), + }; +} + +export function zipEntriesFromDir(dir: string): ZipEntry[] { + const entries: ZipEntry[] = []; + const visit = (current: string) => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const abs = join(current, entry.name); + if (entry.isDirectory()) { + visit(abs); + } else if (entry.isFile()) { + entries.push({ + name: relative(dir, abs).replace(/\\/g, '/'), + data: readFileSync(abs), + }); + } + } + }; + visit(dir); + return entries; +} + +export function writeLoveArchive(stageDir: string, outDir: string, basenameWithoutExt: string): string { + const path = join(outDir, `${basenameWithoutExt}.love`); + writeZip(path, zipEntriesFromDir(stageDir)); + return path; +} + +export function writeDirectoryZip(dir: string, zipPath: string): string { + writeZip(zipPath, zipEntriesFromDir(dir)); + return zipPath; +} + +export function copyDirectory(source: string, dest: string): void { + mkdirSync(dirname(dest), { recursive: true }); + cpSync(source, dest, { + recursive: true, + force: true, + filter: (src) => { + const rel = relative(source, src).replace(/\\/g, '/'); + return !rel.startsWith('.git'); + }, + }); +} + +export function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +export function latestManifestPath(outDir: string): string { + return join(outDir, 'feather-build-manifest.json'); +} + +export function readLatestManifest(outDir: string): BuildManifest | null { + const path = latestManifestPath(outDir); + if (!existsSync(path)) return null; + return JSON.parse(readFileSync(path, 'utf8')) as BuildManifest; +} + +export type BuildArtifact = { + target: string; + type: string; + path: string; +}; + +export type BuildManifest = { + name: string; + version: string; + target: string; + createdAt: string; + artifacts: BuildArtifact[]; +}; + +export function fileSize(path: string): number { + return statSync(path).size; +} + +export function artifactForTarget(manifest: BuildManifest, target: string): BuildArtifact | null { + const artifacts = manifest.artifacts.filter((artifact) => artifact.target === target && artifact.type !== 'metadata'); + const preferredTypes = ['dmg', 'installer', 'appimage', 'zip', 'tar.gz', 'ipa', 'aab', 'apk', 'app', 'html', 'external', 'love']; + for (const type of preferredTypes) { + const artifact = artifacts.find((candidate) => candidate.type === type); + if (artifact) return artifact; + } + return artifacts[0] ?? null; +} + +export function resolveArtifactPath(path: string): string { + return resolve(path); +} + +export function pathName(path: string): string { + return basename(path); +} diff --git a/cli/src/lib/build/ios.ts b/cli/src/lib/build/ios.ts new file mode 100644 index 00000000..d503b73e --- /dev/null +++ b/cli/src/lib/build/ios.ts @@ -0,0 +1,616 @@ +import { spawnSync } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { writeZip, type ZipEntry } from './archive.js'; +import { artifactBaseName, copyDirectory, writeLoveArchive, type BuildArtifact } from './files.js'; +import type { ResolvedBuildConfig } from './config.js'; +import { + createNativeWorkspace, + findFirstPath, + logNativeCommand, + logNativeOutput, + logNativeStep, + type NativeCacheInfo, + type NativeBuildLogger, +} from './native.js'; +import { iosBundleIdentifier } from './validation.js'; + +const XCODEBUILD_MAX_BUFFER = 64 * 1024 * 1024; + +export type IosBuildModeOptions = { + release?: boolean; + cache?: boolean; + debuggerSignature?: string; + verbose?: boolean; + log?: NativeBuildLogger; + onCache?: (cache: NativeCacheInfo) => void; +}; + +export function buildIos(config: ResolvedBuildConfig, stageDir: string, options: IosBuildModeOptions = {}): BuildArtifact[] { + if (process.platform !== 'darwin' && process.env.FEATHER_TEST_ALLOW_IOS_BUILD !== '1') { + throw new Error('iOS builds require macOS with Xcode. Run `feather doctor --build-target ios` for setup guidance.'); + } + + const iosConfig = config.targets.ios ?? {}; + const loveIosDir = iosConfig.loveIosDir ? resolve(config.projectDir, iosConfig.loveIosDir) : ''; + if (!iosConfig.loveIosDir) { + throw new Error('iOS build requires targets.ios.loveIosDir in feather.build.json.'); + } + if (!existsSync(loveIosDir)) { + throw new Error(`iOS template not found at ${loveIosDir}. Run \`feather build vendor add ios --dir ${config.projectDir}\` or update targets.ios.loveIosDir in feather.build.json.`); + } + + const base = artifactBaseName(config); + const lovePath = writeLoveArchive(stageDir, config.outDir, base); + logNativeStep(options.log, `Created .love archive: ${lovePath}`); + if (options.release || options.cache === false) { + logNativeStep(options.log, `Build cache: ${options.release ? 'disabled for release build' : 'disabled by --no-cache'}`); + } + const cacheEnabled = !options.release && options.cache !== false; + logNativeStep(options.log, `iOS template: ${loveIosDir}`); + const sdk = iosConfig.sdk ?? 'iphonesimulator'; + const workspace = createNativeWorkspace('feather-ios-', loveIosDir, 'love-ios', { + enabled: cacheEnabled, + target: 'ios', + outDir: config.outDir, + log: options.log, + requiredPaths: ['platform/xcode/love.xcodeproj'], + keyParts: { + bundleIdentifier: iosBundleIdentifier(config), + displayName: iosConfig.displayName ?? config.name, + scheme: iosConfig.scheme ?? 'love-ios', + configuration: iosConfiguration(config), + sdk, + simulatorArch: sdk.startsWith('iphonesimulator') ? iosSimulatorArch(config) : undefined, + deploymentTarget: iosConfig.deploymentTarget ?? '12.0', + teamId: iosConfig.teamId, + gameLoveResourcePatch: 4, + debuggerSignature: options.debuggerSignature, + }, + }); + options.onCache?.(workspace.cache); + logNativeStep(options.log, `iOS workspace: ${workspace.dir}`); + const derivedDataPath = cacheEnabled + ? join(workspace.root, 'DerivedData') + : iosConfig.derivedDataPath + ? resolve(config.projectDir, iosConfig.derivedDataPath) + : join(workspace.root, 'DerivedData'); + + try { + const xcodeProject = join(workspace.dir, 'platform', 'xcode', 'love.xcodeproj'); + if (!existsSync(xcodeProject)) { + throw new Error('iOS build requires platform/xcode/love.xcodeproj in targets.ios.loveIosDir.'); + } + + const gameLovePath = join(workspace.dir, 'platform', 'xcode', 'game.love'); + mkdirSync(dirname(gameLovePath), { recursive: true }); + cpSync(lovePath, gameLovePath, { force: true }); + logNativeStep(options.log, `Embedded game.love: ${gameLovePath}`); + patchIosPlist(join(workspace.dir, 'platform', 'xcode', 'ios', 'love-ios.plist'), config); + logNativeStep(options.log, 'Patched iOS plist metadata'); + patchIosProject(join(xcodeProject, 'project.pbxproj'), config, base); + logNativeStep(options.log, 'Patched Xcode project resources'); + + if (options.release) { + const releaseArtifacts = buildIosRelease(config, workspace.dir, xcodeProject, base, lovePath, options.log); + return releaseArtifacts; + } + + const args = xcodebuildArgs(config, xcodeProject, derivedDataPath); + runXcodebuild(args, workspace.dir, options.log); + + const appSource = findIosAppArtifact(config, derivedDataPath); + if (!appSource || !existsSync(appSource)) { + throw new Error('iOS build completed but no .app artifact was found. Check targets.ios.derivedDataPath or Xcode build settings.'); + } + const appPath = join(config.outDir, `${base}-ios.app`); + copyDirectory(appSource, appPath); + ensureLoveInAppBundle(appPath, lovePath, options.log); + logNativeStep(options.log, `Copied app artifact: ${appPath}`); + + return [ + { target: 'ios', type: 'love', path: lovePath }, + { target: 'ios', type: 'app', path: appPath }, + ]; + } finally { + workspace.cleanup(); + } +} + +function buildIosRelease( + config: ResolvedBuildConfig, + workDir: string, + xcodeProject: string, + base: string, + lovePath: string, + log?: NativeBuildLogger, +): BuildArtifact[] { + const release = config.targets.ios?.release ?? {}; + const archiveSource = release.archivePath + ? resolve(config.projectDir, release.archivePath) + : join(workDir, '..', `${base}.xcarchive`); + const exportPath = release.exportPath + ? resolve(config.projectDir, release.exportPath) + : join(workDir, '..', 'Export'); + const exportOptionsPlist = release.exportOptionsPlist + ? resolve(config.projectDir, release.exportOptionsPlist) + : writeGeneratedExportOptions(config, join(workDir, '..', 'ExportOptions.plist')); + + runXcodebuild(xcodeArchiveArgs(config, xcodeProject, archiveSource), workDir, log); + if (!existsSync(archiveSource)) { + throw new Error('iOS archive completed but no .xcarchive was found. Check targets.ios.release.archivePath or Xcode archive settings.'); + } + + let ipaSource: string | null; + if (shouldPackageUnsignedIpa(config)) { + ipaSource = packageUnsignedIpaFromArchive(archiveSource, exportPath, base, log); + } else { + runXcodebuild(xcodeExportArchiveArgs(archiveSource, exportPath, exportOptionsPlist), workDir, log); + ipaSource = findFirstPath(exportPath, (_path, entry) => entry.isFile() && entry.name.endsWith('.ipa')); + } + if (!ipaSource || !existsSync(ipaSource)) { + throw new Error('iOS export completed but no .ipa artifact was found. Check targets.ios.release.exportPath or export options.'); + } + + const archivePath = join(config.outDir, `${base}-ios.xcarchive`); + const ipaPath = join(config.outDir, `${base}-ios.ipa`); + copyDirectory(archiveSource, archivePath); + cpSync(ipaSource, ipaPath, { force: true }); + logNativeStep(log, `Copied archive artifact: ${archivePath}`); + logNativeStep(log, `Copied IPA artifact: ${ipaPath}`); + return [ + { target: 'ios', type: 'love', path: lovePath }, + { target: 'ios', type: 'xcarchive', path: archivePath }, + { target: 'ios', type: 'ipa', path: ipaPath }, + ]; +} + +function shouldPackageUnsignedIpa(config: ResolvedBuildConfig): boolean { + const iosConfig = config.targets.ios ?? {}; + const release = iosConfig.release ?? {}; + const teamId = release.teamId ?? iosConfig.teamId; + return !teamId && !release.exportOptionsPlist && !release.provisioningProfileSpecifier; +} + +function packageUnsignedIpaFromArchive(archivePath: string, exportPath: string, base: string, log?: NativeBuildLogger): string { + const appSource = findArchiveApp(archivePath); + if (!appSource) { + throw new Error('iOS archive completed but no .app was found in Products/Applications.'); + } + + const payloadRoot = mkdtempSync(join(tmpdir(), 'feather-ios-payload-')); + const payloadDir = join(payloadRoot, 'Payload'); + const payloadApp = join(payloadDir, appSource.split('/').pop() ?? `${base}.app`); + mkdirSync(payloadDir, { recursive: true }); + copyDirectory(appSource, payloadApp); + + const ipaPath = join(exportPath, `${base}.ipa`); + mkdirSync(exportPath, { recursive: true }); + const ditto = spawnSync('/usr/bin/ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', 'Payload', ipaPath], { + cwd: payloadRoot, + encoding: 'utf8', + stdio: 'pipe', + }); + if (ditto.status === 0) { + logNativeStep(log, `Packaged unsigned IPA: ${ipaPath}`); + return ipaPath; + } + + if (process.platform === 'darwin') { + throw new Error(`Failed to package unsigned IPA with ditto: ${spawnOutput(ditto)}`); + } + + writeZip(ipaPath, collectZipEntries(payloadRoot, 'Payload')); + logNativeStep(log, `Packaged unsigned IPA: ${ipaPath}`); + return ipaPath; +} + +function findArchiveApp(archivePath: string): string | null { + const applicationsDir = join(archivePath, 'Products', 'Applications'); + return findFirstPath(applicationsDir, (_path, entry) => entry.isDirectory() && entry.name.endsWith('.app')) + ?? findFirstPath(archivePath, (_path, entry) => entry.isDirectory() && entry.name.endsWith('.app')); +} + +function collectZipEntries(root: string, relativePath: string): ZipEntry[] { + const absolute = join(root, relativePath); + const stat = statSync(absolute); + if (stat.isFile()) { + return [{ name: relativePath, data: readFileSync(absolute) }]; + } + if (!stat.isDirectory()) return []; + return readdirSync(absolute).flatMap((name) => collectZipEntries(root, join(relativePath, name))); +} + +function ensureLoveInAppBundle(appPath: string, lovePath: string, log?: NativeBuildLogger): void { + if (!existsSync(appPath)) { + throw new Error(`iOS app bundle not found at ${appPath}.`); + } + const bundledLovePath = join(appPath, 'game.love'); + if (existsSync(bundledLovePath)) { + logNativeStep(log, `Bundled game.love in app: ${bundledLovePath}`); + return; + } + cpSync(lovePath, bundledLovePath, { force: true }); + logNativeStep(log, `Bundled missing game.love in app: ${bundledLovePath}`); + maybeAdHocCodesign(appPath, log); +} + +function maybeAdHocCodesign(appPath: string, log?: NativeBuildLogger): void { + if (process.platform !== 'darwin') return; + const result = spawnSync('codesign', ['--force', '--sign', '-', appPath], { + encoding: 'utf8', + stdio: 'pipe', + }); + if (result.status === 0) { + logNativeStep(log, `Ad-hoc signed app bundle: ${appPath}`); + return; + } + logNativeStep(log, 'Ad-hoc codesign skipped; simulator installs may still work, but device installs require signing.'); +} + +function iosConfiguration(config: ResolvedBuildConfig): string { + const iosConfig = config.targets.ios ?? {}; + if (iosConfig.configuration) return iosConfig.configuration; + const sdk = iosConfig.sdk ?? 'iphonesimulator'; + return sdk.startsWith('iphonesimulator') ? 'Debug' : 'Release'; +} + +function iosSimulatorArch(config: ResolvedBuildConfig): string { + return config.targets.ios?.simulatorArch ?? 'x86_64'; +} + +function iosSdkBuildFolder(sdk: string): string { + if (sdk.startsWith('iphonesimulator')) return 'iphonesimulator'; + if (sdk.startsWith('iphoneos')) return 'iphoneos'; + return sdk; +} + +function findIosAppArtifact(config: ResolvedBuildConfig, derivedDataPath: string): string | null { + const sdk = config.targets.ios?.sdk ?? 'iphonesimulator'; + const configuration = iosConfiguration(config); + const productsDir = join(derivedDataPath, 'Build', 'Products'); + const expectedDir = join(productsDir, `${configuration}-${iosSdkBuildFolder(sdk)}`); + const expected = findFirstPath(expectedDir, (_path, entry) => entry.isDirectory() && entry.name.endsWith('.app')); + if (expected) return expected; + + const matchingConfiguration = findFirstPath(productsDir, (path, entry) => ( + entry.isDirectory() + && entry.name.endsWith('.app') + && path.includes(`${configuration}-`) + )); + if (matchingConfiguration) return matchingConfiguration; + + return findFirstPath(derivedDataPath, (_path, entry) => entry.isDirectory() && entry.name.endsWith('.app')); +} + +function patchIosPlist(path: string, config: ResolvedBuildConfig): void { + if (!existsSync(path)) return; + if (process.platform === 'darwin') { + patchIosPlistWithPlistBuddy(path, config); + validateIosPlist(path); + return; + } + + const iosConfig = config.targets.ios ?? {}; + let source = readFileSync(path, 'utf8'); + source = upsertPlistString(source, 'CFBundleName', iosConfig.displayName ?? config.name); + source = upsertPlistString(source, 'CFBundleDisplayName', iosConfig.displayName ?? config.name); + source = upsertPlistString(source, 'CFBundleIdentifier', iosBundleIdentifier(config)); + source = upsertPlistString(source, 'CFBundleExecutable', artifactBaseName(config)); + source = upsertPlistString(source, 'CFBundleShortVersionString', config.version); + source = upsertPlistString(source, 'CFBundleVersion', config.version); + source = upsertPlistBool(source, 'ITSAppUsesNonExemptEncryption', false); + source = upsertPlistBool(source, 'UIApplicationSupportsIndirectInputEvents', true); + source = upsertPlistBool(source, 'UIRequiresFullScreen', true); + source = upsertPlistBool(source, 'UIStatusBarHidden', true); + if (config.copyright) source = upsertPlistString(source, 'NSHumanReadableCopyright', config.copyright); + source = removePlistKey(source, 'CFBundleDocumentTypes'); + source = removePlistKey(source, 'UTExportedTypeDeclarations'); + writeFileSync(path, source); +} + +function patchIosPlistWithPlistBuddy(path: string, config: ResolvedBuildConfig): void { + const iosConfig = config.targets.ios ?? {}; + const executable = artifactBaseName(config); + setPlistBuddyString(path, 'CFBundleName', iosConfig.displayName ?? config.name); + setPlistBuddyString(path, 'CFBundleDisplayName', iosConfig.displayName ?? config.name); + setPlistBuddyString(path, 'CFBundleIdentifier', iosBundleIdentifier(config)); + setPlistBuddyString(path, 'CFBundleExecutable', executable); + setPlistBuddyString(path, 'CFBundleShortVersionString', config.version); + setPlistBuddyString(path, 'CFBundleVersion', config.version); + setPlistBuddyBool(path, 'ITSAppUsesNonExemptEncryption', false); + setPlistBuddyBool(path, 'UIApplicationSupportsIndirectInputEvents', true); + setPlistBuddyBool(path, 'UIRequiresFullScreen', true); + setPlistBuddyBool(path, 'UIStatusBarHidden', true); + if (config.copyright) setPlistBuddyString(path, 'NSHumanReadableCopyright', config.copyright); + deletePlistBuddyKey(path, 'CFBundleDocumentTypes'); + deletePlistBuddyKey(path, 'UTExportedTypeDeclarations'); +} + +function setPlistBuddyString(path: string, key: string, value: string): void { + setPlistBuddyValue(path, key, 'string', value); +} + +function setPlistBuddyBool(path: string, key: string, value: boolean): void { + setPlistBuddyValue(path, key, 'bool', value ? 'true' : 'false'); +} + +function setPlistBuddyValue(path: string, key: string, type: 'bool' | 'string', value: string): void { + const set = runPlistBuddy(path, `Set :${key} ${value}`); + if (set.status === 0) return; + + const add = runPlistBuddy(path, `Add :${key} ${type} ${value}`); + if (add.status !== 0) { + throw new Error(`Failed to update iOS plist key ${key}: ${spawnOutput(add) || spawnOutput(set)}`); + } +} + +function deletePlistBuddyKey(path: string, key: string): void { + runPlistBuddy(path, `Delete :${key}`); +} + +function runPlistBuddy(path: string, command: string): ReturnType { + return spawnSync('/usr/libexec/PlistBuddy', ['-c', command, path], { + encoding: 'utf8', + stdio: 'pipe', + }); +} + +function validateIosPlist(path: string): void { + const result = spawnSync('/usr/bin/plutil', ['-lint', path], { + encoding: 'utf8', + stdio: 'pipe', + }); + if (result.error || result.status !== 0) { + throw new Error(`iOS plist validation failed: ${(result.stderr || result.stdout || result.error?.message || '').trim()}`); + } +} + +function spawnOutput(result: ReturnType): string { + const output = result.stderr || result.stdout || result.error?.message || ''; + return output.toString().trim(); +} + +function upsertPlistString(source: string, key: string, value: string): string { + return upsertPlistValue(source, key, `${escapeXml(value)}`); +} + +function upsertPlistBool(source: string, key: string, value: boolean): string { + return upsertPlistValue(source, key, value ? '' : ''); +} + +function upsertPlistValue(source: string, key: string, valueXml: string): string { + const pattern = new RegExp(`(\\s*${escapeRegExp(key)}\\s*)<[^>]+>[^<]*]+>|(\\s*${escapeRegExp(key)}\\s*)<(?:true|false)\\/>`); + if (pattern.test(source)) { + return source.replace(pattern, `$1$2${valueXml}`); + } + return source.replace('', `\t${key}\n\t${valueXml}\n`); +} + +function removePlistKey(source: string, key: string): string { + const escaped = escapeRegExp(key); + return source.replace(new RegExp(`\\n\\t${escaped}[\\s\\S]*?(?=\\n\\t|\\n<\\/dict>)`, 'g'), ''); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function xcodebuildArgs(config: ResolvedBuildConfig, xcodeProject: string, derivedDataPath: string): string[] { + const iosConfig = config.targets.ios ?? {}; + const sdk = iosConfig.sdk ?? 'iphonesimulator'; + const args = [ + '-project', + xcodeProject, + '-scheme', + iosConfig.scheme ?? 'love-ios', + '-configuration', + iosConfiguration(config), + '-sdk', + sdk, + '-destination', + sdk.startsWith('iphoneos') ? 'generic/platform=iOS' : 'generic/platform=iOS Simulator', + '-derivedDataPath', + derivedDataPath, + `PRODUCT_BUNDLE_IDENTIFIER=${iosBundleIdentifier(config)}`, + `INFOPLIST_KEY_CFBundleDisplayName=${iosConfig.displayName ?? config.name}`, + `IPHONEOS_DEPLOYMENT_TARGET=${iosConfig.deploymentTarget ?? '12.0'}`, + 'OTHER_CFLAGS=-Wno-everything', + ]; + if (sdk.startsWith('iphonesimulator')) { + args.push(`ARCHS=${iosSimulatorArch(config)}`, 'ONLY_ACTIVE_ARCH=NO'); + } + if (iosConfig.teamId) args.push(`DEVELOPMENT_TEAM=${iosConfig.teamId}`); + if (sdk.startsWith('iphonesimulator') && !iosConfig.teamId) { + args.push('CODE_SIGN_IDENTITY=', 'CODE_SIGNING_REQUIRED=NO', 'CODE_SIGNING_ALLOWED=NO'); + } + args.push('build'); + return args; +} + +export function xcodeArchiveArgs(config: ResolvedBuildConfig, xcodeProject: string, archivePath: string): string[] { + const iosConfig = config.targets.ios ?? {}; + const release = iosConfig.release ?? {}; + const teamId = release.teamId ?? iosConfig.teamId; + const args = [ + '-project', + xcodeProject, + '-scheme', + iosConfig.scheme ?? 'love-ios', + '-configuration', + release.configuration ?? 'Release', + '-sdk', + release.sdk ?? 'iphoneos', + '-destination', + 'generic/platform=iOS', + '-archivePath', + archivePath, + `PRODUCT_BUNDLE_IDENTIFIER=${iosBundleIdentifier(config)}`, + `INFOPLIST_KEY_CFBundleDisplayName=${iosConfig.displayName ?? config.name}`, + `IPHONEOS_DEPLOYMENT_TARGET=${iosConfig.deploymentTarget ?? '12.0'}`, + 'OTHER_CFLAGS=-Wno-everything', + ]; + if (teamId) args.push(`DEVELOPMENT_TEAM=${teamId}`); + if (!teamId) { + args.push('CODE_SIGN_IDENTITY=', 'CODE_SIGNING_REQUIRED=NO', 'CODE_SIGNING_ALLOWED=NO'); + } + args.push('archive'); + return args; +} + +export function xcodeExportArchiveArgs(archivePath: string, exportPath: string, exportOptionsPlist: string): string[] { + return [ + '-exportArchive', + '-archivePath', + archivePath, + '-exportPath', + exportPath, + '-exportOptionsPlist', + exportOptionsPlist, + ]; +} + +function runXcodebuild(args: string[], workDir: string, log?: NativeBuildLogger): void { + logNativeCommand(log, 'xcodebuild', args, workDir); + const streamOutput = Boolean(log); + const result = spawnSync('xcodebuild', args, { + cwd: workDir, + encoding: streamOutput ? undefined : 'utf8', + stdio: streamOutput ? 'inherit' : 'pipe', + maxBuffer: XCODEBUILD_MAX_BUFFER, + }); + if (!streamOutput) logNativeOutput(log, result.stdout, result.stderr); + if (result.error) { + const err = result.error as Error & { code?: string }; + if (err.code === 'ENOENT') { + throw new Error('xcodebuild not found. Run `feather doctor --build-target ios`.'); + } + const output = spawnOutput(result); + throw new Error([`xcodebuild failed to run: ${err.message}`, output].filter(Boolean).join('\n')); + } + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || `xcodebuild failed with exit code ${result.status ?? 'unknown'}`).toString().trim()); + } +} + +function writeGeneratedExportOptions(config: ResolvedBuildConfig, path: string): string { + const release = config.targets.ios?.release ?? {}; + const teamId = release.teamId ?? config.targets.ios?.teamId; + const entries: string[] = [ + plistEntry('method', release.exportMethod ?? 'development'), + plistEntry('signingStyle', release.signingStyle ?? 'automatic'), + ]; + if (teamId) entries.push(plistEntry('teamID', teamId)); + if (release.provisioningProfileSpecifier) { + entries.push( + 'provisioningProfiles', + '', + plistEntry(iosBundleIdentifier(config), release.provisioningProfileSpecifier), + '', + ); + } + const plist = [ + '', + '', + '', + '', + ...entries, + '', + '', + '', + ].join('\n'); + writeFileSync(path, plist); + return path; +} + +function plistEntry(key: string, value: string): string { + return `${escapeXml(key)}\n${escapeXml(value)}`; +} + +function escapeXml(value: string): string { + return value.replace(/[<>&"']/g, (char) => ({ + '<': '<', + '>': '>', + '&': '&', + '"': '"', + "'": ''', + }[char]!)); +} + +export function patchIosProject(projectPath: string, config?: ResolvedBuildConfig, productFile?: string): void { + if (!existsSync(projectPath)) return; + const source = readFileSync(projectPath, 'utf8'); + const buildFileId = 'FEATHERGAMELOVE000000000001'; + const fileRefId = 'FEATHERGAMELOVE000000000002'; + const fileRef = `\t\t${fileRefId} /* game.love */ = {isa = PBXFileReference; lastKnownFileType = file; name = game.love; path = game.love; sourceTree = SOURCE_ROOT; };\n`; + const buildFile = `\t\t${buildFileId} /* game.love in Resources */ = {isa = PBXBuildFile; fileRef = ${fileRefId} /* game.love */; };\n`; + let next = source; + if (config && productFile) { + next = patchIosProjectMetadata(next, config, productFile); + } + + if (next.includes(`${buildFileId} /* game.love in Resources */`)) { + next = next.replace( + new RegExp(`\\t\\t${buildFileId} /\\* game\\.love in Resources \\*/ = \\{[^\\n]+\\};\\n`), + buildFile, + ); + } else if (next.includes('/* Begin PBXBuildFile section */')) { + next = next.replace( + /\/\* Begin PBXBuildFile section \*\/\n/, + `/* Begin PBXBuildFile section */\n${buildFile}`, + ); + } + + if (next.includes(`${fileRefId} /* game.love */`)) { + next = next.replace( + new RegExp(`\\t\\t${fileRefId} /\\* game\\.love \\*/ = \\{[^\\n]+\\};\\n`), + fileRef, + ); + } else if (next.includes('/* Begin PBXFileReference section */')) { + next = next.replace( + /\/\* Begin PBXFileReference section \*\/\n/, + `/* Begin PBXFileReference section */\n${fileRef}`, + ); + } + + const resourcesPhaseId = findIosResourcesPhaseId(next); + if (resourcesPhaseId) { + next = ensureResourceBuildFile(next, resourcesPhaseId, buildFileId); + } + + if (next === source) next = `${source}\n/* Feather: include game.love in the app resources. */\n`; + writeFileSync(projectPath, next); +} + +function patchIosProjectMetadata(project: string, config: ResolvedBuildConfig, productFile: string): string { + const bundleIdentifier = iosBundleIdentifier(config); + return project + .replace(/PRODUCT_BUNDLE_IDENTIFIER = [^;]+;/g, `PRODUCT_BUNDLE_IDENTIFIER = ${bundleIdentifier};`) + .replace(/PRODUCT_NAME = "?love"?;/g, `PRODUCT_NAME = ${productFile};`) + .replace(/productName = "?love"?;/g, `productName = ${productFile};`) + .replace(/path = love\.app;/g, `path = ${productFile}.app;`) + .replace(/MARKETING_VERSION = [^;]+;/g, `MARKETING_VERSION = ${config.version};`); +} + +function findIosResourcesPhaseId(project: string): string | null { + const targetMatch = project.match(/([A-Z0-9]{10,}) \/\* love-ios \*\/ = \{[\s\S]*?buildPhases = \(([\s\S]*?)\);[\s\S]*?name = "?love-ios"?;/); + if (targetMatch) { + const resourcesMatch = targetMatch[2]?.match(/([A-Z0-9]{10,}) \/\* Resources \*\//); + if (resourcesMatch?.[1]) return resourcesMatch[1]; + } + + const phaseWithLaunchScreen = project.match(/([A-Z0-9]{10,}) \/\* Resources \*\/ = \{[\s\S]*?files = \([\s\S]*?Launch Screen\.xib in Resources[\s\S]*?\);/); + if (phaseWithLaunchScreen?.[1]) return phaseWithLaunchScreen[1]; + + const firstResourcesPhase = project.match(/([A-Z0-9]{10,}) \/\* Resources \*\/ = \{[\s\S]*?isa = PBXResourcesBuildPhase;/); + return firstResourcesPhase?.[1] ?? null; +} + +function ensureResourceBuildFile(project: string, resourcesPhaseId: string, buildFileId: string): string { + const phasePattern = new RegExp(`(${resourcesPhaseId} /\\* Resources \\*/ = \\{[\\s\\S]*?files = \\(\\n)([\\s\\S]*?)(\\n\\s*\\);)`); + return project.replace(phasePattern, (match, prefix: string, files: string, suffix: string) => { + if (files.includes(`${buildFileId} /* game.love in Resources */`)) return match; + return `${prefix}\t\t\t\t${buildFileId} /* game.love in Resources */,\n${files}${suffix}`; + }); +} diff --git a/cli/src/lib/build/native.ts b/cli/src/lib/build/native.ts new file mode 100644 index 00000000..50d7db12 --- /dev/null +++ b/cli/src/lib/build/native.ts @@ -0,0 +1,187 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + writeFileSync, + type Dirent, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, sep } from 'node:path'; +import { copyDirectory, removePath } from './files.js'; + +export type NativeBuildLogger = (message: string) => void; + +export type NativeCacheInfo = { + enabled: boolean; + hit: boolean; + path?: string; + key?: string; +}; + +export type NativeCacheOptions = { + enabled: boolean; + target: 'android' | 'ios'; + outDir: string; + keyParts: Record; + requiredPaths?: string[]; + log?: NativeBuildLogger; +}; + +export type NativeWorkspace = { + root: string; + dir: string; + cache: NativeCacheInfo; + cleanup: () => void; +}; + +export function createNativeWorkspace( + prefix: string, + templateDir: string, + dirname: string, + cache?: NativeCacheOptions, +): NativeWorkspace { + if (cache?.enabled) { + const key = nativeCacheKey(templateDir, cache.target, cache.keyParts); + const root = join(cache.outDir, '.feather-cache', cache.target, key); + const dir = join(root, dirname); + const hit = existsSync(dir) && cache.requiredPaths?.every((path) => existsSync(join(dir, path))) !== false; + logNativeStep(cache.log, `Build cache: ${hit ? 'hit' : 'miss'} ${root}`); + if (!hit) { + removePath(root); + mkdirSync(root, { recursive: true }); + copyDirectory(templateDir, dir); + } + return { + root, + dir, + cache: { enabled: true, hit, path: root, key }, + cleanup: () => {}, + }; + } + + const root = mkdtempSync(join(tmpdir(), prefix)); + const dir = join(root, dirname); + copyDirectory(templateDir, dir); + return { + root, + dir, + cache: { enabled: false, hit: false }, + cleanup: () => removePath(root), + }; +} + +function nativeCacheKey(templateDir: string, target: 'android' | 'ios', keyParts: Record): string { + const payload = stableJson({ + schema: 1, + target, + templateDir: realpathSync(templateDir), + templateGitHead: gitHead(templateDir), + keyParts, + }); + return createHash('sha256').update(payload).digest('hex').slice(0, 32); +} + +function gitHead(dir: string): string | null { + const result = spawnSync('git', ['-C', dir, 'rev-parse', 'HEAD'], { encoding: 'utf8' }); + return result.status === 0 ? result.stdout.trim() : null; +} + +function stableJson(value: unknown): string { + return JSON.stringify(stableValue(value)); +} + +function stableValue(value: unknown): unknown { + if (!value || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(stableValue); + return Object.fromEntries( + Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => [key, stableValue(item)]), + ); +} + +export function logNativeStep(log: NativeBuildLogger | undefined, message: string): void { + log?.(message); +} + +export function logNativeCommand( + log: NativeBuildLogger | undefined, + command: string, + args: string[], + cwd: string, +): void { + if (!log) return; + log(`$ ${[command, ...args].map(shellQuote).join(' ')}`); + log(` cwd: ${cwd}`); +} + +export function logNativeOutput( + log: NativeBuildLogger | undefined, + stdout: string | Buffer | null | undefined, + stderr: string | Buffer | null | undefined, +): void { + if (!log) return; + const lines = [ + ...String(stdout ?? '').trim().split(/\r?\n/).filter(Boolean), + ...String(stderr ?? '').trim().split(/\r?\n/).filter(Boolean), + ]; + for (const line of lines) log(` ${line}`); +} + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) return value; + return JSON.stringify(value); +} + +export function resolveWorkspacePath(root: string, path: string, label: string): string { + const absolute = resolve(root, path); + const normalizedRoot = resolve(root); + if (absolute !== normalizedRoot && !absolute.startsWith(`${normalizedRoot}${sep}`)) { + throw new Error(`${label} must stay inside the native build workspace.`); + } + return absolute; +} + +export function findFirstPath(root: string, predicate: (path: string, entry: Dirent) => boolean): string | null { + if (!existsSync(root)) return null; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (predicate(path, entry)) return path; + if (entry.isDirectory()) { + const found = findFirstPath(path, predicate); + if (found) return found; + } + } + return null; +} + +export function patchTextFile(path: string, update: (source: string) => string): void { + if (!existsSync(path)) return; + const source = readFileSync(path, 'utf8'); + const next = update(source); + if (next !== source) writeFileSync(path, next); +} + +export function assignmentValue(match: string, key: string, value: string, quote = true): string { + const separator = match.includes('=') ? ' = ' : ' '; + return `${key}${separator}${quote ? `"${value}"` : value}`; +} + +export function ensureParentDir(path: string): void { + mkdirSync(path, { recursive: true }); +} + +export function escapeXml(value: string): string { + return value.replace(/[<>&"']/g, (char) => ({ + '<': '<', + '>': '>', + '&': '&', + '"': '"', + "'": ''', + }[char]!)); +} diff --git a/cli/src/lib/build/upload-safety.ts b/cli/src/lib/build/upload-safety.ts new file mode 100644 index 00000000..93865b11 --- /dev/null +++ b/cli/src/lib/build/upload-safety.ts @@ -0,0 +1,125 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { extname, join, relative } from 'node:path'; + +export type UploadSafetyStatus = 'safe' | 'unsafe' | 'unknown'; + +export type UploadSafetyResult = { + status: UploadSafetyStatus; + artifact: string; + detectedFiles: string[]; + reason?: string; +}; + +const FEATHER_PATTERNS = [ + /^feather(?:\/|$)/, + /^plugins(?:\/|$)/, + /^\.feather-main\.lua$/, + /^feather\.config\.lua$/, + /^feather\.debugger\.lua$/, + /^feather-build-manifest\.json$/, +]; + +function isFeatherPath(path: string): boolean { + const normalized = path.replace(/\\/g, '/').replace(/^\/+/, ''); + return FEATHER_PATTERNS.some((pattern) => pattern.test(normalized)); +} + +function inspectNames(artifact: string, names: string[]): UploadSafetyResult { + const detectedFiles = names.filter(isFeatherPath).sort((a, b) => a.localeCompare(b)); + return { + status: detectedFiles.length > 0 ? 'unsafe' : 'safe', + artifact, + detectedFiles, + }; +} + +function listDirectoryNames(root: string): string[] { + const names: string[] = []; + const visit = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const abs = join(dir, entry.name); + const rel = relative(root, abs).replace(/\\/g, '/'); + names.push(entry.isDirectory() ? `${rel}/` : rel); + if (entry.isDirectory()) visit(abs); + } + }; + visit(root); + return names; +} + +function zipEntryNames(zip: Buffer): string[] { + const names: string[] = []; + let offset = centralDirectoryOffset(zip); + while (offset + 46 <= zip.length) { + const signature = zip.readUInt32LE(offset); + if (signature !== 0x02014b50) break; + const compressedSize = zip.readUInt32LE(offset + 20); + const uncompressedSize = zip.readUInt32LE(offset + 24); + const nameLength = zip.readUInt16LE(offset + 28); + const extraLength = zip.readUInt16LE(offset + 30); + const commentLength = zip.readUInt16LE(offset + 32); + const localHeaderOffset = zip.readUInt32LE(offset + 42); + if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localHeaderOffset === 0xffffffff) { + throw new Error('ZIP64 archives are not supported for upload safety inspection.'); + } + const nameStart = offset + 46; + names.push(zip.subarray(nameStart, nameStart + nameLength).toString('utf8')); + offset = nameStart + nameLength + extraLength + commentLength; + } + return names; +} + +function centralDirectoryOffset(zip: Buffer): number { + const minOffset = Math.max(0, zip.length - 65557); + for (let offset = zip.length - 22; offset >= minOffset; offset -= 1) { + if (zip.readUInt32LE(offset) === 0x06054b50) { + return zip.readUInt32LE(offset + 16); + } + } + throw new Error('central directory not found'); +} + +export function inspectUploadArtifact(artifact: string): UploadSafetyResult { + if (!existsSync(artifact)) { + return { + status: 'unknown', + artifact, + detectedFiles: [], + reason: 'artifact does not exist', + }; + } + + const stat = statSync(artifact); + if (stat.isDirectory()) { + return inspectNames(artifact, listDirectoryNames(artifact)); + } + + const ext = extname(artifact).toLowerCase(); + if (ext === '.love' || ext === '.zip') { + try { + return inspectNames(artifact, zipEntryNames(readFileSync(artifact))); + } catch (error) { + return { + status: 'unknown', + artifact, + detectedFiles: [], + reason: `could not inspect ZIP archive: ${(error as Error).message}`, + }; + } + } + + return { + status: 'unknown', + artifact, + detectedFiles: [], + reason: `artifact type "${ext || 'unknown'}" cannot be safely inspected`, + }; +} + +export function uploadSafetyWarning(safety: UploadSafetyResult): string | null { + if (safety.status === 'safe') return null; + if (safety.status === 'unsafe') { + return `Feather runtime/debugging files were detected in ${safety.artifact}.`; + } + return `Feather could not verify whether ${safety.artifact} contains runtime/debugging files.`; +} diff --git a/cli/src/lib/build/upload.ts b/cli/src/lib/build/upload.ts new file mode 100644 index 00000000..3de6ab57 --- /dev/null +++ b/cli/src/lib/build/upload.ts @@ -0,0 +1,104 @@ +import { existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { loadBuildConfig, type LoadBuildConfigOptions, type UploadTarget } from './config.js'; +import { artifactForTarget, readLatestManifest, resolveArtifactPath } from './files.js'; +import { inspectUploadArtifact, uploadSafetyWarning, type UploadSafetyResult } from './upload-safety.js'; + +export type UploadOptions = LoadBuildConfigOptions & { + target: UploadTarget; + buildTarget?: string; + buildDir?: string; + project?: string; + channel?: string; + userVersion?: string; + dryRun?: boolean; + ifChanged?: boolean; + hidden?: boolean; + allowFeatherRuntime?: boolean; +}; + +export type UploadResult = { + ok: true; + dryRun: boolean; + target: UploadTarget; + buildTarget: string; + artifact: string; + project: string; + channel: string; + userVersion: string; + command: string[]; + safety: UploadSafetyResult; + warning?: string; +} | { + ok: false; + error: string; + safety?: UploadSafetyResult; +}; + +export function runUpload(options: UploadOptions): UploadResult { + try { + if (options.target === 'steam') { + return { ok: false, error: 'Upload target "steam" is planned but not supported yet.' }; + } + + const config = loadBuildConfig(options); + const outDir = options.buildDir ? resolve(config.projectDir, options.buildDir) : config.outDir; + const manifest = readLatestManifest(outDir); + if (!manifest) throw new Error(`No build manifest found in ${outDir}. Run \`feather build \` first.`); + const buildTarget = options.buildTarget ?? manifest.target; + const artifact = artifactForTarget(manifest, buildTarget); + if (!artifact) throw new Error(`No artifact found for ${buildTarget}. Run \`feather build ${buildTarget}\` first.`); + if (!existsSync(artifact.path)) throw new Error(`Build artifact is missing: ${artifact.path}`); + + const itch = config.upload.itch ?? {}; + const project = options.project ?? itch.project; + if (!project) throw new Error('Itch upload requires upload.itch.project in feather.build.json.'); + const channel = options.channel ?? itch.channels?.[buildTarget] ?? buildTarget; + const userVersion = options.userVersion ?? config.version; + const safety = inspectUploadArtifact(resolveArtifactPath(artifact.path)); + const warning = uploadSafetyWarning(safety) ?? undefined; + if (!options.dryRun && safety.status !== 'safe' && !options.allowFeatherRuntime) { + return { + ok: false, + error: + safety.status === 'unsafe' + ? 'Upload blocked because Feather runtime/debugging files were detected. Pass --allow-feather-runtime only for intentional internal builds.' + : 'Upload blocked because Feather could not inspect the artifact for runtime/debugging files. Pass --allow-feather-runtime only if you have verified the artifact.', + safety, + }; + } + const command = [ + 'butler', + 'push', + resolveArtifactPath(artifact.path), + `${project}:${channel}`, + '--userversion', + userVersion, + ...(options.ifChanged ? ['--if-changed'] : []), + ...(options.hidden ? ['--hidden'] : []), + ]; + + if (!options.dryRun) { + const result = spawnSync(command[0]!, command.slice(1), { encoding: 'utf8', stdio: 'pipe' }); + if (result.error) throw new Error('butler not found. Run `feather doctor --upload-target itch`.'); + if (result.status !== 0) throw new Error((result.stderr || result.stdout || 'butler push failed').trim()); + } + + return { + ok: true, + dryRun: Boolean(options.dryRun), + target: 'itch', + buildTarget, + artifact: resolveArtifactPath(artifact.path), + project, + channel, + userVersion, + command, + safety, + warning, + }; + } catch (err) { + return { ok: false, error: (err as Error).message }; + } +} diff --git a/cli/src/lib/build/validation.ts b/cli/src/lib/build/validation.ts new file mode 100644 index 00000000..2ee9ccbe --- /dev/null +++ b/cli/src/lib/build/validation.ts @@ -0,0 +1,281 @@ +import { resolve } from 'node:path'; +import { assertSafeRelativePath } from '../path-safety.js'; +import { buildSlug } from './files.js'; +import type { BuildTarget, ResolvedBuildConfig } from './config.js'; + +const ANDROID_PRODUCT_ID_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/; +const IOS_BUNDLE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9-]*(\.[A-Za-z0-9][A-Za-z0-9-]*)+$/; +const GRADLE_TASK_RE = /^[A-Za-z0-9:_-]+$/; +const XCODE_VALUE_RE = /^[A-Za-z0-9_. -]+$/; +const TEAM_ID_RE = /^[A-Za-z0-9]+$/; +const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const IOS_EXPORT_METHODS = new Set([ + 'app-store', + 'app-store-connect', + 'ad-hoc', + 'enterprise', + 'development', + 'developer-id', + 'mac-application', + 'release-testing', + 'debugging', +]); +const IOS_SIGNING_STYLES = new Set(['automatic', 'manual']); +const ANDROID_ORIENTATIONS = new Set([ + 'unspecified', + 'landscape', + 'portrait', + 'user', + 'behind', + 'sensor', + 'nosensor', + 'sensorLandscape', + 'sensorPortrait', + 'reverseLandscape', + 'reversePortrait', + 'fullSensor', + 'userLandscape', + 'userPortrait', + 'fullUser', + 'locked', +]); + +export type BuildValidationIssue = { + target: 'android' | 'ios'; + field: string; + message: string; +}; + +export function validateBuildConfigForTarget(config: ResolvedBuildConfig, target: BuildTarget, release = false): BuildValidationIssue[] { + if (target === 'android') return validateAndroidBuildConfig(config, release); + if (target === 'ios') return validateIosBuildConfig(config, release); + return []; +} + +export function assertBuildConfigValidForTarget(config: ResolvedBuildConfig, target: BuildTarget, release = false): void { + const issues = validateBuildConfigForTarget(config, target, release); + if (issues.length === 0) return; + throw new Error([ + `Invalid ${target} build config.`, + ...issues.map((issue) => `${issue.field}: ${issue.message}`), + ].join('\n')); +} + +export function validateAndroidBuildConfig(config: ResolvedBuildConfig, release = false): BuildValidationIssue[] { + const android = config.targets.android ?? {}; + const releaseConfig = android.release ?? {}; + const issues: BuildValidationIssue[] = []; + const productId = android.productId ?? config.productId ?? defaultProductId(config, 'android'); + if (!ANDROID_PRODUCT_ID_RE.test(productId)) { + issues.push({ + target: 'android', + field: 'productId', + message: 'Use a reverse-DNS Android application id, for example com.example.game.', + }); + } + if (android.versionCode !== undefined && (!Number.isInteger(android.versionCode) || android.versionCode < 1)) { + issues.push({ + target: 'android', + field: 'targets.android.versionCode', + message: 'Use a positive integer.', + }); + } + if (android.versionName !== undefined && !isNonEmptySingleLine(android.versionName)) { + issues.push({ + target: 'android', + field: 'targets.android.versionName', + message: 'Use a non-empty single-line version string.', + }); + } + if (android.orientation !== undefined && !ANDROID_ORIENTATIONS.has(android.orientation)) { + issues.push({ + target: 'android', + field: 'targets.android.orientation', + message: `Use one of: ${[...ANDROID_ORIENTATIONS].join(', ')}.`, + }); + } + if (android.gradleTask !== undefined && (!isNonEmptySingleLine(android.gradleTask) || !GRADLE_TASK_RE.test(android.gradleTask))) { + issues.push({ + target: 'android', + field: 'targets.android.gradleTask', + message: 'Use a Gradle task name containing only letters, numbers, colon, underscore, or dash.', + }); + } + if (android.artifactPath !== undefined) { + validateRelativePathish(android.artifactPath, 'targets.android.artifactPath', 'android', issues); + } + if (release) { + for (const [field, value] of [ + ['targets.android.release.bundleTask', releaseConfig.bundleTask], + ['targets.android.release.apkTask', releaseConfig.apkTask], + ] as const) { + if (value !== undefined && (!isNonEmptySingleLine(value) || !GRADLE_TASK_RE.test(value))) { + issues.push({ + target: 'android', + field, + message: 'Use a Gradle task name containing only letters, numbers, colon, underscore, or dash.', + }); + } + } + for (const [field, value] of [ + ['targets.android.release.bundleArtifactPath', releaseConfig.bundleArtifactPath], + ['targets.android.release.apkArtifactPath', releaseConfig.apkArtifactPath], + ['targets.android.release.keystorePath', releaseConfig.keystorePath], + ] as const) { + if (value !== undefined) validateRelativePathish(value, field, 'android', issues); + } + const signingFields = [ + releaseConfig.keystorePath, + releaseConfig.keyAlias, + releaseConfig.storePasswordEnv, + releaseConfig.keyPasswordEnv, + ]; + if (signingFields.some((value) => value !== undefined)) { + for (const [field, value] of [ + ['targets.android.release.keystorePath', releaseConfig.keystorePath], + ['targets.android.release.keyAlias', releaseConfig.keyAlias], + ['targets.android.release.storePasswordEnv', releaseConfig.storePasswordEnv], + ['targets.android.release.keyPasswordEnv', releaseConfig.keyPasswordEnv], + ] as const) { + if (value === undefined || !isNonEmptySingleLine(value)) { + issues.push({ target: 'android', field, message: 'Required when Android release signing is configured.' }); + } + } + for (const [field, value] of [ + ['targets.android.release.storePasswordEnv', releaseConfig.storePasswordEnv], + ['targets.android.release.keyPasswordEnv', releaseConfig.keyPasswordEnv], + ] as const) { + if (value !== undefined && !ENV_NAME_RE.test(value)) { + issues.push({ target: 'android', field, message: 'Use a valid environment variable name.' }); + } + } + } + } + return issues; +} + +export function validateIosBuildConfig(config: ResolvedBuildConfig, release = false): BuildValidationIssue[] { + const ios = config.targets.ios ?? {}; + const releaseConfig = ios.release ?? {}; + const issues: BuildValidationIssue[] = []; + const bundleId = ios.bundleIdentifier ?? ios.productId ?? config.productId ?? defaultProductId(config, 'ios'); + if (!IOS_BUNDLE_ID_RE.test(bundleId)) { + issues.push({ + target: 'ios', + field: 'bundleIdentifier', + message: 'Use a reverse-DNS iOS bundle id, for example com.example.game.', + }); + } + for (const [field, value] of [ + ['targets.ios.scheme', ios.scheme], + ['targets.ios.configuration', ios.configuration], + ['targets.ios.sdk', ios.sdk], + ['targets.ios.simulatorArch', ios.simulatorArch], + ['targets.ios.deploymentTarget', ios.deploymentTarget], + ] as const) { + if (value !== undefined && (!isNonEmptySingleLine(value) || !XCODE_VALUE_RE.test(value))) { + issues.push({ + target: 'ios', + field, + message: 'Use a non-empty Xcode value without shell metacharacters.', + }); + } + } + if (ios.teamId !== undefined && (!isNonEmptySingleLine(ios.teamId) || !TEAM_ID_RE.test(ios.teamId))) { + issues.push({ + target: 'ios', + field: 'targets.ios.teamId', + message: 'Use an Apple team id containing only letters and numbers.', + }); + } + if (ios.derivedDataPath !== undefined) { + try { + assertSafeRelativePath(ios.derivedDataPath, 'targets.ios.derivedDataPath'); + } catch (err) { + issues.push({ + target: 'ios', + field: 'targets.ios.derivedDataPath', + message: (err as Error).message, + }); + } + } + if (release) { + for (const [field, value] of [ + ['targets.ios.release.archivePath', releaseConfig.archivePath], + ['targets.ios.release.exportPath', releaseConfig.exportPath], + ['targets.ios.release.exportOptionsPlist', releaseConfig.exportOptionsPlist], + ] as const) { + if (value !== undefined) validateRelativePathish(value, field, 'ios', issues); + } + for (const [field, value] of [ + ['targets.ios.release.configuration', releaseConfig.configuration], + ['targets.ios.release.sdk', releaseConfig.sdk], + ['targets.ios.release.provisioningProfileSpecifier', releaseConfig.provisioningProfileSpecifier], + ] as const) { + if (value !== undefined && (!isNonEmptySingleLine(value) || !XCODE_VALUE_RE.test(value))) { + issues.push({ target: 'ios', field, message: 'Use a non-empty Xcode value without shell metacharacters.' }); + } + } + if (releaseConfig.teamId !== undefined && (!isNonEmptySingleLine(releaseConfig.teamId) || !TEAM_ID_RE.test(releaseConfig.teamId))) { + issues.push({ + target: 'ios', + field: 'targets.ios.release.teamId', + message: 'Use an Apple team id containing only letters and numbers.', + }); + } + if (releaseConfig.exportMethod !== undefined && !IOS_EXPORT_METHODS.has(releaseConfig.exportMethod)) { + issues.push({ + target: 'ios', + field: 'targets.ios.release.exportMethod', + message: `Use one of: ${[...IOS_EXPORT_METHODS].join(', ')}.`, + }); + } + if (releaseConfig.signingStyle !== undefined && !IOS_SIGNING_STYLES.has(releaseConfig.signingStyle)) { + issues.push({ + target: 'ios', + field: 'targets.ios.release.signingStyle', + message: 'Use automatic or manual.', + }); + } + } + return issues; +} + +export function defaultProductId(config: ResolvedBuildConfig, target: 'android' | 'ios'): string { + const slug = buildSlug(config.name) + .replace(/[^a-z0-9]+/g, '.') + .replace(/^\.+|\.+$/g, '') + || 'game'; + return `org.feather.${slug}.${target}`; +} + +export function androidProductId(config: ResolvedBuildConfig): string { + return config.targets.android?.productId ?? config.productId ?? defaultProductId(config, 'android'); +} + +export function iosBundleIdentifier(config: ResolvedBuildConfig): string { + return config.targets.ios?.bundleIdentifier + ?? config.targets.ios?.productId + ?? config.productId + ?? defaultProductId(config, 'ios'); +} + +function isNonEmptySingleLine(value: string): boolean { + return value.trim().length > 0 && !/[\r\n\0]/.test(value); +} + +function validateRelativePathish( + value: string, + field: string, + target: 'android' | 'ios', + issues: BuildValidationIssue[], +): void { + if (!isNonEmptySingleLine(value)) { + issues.push({ target, field, message: 'Use a non-empty path.' }); + return; + } + const resolved = resolve('/', value); + if (resolved === '/') { + issues.push({ target, field, message: 'Use a file path, not a directory root.' }); + } +} diff --git a/cli/src/lib/build/vendor.ts b/cli/src/lib/build/vendor.ts new file mode 100644 index 00000000..e746f8ba --- /dev/null +++ b/cli/src/lib/build/vendor.ts @@ -0,0 +1,625 @@ +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { inflateRawSync } from 'node:zlib'; +import { dirname, join, relative, resolve } from 'node:path'; +import { + buildConfigPath, + loadBuildConfig, + readBuildConfig, + type FeatherBuildConfig, +} from './config.js'; +import { removePath } from './files.js'; +import { assertNoSymlinkEscape, assertSafeRelativePath, isPathInside } from '../path-safety.js'; + +export const buildVendorTargets = ['web', 'android', 'ios', 'mobile', 'desktop', 'windows', 'macos', 'linux', 'steamos', 'all'] as const; +export type BuildVendorTargetInput = typeof buildVendorTargets[number]; +export type ConcreteBuildVendorTarget = 'web' | 'android' | 'ios' | 'windows' | 'macos' | 'linux' | 'steamos'; + +export type BuildVendorAddOptions = { + projectDir?: string; + configPath?: string; + vendorDir?: string; + ref?: string; + webRef?: string; + androidRef?: string; + iosRef?: string; + force?: boolean; + dryRun?: boolean; + updateConfig?: boolean; +}; + +export type BuildVendorListOptions = { + projectDir?: string; + configPath?: string; + vendorDir?: string; +}; + +export type BuildVendorResult = { + target: ConcreteBuildVendorTarget; + path: string; + relativePath: string; + ref: string; + repo: string; + installed: boolean; + skipped: boolean; + configUpdated: boolean; + actions: string[]; +}; + +export type BuildVendorAddResult = { + ok: true; + projectDir: string; + configPath: string; + loveVersion: string; + vendors: BuildVendorResult[]; +}; + +export type BuildVendorListEntry = { + target: ConcreteBuildVendorTarget; + path: string; + relativePath: string; + configuredPath?: string; + configured: boolean; + exists: boolean; + valid: boolean; + detail: string; +}; + +export type BuildVendorListResult = { + ok: true; + projectDir: string; + configPath: string; + vendors: BuildVendorListEntry[]; +}; + +const DEFAULT_LOVE_VERSION = '11.5'; +const DEFAULT_LOVE_JS_REF = 'main'; +const LOVE_JS_REPO = 'https://github.com/2dengine/love.js'; +const LOVE_ANDROID_REPO = 'https://github.com/love2d/love-android'; +const LOVE_REPO = 'https://github.com/love2d/love'; +const LOVE_RELEASE_BASE = 'https://github.com/love2d/love/releases/download'; +const APPIMAGETOOL_URL = 'https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage'; + +export function isBuildVendorTarget(value: string): value is BuildVendorTargetInput { + return (buildVendorTargets as readonly string[]).includes(value); +} + +export async function addBuildVendors(targets: BuildVendorTargetInput[], options: BuildVendorAddOptions = {}): Promise { + const projectDir = resolve(options.projectDir ?? process.cwd()); + let raw = readBuildConfig(projectDir, options.configPath); + const configPath = buildConfigPath(projectDir, options.configPath); + const config = loadBuildConfig({ projectDir, configPath: options.configPath }); + const loveVersion = sanitizeRef(options.ref ?? config.loveVersion ?? DEFAULT_LOVE_VERSION, 'LÖVE version'); + const vendorDir = resolveVendorDir(projectDir, options.vendorDir ?? 'vendor'); + const expanded = expandVendorTargets(targets); + const results: BuildVendorResult[] = []; + + for (const target of expanded) { + const result = await addSingleVendor({ + target, + projectDir, + raw, + configPath, + vendorDir, + loveVersion, + ref: vendorRef(target, options, config.loveVersion), + force: Boolean(options.force), + dryRun: Boolean(options.dryRun), + updateConfig: options.updateConfig !== false, + }); + results.push(result); + if (result.configUpdated) { + raw = readBuildConfig(projectDir, options.configPath); + } + } + + return { + ok: true, + projectDir, + configPath, + loveVersion, + vendors: results, + }; +} + +export function listBuildVendors(options: BuildVendorListOptions = {}): BuildVendorListResult { + const projectDir = resolve(options.projectDir ?? process.cwd()); + const raw = readBuildConfig(projectDir, options.configPath); + const configPath = buildConfigPath(projectDir, options.configPath); + const vendorDir = resolveVendorDir(projectDir, options.vendorDir ?? 'vendor'); + const vendors = (['web', 'android', 'ios', 'windows', 'macos', 'linux', 'steamos'] as const).map((target) => vendorStatus(projectDir, raw, vendorDir, target)); + return { ok: true, projectDir, configPath, vendors }; +} + +function expandVendorTargets(targets: BuildVendorTargetInput[]): ConcreteBuildVendorTarget[] { + const requested: BuildVendorTargetInput[] = targets.length > 0 ? targets : ['mobile']; + const expanded = new Set(); + for (const target of requested) { + if (target === 'mobile' || target === 'all') { + expanded.add('android'); + expanded.add('ios'); + if (target === 'all') expanded.add('web'); + if (target === 'all') { + expanded.add('windows'); + expanded.add('macos'); + expanded.add('linux'); + } + } else if (target === 'desktop') { + expanded.add('windows'); + expanded.add('macos'); + expanded.add('linux'); + } else { + expanded.add(target); + } + } + return [...expanded]; +} + +type AddSingleVendorInput = { + target: ConcreteBuildVendorTarget; + projectDir: string; + raw: FeatherBuildConfig; + configPath: string; + vendorDir: string; + loveVersion: string; + ref: string; + force: boolean; + dryRun: boolean; + updateConfig: boolean; +}; + +async function addSingleVendor(input: AddSingleVendorInput): Promise { + const defaultRelativePath = defaultVendorRelativePath(input.projectDir, input.vendorDir, input.target); + const configuredPath = configuredVendorPath(input.raw, input.target); + if (configuredPath && configuredPath !== defaultRelativePath && !input.force) { + throw new Error(`${input.target} vendor is already configured at ${configuredPath}. Use --force to replace it with ${defaultRelativePath}.`); + } + + const targetPath = resolveProjectVendorPath(input.projectDir, configuredPath && !input.force ? configuredPath : defaultRelativePath, `${input.target} vendor directory`); + const relativePath = relativeProjectPath(input.projectDir, targetPath); + const repo = vendorRepo(input.target, input.loveVersion); + const actions: string[] = []; + const canReuseSteamosRuntime = input.target === 'steamos' + && existsSync(targetPath) + && vendorPathValid(targetPath, 'steamos') + && !input.force; + + if (existsSync(targetPath) && !input.force && !canReuseSteamosRuntime) { + throw new Error(`${input.target} vendor directory already exists: ${targetPath}. Use --force to replace it.`); + } + + if (canReuseSteamosRuntime) { + actions.push(`reuse ${relativePath} for steamos`); + } else { + actions.push(`${isDownloadedRuntimeTarget(input.target) ? 'download' : 'clone'} ${repo}#${input.ref} -> ${relativePath}`); + } + if (input.target === 'ios') { + actions.push(`install love-${input.loveVersion}-apple-libraries.zip`); + } else if ((input.target === 'linux' || input.target === 'steamos') && !canReuseSteamosRuntime) { + actions.push('install appimagetool.AppImage'); + } + if (input.updateConfig) { + actions.push(`update ${relative(input.projectDir, input.configPath) || 'feather.build.json'}`); + } + + if (!input.dryRun) { + if (canReuseSteamosRuntime) { + if (input.updateConfig) { + updateVendorConfig(input.projectDir, input.configPath, input.raw, input.target, relativePath); + } + } else { + if (!isDownloadedRuntimeTarget(input.target)) assertGitAvailable(); + const shouldCleanupOnFailure = input.force || !existsSync(targetPath); + try { + if (existsSync(targetPath) && input.force) removePath(targetPath); + mkdirSync(dirname(targetPath), { recursive: true }); + if (isDownloadedRuntimeTarget(input.target)) { + await installDesktopRuntime(input.target, input.loveVersion, targetPath); + } else { + cloneVendor(repo, input.ref, targetPath, input.target === 'android'); + } + if (input.target === 'ios') { + await installAppleLibraries(input.loveVersion, targetPath); + } + if (input.updateConfig) { + updateVendorConfig(input.projectDir, input.configPath, input.raw, input.target, relativePath); + } + } catch (err) { + if (shouldCleanupOnFailure) removePath(targetPath); + throw err; + } + } + } + + return { + target: input.target, + path: targetPath, + relativePath, + ref: input.ref, + repo, + installed: !input.dryRun && !canReuseSteamosRuntime, + skipped: canReuseSteamosRuntime, + configUpdated: input.updateConfig && !input.dryRun, + actions, + }; +} + +function vendorStatus(projectDir: string, raw: FeatherBuildConfig, vendorDir: string, target: ConcreteBuildVendorTarget): BuildVendorListEntry { + const configuredPath = configuredVendorPath(raw, target); + const fallback = join(projectDir, defaultVendorRelativePath(projectDir, vendorDir, target)); + const path = configuredPath + ? resolveProjectVendorPath(projectDir, configuredPath, `${target} vendor directory`) + : fallback; + const exists = existsSync(path); + const valid = vendorPathValid(path, target); + return { + target, + path, + relativePath: relativeProjectPath(projectDir, path), + configuredPath, + configured: Boolean(configuredPath), + exists, + valid, + detail: valid ? 'ready' : exists ? 'present but missing expected build files' : 'missing', + }; +} + +function vendorRef(target: ConcreteBuildVendorTarget, options: BuildVendorAddOptions, loveVersion: string | undefined): string { + if (target === 'web') { + return sanitizeRef(options.webRef ?? options.ref ?? DEFAULT_LOVE_JS_REF, 'love.js vendor ref'); + } + if (target === 'android') { + return sanitizeRef(options.androidRef ?? options.ref ?? loveVersion ?? DEFAULT_LOVE_VERSION, 'Android vendor ref'); + } + if (target === 'ios') { + return sanitizeRef(options.iosRef ?? options.ref ?? loveVersion ?? DEFAULT_LOVE_VERSION, 'iOS vendor ref'); + } + return sanitizeRef(options.ref ?? loveVersion ?? DEFAULT_LOVE_VERSION, `${target} runtime version`); +} + +function defaultVendorRelativePath(projectDir: string, vendorDir: string, target: ConcreteBuildVendorTarget): string { + const dirname = target === 'web' + ? 'love.js' + : target === 'android' + ? 'love-android' + : target === 'ios' + ? 'love-ios' + : target === 'steamos' + ? 'love-linux' + : `love-${target}`; + return relativeProjectPath(projectDir, join(vendorDir, dirname)); +} + +function configuredVendorPath(raw: FeatherBuildConfig, target: ConcreteBuildVendorTarget): string | undefined { + if (target === 'web') return raw.targets?.web?.loveJsDir; + if (target === 'android') return raw.targets?.android?.loveAndroidDir; + if (target === 'ios') return raw.targets?.ios?.loveIosDir; + if (target === 'steamos') return raw.targets?.steamos?.loveRuntimeDir ?? raw.targets?.linux?.loveRuntimeDir; + return raw.targets?.[target]?.loveRuntimeDir; +} + +function vendorRepo(target: ConcreteBuildVendorTarget, loveVersion: string): string { + if (target === 'web') return LOVE_JS_REPO; + if (target === 'android') return LOVE_ANDROID_REPO; + if (target === 'windows') return `${LOVE_RELEASE_BASE}/${loveVersion}/love-${loveVersion}-win64.zip`; + if (target === 'macos') return `${LOVE_RELEASE_BASE}/${loveVersion}/love-${loveVersion}-macos.zip`; + if (target === 'linux' || target === 'steamos') return `${LOVE_RELEASE_BASE}/${loveVersion}/love-${loveVersion}-x86_64.AppImage`; + return LOVE_REPO; +} + +function vendorPathValid(path: string, target: ConcreteBuildVendorTarget): boolean { + if (target === 'web') { + return existsSync(join(path, 'index.html')) && (existsSync(join(path, 'player.js')) || existsSync(join(path, 'player.min.js'))); + } + if (target === 'android') { + return existsSync(join(path, process.platform === 'win32' ? 'gradlew.bat' : 'gradlew')) || existsSync(join(path, 'gradlew')) || existsSync(join(path, 'gradlew.bat')); + } + if (target === 'ios') return existsSync(join(path, 'platform', 'xcode', 'love.xcodeproj')); + if (target === 'windows') return existsSync(join(path, 'love.exe')); + if (target === 'macos') return existsSync(join(path, 'love.app', 'Contents', 'Info.plist')); + return existsSync(join(path, 'squashfs-root', 'bin', 'love')) && existsSync(join(path, 'appimagetool.AppImage')); +} + +function resolveVendorDir(projectDir: string, vendorDir: string): string { + assertSafeRelativePath(vendorDir, 'Vendor directory'); + const absolute = resolve(projectDir, vendorDir); + assertNoSymlinkEscape(projectDir, absolute, 'Vendor directory'); + return absolute; +} + +function resolveProjectVendorPath(projectDir: string, path: string, label: string): string { + assertSafeRelativePath(path, label); + const absolute = resolve(projectDir, path); + assertNoSymlinkEscape(projectDir, absolute, label); + return absolute; +} + +function relativeProjectPath(projectDir: string, path: string): string { + const relativePath = relative(projectDir, path).replace(/\\/g, '/'); + if (!relativePath || relativePath.startsWith('..') || !isPathInside(projectDir, path)) { + throw new Error(`Vendor path must stay inside project root: ${path}`); + } + return relativePath; +} + +function sanitizeRef(value: string, label: string): string { + const trimmed = value.trim(); + if (!trimmed || /[\0\r\n]/.test(trimmed) || trimmed.startsWith('-')) { + throw new Error(`${label} must be a non-empty branch, tag, or version.`); + } + return trimmed; +} + +function assertGitAvailable(): void { + const result = spawnSync('git', ['--version'], { encoding: 'utf8' }); + if (result.error || result.status !== 0) { + throw new Error('git is required to fetch build vendors. Install git and make sure it is on PATH.'); + } +} + +function cloneVendor(repo: string, ref: string, targetPath: string, recurseSubmodules: boolean): void { + const args = ['clone']; + if (recurseSubmodules) args.push('--recurse-submodules'); + args.push('--depth', '1', '--branch', ref, repo, targetPath); + const result = spawnSync('git', args, { encoding: 'utf8' }); + if (result.error) throw new Error(`git clone failed to start: ${result.error.message}`); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || `git clone failed for ${repo}`).trim()); + } +} + +function isDownloadedRuntimeTarget(target: ConcreteBuildVendorTarget): target is 'windows' | 'macos' | 'linux' | 'steamos' { + return target === 'windows' || target === 'macos' || target === 'linux' || target === 'steamos'; +} + +async function installDesktopRuntime(target: 'windows' | 'macos' | 'linux' | 'steamos', loveVersion: string, targetPath: string): Promise { + removePath(targetPath); + mkdirSync(targetPath, { recursive: true }); + if (target === 'windows') { + const zip = await downloadRuntimeArchive(target, loveVersion); + extractZip(zip, targetPath, { stripRoot: true }); + return; + } + if (target === 'macos') { + const zip = await downloadRuntimeArchive(target, loveVersion); + extractZip(zip, targetPath, { stripRoot: false }); + const loveBinary = join(targetPath, 'love.app', 'Contents', 'MacOS', 'love'); + if (existsSync(loveBinary)) chmodSync(loveBinary, 0o755); + return; + } + + const loveAppImage = join(targetPath, 'love.AppImage'); + const appImageTool = join(targetPath, 'appimagetool.AppImage'); + await downloadRuntimeFile(runtimeUrl('linux', loveVersion), loveAppImage, 'FEATHER_TEST_LOVE_LINUX_APPIMAGE'); + await downloadRuntimeFile(APPIMAGETOOL_URL, appImageTool, 'FEATHER_TEST_APPIMAGETOOL'); + chmodSync(loveAppImage, 0o755); + chmodSync(appImageTool, 0o755); + const result = spawnSync(loveAppImage, ['--appimage-extract'], { cwd: targetPath, encoding: 'utf8' }); + if (result.error) { + throw new Error(`Failed to extract LÖVE AppImage: ${result.error.message}`); + } + if (result.status !== 0 || !existsSync(join(targetPath, 'squashfs-root', 'bin', 'love'))) { + throw new Error((result.stderr || result.stdout || 'Failed to extract LÖVE AppImage.').trim()); + } +} + +async function downloadRuntimeArchive(target: 'windows' | 'macos', loveVersion: string): Promise { + const fixture = process.env[target === 'windows' ? 'FEATHER_TEST_LOVE_WINDOWS_ZIP' : 'FEATHER_TEST_LOVE_MACOS_ZIP']; + if (fixture) return readFileSync(fixture); + const response = await fetch(runtimeUrl(target, loveVersion)); + if (!response.ok) { + throw new Error(`Failed to download ${runtimeUrl(target, loveVersion)}: HTTP ${response.status}`); + } + return Buffer.from(await response.arrayBuffer()); +} + +async function downloadRuntimeFile(url: string, path: string, fixtureEnv: string): Promise { + const fixture = process.env[fixtureEnv]; + mkdirSync(dirname(path), { recursive: true }); + if (fixture) { + cpSync(fixture, path, { force: true }); + return; + } + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download ${url}: HTTP ${response.status}`); + } + writeFileSync(path, Buffer.from(await response.arrayBuffer())); +} + +function runtimeUrl(target: 'windows' | 'macos' | 'linux', loveVersion: string): string { + if (target === 'windows') return `${LOVE_RELEASE_BASE}/${encodeURIComponent(loveVersion)}/love-${encodeURIComponent(loveVersion)}-win64.zip`; + if (target === 'macos') return `${LOVE_RELEASE_BASE}/${encodeURIComponent(loveVersion)}/love-${encodeURIComponent(loveVersion)}-macos.zip`; + return `${LOVE_RELEASE_BASE}/${encodeURIComponent(loveVersion)}/love-${encodeURIComponent(loveVersion)}-x86_64.AppImage`; +} + +function extractZip(zip: Buffer, targetPath: string, options: { stripRoot: boolean }): void { + const entries = unzip(zip); + const fileEntries: Array = entries + .map((entry) => ({ ...entry, normalized: normalizeZipEntry(entry.name) })) + .filter((entry): entry is UnzippedEntry & { normalized: string } => typeof entry.normalized === 'string' && entry.normalized.length > 0); + const root = options.stripRoot ? commonRoot(fileEntries.map((entry) => entry.normalized)) : null; + for (const entry of fileEntries) { + const name = root ? entry.normalized.slice(root.length + 1) : entry.normalized; + if (!name || name.endsWith('/')) continue; + const destination = resolve(targetPath, name); + if (!isPathInside(targetPath, destination)) { + throw new Error(`Runtime ZIP contains an unsafe path: ${entry.name}`); + } + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(destination, entry.data); + } +} + +function normalizeZipEntry(name: string): string | null { + const normalized = name.replace(/\\/g, '/').replace(/^\/+/, ''); + if (!normalized || normalized.startsWith('__MACOSX/') || normalized.includes('/.__MACOSX/')) return null; + if (normalized.startsWith('._') || normalized.includes('/._')) return null; + return normalized; +} + +function commonRoot(names: string[]): string | null { + const roots = new Set(names.map((name) => name.split('/')[0]).filter(Boolean)); + return roots.size === 1 ? [...roots][0]! : null; +} + +async function installAppleLibraries(loveVersion: string, loveIosDir: string): Promise { + const zip = await appleLibrariesZip(loveVersion); + const entries = unzip(zip); + let installed = 0; + const librariesRoot = join(loveIosDir, 'platform', 'xcode', 'ios', 'libraries'); + const macosFrameworksRoot = join(loveIosDir, 'platform', 'xcode', 'macosx', 'Frameworks'); + const sharedRoot = join(loveIosDir, 'platform', 'xcode', 'shared'); + for (const entry of entries) { + const normalized = normalizeAppleLibrariesEntry(entry.name); + if (!normalized) continue; + const librariesPrefix = 'iOS/libraries/'; + const macosFrameworksPrefix = 'macOS/Frameworks/'; + const sharedPrefix = 'shared/'; + let destination: string | null = null; + let destinationRoot: string | null = null; + if (normalized.startsWith(librariesPrefix)) { + destinationRoot = librariesRoot; + destination = join(librariesRoot, normalized.slice(librariesPrefix.length)); + } else if (normalized.startsWith(macosFrameworksPrefix)) { + destinationRoot = macosFrameworksRoot; + destination = join(macosFrameworksRoot, normalized.slice(macosFrameworksPrefix.length)); + } else if (normalized.startsWith(sharedPrefix)) { + destinationRoot = sharedRoot; + destination = join(sharedRoot, normalized.slice(sharedPrefix.length)); + } + if (!destination || !destinationRoot || destination.endsWith('/') || entry.data.length === 0) continue; + if (!isPathInside(destinationRoot, resolve(destination))) { + throw new Error(`Apple libraries ZIP contains an unsafe path: ${entry.name}`); + } + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(destination, entry.data); + installed += 1; + } + if (installed === 0) { + throw new Error(`love-${loveVersion}-apple-libraries.zip did not contain Apple dependency files.`); + } +} + +function normalizeAppleLibrariesEntry(name: string): string | null { + let normalized = name.replace(/\\/g, '/').replace(/^\/+/, ''); + if (!normalized || normalized.startsWith('__MACOSX/') || normalized.includes('/.__MACOSX/')) { + return null; + } + if (normalized.startsWith('love-apple-dependencies/')) { + normalized = normalized.slice('love-apple-dependencies/'.length); + } + if (normalized.startsWith('._') || normalized.includes('/._')) { + return null; + } + return normalized; +} + +async function appleLibrariesZip(loveVersion: string): Promise { + const fixture = process.env.FEATHER_TEST_LOVE_APPLE_LIBRARIES_ZIP; + if (fixture) return readFileSync(fixture); + const url = `https://github.com/love2d/love/releases/download/${encodeURIComponent(loveVersion)}/love-${encodeURIComponent(loveVersion)}-apple-libraries.zip`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download ${url}: HTTP ${response.status}`); + } + return Buffer.from(await response.arrayBuffer()); +} + +type UnzippedEntry = { + name: string; + data: Buffer; +}; + +function unzip(zip: Buffer): UnzippedEntry[] { + const entries: UnzippedEntry[] = []; + let offset = centralDirectoryOffset(zip); + while (offset + 46 <= zip.length) { + const signature = zip.readUInt32LE(offset); + if (signature !== 0x02014b50) break; + const method = zip.readUInt16LE(offset + 10); + const compressedSize = zip.readUInt32LE(offset + 20); + const uncompressedSize = zip.readUInt32LE(offset + 24); + const nameLength = zip.readUInt16LE(offset + 28); + const extraLength = zip.readUInt16LE(offset + 30); + const commentLength = zip.readUInt16LE(offset + 32); + const localHeaderOffset = zip.readUInt32LE(offset + 42); + if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localHeaderOffset === 0xffffffff) { + throw new Error('ZIP64 archives are not supported for Apple libraries.'); + } + const nameStart = offset + 46; + const name = zip.subarray(nameStart, nameStart + nameLength).toString('utf8'); + const dataStart = localFileDataOffset(zip, localHeaderOffset); + const dataEnd = dataStart + compressedSize; + if (dataEnd > zip.length) throw new Error('Invalid ZIP archive.'); + const compressed = zip.subarray(dataStart, dataEnd); + let data: Buffer; + if (method === 0) { + data = Buffer.from(compressed); + } else if (method === 8) { + data = inflateRawSync(compressed); + } else { + throw new Error(`Unsupported ZIP compression method: ${method}`); + } + if (data.length !== uncompressedSize) throw new Error(`Invalid ZIP entry size for ${name}.`); + entries.push({ name, data }); + offset = nameStart + nameLength + extraLength + commentLength; + } + return entries; +} + +function centralDirectoryOffset(zip: Buffer): number { + const minOffset = Math.max(0, zip.length - 65557); + for (let offset = zip.length - 22; offset >= minOffset; offset -= 1) { + if (zip.readUInt32LE(offset) === 0x06054b50) { + return zip.readUInt32LE(offset + 16); + } + } + throw new Error('Invalid ZIP archive: central directory not found.'); +} + +function localFileDataOffset(zip: Buffer, offset: number): number { + if (offset + 30 > zip.length || zip.readUInt32LE(offset) !== 0x04034b50) { + throw new Error('Invalid ZIP archive: local file header not found.'); + } + const nameLength = zip.readUInt16LE(offset + 26); + const extraLength = zip.readUInt16LE(offset + 28); + const dataOffset = offset + 30 + nameLength + extraLength; + if (dataOffset > zip.length) throw new Error('Invalid ZIP archive.'); + return dataOffset; +} + +function updateVendorConfig( + projectDir: string, + configPath: string, + raw: FeatherBuildConfig, + target: ConcreteBuildVendorTarget, + relativePath: string, +): void { + const next: FeatherBuildConfig = { + ...raw, + targets: { + ...(raw.targets ?? {}), + [target]: { + ...((raw.targets ?? {})[target] ?? {}), + [vendorConfigKey(target)]: relativePath, + }, + }, + }; + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`); +} + +function vendorConfigKey(target: ConcreteBuildVendorTarget): 'loveJsDir' | 'loveAndroidDir' | 'loveIosDir' | 'loveRuntimeDir' { + if (target === 'web') return 'loveJsDir'; + if (target === 'android') return 'loveAndroidDir'; + if (target === 'ios') return 'loveIosDir'; + return 'loveRuntimeDir'; +} diff --git a/cli/src/lib/build/web.ts b/cli/src/lib/build/web.ts new file mode 100644 index 00000000..ce6711df --- /dev/null +++ b/cli/src/lib/build/web.ts @@ -0,0 +1,67 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { artifactBaseName, copyDirectory, writeDirectoryZip, writeLoveArchive, type BuildArtifact } from './files.js'; +import type { ResolvedBuildConfig } from './config.js'; + +export function buildWeb(config: ResolvedBuildConfig, stageDir: string): BuildArtifact[] { + const webConfig = config.targets.web ?? {}; + const loveJsDir = webConfig.loveJsDir ? resolve(config.projectDir, webConfig.loveJsDir) : ''; + if (!loveJsDir || !existsSync(loveJsDir)) { + throw new Error('Web build requires targets.web.loveJsDir in feather.build.json.'); + } + const base = artifactBaseName(config); + const lovePath = writeLoveArchive(stageDir, config.outDir, base); + const htmlDir = join(config.outDir, webConfig.outputName ?? `${base}-html`); + copyDirectory(loveJsDir, htmlDir); + const gameLovePath = join(htmlDir, 'game.love'); + writeFileSync(gameLovePath, readFileSync(lovePath)); + patchLoveJsIndex(join(htmlDir, 'index.html'), webConfig.title ?? config.name); + const zipPath = writeDirectoryZip(htmlDir, join(config.outDir, `${base}-html.zip`)); + return [ + { target: 'web', type: 'love', path: lovePath }, + { target: 'web', type: 'html', path: htmlDir }, + { target: 'web', type: 'zip', path: zipPath }, + ]; +} + +function patchLoveJsIndex(indexPath: string, title: string): void { + const fallback = [ + '', + '', + 'löve.js', + '', + '', + '', + ].join('\n'); + const existing = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : fallback; + let next = existing.replace(/[\s\S]*?<\/title>/i, `<title>${escapeHtml(title)}`); + if (next === existing && !//i.test(next)) { + next = next.replace(/<head[^>]*>/i, (match) => `${match}<title>${escapeHtml(title)}`); + } + next = normalizeBaseHref(next); + next = next.replace(/player(?:\.min)?\.js(?:\?g=[^"']*)?/g, (match) => { + const script = match.startsWith('player.min') ? 'player.min.js' : 'player.js'; + return `${script}?g=game.love`; + }); + if (!/\?g=game\.love/.test(next)) { + next = next.replace('', ''); + } + writeFileSync(indexPath, next); +} + +function normalizeBaseHref(html: string): string { + if (//i, ''); + } + return html; +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[char]!)); +} diff --git a/cli/src/lib/command.ts b/cli/src/lib/command.ts new file mode 100644 index 00000000..9c1486e4 --- /dev/null +++ b/cli/src/lib/command.ts @@ -0,0 +1,51 @@ +import { statusLine, style } from './output.js'; +import { redactSensitiveText } from './redact.js'; + +export type CliErrorOptions = { + exitCode?: number; + details?: string[]; + cause?: unknown; + silent?: boolean; +}; + +export class CliError extends Error { + exitCode: number; + details: string[]; + silent: boolean; + + constructor(message: string, options: CliErrorOptions = {}) { + super(message); + this.name = 'CliError'; + this.exitCode = options.exitCode ?? 1; + this.details = options.details ?? []; + this.silent = options.silent ?? false; + if (options.cause !== undefined) this.cause = options.cause; + } +} + +export function fail(message: string, options: CliErrorOptions = {}): never { + throw new CliError(message, options); +} + +export async function runCliAction(action: () => Promise | void | number): Promise { + try { + const result = await action(); + if (typeof result === 'number') process.exitCode = result; + } catch (err) { + if (err instanceof CliError) { + if (!err.silent && err.message) { + console.error(statusLine('error', redactSensitiveText(err.message))); + for (const detail of err.details) console.error(style.muted(` ${redactSensitiveText(detail)}`)); + } + process.exitCode = err.exitCode; + return; + } + + const message = err instanceof Error ? err.message : String(err); + console.error(statusLine('error', redactSensitiveText(message || 'Unexpected error'))); + if (process.env.FEATHER_DEBUG === '1' && err instanceof Error && err.stack) { + console.error(style.muted(redactSensitiveText(err.stack))); + } + process.exitCode = 1; + } +} diff --git a/cli/src/lib/github.ts b/cli/src/lib/github.ts new file mode 100644 index 00000000..1546c259 --- /dev/null +++ b/cli/src/lib/github.ts @@ -0,0 +1,24 @@ +export const GH_HEADERS: Record = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', +}; + +export async function fetchCommitSha(repo: string, ref: string): Promise { + const res = await fetch(`https://api.github.com/repos/${repo}/commits/${encodeURIComponent(ref)}`, { + headers: GH_HEADERS, + }); + if (!res.ok) throw new Error(`GitHub API ${res.status} resolving ${repo}@${ref} to commit SHA`); + return ((await res.json()) as { sha: string }).sha; +} + +export async function fetchLuaFiles(repo: string, ref: string): Promise { + const res = await fetch(`https://api.github.com/repos/${repo}/git/trees/${ref}?recursive=1`, { + headers: GH_HEADERS, + }); + if (!res.ok) throw new Error(`GitHub API ${res.status} fetching file tree for ${repo}@${ref}`); + const data = (await res.json()) as { tree: Array<{ path: string; type: string }> }; + return data.tree + .filter((n) => n.type === 'blob' && n.path.endsWith('.lua')) + .map((n) => n.path) + .sort(); +} diff --git a/cli/src/lib/install.ts b/cli/src/lib/install.ts index 1ce55354..3aa29351 100644 --- a/cli/src/lib/install.ts +++ b/cli/src/lib/install.ts @@ -1,7 +1,14 @@ -import { cpSync, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; +import { cpSync, createWriteStream, existsSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { pipeline } from "node:stream/promises"; import { Readable } from "node:stream"; +import { + assertValidPluginId, + findLocalPluginIds, + pluginIdToSourceDir, + validatePluginManifest, +} from "./plugin-utils.js"; +import { assertSafeProjectTarget, assertSafeRelativePath } from "./path-safety.js"; const GITHUB_RAW = "https://raw.githubusercontent.com/Kyonru/feather/{branch}/src-lua/{path}"; @@ -80,8 +87,9 @@ export async function installCore( const root = normalizeInstallDir(installDir); for (const entry of entries) { if (entry.type !== "core") continue; - const dest = join(targetDir, root, entry.path.replace(/^feather\//, "")); - if (!existsSync(dirname(dest))) mkdirSync(dirname(dest), { recursive: true }); + const relativeTarget = join(root, entry.path.replace(/^feather\//, "")); + const dest = assertSafeProjectTarget(targetDir, relativeTarget, "Core install target"); + mkdirSync(dirname(dest), { recursive: true }); await downloadFile(entry.path, dest, branch); onProgress?.(entry.path); } @@ -97,7 +105,7 @@ export function installCoreFromLocal( if (!existsSync(source)) throw new Error(`No feather core found at ${source}`); const root = normalizeInstallDir(installDir); - const dest = join(targetDir, root); + const dest = assertSafeProjectTarget(targetDir, root, "Core install target"); mkdirSync(dirname(dest), { recursive: true }); cpSync(source, dest, { recursive: true, force: true }); onProgress?.("feather"); @@ -114,12 +122,16 @@ export function installPluginsFromLocal( if (!existsSync(pluginsRoot)) throw new Error(`No plugins directory found at ${pluginsRoot}`); const root = normalizeInstallDir(installDir); - for (const pluginId of pluginIds) { - const sourceDir = pluginId.replace(/\./g, "/"); + const plans = pluginIds.map((pluginId) => { + const sourceDir = pluginIdToSourceDir(pluginId); const source = join(pluginsRoot, sourceDir); if (!existsSync(source)) throw new Error(`Unknown plugin: ${pluginId}`); + validatePluginManifest(source, { expectedId: pluginId, expectedSourceDir: sourceDir }); + return { pluginId, sourceDir, source }; + }); - const dest = join(targetDir, root, "plugins", sourceDir); + for (const { pluginId, sourceDir, source } of plans) { + const dest = assertSafeProjectTarget(targetDir, join(root, "plugins", sourceDir), "Plugin install target"); mkdirSync(dirname(dest), { recursive: true }); cpSync(source, dest, { recursive: true, force: true }); onProgress?.(pluginId); @@ -134,13 +146,32 @@ export async function installPlugin( onProgress?: (file: string) => void, installDir = "feather" ): Promise { + assertValidPluginId(pluginId); const pluginEntries = entries.filter((e) => e.type === "plugin" && e.plugin === pluginId); if (pluginEntries.length === 0) throw new Error(`Unknown plugin: ${pluginId}`); const root = normalizeInstallDir(installDir); - for (const entry of pluginEntries) { + const plans = pluginEntries.map((entry) => { const sourceDir = entry.sourceDir ?? pluginId.replace(/\./g, "/"); const file = entry.file ?? entry.path.replace(new RegExp(`^plugins/${sourceDir}/`), ""); - const dest = join(targetDir, root, "plugins", sourceDir, file); + if (sourceDir !== pluginIdToSourceDir(pluginId)) throw new Error(`Plugin manifest path mismatch: ${pluginId} should live in plugins/${pluginIdToSourceDir(pluginId)}`); + try { + assertSafeRelativePath(file, "Plugin file path"); + } catch { + throw new Error(`Unsafe plugin file path: ${file}`); + } + try { + assertSafeRelativePath(entry.path, "Plugin manifest path"); + } catch { + throw new Error(`Unsafe plugin manifest path: ${entry.path}`); + } + if (!entry.path.startsWith(`plugins/${sourceDir}/`)) { + throw new Error(`Unsafe plugin manifest path: ${entry.path}`); + } + return { entry, sourceDir, file }; + }); + + for (const { entry, sourceDir, file } of plans) { + const dest = assertSafeProjectTarget(targetDir, join(root, "plugins", sourceDir, file), "Plugin install target"); mkdirSync(dirname(dest), { recursive: true }); await downloadFile(entry.path, dest, branch); onProgress?.(entry.path); @@ -148,33 +179,22 @@ export async function installPlugin( } export function getPluginIds(entries: ManifestEntry[]): string[] { - return [...new Set(entries.filter((e) => e.type === "plugin").map((e) => e.plugin!))]; + return [...new Set(entries.filter((e) => e.type === "plugin").map((e) => e.plugin!).filter((id) => { + try { + assertValidPluginId(id); + return true; + } catch { + return false; + } + }))]; } export function normalizeInstallDir(installDir = "feather"): string { - return installDir.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "") || "feather"; + const normalized = installDir.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "") || "feather"; + assertSafeRelativePath(normalized, "Install directory"); + return normalized; } export function getLocalPluginIds(sourceRoot: string): string[] { - const pluginsRoot = join(sourceRoot, "plugins"); - if (!existsSync(pluginsRoot)) return []; - - const ids: string[] = []; - const visit = (dir: string) => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const pluginDir = join(dir, entry.name); - const manifest = join(pluginDir, "manifest.lua"); - if (existsSync(manifest)) { - const src = readFileSync(manifest, "utf8"); - const id = src.match(/id\s*=\s*"([^"]+)"/)?.[1]; - if (id) ids.push(id); - } else { - visit(pluginDir); - } - } - }; - - visit(pluginsRoot); - return ids.sort(); + return findLocalPluginIds(sourceRoot); } diff --git a/cli/src/lib/output.ts b/cli/src/lib/output.ts new file mode 100644 index 00000000..ab8b89cb --- /dev/null +++ b/cli/src/lib/output.ts @@ -0,0 +1,146 @@ +import chalk from 'chalk'; +import ora, { type Ora } from 'ora'; +import { redactSensitiveText, redactSensitiveValue } from './redact.js'; + +export const icon = { + success: chalk.green('✔'), + error: chalk.red('✖'), + warning: chalk.yellow('!'), + info: chalk.cyan('i'), + danger: chalk.red('!'), +}; + +export const style = { + heading: chalk.bold, + muted: chalk.dim, + success: chalk.green, + warning: chalk.yellow, + danger: chalk.red, + info: chalk.cyan, +}; + +export function heading(message: string): string { + return style.heading(message); +} + +export function statusLine(kind: keyof typeof icon, message: string): string { + return `${icon[kind]} ${message}`; +} + +export function printJson(value: unknown): void { + console.log(JSON.stringify(redactSensitiveValue(value), null, 2)); +} + +export function printLine(message = ''): void { + console.log(redactSensitiveText(message)); +} + +export function printBlank(): void { + printLine(); +} + +export function printLines(lines: string[]): void { + for (const line of lines) printLine(line); +} + +export function printHeading(message: string): void { + printLine(heading(message)); +} + +export function printStatus(kind: keyof typeof icon, message: string): void { + printLine(statusLine(kind, message)); +} + +export function printMuted(message: string): void { + printLine(style.muted(message)); +} + +export function printWarning(message: string): void { + printLine(style.warning(message)); +} + +export function printDanger(message: string): void { + printLine(style.danger(message)); +} + +export function printSuccess(message: string): void { + printLine(style.success(message)); +} + +export function printInfo(message: string): void { + printLine(style.info(message)); +} + +export function section(title: string, lines: string[] = []): string[] { + return ['', heading(title), ...lines, '']; +} + +export function createSpinner(text: string): Ora { + return ora(text); +} + +export async function withSpinner( + text: string, + action: (spinner: Ora) => Promise, + messages?: { success?: string; fail?: (err: unknown) => string }, +): Promise { + const spinner = createSpinner(text).start(); + try { + const result = await action(spinner); + spinner.succeed(messages?.success ?? text); + return result; + } catch (err) { + spinner.fail(messages?.fail ? messages.fail(err) : (err as Error).message); + throw err; + } +} + +export function keyValueRows(rows: Array<[string, string | number | undefined | null]>): string[] { + const visible = rows.filter(([, value]) => value !== undefined && value !== null && value !== ''); + const width = Math.max(0, ...visible.map(([key]) => key.length)); + return visible.map(([key, value]) => ` ${style.muted(key.padEnd(width))} ${value}`); +} + +export function printKeyValues(rows: Array<[string, string | number | undefined | null]>): void { + printLines(keyValueRows(rows)); +} + +export function table>(input: { + columns: Array<{ key: keyof Row; label: string; color?: (value: string, row: Row) => string }>; + rows: Row[]; + indent?: string; +}): string[] { + const indent = input.indent ?? ' '; + const widths = input.columns.map((column) => + Math.max( + column.label.length, + ...input.rows.map((row) => String(row[column.key] ?? '').length), + ), + ); + + const header = input.columns + .map((column, index) => column.label.padEnd(widths[index]!)) + .join(' '); + const lines = [`${indent}${style.muted(header)}`]; + + for (const row of input.rows) { + lines.push( + `${indent}${input.columns + .map((column, index) => { + const value = String(row[column.key] ?? '').padEnd(widths[index]!); + return column.color ? column.color(value, row) : value; + }) + .join(' ')}`, + ); + } + + return lines; +} + +export function printTable>(input: { + columns: Array<{ key: keyof Row; label: string; color?: (value: string, row: Row) => string }>; + rows: Row[]; + indent?: string; +}): void { + printLines(table(input)); +} diff --git a/cli/src/lib/package/add-plan.ts b/cli/src/lib/package/add-plan.ts new file mode 100644 index 00000000..7065b8e9 --- /dev/null +++ b/cli/src/lib/package/add-plan.ts @@ -0,0 +1,96 @@ +import { + installCustomRepoPackage, + installCustomUrlPackage, + type CustomPackageInstallResult, + type CustomRepoPackageInput, + type CustomUrlFileInput, + type CustomUrlPackageInput, +} from './custom-add.js'; +import type { Lockfile } from './lockfile.js'; + +export type PackageAddRepoPlan = { + kind: 'repo'; + id: string; + requirePath: string; + repoName: string; + tag: string; + commitSha: string; + baseUrl: string; + selectedFiles: string[]; + targetMap: Record; +}; + +export type PackageAddUrlPlan = { + kind: 'url'; + id: string; + requirePath: string; + urlFiles: CustomUrlFileInput[]; +}; + +export type PackageAddPlan = PackageAddRepoPlan | PackageAddUrlPlan; + +export function packageAddPlanFiles(plan: PackageAddPlan): Array<{ name: string; target: string }> { + if (plan.kind === 'repo') { + return plan.selectedFiles.map((name) => ({ name, target: plan.targetMap[name] ?? name })); + } + return plan.urlFiles.map((file) => ({ name: file.name, target: file.target })); +} + +export function toCustomRepoPackageInput(input: { + plan: PackageAddRepoPlan; + projectDir: string; + lockfile: Lockfile; + onFileStart?: (name: string) => void; +}): CustomRepoPackageInput { + return { + id: input.plan.id, + repoName: input.plan.repoName, + tag: input.plan.tag, + commitSha: input.plan.commitSha, + baseUrl: input.plan.baseUrl, + selectedFiles: input.plan.selectedFiles, + targetMap: input.plan.targetMap, + projectDir: input.projectDir, + lockfile: input.lockfile, + onFileStart: input.onFileStart, + }; +} + +export function toCustomUrlPackageInput(input: { + plan: PackageAddUrlPlan; + projectDir: string; + lockfile: Lockfile; +}): CustomUrlPackageInput { + return { + id: input.plan.id, + urlFiles: input.plan.urlFiles, + projectDir: input.projectDir, + lockfile: input.lockfile, + }; +} + +export async function installPackageAddPlan(input: { + plan: PackageAddPlan; + projectDir: string; + lockfile: Lockfile; + onFileStart?: (name: string) => void; +}): Promise { + if (input.plan.kind === 'repo') { + return installCustomRepoPackage( + toCustomRepoPackageInput({ + plan: input.plan, + projectDir: input.projectDir, + lockfile: input.lockfile, + onFileStart: input.onFileStart, + }), + ); + } + + return installCustomUrlPackage( + toCustomUrlPackageInput({ + plan: input.plan, + projectDir: input.projectDir, + lockfile: input.lockfile, + }), + ); +} diff --git a/cli/src/lib/package/custom-add.ts b/cli/src/lib/package/custom-add.ts new file mode 100644 index 00000000..bc773162 --- /dev/null +++ b/cli/src/lib/package/custom-add.ts @@ -0,0 +1,144 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { sha256Buffer } from "./checksum.js"; +import { addToLockfile, type Lockfile, writeLockfile } from "./lockfile.js"; +import { resolveProjectTarget } from "./target.js"; + +export type CustomPackageFile = { + name: string; + url?: string; + target: string; + sha256: string; +}; + +export type CustomPackageInstallResult = { + ok: boolean; + files: CustomPackageFile[]; + error?: string; +}; + +export type CustomRepoPackageInput = { + id: string; + repoName: string; + tag: string; + commitSha?: string; + baseUrl: string; + selectedFiles: string[]; + targetMap: Record; + projectDir: string; + lockfile: Lockfile; + onFileStart?: (name: string) => void; +}; + +const COMMIT_SHA_RE = /^[a-f0-9]{40}$/i; + +export type CustomUrlFileInput = { + name: string; + url: string; + sha256: string; + target: string; + buffer: Buffer; +}; + +export type CustomUrlPackageInput = { + id: string; + urlFiles: CustomUrlFileInput[]; + projectDir: string; + lockfile: Lockfile; +}; + +function validateTargets(projectDir: string, files: Array<{ target: string }>): string[] | { error: string } { + const targets: string[] = []; + for (const file of files) { + const absoluteTarget = resolveProjectTarget(projectDir, file.target); + if (!absoluteTarget) return { error: `Target path escapes project root: ${file.target}` }; + targets.push(absoluteTarget); + } + return targets; +} + +export async function installCustomRepoPackage(input: CustomRepoPackageInput): Promise { + if (input.commitSha !== undefined && !COMMIT_SHA_RE.test(input.commitSha)) { + return { ok: false, files: [], error: "commitSha must be a 40-character SHA" }; + } + + const plannedFiles = input.selectedFiles.map((name) => ({ + name, + target: input.targetMap[name] ?? name, + })); + const targets = validateTargets(input.projectDir, plannedFiles); + if ("error" in targets) return { ok: false, files: [], error: targets.error }; + + const lockedFiles: CustomPackageFile[] = []; + + for (const [index, file] of plannedFiles.entries()) { + input.onFileStart?.(file.name); + + const url = input.baseUrl + file.name; + let res: Response; + try { + res = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + } catch (err) { + return { ok: false, files: lockedFiles, error: `Network error: ${(err as Error).message}` }; + } + if (!res.ok) return { ok: false, files: lockedFiles, error: `HTTP ${res.status} fetching ${url}` }; + + const buffer = Buffer.from(await res.arrayBuffer()); + const sha256 = sha256Buffer(buffer); + const absoluteTarget = targets[index]!; + mkdirSync(dirname(absoluteTarget), { recursive: true }); + writeFileSync(absoluteTarget, buffer); + lockedFiles.push({ name: file.name, url, target: file.target, sha256 }); + } + + addToLockfile(input.lockfile, input.id, { + version: input.tag, + trust: "experimental", + source: { + repo: input.repoName, + tag: input.tag, + ...(input.commitSha ? { resolvedRef: input.commitSha, commitSha: input.commitSha } : {}), + }, + files: lockedFiles, + }); + writeLockfile(input.projectDir, input.lockfile); + + return { ok: true, files: lockedFiles }; +} + +export async function installCustomUrlPackage(input: CustomUrlPackageInput): Promise { + if (input.urlFiles.length === 0) return { ok: false, files: [], error: "No URL files selected" }; + + const targets = validateTargets(input.projectDir, input.urlFiles); + if ("error" in targets) return { ok: false, files: [], error: targets.error }; + + const lockedFiles = input.urlFiles.map((file) => { + const actualSha256 = sha256Buffer(file.buffer); + return { + name: file.name, + url: file.url, + target: file.target, + sha256: actualSha256, + }; + }); + + for (const [index, file] of input.urlFiles.entries()) { + const absoluteTarget = targets[index]!; + mkdirSync(dirname(absoluteTarget), { recursive: true }); + writeFileSync(absoluteTarget, file.buffer); + } + + addToLockfile(input.lockfile, input.id, { + version: "url", + trust: "experimental", + source: { + kind: "url", + url: input.urlFiles[0]!.url, + urls: input.urlFiles.map((file) => file.url), + }, + files: lockedFiles, + }); + writeLockfile(input.projectDir, input.lockfile); + + return { ok: true, files: lockedFiles }; +} diff --git a/cli/src/lib/package/install.ts b/cli/src/lib/package/install.ts index 3b8a23ee..92cec26c 100644 --- a/cli/src/lib/package/install.ts +++ b/cli/src/lib/package/install.ts @@ -1,7 +1,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join, resolve as resolvePath } from "node:path"; +import { dirname, join } from "node:path"; import { sha256Buffer } from "./checksum.js"; import { addToLockfile, type Lockfile, type LockfileEntry } from "./lockfile.js"; +import { lockfileFileUrl } from "./provenance.js"; +import { resolveProjectTarget } from "./target.js"; import type { ResolvedPackage } from "./resolve.js"; export type InstallOptions = { @@ -58,12 +60,6 @@ async function downloadLive(url: string, repo: string, version: string): Promise return Buffer.from(await res.arrayBuffer()); } -function safeTarget(projectDir: string, relTarget: string): string | null { - const abs = resolvePath(projectDir, relTarget); - if (!abs.startsWith(resolvePath(projectDir))) return null; - return abs; -} - export async function installPackage( pkg: ResolvedPackage, lockfile: Lockfile, @@ -74,23 +70,37 @@ export async function installPackage( const lockedFiles: LockfileEntry["files"] = []; const src = pkg.entry.source; - const effectiveTag = pkg.versionOverride ?? src.tag; - const baseUrl = pkg.versionOverride - ? src.baseUrl.replace(src.tag, pkg.versionOverride) - : src.baseUrl; - - for (const file of pkg.files) { - const relTarget = targetOverride + const effectiveTag = pkg.versionOverride ?? src.tag ?? 'url'; + const baseUrl = + pkg.versionOverride && src.baseUrl && src.tag + ? src.baseUrl.replace(src.tag, pkg.versionOverride) + : (src.baseUrl ?? ''); + + const plannedFiles = pkg.files.map((file) => ({ + file, + relTarget: targetOverride ? join(targetOverride, file.name.split("/").pop()!) - : file.target; + : file.target, + })); + const resolvedTargets: string[] = []; - const absTarget = safeTarget(projectDir, relTarget); + for (const { file, relTarget } of plannedFiles) { + const absTarget = resolveProjectTarget(projectDir, relTarget); if (!absTarget) { - fileResults.push({ name: file.name, target: relTarget, sha256: "", ok: false, error: "Target path escapes project root" }); - continue; + return { + id: pkg.id, + ok: false, + files: [{ name: file.name, target: relTarget, sha256: "", ok: false, error: "Target path escapes project root" }], + error: "Target path escapes project root", + }; } + resolvedTargets.push(absTarget); + } - const url = baseUrl + file.name; + for (const [index, { file, relTarget }] of plannedFiles.entries()) { + const absTarget = resolvedTargets[index]!; + + const url = file.url ?? baseUrl + file.name; if (dryRun) { fileResults.push({ name: file.name, target: relTarget, sha256: file.sha256, ok: true }); @@ -135,7 +145,11 @@ export async function installPackage( parent: pkg.entry.parent, version: effectiveTag, trust: pkg.versionOverride ? "experimental" : pkg.entry.trust, - source: { repo: src.repo, tag: effectiveTag }, + source: { + repo: src.repo ?? pkg.id, + tag: effectiveTag, + ...(!pkg.versionOverride && src.commitSha ? { resolvedRef: src.commitSha, commitSha: src.commitSha } : {}), + }, files: lockedFiles, }); } @@ -178,7 +192,7 @@ export async function installFromUrl( return { ok: true, sha256: hash, size: buf.byteLength, target }; } - const absTarget = safeTarget(projectDir, target); + const absTarget = resolveProjectTarget(projectDir, target); if (!absTarget) return { ok: false, sha256: hash, size: buf.byteLength, target, error: "Target path escapes project root" }; mkdirSync(dirname(absTarget), { recursive: true }); @@ -188,8 +202,8 @@ export async function installFromUrl( addToLockfile(lockfile, name.replace(/\.lua$/, ""), { version: "0.0.0", trust: "experimental", - source: { url }, - files: [{ name, target, sha256: hash }], + source: { kind: "url", url, urls: [url] }, + files: [{ name, url, target, sha256: hash }], }); return { ok: true, sha256: hash, size: buf.byteLength, target }; @@ -209,13 +223,23 @@ export async function restorePackage( ): Promise { const { projectDir, dryRun, onFileStart, onFileComplete } = opts; const fileResults: InstallFileResult[] = []; + const resolvedTargets: string[] = []; for (const file of entry.files) { - const absTarget = safeTarget(projectDir, file.target); + const absTarget = resolveProjectTarget(projectDir, file.target); if (!absTarget) { - fileResults.push({ name: file.name, target: file.target, sha256: "", ok: false, error: "Target path escapes project root" }); - continue; + return { + id, + ok: false, + files: [{ name: file.name, target: file.target, sha256: "", ok: false, error: "Target path escapes project root" }], + error: "Target path escapes project root", + }; } + resolvedTargets.push(absTarget); + } + + for (const [index, file] of entry.files.entries()) { + const absTarget = resolvedTargets[index]!; // Skip files already on disk with the correct locked checksum if (!dryRun && existsSync(absTarget)) { @@ -232,9 +256,7 @@ export async function restorePackage( onFileStart?.(file.name); - const url = "url" in entry.source - ? entry.source.url - : `https://raw.githubusercontent.com/${entry.source.repo}/${entry.source.tag}/${file.name}`; + const url = lockfileFileUrl(entry, file); const result = await downloadVerified(url, file.sha256); if ("error" in result) { diff --git a/cli/src/lib/package/lockfile.ts b/cli/src/lib/package/lockfile.ts index bfa58b00..dd62e7f7 100644 --- a/cli/src/lib/package/lockfile.ts +++ b/cli/src/lib/package/lockfile.ts @@ -1,14 +1,25 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +export type LockfileRepoSource = { + repo: string; + tag: string; + commitSha?: string; + resolvedRef?: string; +}; + +export type LockfileUrlSource = { + kind?: "url"; + url: string; + urls?: string[]; +}; + export type LockfileEntry = { parent?: string; version: string; trust: "verified" | "known" | "experimental"; - source: - | { repo: string; tag: string } - | { url: string }; - files: { name: string; target: string; sha256: string }[]; + source: LockfileRepoSource | LockfileUrlSource; + files: { name: string; url?: string; target: string; sha256: string }[]; installedAt: string; }; @@ -19,6 +30,35 @@ export type Lockfile = { }; const LOCKFILE_NAME = "feather.lock.json"; +const COMMIT_SHA_RE = /^[a-f0-9]{40}$/i; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function validateLockfileSource(source: unknown): void { + if (!isObject(source)) throw new Error("Lockfile source must be an object"); + + if ("repo" in source) { + if (typeof source.repo !== "string" || !source.repo) throw new Error("Lockfile source.repo must be a string"); + if (typeof source.tag !== "string" || !source.tag) throw new Error("Lockfile source.tag must be a string"); + if (source.commitSha !== undefined && (typeof source.commitSha !== "string" || !COMMIT_SHA_RE.test(source.commitSha))) { + throw new Error("Lockfile source.commitSha must be a 40-character SHA"); + } + if (source.resolvedRef !== undefined && typeof source.resolvedRef !== "string") { + throw new Error("Lockfile source.resolvedRef must be a string"); + } + return; + } + + if (typeof source.url !== "string" || !source.url) throw new Error("Lockfile source.url must be a string"); + if (source.kind !== undefined && source.kind !== "url") throw new Error('Lockfile source.kind must be "url"'); + if (source.urls !== undefined) { + if (!Array.isArray(source.urls) || source.urls.length === 0 || source.urls.some((url) => typeof url !== "string" || !url)) { + throw new Error("Lockfile source.urls must be non-empty strings"); + } + } +} export function lockfilePath(projectDir: string): string { return join(projectDir, LOCKFILE_NAME); @@ -46,6 +86,7 @@ export function addToLockfile( id: string, entry: Omit ): void { + validateLockfileSource(entry.source); lockfile.packages[id] = { ...entry, installedAt: new Date().toISOString() }; } diff --git a/cli/src/lib/package/provenance.ts b/cli/src/lib/package/provenance.ts new file mode 100644 index 00000000..1b21ae9e --- /dev/null +++ b/cli/src/lib/package/provenance.ts @@ -0,0 +1,89 @@ +import type { Lockfile, LockfileEntry } from "./lockfile.js"; + +export type PackageUrlTrust = { + trusted: boolean; + reason?: string; + host?: string; +}; + +export type LockfileUrlFinding = { + id: string; + name: string; + target: string; + url: string; + reason: string; +}; + +const TRUSTED_PACKAGE_URL_HOSTS = new Set(["raw.githubusercontent.com"]); + +export function lockfileFileUrl(entry: LockfileEntry, file: LockfileEntry["files"][number]): string { + if (file.url) return file.url; + if ("url" in entry.source) return entry.source.url; + + const ref = entry.source.resolvedRef ?? entry.source.commitSha ?? entry.source.tag; + return `https://raw.githubusercontent.com/${entry.source.repo}/${ref}/${file.name}`; +} + +export function assessPackageUrl(url: string): PackageUrlTrust { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { trusted: false, reason: "invalid URL" }; + } + + if (parsed.protocol !== "https:") { + return { trusted: false, reason: `${parsed.protocol || "unknown"} URL`, host: parsed.host || undefined }; + } + + if (!TRUSTED_PACKAGE_URL_HOSTS.has(parsed.hostname)) { + return { trusted: false, reason: `untrusted host ${parsed.hostname}`, host: parsed.hostname }; + } + + return { trusted: true, host: parsed.hostname }; +} + +export function lockfileEntryUrlFindings(id: string, entry: LockfileEntry): LockfileUrlFinding[] { + const findings: LockfileUrlFinding[] = []; + + for (const file of entry.files) { + const url = lockfileFileUrl(entry, file); + const trust = assessPackageUrl(url); + if (!trust.trusted) { + findings.push({ + id, + name: file.name, + target: file.target, + url, + reason: trust.reason ?? "untrusted URL", + }); + } + } + + return findings; +} + +export function lockfileUrlFindings(lockfile: Lockfile): LockfileUrlFinding[] { + return Object.entries(lockfile.packages).flatMap(([id, entry]) => lockfileEntryUrlFindings(id, entry)); +} + +export function lockfileEntryRequiresUntrustedRepair(id: string, entry: LockfileEntry): boolean { + return entry.trust === "experimental" || lockfileEntryUrlFindings(id, entry).length > 0; +} + +export function packageUrlSummary(url: string): string { + try { + const parsed = new URL(url); + if (parsed.host) return `${parsed.protocol}//${parsed.host}`; + return `${parsed.protocol} URL`; + } catch { + return "invalid URL"; + } +} + +export function lockfileEntrySourceSummary(id: string, entry: LockfileEntry): string { + if ("url" in entry.source) return packageUrlSummary(entry.source.url); + if (entry.source.commitSha) return `${entry.source.repo}@${entry.source.commitSha}`; + if (entry.source.resolvedRef) return `${entry.source.repo}@${entry.source.resolvedRef}`; + return `${entry.source.repo}@${entry.source.tag || id}`; +} diff --git a/cli/src/lib/package/registry.ts b/cli/src/lib/package/registry.ts index e221a702..7100b58a 100644 --- a/cli/src/lib/package/registry.ts +++ b/cli/src/lib/package/registry.ts @@ -2,9 +2,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; +import { resolveProjectTarget } from "./target.js"; export type PackageFile = { name: string; + url?: string; sha256: string; target: string; }; @@ -22,10 +24,13 @@ export type RegistryEntry = { description: string; tags: string[]; homepage?: string; + license?: string; source: { - repo: string; - tag: string; - baseUrl: string; + type?: string; + repo?: string; + tag?: string; + commitSha?: string; + baseUrl?: string; }; install: { files: PackageFile[] }; subpackages?: string[]; @@ -42,6 +47,71 @@ export type Registry = { const REGISTRY_URL = "https://raw.githubusercontent.com/Kyonru/feather/packages/registry.json"; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const TRUST_VALUES = new Set(["verified", "known", "experimental"]); +const SHA256_RE = /^[a-f0-9]{64}$/i; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function validatePackageId(id: string): void { + if (!/^[a-z0-9][a-z0-9.-]*$/.test(id)) throw new Error(`Invalid package id: ${id}`); +} + +export function validateRegistry(value: unknown): Registry { + if (!isObject(value)) throw new Error("Registry must be an object"); + if (value.version !== 1) throw new Error("Registry version must be 1"); + if (typeof value.updatedAt !== "string") throw new Error("Registry updatedAt must be a string"); + if (!isObject(value.packages)) throw new Error("Registry packages must be an object"); + + const packages = value.packages as Record; + for (const [id, entry] of Object.entries(packages)) { + validatePackageId(id); + if (!isObject(entry)) throw new Error(`Package ${id} must be an object`); + if (entry.parent !== undefined && typeof entry.parent !== "string") throw new Error(`Package ${id} parent must be a string`); + if (entry.parent && !packages[entry.parent]) throw new Error(`Package ${id} parent ${entry.parent} is missing`); + if (typeof entry.type !== "string") throw new Error(`Package ${id} type is required`); + if (!TRUST_VALUES.has(entry.trust)) throw new Error(`Package ${id} has invalid trust`); + if (typeof entry.description !== "string") throw new Error(`Package ${id} description is required`); + if (!Array.isArray(entry.tags) || entry.tags.some((tag) => typeof tag !== "string")) { + throw new Error(`Package ${id} tags must be strings`); + } + if (!isObject(entry.source)) throw new Error(`Package ${id} source is required`); + if (typeof entry.source.repo !== "string" || !/^[^/]+\/[^/]+$/.test(entry.source.repo)) { + throw new Error(`Package ${id} source.repo must be owner/repo`); + } + if (typeof entry.source.tag !== "string" || !entry.source.tag) throw new Error(`Package ${id} source.tag is required`); + if (typeof entry.source.baseUrl !== "string" || !entry.source.baseUrl.startsWith("https://raw.githubusercontent.com/")) { + throw new Error(`Package ${id} source.baseUrl must be a raw GitHub URL`); + } + if (typeof entry.source.commitSha !== "string" || !/^[a-f0-9]{40}$/i.test(entry.source.commitSha)) { + throw new Error(`Package ${id} source.commitSha must be a 40-character SHA`); + } + if (!entry.source.baseUrl.includes(entry.source.commitSha)) { + throw new Error(`Package ${id} source.baseUrl must include source.commitSha`); + } + if (!isObject(entry.install) || !Array.isArray(entry.install.files) || entry.install.files.length === 0) { + throw new Error(`Package ${id} install.files is required`); + } + for (const file of entry.install.files) { + if (!isObject(file)) throw new Error(`Package ${id} install file must be an object`); + if (typeof file.name !== "string" || !file.name) throw new Error(`Package ${id} file.name is required`); + if (typeof file.target !== "string" || !resolveProjectTarget("/project", file.target)) { + throw new Error(`Package ${id} file target escapes project root: ${file.target}`); + } + if (typeof file.sha256 !== "string" || !SHA256_RE.test(file.sha256)) { + throw new Error(`Package ${id} file ${file.name} has invalid sha256`); + } + if (file.url !== undefined && typeof file.url !== "string") throw new Error(`Package ${id} file.url must be a string`); + } + if (typeof entry.require !== "string" || !entry.require) throw new Error(`Package ${id} require is required`); + if (entry.subpackages?.some((subpackage) => typeof subpackage !== "string" || packages[subpackage]?.parent !== id)) { + throw new Error(`Package ${id} has invalid subpackage references`); + } + } + + return value as Registry; +} function cacheDir(): string { return join(homedir(), ".feather"); @@ -63,7 +133,7 @@ function readCache(): Registry | null { if (!existsSync(path)) return null; try { const { fetchedAt, registry } = JSON.parse(readFileSync(path, "utf8")) as CacheFile; - if (Date.now() - new Date(fetchedAt).getTime() < CACHE_TTL_MS) return registry; + if (Date.now() - new Date(fetchedAt).getTime() < CACHE_TTL_MS) return validateRegistry(registry); } catch { // ignore corrupt cache } @@ -78,7 +148,7 @@ function writeCache(registry: Registry): void { } function readBundled(): Registry { - return JSON.parse(readFileSync(bundledRegistryPath(), "utf8")) as Registry; + return validateRegistry(JSON.parse(readFileSync(bundledRegistryPath(), "utf8"))); } export type RegistryLoadOptions = { @@ -99,7 +169,7 @@ export async function loadRegistry(opts: RegistryLoadOptions = {}): Promise part === '' || part === '..'); +} + +export function assertSafeRelativePath(path: string, label: string): void { + if (!isSafeRelativePath(path)) { + throw new Error(`${label} must be a relative path inside the project: ${path}`); + } +} + +function nearestExistingPath(path: string): string | null { + let current = resolve(path); + while (!existsSync(current)) { + const next = dirname(current); + if (next === current) return null; + current = next; + } + return current; +} + +export function assertNoSymlinkEscape(root: string, target: string, label: string): void { + const rootPath = resolve(root); + const targetPath = resolve(target); + assertPathInside(rootPath, targetPath, label); + const projectRoot = realpathSync(rootPath); + + const existing = nearestExistingPath(targetPath); + if (!existing) return; + + const resolvedExisting = realpathSync(existing); + if (!isPathInside(projectRoot, resolvedExisting)) { + throw new Error(`${label} resolves outside project root: ${existing}`); + } + + if (existsSync(targetPath)) { + const stat = lstatSync(targetPath); + if (stat.isSymbolicLink()) { + const resolvedTarget = realpathSync(targetPath); + if (!isPathInside(projectRoot, resolvedTarget)) { + throw new Error(`${label} symlink resolves outside project root: ${targetPath}`); + } + } + } +} + +export function assertSafeProjectTarget(root: string, relativePath: string, label: string): string { + assertSafeRelativePath(relativePath, label); + const target = join(resolve(root), relativePath); + assertNoSymlinkEscape(root, target, label); + return target; +} + +function inspectSymlink(projectRoot: string, path: string): { path: string; target: string } | null { + const target = realpathSync(path); + return isPathInside(projectRoot, target) ? null : { path, target }; +} + +function pathComponentEscape(root: string, path: string): { path: string; target: string } | null { + const rootPath = resolve(root); + const projectRoot = realpathSync(rootPath); + const absolute = resolve(path); + if (!isPathInside(rootPath, absolute)) return null; + const parts = relative(rootPath, absolute).split(sep).filter(Boolean); + let current = rootPath; + for (const part of parts) { + current = join(current, part); + if (!existsSync(current)) break; + const stat = lstatSync(current); + if (!stat.isSymbolicLink()) continue; + const escape = inspectSymlink(projectRoot, current); + if (escape) return escape; + } + return null; +} + +export function findSymlinkEscapes(root: string, paths: string[]): Array<{ path: string; target: string }> { + const projectRoot = realpathSync(resolve(root)); + const seen = new Set(); + const escapes: Array<{ path: string; target: string }> = []; + + const visit = (path: string) => { + const absolute = resolve(path); + if (seen.has(absolute) || !existsSync(absolute)) return; + seen.add(absolute); + + const componentEscape = pathComponentEscape(root, absolute); + if (componentEscape) { + escapes.push(componentEscape); + return; + } + + const stat = lstatSync(absolute); + if (stat.isSymbolicLink()) { + const escape = inspectSymlink(projectRoot, absolute); + if (escape) escapes.push(escape); + return; + } + if (!stat.isDirectory()) return; + + for (const entry of readdirSync(absolute, { withFileTypes: true })) { + visit(join(absolute, entry.name)); + } + }; + + for (const path of paths) visit(path); + + return escapes; +} diff --git a/cli/src/lib/paths.ts b/cli/src/lib/paths.ts new file mode 100644 index 00000000..fe0a8a19 --- /dev/null +++ b/cli/src/lib/paths.ts @@ -0,0 +1,25 @@ +import { existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export function bundledLuaRoot(): string { + return resolve(__dirname, '../../lua'); +} + +export function repoLuaRoot(): string | null { + const candidate = resolve(__dirname, '../../../src-lua'); + return existsSync(join(candidate, 'feather', 'init.lua')) ? candidate : null; +} + +export function resolveLocalLuaRoot(opts: { localSrc?: string }): string { + if (opts.localSrc) return resolve(opts.localSrc); + return repoLuaRoot() ?? bundledLuaRoot(); +} + +export function findProjectDir(cwd = process.cwd()): string { + if (existsSync(join(cwd, 'feather', 'init.lua'))) return cwd; + if (existsSync(join(cwd, 'main.lua'))) return cwd; + return cwd; +} diff --git a/cli/src/lib/plugin-utils.ts b/cli/src/lib/plugin-utils.ts new file mode 100644 index 00000000..dddb35fb --- /dev/null +++ b/cli/src/lib/plugin-utils.ts @@ -0,0 +1,133 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { isAbsolute, join, relative } from 'node:path'; + +export type PluginManifest = { + id: string; + name: string; + version: string; +}; + +export type PluginTrust = 'bundled-core' | 'bundled-opt-in' | 'local' | 'remote' | 'unknown' | 'malformed'; + +export const dangerousPluginIds = new Set(['console', 'hot-reload']); + +export function parseManagedValue(src: string, key: string): string | null { + return src.match(new RegExp(`^--\\s*${key}:\\s*(.+)$`, 'm'))?.[1]?.trim() ?? null; +} + +export function findInstalledPluginDirs(root: string): string[] { + if (!existsSync(root)) return []; + + const found: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const dir = join(root, entry.name); + if (existsSync(join(dir, 'manifest.lua'))) { + found.push(dir); + } else { + found.push(...findInstalledPluginDirs(dir)); + } + } + return found; +} + +export function readPluginManifest(dir: string): PluginManifest | null { + const manifestPath = join(dir, 'manifest.lua'); + if (!existsSync(manifestPath)) return null; + const src = readFileSync(manifestPath, 'utf8'); + const get = (key: string) => src.match(new RegExp(`${key}\\s*=\\s*"([^"]+)"`))?.[1] ?? ''; + return { id: get('id'), name: get('name'), version: get('version') }; +} + +export function isValidPluginId(id: string): boolean { + return /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/.test(id) && !id.split('.').includes('..'); +} + +export function assertValidPluginId(id: string): void { + if (!isValidPluginId(id)) { + throw new Error(`Invalid plugin id: ${id}`); + } +} + +export function isValidPluginVersion(version: string): boolean { + return /^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(version); +} + +export function pluginIdToSourceDir(id: string): string { + assertValidPluginId(id); + return id.replace(/\./g, '/'); +} + +export function isSafePluginRelativePath(path: string): boolean { + const normalized = path.replace(/\\/g, '/'); + return Boolean(normalized) && !isAbsolute(normalized) && !normalized.split('/').some((part) => part === '' || part === '..'); +} + +export function validatePluginManifest( + dir: string, + options: { expectedId?: string; expectedSourceDir?: string } = {}, +): PluginManifest { + const manifest = readPluginManifest(dir); + if (!manifest) throw new Error(`Missing plugin manifest: ${join(dir, 'manifest.lua')}`); + if (!manifest.id) throw new Error(`Plugin manifest is missing id: ${join(dir, 'manifest.lua')}`); + if (!isValidPluginId(manifest.id)) throw new Error(`Plugin manifest has invalid id: ${manifest.id}`); + if (!manifest.version) throw new Error(`Plugin manifest is missing version: ${manifest.id}`); + if (!isValidPluginVersion(manifest.version)) throw new Error(`Plugin manifest has invalid version: ${manifest.id}`); + if (options.expectedId && manifest.id !== options.expectedId) { + throw new Error(`Plugin manifest id mismatch: expected ${options.expectedId}, found ${manifest.id}`); + } + if (options.expectedSourceDir) { + const expectedSourceDir = options.expectedSourceDir.replace(/\\/g, '/'); + const actualSourceDir = pluginIdToSourceDir(manifest.id); + if (actualSourceDir !== expectedSourceDir) { + throw new Error(`Plugin manifest path mismatch: ${manifest.id} should live in plugins/${actualSourceDir}`); + } + } + return manifest; +} + +export function classifyPluginTrust( + manifest: PluginManifest | null, + catalogEntry?: { optIn?: boolean } | null, +): PluginTrust { + if (!manifest?.id || !manifest.version) return 'malformed'; + if (catalogEntry) return catalogEntry.optIn ? 'bundled-opt-in' : 'bundled-core'; + return 'unknown'; +} + +export function pluginTrustLabel(trust: PluginTrust): string { + if (trust === 'bundled-core') return 'bundled/core'; + if (trust === 'bundled-opt-in') return 'opt-in bundled'; + if (trust === 'local') return 'local'; + if (trust === 'remote') return 'remote'; + if (trust === 'unknown') return 'unknown'; + return 'malformed'; +} + +export function findLocalPluginIds(sourceRoot: string): string[] { + const pluginsRoot = join(sourceRoot, 'plugins'); + if (!existsSync(pluginsRoot)) return []; + + const ids: string[] = []; + const visit = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const pluginDir = join(dir, entry.name); + const manifest = join(pluginDir, 'manifest.lua'); + if (existsSync(manifest)) { + try { + const sourceDir = relative(pluginsRoot, pluginDir).replace(/\\/g, '/'); + ids.push(validatePluginManifest(pluginDir, { expectedSourceDir: sourceDir }).id); + } catch { + // Invalid local plugins are intentionally omitted from discovery, but + // explicit install/update still validates and reports the concrete issue. + } + } else { + visit(pluginDir); + } + } + }; + + visit(pluginsRoot); + return ids.sort(); +} diff --git a/cli/src/lib/redact.ts b/cli/src/lib/redact.ts new file mode 100644 index 00000000..27c93bbd --- /dev/null +++ b/cli/src/lib/redact.ts @@ -0,0 +1,21 @@ +const SENSITIVE_KEY_RE = /^(?:apiKey|api_key|apikey|authorization|password|secret|token)$/i; +const SENSITIVE_ASSIGNMENT_RE = + /\b(apiKey|api_key|apikey|authorization|password|secret|token)\b(\s*[:=]\s*)(["']?)([^"',\s}\]]+)(\3)/gi; + +export function redactSensitiveText(value: string): string { + return value.replace(SENSITIVE_ASSIGNMENT_RE, (_match, key: string, separator: string, quote: string) => { + return `${key}${separator}${quote}[redacted]${quote}`; + }); +} + +export function redactSensitiveValue(value: T): T { + if (typeof value === "string") return redactSensitiveText(value) as T; + if (Array.isArray(value)) return value.map((item) => redactSensitiveValue(item)) as T; + if (!value || typeof value !== "object") return value; + + const output: Record = {}; + for (const [key, item] of Object.entries(value as Record)) { + output[key] = SENSITIVE_KEY_RE.test(key) ? "[redacted]" : redactSensitiveValue(item); + } + return output as T; +} diff --git a/cli/src/lib/run/mobile.ts b/cli/src/lib/run/mobile.ts new file mode 100644 index 00000000..dc52d83b --- /dev/null +++ b/cli/src/lib/run/mobile.ts @@ -0,0 +1,180 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import type { BuildArtifact } from '../build/files.js'; +import { loadBuildConfig, type BuildTarget, type LoadBuildConfigOptions } from '../build/config.js'; +import { runBuild } from '../build/build.js'; +import { androidProductId, iosBundleIdentifier } from '../build/validation.js'; +import { printMuted } from '../output.js'; +import { logNativeCommand, logNativeOutput, type NativeBuildLogger } from '../build/native.js'; + +export type MobileRunTarget = Extract; + +export type MobileRunOptions = LoadBuildConfigOptions & { + target: MobileRunTarget; + device?: string; + clean?: boolean; + noCache?: boolean; + debugger?: boolean; + runtimeConfigPath?: string; + noPlugins?: boolean; + featherOverride?: string; + pluginsOverride?: string; + verbose?: boolean; + adbReverse?: boolean; + port?: number; +}; + +export type MobileRunResult = { + target: MobileRunTarget; + projectDir: string; + artifact: string; + appId: string; + device: string; + adbReverse?: boolean; + port?: number; +}; + +export function runMobile(options: MobileRunOptions): MobileRunResult { + const buildResult = runBuild({ + target: options.target, + projectDir: options.projectDir, + configPath: options.configPath, + sourceDir: options.sourceDir, + outDir: options.outDir, + clean: options.clean, + noCache: options.noCache, + debugger: options.debugger, + runtimeConfigPath: options.runtimeConfigPath, + noPlugins: options.noPlugins, + featherOverride: options.featherOverride, + pluginsOverride: options.pluginsOverride, + allowUnsafe: true, + verbose: options.verbose, + log: options.verbose ? printMuted : undefined, + }); + + if (!buildResult.ok) { + throw new Error(buildResult.error); + } + + const config = loadBuildConfig({ + projectDir: buildResult.projectDir, + configPath: options.configPath, + sourceDir: options.sourceDir, + outDir: options.outDir, + }); + + if (options.target === 'android') { + const apk = requireArtifact(buildResult.artifacts, 'apk', 'Android APK'); + const appId = androidProductId(config); + installAndLaunchAndroid({ + apk, + appId, + device: options.device, + adbReverse: options.adbReverse !== false, + port: options.port ?? 4004, + log: options.verbose ? printMuted : undefined, + }); + return { + target: 'android', + projectDir: buildResult.projectDir, + artifact: apk, + appId, + device: options.device ?? 'default', + adbReverse: options.adbReverse !== false, + port: options.port ?? 4004, + }; + } + + const app = requireArtifact(buildResult.artifacts, 'app', 'iOS app'); + const appId = iosBundleIdentifier(config); + const device = options.device ?? 'booted'; + installAndLaunchIos({ app, appId, device, log: options.verbose ? printMuted : undefined }); + return { + target: 'ios', + projectDir: buildResult.projectDir, + artifact: app, + appId, + device, + }; +} + +function requireArtifact(artifacts: BuildArtifact[], type: string, label: string): string { + const artifact = artifacts.find((item) => item.type === type); + if (!artifact || !existsSync(artifact.path)) { + throw new Error(`${label} artifact was not found after build.`); + } + return artifact.path; +} + +function installAndLaunchAndroid(input: { + apk: string; + appId: string; + device?: string; + adbReverse: boolean; + port: number; + log?: NativeBuildLogger; +}): void { + runAdb(input.device, ['version'], 'adb not found. Run `feather doctor --build-target android` for setup guidance.', input.log); + runAdb(input.device, ['install', '-r', input.apk], 'Android install failed.', input.log); + runAdb(input.device, ['shell', 'am', 'force-stop', input.appId], 'Android force-stop failed.', input.log); + if (input.adbReverse) { + runAdb( + input.device, + ['reverse', `tcp:${input.port}`, `tcp:${input.port}`], + 'Android adb reverse failed. Check USB debugging or pass --no-adb-reverse.', + input.log, + ); + } + runAdb( + input.device, + ['shell', 'monkey', '-p', input.appId, '-c', 'android.intent.category.LAUNCHER', '1'], + 'Android launch failed.', + input.log, + ); +} + +function runAdb(device: string | undefined, args: string[], message: string, log?: NativeBuildLogger): void { + const fullArgs = device ? ['-s', device, ...args] : args; + logNativeCommand(log, 'adb', fullArgs, process.cwd()); + const streamOutput = Boolean(log); + const result = spawnSync('adb', fullArgs, { + encoding: streamOutput ? undefined : 'utf8', + stdio: streamOutput ? 'inherit' : 'pipe', + }); + if (!streamOutput) logNativeOutput(log, result.stdout, result.stderr); + if (result.error) throw new Error(`${message} ${(result.error as Error).message}`.trim()); + if (result.status !== 0) { + throw new Error(`${message} ${(result.stderr || result.stdout || `exit code ${result.status ?? 'unknown'}`).toString().trim()}`.trim()); + } +} + +function installAndLaunchIos(input: { app: string; appId: string; device: string; log?: NativeBuildLogger }): void { + runXcrunOptional(['simctl', 'terminate', input.device, input.appId], input.log); + runXcrunOptional(['simctl', 'uninstall', input.device, input.appId], input.log); + runXcrun(['simctl', 'install', input.device, input.app], 'iOS simulator install failed.', input.log); + runXcrun(['simctl', 'launch', input.device, input.appId], 'iOS simulator launch failed.', input.log); +} + +function runXcrunOptional(args: string[], log?: NativeBuildLogger): void { + logNativeCommand(log, 'xcrun', args, process.cwd()); + const result = spawnSync('xcrun', args, { + encoding: 'utf8', + stdio: 'pipe', + }); + logNativeOutput(log, result.stdout, result.stderr); +} + +function runXcrun(args: string[], message: string, log?: NativeBuildLogger): void { + logNativeCommand(log, 'xcrun', args, process.cwd()); + const streamOutput = Boolean(log); + const result = spawnSync('xcrun', args, { + encoding: streamOutput ? undefined : 'utf8', + stdio: streamOutput ? 'inherit' : 'pipe', + }); + if (!streamOutput) logNativeOutput(log, result.stdout, result.stderr); + if (result.error) throw new Error(`${message} xcrun not found. Run \`feather doctor --build-target ios\` for setup guidance.`); + if (result.status !== 0) { + throw new Error(`${message} ${(result.stderr || result.stdout || `exit code ${result.status ?? 'unknown'}`).toString().trim()}`.trim()); + } +} diff --git a/cli/src/lib/run/web.ts b/cli/src/lib/run/web.ts new file mode 100644 index 00000000..d5b23536 --- /dev/null +++ b/cli/src/lib/run/web.ts @@ -0,0 +1,187 @@ +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { createServer, type ServerResponse } from 'node:http'; +import { extname, join, normalize, relative, resolve, sep } from 'node:path'; +import type { BuildArtifact } from '../build/files.js'; +import { runBuild } from '../build/build.js'; +import type { LoadBuildConfigOptions } from '../build/config.js'; +import { printMuted } from '../output.js'; + +export type WebRunOptions = LoadBuildConfigOptions & { + clean?: boolean; + debugger?: boolean; + runtimeConfigPath?: string; + noPlugins?: boolean; + featherOverride?: string; + pluginsOverride?: string; + verbose?: boolean; + host?: string; + port?: number; +}; + +export type WebRunResult = { + target: 'web'; + projectDir: string; + htmlDir: string; + url: string; + host: string; + port: number; + debugger: boolean; + wait: Promise; +}; + +export async function runWeb(options: WebRunOptions): Promise { + const buildResult = runBuild({ + target: 'web', + projectDir: options.projectDir, + configPath: options.configPath, + sourceDir: options.sourceDir, + outDir: options.outDir, + clean: options.clean, + allowUnsafe: true, + embedDebugger: true, + debugger: options.debugger, + runtimeConfigPath: options.runtimeConfigPath, + noPlugins: options.noPlugins, + featherOverride: options.featherOverride, + pluginsOverride: options.pluginsOverride, + verbose: options.verbose, + log: options.verbose ? printMuted : undefined, + }); + + if (!buildResult.ok) { + throw new Error(buildResult.error); + } + + const htmlDir = requireArtifact(buildResult.artifacts, 'html', 'Web HTML'); + const host = options.host ?? '127.0.0.1'; + const requestedPort = options.port ?? 8000; + const { url, port, close } = process.env.FEATHER_TEST_WEB_RUN_NO_SERVER === '1' + ? { url: `http://${host}:${requestedPort}/`, port: requestedPort, close: () => {} } + : await serveStatic(htmlDir, host, requestedPort); + + const shutdown = () => { + close(); + process.exit(0); + }; + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); + + return { + target: 'web', + projectDir: buildResult.projectDir, + htmlDir, + url, + host, + port, + debugger: options.debugger !== false, + wait: new Promise(() => {}), + }; +} + +function requireArtifact(artifacts: BuildArtifact[], type: string, label: string): string { + const artifact = artifacts.find((item) => item.type === type); + if (!artifact || !existsSync(artifact.path)) { + throw new Error(`${label} artifact was not found after build.`); + } + return artifact.path; +} + +async function serveStatic(root: string, host: string, port: number): Promise<{ url: string; port: number; close: () => void }> { + const rootPath = resolve(root); + const server = createServer((request, response) => { + const url = new URL(request.url ?? '/', `http://${host}`); + const pathname = decodeURIComponent(url.pathname); + const filePath = resolvePath(rootPath, pathname); + if (!filePath) { + respondText(response, 403, 'Forbidden'); + return; + } + + const target = existsSync(filePath) && statSync(filePath).isDirectory() + ? join(filePath, 'index.html') + : filePath; + + if (!existsSync(target) || !statSync(target).isFile()) { + respondText(response, 404, 'Not found'); + return; + } + + response.writeHead(200, responseHeaders(target)); + createReadStream(target).pipe(response); + }); + + await new Promise((resolveListen, rejectListen) => { + server.once('error', rejectListen); + server.listen(port, host, () => { + server.off('error', rejectListen); + resolveListen(); + }); + }); + + const address = server.address(); + const actualPort = typeof address === 'object' && address ? address.port : port; + return { + url: `http://${host}:${actualPort}/`, + port: actualPort, + close: () => server.close(), + }; +} + +function resolvePath(root: string, pathname: string): string | null { + const normalizedPath = normalize(pathname).replace(/^(\.\.[/\\])+/, ''); + const filePath = resolve(root, `.${sep}${normalizedPath}`); + const rel = relative(root, filePath); + if (rel.startsWith('..') || rel === '..' || rel.includes(`..${sep}`)) return null; + return filePath; +} + +function respondText(response: ServerResponse, status: number, body: string): void { + response.writeHead(status, { + ...devServerHeaders(), + 'content-type': 'text/plain; charset=utf-8', + }); + response.end(body); +} + +function responseHeaders(path: string): Record { + return { + ...devServerHeaders(), + 'content-type': contentType(path), + }; +} + +function devServerHeaders(): Record { + return { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Resource-Policy': 'same-origin', + 'Content-Security-Policy': [ + "default-src 'self' data: blob:", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "media-src 'self' data: blob:", + "font-src 'self' data:", + "connect-src 'self' ws: wss: http: https:", + "worker-src 'self' blob:", + ].join('; '), + }; +} + +function contentType(path: string): string { + switch (extname(path).toLowerCase()) { + case '.html': return 'text/html; charset=utf-8'; + case '.js': return 'text/javascript; charset=utf-8'; + case '.css': return 'text/css; charset=utf-8'; + case '.json': return 'application/json; charset=utf-8'; + case '.wasm': return 'application/wasm'; + case '.data': return 'application/octet-stream'; + case '.mem': return 'application/octet-stream'; + case '.love': return 'application/octet-stream'; + case '.png': return 'image/png'; + case '.jpg': + case '.jpeg': return 'image/jpeg'; + case '.svg': return 'image/svg+xml'; + default: return 'application/octet-stream'; + } +} diff --git a/cli/src/lib/shim.ts b/cli/src/lib/shim.ts index ffd3586d..ece98565 100644 --- a/cli/src/lib/shim.ts +++ b/cli/src/lib/shim.ts @@ -5,8 +5,17 @@ import { fileURLToPath } from 'node:url'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); -// Path to the bundled Lua library shipped with this CLI package -const BUNDLED_LUA = resolve(__dirname, '../../lua'); +// Path to the bundled Lua library shipped with this CLI package. +// In a source checkout `npm run build` does not run the publish-time Lua bundle, +// so fall back to the repository's src-lua directory for local development. +const PACKAGED_LUA = resolve(__dirname, '../../lua'); +const SOURCE_LUA = resolve(__dirname, '../../../src-lua'); + +export function bundledLuaRoot(): string { + if (existsSync(join(PACKAGED_LUA, 'feather', 'auto.lua'))) return PACKAGED_LUA; + if (existsSync(join(SOURCE_LUA, 'feather', 'auto.lua'))) return SOURCE_LUA; + return PACKAGED_LUA; +} export interface ShimOptions { gamePath: string; @@ -22,16 +31,16 @@ export interface Shim { cleanup: () => void; } -function featherRoot(override?: string): string { +export function featherRoot(override?: string): string { if (override) return resolve(override); - return join(BUNDLED_LUA, 'feather'); + return join(bundledLuaRoot(), 'feather'); } -function pluginsRoot(featherDir: string, override?: string): string { +export function pluginsRoot(featherDir: string, override?: string): string { if (override) return join(resolve(override), '..', 'plugins'); const localPlugins = join(featherDir, '..', 'plugins'); if (existsSync(localPlugins)) return resolve(localPlugins); - return join(BUNDLED_LUA, 'plugins'); + return join(bundledLuaRoot(), 'plugins'); } /** Read plugin IDs from a plugins directory by checking for manifest.lua in each subdir. */ @@ -82,12 +91,13 @@ ${pluginListLine} FEATHER_PATH = "feather" FEATHER_PLUGIN_PATH = "" ${opts.noPlugins ? 'FEATHER_SKIP_PLUGINS = true' : ''} -require("feather.auto").setup({ +FEATHER_AUTO_CONFIG = { debug = true, wrapPrint = true, sessionName = os.getenv("FEATHER_SESSION_NAME") or ${JSON.stringify(sessionName)}, ${configLines} -}) +} +require("feather.auto") -- Load the game's main.lua via absolute OS path to avoid shadowing by this file local gamePath = os.getenv("FEATHER_GAME_PATH") diff --git a/cli/src/lib/trust.ts b/cli/src/lib/trust.ts new file mode 100644 index 00000000..b1116d52 --- /dev/null +++ b/cli/src/lib/trust.ts @@ -0,0 +1,19 @@ +import { style } from './output.js'; + +export function trustBadge(trust: string): string { + if (trust === 'verified') return style.success('[verified]'); + if (trust === 'known') return style.warning('[known]'); + return style.danger('[experimental]'); +} + +export function trustLabel(trust: string): string { + if (trust === 'verified') return 'verified'; + if (trust === 'known') return 'known'; + return 'experimental'; +} + +export function trustColor(trust: string): string { + if (trust === 'verified') return 'green'; + if (trust === 'known') return 'yellow'; + return 'red'; +} diff --git a/cli/src/lib/url.ts b/cli/src/lib/url.ts new file mode 100644 index 00000000..00ab5a6e --- /dev/null +++ b/cli/src/lib/url.ts @@ -0,0 +1,7 @@ +export function fileNameFromUrl(url: string): string { + try { + return new URL(url).pathname.split('/').filter(Boolean).pop() ?? 'file.lua'; + } catch { + return url.split('/').pop() ?? 'file.lua'; + } +} diff --git a/cli/src/ui/components.tsx b/cli/src/ui/components.tsx new file mode 100644 index 00000000..875ce957 --- /dev/null +++ b/cli/src/ui/components.tsx @@ -0,0 +1,495 @@ +import React, { useState, useEffect, type ReactNode } from 'react'; +import { Text, Box, useInput } from 'ink'; +import { createHash } from 'node:crypto'; +import { useTextInput } from '../hooks/use-text-input.js'; + +export interface UrlFile { + name: string; + url: string; + sha256: string; + target: string; + buffer: Buffer; +} + +export function CursorText({ before, at, after }: { before: string; at: string; after: string }) { + return ( + + {before} + {at || ' '} + {after} + + ); +} + +export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +export function Spinner({ label }: { label: string }) { + const [frame, setFrame] = useState(0); + useEffect(() => { + const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80); + return () => clearInterval(id); + }, []); + return ( + + {SPINNER_FRAMES[frame]} + {label} + + ); +} + +export function Header({ step, total, title }: { step?: number; total?: number; title?: string }) { + return ( + + + {' '} + {title ?? 'feather package add'} + + {step !== undefined && total !== undefined && {` Step ${step} of ${total}`}} + + ); +} + +export function Hint({ children }: { children: string }) { + return ( + + {' '} + {children} + + ); +} + +export function TextInputStep({ + stepNum, + total, + label, + hint, + defaultValue = '', + validate, + onSubmit, + title, +}: { + stepNum?: number; + total?: number; + label: string; + hint?: string; + defaultValue?: string; + validate?: (v: string) => string | null; + onSubmit: (value: string) => void; + title?: string; +}) { + const inp = useTextInput(defaultValue); + const [error, setError] = useState(null); + + useInput((char, key) => { + if (key.return) { + const err = validate ? validate(inp.value.trim()) : null; + if (err) { + setError(err); + return; + } + onSubmit(inp.value.trim()); + return; + } + inp.handleKey(char, key); + setError(null); + }); + + return ( + +
+ + {' '} + {label} + + {hint && {hint}} + + {' '} + + + {error && ( + + + {' ✖ '} + {error} + + + )} + + {' ←→ move · Backspace/Delete edit · Enter confirm'} + + + ); +} + +export function SelectStep({ + stepNum, + total, + label, + hint, + options, + labels, + descriptions, + initialIndex = 0, + onSelect, + onCancel, + title, +}: { + stepNum?: number; + total?: number; + label: string; + hint?: string; + options: string[]; + labels?: string[]; + descriptions?: string[]; + initialIndex?: number; + onSelect: (value: string) => void; + onCancel?: () => void; + title?: string; +}) { + const [cursor, setCursor] = useState(initialIndex); + + useInput((input, key) => { + if (key.escape) { onCancel?.(); return; } + if (key.upArrow || input === 'k') setCursor((c) => Math.max(0, c - 1)); + else if (key.downArrow || input === 'j') setCursor((c) => Math.min(options.length - 1, c + 1)); + else { + const n = Number(input); + if (n >= 1 && n <= options.length) { setCursor(n - 1); return; } + } + if (key.return) onSelect(options[cursor]!); + }); + + return ( + +
+ + {' '} + {label} + + {hint && {hint}} + + {options.map((opt, i) => ( + + + {' '} + {i === cursor ? '❯ ' : ' '} + {labels?.[i] ?? opt} + + {descriptions?.[i] && {' '}{descriptions[i]}} + + ))} + + + {' ↑↓ or j/k navigate · Enter select'} + + + ); +} + +export function MultiSelectStep({ + stepNum, + total, + label, + hint, + options, + labels, + descriptions, + initialSelected, + onSubmit, + onCancel, + title, +}: { + stepNum?: number; + total?: number; + label: string; + hint?: string; + options: string[]; + labels?: string[]; + descriptions?: string[]; + initialSelected?: Set; + onSubmit: (selected: string[]) => void; + onCancel?: () => void; + title?: string; +}) { + const [cursor, setCursor] = useState(0); + const [selected, setSelected] = useState>(initialSelected ?? new Set(options.map((_, i) => i))); + + useInput((input, key) => { + if (key.escape) { onCancel?.(); return; } + if (key.upArrow || input === 'k') setCursor((c) => Math.max(0, c - 1)); + else if (key.downArrow || input === 'j') setCursor((c) => Math.min(options.length - 1, c + 1)); + else if (input === 'a') setSelected(new Set(options.map((_, i) => i))); + else if (input === ' ') { + setSelected((s) => { + const next = new Set(s); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + next.has(cursor) ? next.delete(cursor) : next.add(cursor); + return next; + }); + } + if (key.return) { + const chosen = options.filter((_, i) => selected.has(i)); + if (chosen.length > 0) onSubmit(chosen); + } + }); + + return ( + +
+ + {' '} + {label} + + {hint && {hint}} + + {options.map((opt, i) => ( + + + {' '} + {i === cursor ? '❯ ' : ' '} + {selected.has(i) ? '◉ ' : '○ '} + {labels?.[i] ?? opt} + + {descriptions?.[i] && {' '}{descriptions[i]}} + + ))} + + + {' ↑↓ or j/k navigate · Space toggle · a select all · Enter confirm'} + + + ); +} + +export function BooleanStep({ + stepNum, + total, + title, + label, + hint, + children, + defaultYes = true, + tone = 'default', + onConfirm, + onCancel, +}: { + stepNum?: number; + total?: number; + title?: string; + label: string; + hint?: string; + children?: ReactNode; + defaultYes?: boolean; + tone?: 'default' | 'danger'; + onConfirm: () => void; + onCancel: () => void; +}) { + const [yes, setYes] = useState(defaultYes); + + useInput((input, key) => { + if (key.escape) { onCancel(); return; } + if (input === 'y' || input === 'Y' || key.leftArrow) setYes(true); + else if (input === 'n' || input === 'N' || key.rightArrow) setYes(false); + else if (key.return) { + if (yes) onConfirm(); + else onCancel(); + } + }); + + return ( + +
+ {' '}{label} + {hint && {hint}} + {children} + + {' '} + Yes + / + No + + + {' y/← = yes · n/→ = no · Enter confirm'} + + + ); +} + +export function AutoStep({ + label, + run, + onError, +}: { + label: string; + run: () => Promise; + onError: (msg: string) => void; +}) { + const [status, setStatus] = useState<'running' | 'done' | 'error'>('running'); + const [error, setError] = useState(''); + + useEffect(() => { + run() + .then(() => setStatus('done')) + .catch((err: Error) => { + setError(err.message); + setStatus('error'); + onError(err.message); + }); + }, []); + + return ( + + {status === 'running' && } + {status === 'done' && ✔ {label}} + {status === 'error' && ✖ {error}} + + ); +} + +export function TargetsStep({ + stepNum, + total, + id, + files, + initialTargets, + onSubmit, + title, +}: { + stepNum?: number; + total?: number; + id: string; + files: string[]; + initialTargets?: Record; + onSubmit: (targets: Record) => void; + title?: string; +}) { + const defaultTarget = (f: string) => initialTargets?.[f] ?? (files.length === 1 ? `lib/${f}` : `lib/${id}/${f}`); + const [index, setIndex] = useState(0); + const [targets, setTargets] = useState>( + Object.fromEntries(files.map((f) => [f, defaultTarget(f)])), + ); + const inp = useTextInput(defaultTarget(files[0]!)); + const [error, setError] = useState(null); + const file = files[index]!; + + useInput((char, key) => { + if (key.return) { + const val = inp.value.trim(); + if (!val) { + setError('Required'); + return; + } + if (!val.endsWith('.lua')) { + setError('Must end in .lua'); + return; + } + const next = { ...targets, [file]: val }; + setTargets(next); + if (index + 1 < files.length) { + const nextFile = files[index + 1]!; + setIndex(index + 1); + inp.reset(next[nextFile] ?? defaultTarget(nextFile)); + setError(null); + } else { + onSubmit(next); + } + return; + } + inp.handleKey(char, key); + setError(null); + }); + + return ( + +
+ {' '}Install target paths + {`File ${index + 1} of ${files.length}: ${file}`} + + {' '} + + + {error && ( + + + {' ✖ '} + {error} + + + )} + + {' ←→ move · Backspace/Delete edit · Enter confirm'} + + + ); +} + +export function FileFetchStep({ + url, + onDone, + onError, +}: { + url: string; + onDone: (sha256: string, buffer: Buffer) => void; + onError: (msg: string) => void; +}) { + const [done, setDone] = useState(false); + const [sha, setSha] = useState(''); + + useEffect(() => { + const run = async () => { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); + const buf = Buffer.from(await res.arrayBuffer()); + const hash = createHash('sha256').update(buf).digest('hex'); + setSha(hash); + setDone(true); + onDone(hash, buf); + }; + run().catch((err: Error) => onError(err.message)); + }, []); + + return ( + + {done ? ✔ sha256: {sha.slice(0, 16)}… : } + + ); +} + +export function FileMoreStep({ + urlFiles, + step = 2, + total, + onYes, + onNo, +}: { + urlFiles: UrlFile[]; + step?: number; + total?: number; + onYes: () => void; + onNo: () => void; +}) { + useInput((input, key) => { + if (input === 'y' || input === 'Y') onYes(); + else if (input === 'n' || input === 'N' || key.return || key.escape) onNo(); + }); + + return ( + +
+ {' '}Add another file? + + {urlFiles.map((f) => ( + + {' ✔ '} + {f.name} + {' '} + → {f.target} + + ))} + + + {' y = add another · n/Enter = done'} + + + ); +} diff --git a/cli/src/ui/confirm.tsx b/cli/src/ui/confirm.tsx new file mode 100644 index 00000000..df3f6fc7 --- /dev/null +++ b/cli/src/ui/confirm.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Text, render, useApp } from 'ink'; +import { BooleanStep } from './components.js'; + +export type ConfirmActionOptions = { + title?: string; + label: string; + hint?: string; + rows?: string[]; + danger?: boolean; + defaultYes?: boolean; +}; + +function ConfirmAction({ + title, + label, + hint, + rows = [], + danger, + defaultYes, + onComplete, +}: ConfirmActionOptions & { onComplete: (confirmed: boolean) => void }) { + const { exit } = useApp(); + const finish = (confirmed: boolean) => { + onComplete(confirmed); + exit(); + }; + + return ( + finish(true)} + onCancel={() => finish(false)} + > + {rows.map((row) => ( + + {' - '} + {row} + + ))} + + ); +} + +export async function confirmAction(options: ConfirmActionOptions): Promise { + let confirmed = false; + const { waitUntilExit } = render( { confirmed = value; }} />); + await waitUntilExit(); + return confirmed; +} diff --git a/cli/src/ui/init/config.ts b/cli/src/ui/init/config.ts new file mode 100644 index 00000000..6df1cadd --- /dev/null +++ b/cli/src/ui/init/config.ts @@ -0,0 +1,93 @@ +import { + dangerousInsecureConnection, + numericValue, + parseCapabilities, + type InitMode, + type InitSetup, +} from "./model.js"; + +export type InitSetupState = { + mode: InitMode; + installSource: "local" | "remote"; + branch: string; + installDir: string; + installPlugins: boolean; + pluginPromptsEnabled: boolean; + include: Set; + exclude: Set; + advanced: boolean; + sessionName: string; + host: string; + port: string; + socketModeIndex: number; + baseDir: string; + sampleRate: string; + updateInterval: string; + maxTempLogs: string; + outputDir: string; + retryInterval: string; + connectTimeout: string; + errorWait: string; + binaryTextThreshold: string; + deviceId: string; + capabilities: string; + toggles: Set; + needsApiKey: boolean; + apiKey: string; + appIdInput: string; +}; + +export function buildInitSetup(input: InitSetupState): InitSetup { + const config: Record = {}; + if (input.sessionName.trim()) config.sessionName = input.sessionName.trim(); + if (input.pluginPromptsEnabled && input.include.size > 0) config.include = [...input.include]; + if (input.pluginPromptsEnabled && input.exclude.size > 0) config.exclude = [...input.exclude]; + + if (input.advanced) { + config.debug = input.toggles.has("debug"); + config.host = input.host.trim() || "127.0.0.1"; + config.port = numericValue(input.port, 4004); + config.mode = input.socketModeIndex === 0 ? "socket" : "disk"; + if (input.baseDir.trim()) config.baseDir = input.baseDir.trim(); + config.wrapPrint = input.toggles.has("wrapPrint"); + config.maxTempLogs = numericValue(input.maxTempLogs, 200); + config.sampleRate = numericValue(input.sampleRate, 1); + config.updateInterval = numericValue(input.updateInterval, 0.1); + config.defaultObservers = input.toggles.has("defaultObservers"); + config.errorWait = numericValue(input.errorWait, 3); + config.autoRegisterErrorHandler = input.toggles.has("autoRegisterErrorHandler"); + config.captureScreenshot = input.toggles.has("captureScreenshot"); + config.writeToDisk = input.toggles.has("writeToDisk"); + config.outputDir = input.outputDir.trim() || "logs"; + config.capabilities = parseCapabilities(input.capabilities); + config.retryInterval = numericValue(input.retryInterval, 5); + config.connectTimeout = numericValue(input.connectTimeout, 2); + config.debugger = input.toggles.has("debugger"); + config.assetPreview = input.toggles.has("assetPreview"); + config.binaryTextThreshold = numericValue(input.binaryTextThreshold, 4096); + if (input.deviceId.trim()) config.deviceId = input.deviceId.trim(); + } + + if (input.needsApiKey) { + config.apiKey = input.apiKey; + config.pluginOptions = { + console: { evalEnabled: true }, + }; + } + + if (input.appIdInput.trim()) { + config.appId = input.appIdInput.trim(); + } else { + config[dangerousInsecureConnection] = true; + } + + return { + mode: input.mode, + source: input.installSource, + branch: input.branch.trim() || "main", + installDir: input.installDir.trim() || "feather", + installPlugins: input.installPlugins, + config, + exclude: input.pluginPromptsEnabled ? [...input.exclude] : [], + }; +} diff --git a/cli/src/ui/init/index.ts b/cli/src/ui/init/index.ts new file mode 100644 index 00000000..19ac23fe --- /dev/null +++ b/cli/src/ui/init/index.ts @@ -0,0 +1,2 @@ +export type { InitMode, InitSetup } from './model.js'; +export { chooseInitMode } from './workflow.js'; diff --git a/cli/src/ui/init/model.ts b/cli/src/ui/init/model.ts new file mode 100644 index 00000000..607932de --- /dev/null +++ b/cli/src/ui/init/model.ts @@ -0,0 +1,192 @@ +import type { ReactNode } from "react"; +import { pluginCatalog } from "../../generated/plugin-catalog.js"; + +export type InitMode = "cli" | "auto" | "manual"; + +export type InitSetup = { + mode: InitMode; + source: "local" | "remote"; + branch: string; + installDir: string; + installPlugins: boolean; + config: Record; + exclude: string[]; +}; + +export type Phase = + | "mode" + | "installDir" + | "source" + | "branch" + | "sessionName" + | "installPlugins" + | "include" + | "exclude" + | "advanced" + | "host" + | "port" + | "modeConfig" + | "baseDir" + | "sampleRate" + | "updateInterval" + | "maxTempLogs" + | "outputDir" + | "retryInterval" + | "connectTimeout" + | "errorWait" + | "binaryTextThreshold" + | "deviceId" + | "capabilities" + | "toggles" + | "apiKey" + | "appId" + | "summary"; + +export type Option = { + value: T; + label: string; + description?: string; +}; + +export type Tone = "info" | "success" | "warning" | "danger"; + +export type SummaryRow = { + id: string; + label: string; + value: ReactNode; + tone?: Tone; +}; + +export const dangerousInsecureConnection = "__DANGEROUS_INSECURE_CONNECTION__"; +export const dangerousPluginIds = new Set(["console", "hot-reload"]); +export const dangerousToggleIds = new Set(["debugger", "captureScreenshot"]); +export const warningToggleIds = new Set(["autoRegisterErrorHandler", "writeToDisk"]); + +export const toneColor = (tone?: Tone) => { + if (tone === "danger") return "red"; + if (tone === "warning") return "yellow"; + if (tone === "success") return "green"; + if (tone === "info") return "cyan"; + return undefined; +}; + +export const pluginTone = (value: string): Tone | undefined => (dangerousPluginIds.has(value) ? "danger" : undefined); +export const toggleTone = (value: string): Tone | undefined => { + if (dangerousToggleIds.has(value)) return "danger"; + if (warningToggleIds.has(value)) return "warning"; + return undefined; +}; + +export const modes: Option[] = [ + { + value: "cli", + label: "CLI injection", + description: "Create feather.config.lua only. Run with `feather run .`.", + }, + { + value: "auto", + label: "Auto require", + description: 'Patch main.lua with a USE_DEBUGGER-guarded require("feather.auto").', + }, + { + value: "manual", + label: "Manual setup", + description: "Create feather.debugger.lua and load it when USE_DEBUGGER is set.", + }, +]; + +export const installSources: Option<"local" | "remote">[] = [ + { + value: "local", + label: "Bundled/local copy", + description: "Copy the CLI-bundled Lua runtime, or src-lua when running from the repo.", + }, + { + value: "remote", + label: "GitHub download", + description: "Fetch files from GitHub using the selected branch or tag.", + }, +]; + +const pluginToOption = (plugin: (typeof pluginCatalog)[number]): Option => ({ + value: plugin.id, + label: plugin.name, + description: plugin.description, +}); + +export const optionalPlugins: Option[] = pluginCatalog.filter((plugin) => plugin.optIn).map(pluginToOption); +export const skipPluginOptions: Option[] = pluginCatalog.map(pluginToOption); +export const defaultSkippedPlugins = new Set(["console", "hot-reload", "hump.signal", "lua-state-machine"]); + +export const configToggles: Option[] = [ + { value: "debug", label: "Enable Feather", description: "Set debug = true." }, + { value: "wrapPrint", label: "Wrap print()", description: "Send print output to Logs." }, + { value: "defaultObservers", label: "Default observers", description: "Capture built-in runtime observers." }, + { + value: "autoRegisterErrorHandler", + label: "Error handler", + description: "Capture Lua errors before LÖVE shows its handler.", + }, + { value: "writeToDisk", label: "Write logs to disk", description: "Persist .featherlog files." }, + { value: "debugger", label: "Step debugger", description: "Enable debugger commands by default." }, + { value: "captureScreenshot", label: "Error screenshots", description: "Capture screenshots on errors." }, + { value: "assetPreview", label: "Asset previews", description: "Track loaded assets for the Assets tab." }, +]; + +export const numberPhases = new Set([ + "port", + "sampleRate", + "updateInterval", + "maxTempLogs", + "retryInterval", + "connectTimeout", + "errorWait", + "binaryTextThreshold", +]); + +export const textPromptTitles: Partial> = { + installDir: "Install directory", + branch: "GitHub branch or tag", + sessionName: "Session name shown in Feather", + host: "Desktop host", + port: "Desktop WebSocket port", + baseDir: "Base directory for file links", + sampleRate: "Sample rate in seconds", + updateInterval: "Update interval in seconds", + maxTempLogs: "Max temporary logs", + outputDir: "Output directory for logs", + retryInterval: "Reconnect interval in seconds", + connectTimeout: "Connect timeout in seconds", + errorWait: "Error delivery wait in seconds", + binaryTextThreshold: "Binary text threshold in bytes", + deviceId: "Device ID override", + capabilities: 'Capabilities ("all" or comma-separated)', +}; + +export const isStrongApiKey = (value: string) => { + if (value.length < 16) return false; + if (/^(password|secret|changeme|dev|test|console|apikey)$/i.test(value)) return false; + + const classes = [ + /[a-z]/.test(value), + /[A-Z]/.test(value), + /\d/.test(value), + /[^A-Za-z0-9]/.test(value), + ].filter(Boolean).length; + + return classes >= 3 || value.length >= 24; +}; + +export const numericValue = (value: string, fallback: number) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +export const parseCapabilities = (value: string): string[] | "all" => { + const trimmed = value.trim(); + if (!trimmed || trimmed.toLowerCase() === "all") return "all"; + return trimmed + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +}; diff --git a/cli/src/ui/init/prompts.tsx b/cli/src/ui/init/prompts.tsx new file mode 100644 index 00000000..bf325688 --- /dev/null +++ b/cli/src/ui/init/prompts.tsx @@ -0,0 +1,188 @@ +import React, { type ReactNode } from "react"; +import { Box, Text } from "ink"; +import { toneColor, type Option, type SummaryRow, type Tone } from "./model.js"; + +export function DangerousName({ children }: { children: string }) { + return ( + + {children} + + ); +} + +export function NameList({ + values, + getTone, +}: { + values: string[]; + getTone?: (value: string) => Tone | undefined; +}) { + if (values.length === 0) return (none); + + return ( + <> + {values.map((value, index) => ( + + {index > 0 ? , : null} + {value} + + ))} + + ); +} + +function SummaryValue({ value, tone }: { value: ReactNode; tone?: Tone }) { + if (typeof value === "string") return {value}; + return <>{value}; +} + +export function SummaryRows({ rows }: { rows: SummaryRow[] }) { + return ( + + {rows.map((row) => ( + + {row.label.padEnd(17)} + + + ))} + + ); +} + +export function InfoPanel({ + title, + tone = "info", + children, +}: { + title: string; + tone?: Tone; + children: ReactNode; +}) { + return ( + + + {title} + + + {children} + + + ); +} + +function cursorLine(active: boolean, text: string, description?: string, tone?: Tone) { + return ( + + + {active ? "❯" : " "}{" "} + {text} + + {description ? {description} : null} + + ); +} + +export function TextInputPrompt({ + title, + value, + placeholder, + secure, + error, +}: { + title: string; + value: string; + placeholder?: string; + secure?: boolean; + error?: string; +}) { + const shown = secure && value ? "•".repeat(value.length) : value; + return ( + + {title} + + {shown || placeholder || " "} + + {error ? {error} : ←→ move · Backspace delete · Enter confirm} + + ); +} + +export function SingleSelect({ + title, + options, + selected, + getTone, +}: { + title: string; + options: Option[]; + selected: number; + getTone?: (option: Option) => Tone | undefined; +}) { + return ( + + {title} + + {options.map((option, index) => ( + + {cursorLine(index === selected, `${index + 1}. ${option.label}`, option.description, getTone?.(option))} + + ))} + + ↑↓ or j/k navigate · 1-{options.length} jump · Enter select + + ); +} + +export function MultiSelect({ + title, + options, + selected, + cursor, + getTone, + hint = "↑↓ or j/k navigate · Space toggle · a select all · Enter confirm", +}: { + title: string; + options: Option[]; + selected: Set; + cursor: number; + getTone?: (option: Option) => Tone | undefined; + hint?: string; +}) { + return ( + + {title} + + {options.length === 0 ? No options available. : null} + {options.map((option, index) => { + const tone = getTone?.(option); + return ( + + + + {index === cursor ? "❯" : " "} {selected.has(option.value) ? "◉" : "○"} + {" "} + {option.label} + + {option.description ? {option.description} : null} + + ); + })} + + {hint} + + ); +} + +export function ConfirmPrompt({ title, value }: { title: string; value: boolean }) { + return ( + + {title} + + Yes + / + No + + y/← = yes · n/→ = no · Enter confirm + + ); +} diff --git a/cli/src/ui/init/summary.tsx b/cli/src/ui/init/summary.tsx new file mode 100644 index 00000000..26afd482 --- /dev/null +++ b/cli/src/ui/init/summary.tsx @@ -0,0 +1,83 @@ +import { Text } from "ink"; +import { + dangerousInsecureConnection, + installSources, + modes, + pluginTone, + type SummaryRow, +} from "./model.js"; +import { DangerousName, NameList } from "./prompts.js"; + +export type InitSummaryInput = { + advanced: boolean; + apiKeyCopied: boolean | null; + appIdInput: string; + branch: string; + exclude: Set; + include: Set; + installDir: string; + installPlugins: boolean; + installsFiles: boolean; + installSource: "local" | "remote"; + modeIndex: number; + needsApiKey: boolean; + pluginPromptsEnabled: boolean; + sessionName: string; + sourceIndex: number; +}; + +export function buildInitSummaryRows(input: InitSummaryInput): SummaryRow[] { + const includeList = [...input.include]; + const excludeList = [...input.exclude]; + const rows: SummaryRow[] = [ + { id: "mode", label: "Mode", value: modes[input.modeIndex].label }, + { + id: "install-dir", + label: "Install dir", + value: input.installsFiles ? `${input.installDir || "feather"}/` : "bundled CLI runtime", + }, + input.installsFiles ? { id: "source", label: "Source", value: installSources[input.sourceIndex].label } : undefined, + input.installsFiles && input.installSource === "remote" ? { id: "branch", label: "Branch", value: input.branch || "main" } : undefined, + input.installsFiles ? { id: "plugins", label: "Install plugins", value: input.installPlugins ? "yes" : "no" } : undefined, + { id: "session", label: "Session", value: input.sessionName || "(default)" }, + input.pluginPromptsEnabled + ? { id: "include", label: "Include", value: } + : undefined, + input.pluginPromptsEnabled + ? { id: "exclude", label: "Exclude", value: } + : undefined, + { id: "advanced", label: "Advanced config", value: input.advanced ? "yes" : "no", tone: input.advanced ? "warning" : undefined }, + ].filter(Boolean) as SummaryRow[]; + + if (input.needsApiKey) { + rows.push({ + id: "api-key", + label: "Console API key", + value: + input.apiKeyCopied === true + ? "set and copied to clipboard" + : input.apiKeyCopied === false + ? "set (clipboard copy unavailable)" + : "set", + tone: "success", + }); + } + + rows.push( + input.appIdInput.trim() + ? { id: "app-id", label: "App ID", value: input.appIdInput.trim(), tone: "success" } + : { + id: "app-id", + label: "App ID", + value: ( + <> + not set → + {dangerousInsecureConnection} + = true + + ), + tone: "danger", + }, + ); + return rows; +} diff --git a/cli/src/ui/init-mode.tsx b/cli/src/ui/init/workflow.tsx similarity index 51% rename from cli/src/ui/init-mode.tsx rename to cli/src/ui/init/workflow.tsx index c0521240..15b914b5 100644 --- a/cli/src/ui/init-mode.tsx +++ b/cli/src/ui/init/workflow.tsx @@ -1,272 +1,36 @@ -import React, { useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Box, Text, render, useApp, useInput } from "ink"; -import { copyToClipboard } from "../lib/clipboard.js"; -import { pluginCatalog } from "../generated/plugin-catalog.js"; - -export type InitMode = "cli" | "auto" | "manual"; - -export type InitSetup = { - mode: InitMode; - source: "local" | "remote"; - branch: string; - installDir: string; - installPlugins: boolean; - config: Record; - exclude: string[]; -}; - -type Phase = - | "mode" - | "installDir" - | "source" - | "branch" - | "sessionName" - | "installPlugins" - | "include" - | "exclude" - | "advanced" - | "host" - | "port" - | "modeConfig" - | "baseDir" - | "sampleRate" - | "updateInterval" - | "maxTempLogs" - | "outputDir" - | "retryInterval" - | "connectTimeout" - | "errorWait" - | "binaryTextThreshold" - | "deviceId" - | "capabilities" - | "toggles" - | "apiKey" - | "appId" - | "summary"; - -type Option = { - value: T; - label: string; - description?: string; -}; - -const modes: Option[] = [ - { - value: "cli", - label: "CLI injection", - description: "Create feather.config.lua only. Run with `feather run .`.", - }, - { - value: "auto", - label: "Auto require", - description: 'Patch main.lua with a USE_DEBUGGER-guarded require("feather.auto").', - }, - { - value: "manual", - label: "Manual setup", - description: "Create feather.debugger.lua and load it when USE_DEBUGGER is set.", - }, -]; - -const installSources: Option<"local" | "remote">[] = [ - { - value: "local", - label: "Bundled/local copy", - description: "Copy the CLI-bundled Lua runtime, or src-lua when running from the repo.", - }, - { - value: "remote", - label: "GitHub download", - description: "Fetch files from GitHub using the selected branch or tag.", - }, -]; - -const pluginToOption = (plugin: (typeof pluginCatalog)[number]): Option => ({ - value: plugin.id, - label: plugin.name, - description: plugin.description, -}); - -const optionalPlugins: Option[] = pluginCatalog.filter((plugin) => plugin.optIn).map(pluginToOption); -const skipPluginOptions: Option[] = pluginCatalog.map(pluginToOption); -const defaultSkippedPlugins = new Set(["console", "hot-reload", "hump.signal", "lua-state-machine"]); - -const configToggles: Option[] = [ - { value: "debug", label: "Enable Feather", description: "Set debug = true." }, - { value: "wrapPrint", label: "Wrap print()", description: "Send print output to Logs." }, - { value: "defaultObservers", label: "Default observers", description: "Capture built-in runtime observers." }, - { - value: "autoRegisterErrorHandler", - label: "Error handler", - description: "Capture Lua errors before LÖVE shows its handler.", - }, - { value: "writeToDisk", label: "Write logs to disk", description: "Persist .featherlog files." }, - { value: "debugger", label: "Step debugger", description: "Enable debugger commands by default." }, - { value: "captureScreenshot", label: "Error screenshots", description: "Capture screenshots on errors." }, - { value: "assetPreview", label: "Asset previews", description: "Track loaded assets for the Assets tab." }, -]; - -const numberPhases = new Set([ - "port", - "sampleRate", - "updateInterval", - "maxTempLogs", - "retryInterval", - "connectTimeout", - "errorWait", - "binaryTextThreshold", -]); - -const textPromptTitles: Partial> = { - installDir: "Install directory", - branch: "GitHub branch or tag", - sessionName: "Session name shown in Feather", - host: "Desktop host", - port: "Desktop WebSocket port", - baseDir: "Base directory for file links", - sampleRate: "Sample rate in seconds", - updateInterval: "Update interval in seconds", - maxTempLogs: "Max temporary logs", - outputDir: "Output directory for logs", - retryInterval: "Reconnect interval in seconds", - connectTimeout: "Connect timeout in seconds", - errorWait: "Error delivery wait in seconds", - binaryTextThreshold: "Binary text threshold in bytes", - deviceId: "Device ID override", - capabilities: 'Capabilities ("all" or comma-separated)', -}; - -const isStrongApiKey = (value: string) => { - if (value.length < 16) return false; - if (/^(password|secret|changeme|dev|test|console|apikey)$/i.test(value)) return false; - - const classes = [ - /[a-z]/.test(value), - /[A-Z]/.test(value), - /\d/.test(value), - /[^A-Za-z0-9]/.test(value), - ].filter(Boolean).length; - - return classes >= 3 || value.length >= 24; -}; - -const numericValue = (value: string, fallback: number) => { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : fallback; -}; - -const parseCapabilities = (value: string): string[] | "all" => { - const trimmed = value.trim(); - if (!trimmed || trimmed.toLowerCase() === "all") return "all"; - return trimmed - .split(",") - .map((item) => item.trim()) - .filter(Boolean); -}; - -function cursorLine(active: boolean, text: string, description?: string) { - return ( - - - {active ? "›" : " "} {text} - - {description ? {description} : null} - - ); -} - -function TextInputPrompt({ - title, - value, - placeholder, - secure, - error, -}: { - title: string; - value: string; - placeholder?: string; - secure?: boolean; - error?: string; -}) { - const shown = secure && value ? "•".repeat(value.length) : value; - return ( - - {title} - - {shown || placeholder || " "} - - {error ? {error} : Enter to continue. Backspace edits.} - - ); -} - -function SingleSelect({ - title, - options, - selected, -}: { - title: string; - options: Option[]; - selected: number; -}) { - return ( - - {title} - - {options.map((option, index) => ( - {cursorLine(index === selected, `${index + 1}. ${option.label}`, option.description)} - ))} - - Use ↑/↓, j/k, 1-{options.length}, then Enter. - - ); -} - -function MultiSelect({ - title, - options, - selected, - cursor, - hint = "Space toggles, Enter continues.", -}: { - title: string; - options: Option[]; - selected: Set; - cursor: number; - hint?: string; -}) { - return ( - - {title} - - {options.length === 0 ? No options available. : null} - {options.map((option, index) => ( - - - {index === cursor ? "›" : " "} {selected.has(option.value) ? "●" : "○"} {option.label} - - {option.description ? {option.description} : null} - - ))} - - {hint} - - ); -} - -function ConfirmPrompt({ title, value }: { title: string; value: boolean }) { - return ( - - {title} - - Yes - / - No - - Use y/n, ←/→, then Enter. - - ); -} +import { copyToClipboard } from "../../lib/clipboard.js"; +import { buildInitSetup } from "./config.js"; +import { + configToggles, + dangerousInsecureConnection, + defaultSkippedPlugins, + installSources, + isStrongApiKey, + modes, + numberPhases, + optionalPlugins, + pluginTone, + skipPluginOptions, + textPromptTitles, + toggleTone, + type InitMode, + type InitSetup, + type Phase, + type SummaryRow, +} from "./model.js"; +import { + ConfirmPrompt, + DangerousName, + InfoPanel, + MultiSelect, + NameList, + SingleSelect, + SummaryRows, + TextInputPrompt, +} from "./prompts.js"; +import { buildInitSummaryRows } from "./summary.js"; function InitSetupPrompt({ defaultMode, @@ -347,58 +111,36 @@ function InitSetupPrompt({ const nextAfterToggles = () => setPhase(needsApiKey ? "apiKey" : "appId"); const finish = () => { - const config: Record = {}; - if (sessionName.trim()) config.sessionName = sessionName.trim(); - if (pluginPromptsEnabled && include.size > 0) config.include = [...include]; - if (pluginPromptsEnabled && exclude.size > 0) config.exclude = [...exclude]; - - if (advanced) { - config.debug = toggles.has("debug"); - config.host = host.trim() || "127.0.0.1"; - config.port = numericValue(port, 4004); - config.mode = socketModeIndex === 0 ? "socket" : "disk"; - if (baseDir.trim()) config.baseDir = baseDir.trim(); - config.wrapPrint = toggles.has("wrapPrint"); - config.maxTempLogs = numericValue(maxTempLogs, 200); - config.sampleRate = numericValue(sampleRate, 1); - config.updateInterval = numericValue(updateInterval, 0.1); - config.defaultObservers = toggles.has("defaultObservers"); - config.errorWait = numericValue(errorWait, 3); - config.autoRegisterErrorHandler = toggles.has("autoRegisterErrorHandler"); - config.captureScreenshot = toggles.has("captureScreenshot"); - config.writeToDisk = toggles.has("writeToDisk"); - config.outputDir = outputDir.trim() || "logs"; - config.capabilities = parseCapabilities(capabilities); - config.retryInterval = numericValue(retryInterval, 5); - config.connectTimeout = numericValue(connectTimeout, 2); - config.debugger = toggles.has("debugger"); - config.assetPreview = toggles.has("assetPreview"); - config.binaryTextThreshold = numericValue(binaryTextThreshold, 4096); - if (deviceId.trim()) config.deviceId = deviceId.trim(); - } - - if (needsApiKey) { - config.apiKey = apiKey; - config.pluginOptions = { - console: { evalEnabled: true }, - }; - } - - if (appIdInput.trim()) { - config.appId = appIdInput.trim(); - } else { - config.__DANGEROUS_INSECURE_CONNECTION__ = true; - } - - onComplete({ + onComplete(buildInitSetup({ mode, - source: installSource, - branch: branch.trim() || "main", - installDir: installDir.trim() || "feather", + installSource, + branch, + installDir, installPlugins, - config, - exclude: pluginPromptsEnabled ? [...exclude] : [], - }); + pluginPromptsEnabled, + include, + exclude, + advanced, + sessionName, + host, + port, + socketModeIndex, + baseDir, + sampleRate, + updateInterval, + maxTempLogs, + outputDir, + retryInterval, + connectTimeout, + errorWait, + binaryTextThreshold, + deviceId, + capabilities, + toggles, + needsApiKey, + apiKey, + appIdInput, + })); exit(); }; @@ -602,30 +344,23 @@ function InitSetupPrompt({ if (key.return) finish(); }); - const summary = useMemo(() => { - const rows = [ - `Mode: ${modes[modeIndex].label}`, - installsFiles ? `Install dir: ${installDir || "feather"}/` : "Install dir: bundled CLI runtime", - installsFiles ? `Source: ${installSources[sourceIndex].label}` : undefined, - installsFiles && installSource === "remote" ? `Branch: ${branch || "main"}` : undefined, - installsFiles ? `Install plugins: ${installPlugins ? "yes" : "no"}` : undefined, - `Session: ${sessionName || "(default)"}`, - pluginPromptsEnabled ? `Include: ${include.size > 0 ? [...include].join(", ") : "(none)"}` : undefined, - pluginPromptsEnabled ? `Exclude: ${exclude.size > 0 ? [...exclude].join(", ") : "(none)"}` : undefined, - advanced ? "Advanced config: yes" : "Advanced config: no", - ].filter(Boolean) as string[]; - if (needsApiKey) { - rows.push( - apiKeyCopied === true - ? "Console API key: set and copied to clipboard" - : apiKeyCopied === false - ? "Console API key: set (clipboard copy unavailable)" - : "Console API key: set", - ); - } - rows.push(appIdInput.trim() ? `App ID: ${appIdInput.trim()}` : "App ID: not set → __DANGEROUS_INSECURE_CONNECTION__ = true (any Feather desktop can connect)"); - return rows; - }, [ + const summary = useMemo(() => buildInitSummaryRows({ + advanced, + apiKeyCopied, + appIdInput, + branch, + exclude, + include, + installDir, + installPlugins, + installsFiles, + installSource, + modeIndex, + needsApiKey, + pluginPromptsEnabled, + sessionName, + sourceIndex, + }), [ advanced, apiKeyCopied, appIdInput, @@ -646,7 +381,17 @@ function InitSetupPrompt({ if (phase === "mode") return ; if (phase === "source") return ; if (phase === "installPlugins") return ; - if (phase === "include") return ; + if (phase === "include") { + return ( + pluginTone(option.value)} + /> + ); + } if (phase === "exclude") { return ( pluginTone(option.value)} /> ); } @@ -670,20 +416,34 @@ function InitSetupPrompt({ /> ); } - if (phase === "toggles") return ; + if (phase === "toggles") { + return ( + toggleTone(option.value)} + /> + ); + } if (phase === "apiKey") { return ; } if (phase === "appId") { return ( - - Find it in the Feather desktop app → Settings → Security → Desktop App ID. - If skipped, __DANGEROUS_INSECURE_CONNECTION__ = true is written to feather.config.lua so any Feather desktop can send commands to the game. + + + Feather desktop app → Settings → Security → Desktop App ID. + + + + Writes {dangerousInsecureConnection} + = true to feather.config.lua. + + Use only for trusted local development. Any Feather desktop can connect to the game. + ); } @@ -692,14 +452,52 @@ function InitSetupPrompt({ return ; } + const enabledAdvancedOptions = advanced ? configToggles.filter((option) => toggles.has(option.value)).map((option) => option.value) : []; + const writesRuntimeFiles = mode !== "cli"; + const patchesMainLua = mode === "auto"; + return ( - Ready to initialize Feather - - {summary.map((row) => ( - {row} - ))} - + + Ready to initialize Feather + + + + {writesRuntimeFiles ? ( + <> + + Runtime files will be written under {installDir || "feather"}/. + + {patchesMainLua ? ( + main.lua will be patched with a USE_DEBUGGER-guarded require. + ) : ( + feather.debugger.lua will be created for manual loading. + )} + + ) : ( + No runtime files will be copied; the CLI runtime is used at launch. + )} + + + {appIdInput.trim() ? ( + Desktop connections are limited to the configured App ID. + ) : ( + <> + + {dangerousInsecureConnection} + is enabled. + + Any Feather desktop can send commands to this game while it is running. + + )} + + {advanced ? ( + + + Enabled: + + + ) : null} Press Enter to continue. ); diff --git a/cli/src/ui/package/add-helpers.ts b/cli/src/ui/package/add-helpers.ts new file mode 100644 index 00000000..063cd9da --- /dev/null +++ b/cli/src/ui/package/add-helpers.ts @@ -0,0 +1,27 @@ +import { GH_HEADERS } from '../../lib/github.js'; + +export const REPO_TOTAL = 7; +export const URL_TOTAL = 4; + +export async function fetchRepoMeta(repo: string): Promise<{ values: string[]; labels: string[] }> { + const [tagsRes, repoRes, branchesRes] = await Promise.all([ + fetch(`https://api.github.com/repos/${repo}/tags?per_page=20`, { headers: GH_HEADERS }), + fetch(`https://api.github.com/repos/${repo}`, { headers: GH_HEADERS }), + fetch(`https://api.github.com/repos/${repo}/branches?per_page=30`, { headers: GH_HEADERS }), + ]); + if (!tagsRes.ok) throw new Error(`GitHub API ${tagsRes.status} fetching tags for ${repo}`); + if (!repoRes.ok) throw new Error(`GitHub API ${repoRes.status} fetching repo info for ${repo}`); + if (!branchesRes.ok) throw new Error(`GitHub API ${branchesRes.status} fetching branches for ${repo}`); + const [tagsData, repoData, branchesData] = await Promise.all([ + tagsRes.json() as Promise>, + repoRes.json() as Promise<{ default_branch?: string }>, + branchesRes.json() as Promise>, + ]); + const tags = tagsData.map((tag) => tag.name); + const defaultBranch = repoData.default_branch ?? 'main'; + const branches = branchesData.map((branch) => branch.name); + const orderedBranches = [defaultBranch, ...branches.filter((branch) => branch !== defaultBranch)]; + const values = [...tags, ...orderedBranches]; + const labels = [...tags, ...orderedBranches.map((branch) => `⎇ ${branch}`)]; + return { values, labels }; +} diff --git a/cli/src/ui/package/add-steps.tsx b/cli/src/ui/package/add-steps.tsx new file mode 100644 index 00000000..e5eadb0d --- /dev/null +++ b/cli/src/ui/package/add-steps.tsx @@ -0,0 +1,159 @@ +import { Box, Text, useInput } from 'ink'; +import type { UrlFile } from '../components.js'; +import { REPO_TOTAL, URL_TOTAL } from './add-helpers.js'; + +export function RepoConfirmStep({ + id, + repoName, + tag, + selectedFiles, + targetMap, + onConfirm, + onAbort, +}: { + id: string; + repoName: string; + tag: string; + selectedFiles: string[]; + targetMap: Record; + onConfirm: () => void; + onAbort: () => void; +}) { + useInput((input, key) => { + if (input === 'y' || input === 'Y' || key.return) onConfirm(); + else if (input === 'n' || input === 'N' || key.escape) onAbort(); + }); + return ( + + + + {' '}feather package add + + {` Step ${REPO_TOTAL} of ${REPO_TOTAL}`} + + {' '}Review before installing + + + {' '}Package: {id} + + + {' '}Source: github.com/{repoName} + + + {' '}Version: {tag} (commit SHA pinned) + + + {' '}Trust: experimental ⚠ + + {' '}Not reviewed by the Feather team. + + + {selectedFiles.map((file) => ( + + {' '} + {file} → {targetMap[file]} + + ))} + + + {' y/Enter = install · n/Esc = abort'} + + + ); +} +export function UrlConfirmStep({ + id, + urlFiles, + onConfirm, + onAbort, +}: { + id: string; + urlFiles: UrlFile[]; + onConfirm: () => void; + onAbort: () => void; +}) { + useInput((input, key) => { + if (input === 'y' || input === 'Y' || key.return) onConfirm(); + else if (input === 'n' || input === 'N' || key.escape) onAbort(); + }); + return ( + + + + {' '}feather package add + + {` Step ${URL_TOTAL} of ${URL_TOTAL}`} + + {' '}Review before installing + + + {' '}Package: {id} + + + {' '}Trust: experimental ⚠ + + {' '}Not reviewed by the Feather team. + + + {urlFiles.map((file) => ( + + + {' '} + {file.name} → {file.target} + + + {' sha256: '} + {file.sha256.slice(0, 24)}… + + + ))} + + + {' y/Enter = install · n/Esc = abort'} + + + ); +} + +export function DoneStep({ + id, + files, + requirePath, + onExit, +}: { + id: string; + files: Array<{ name: string; target: string }>; + requirePath: string; + onExit: () => void; +}) { + useInput((_, key) => { + if (key.return || key.escape) onExit(); + }); + return ( + + + ✔ Installed + + {files.map((file) => ( + + {' '} + {file.name} → {file.target} + + ))} + + + Usage:{' '} + + local {id.replace(/[.-]/g, '_')} = require('{requirePath}') + + + + + Trust: experimental ⚠ — not reviewed by the Feather team + + + Press Enter to exit + + + ); +} diff --git a/cli/src/ui/package/add.tsx b/cli/src/ui/package/add.tsx new file mode 100644 index 00000000..5e71f8a2 --- /dev/null +++ b/cli/src/ui/package/add.tsx @@ -0,0 +1,387 @@ +import { render, Text, Box, useApp } from 'ink'; +import { useState } from 'react'; +import type { PackageAddPlan } from '../../lib/package/add-plan.js'; +import type { Lockfile } from '../../lib/package/lockfile.js'; +import { + type UrlFile, + TextInputStep, + SelectStep, + MultiSelectStep, + AutoStep, + TargetsStep, + FileFetchStep, + FileMoreStep, +} from '../components.js'; +import { fetchCommitSha, fetchLuaFiles } from '../../lib/github.js'; +import { fileNameFromUrl } from '../../lib/url.js'; +import { fetchRepoMeta, REPO_TOTAL, URL_TOTAL } from './add-helpers.js'; +import { RepoConfirmStep, UrlConfirmStep } from './add-steps.js'; + +type Step = + | 'choose' + | 'id' + | 'repo' + | 'fetch-tags' + | 'tag' + | 'resolve-commit' + | 'fetch-files' + | 'files' + | 'targets' + | 'file-url' + | 'file-fetch' + | 'file-target' + | 'file-more' + | 'require' + | 'confirm' + | 'error'; + +function Wizard({ lockfile, onPlan }: { lockfile: Lockfile; onPlan: (plan: PackageAddPlan | null) => void }) { + const { exit } = useApp(); + const [step, setStep] = useState('choose'); + const [mode, setMode] = useState<'repo' | 'url' | null>(null); + + // Shared + const [id, setId] = useState(''); + const [requirePath, setRequirePath] = useState(''); + + // Repo flow + const [repoName, setRepoName] = useState(''); + const [tag, setTag] = useState(''); + const [commitSha, setCommitSha] = useState(''); + const [baseUrl, setBaseUrl] = useState(''); + const [tagOptions, setTagOptions] = useState([]); + const [tagLabels, setTagLabels] = useState([]); + const [luaFiles, setLuaFiles] = useState([]); + const [selectedFiles, setSelectedFiles] = useState([]); + const [targetMap, setTargetMap] = useState>({}); + + // URL flow + const [urlFiles, setUrlFiles] = useState([]); + const [currentUrl, setCurrentUrl] = useState(''); + const [currentSha, setCurrentSha] = useState(''); + const [currentBuffer, setCurrentBuffer] = useState(Buffer.alloc(0)); + + const [errorMsg, setErrorMsg] = useState(''); + + const handleError = (msg: string) => { + setErrorMsg(msg); + setStep('error'); + }; + + const finishWithPlan = (plan: PackageAddPlan | null) => { + onPlan(plan); + exit(); + }; + + if (step === 'choose') { + return ( + { + setMode(v as 'repo' | 'url'); + setStep('id'); + }} + /> + ); + } + + if (step === 'id') { + return ( + { + if (!v) return 'Required'; + if (!/^[a-z0-9][a-z0-9.-]*$/.test(v)) return 'Use only a-z, 0-9, dots, hyphens'; + if (lockfile.packages[v]) return `"${v}" is already installed`; + return null; + }} + onSubmit={(v) => { + setId(v); + setStep(mode === 'repo' ? 'repo' : 'file-url'); + }} + /> + ); + } + + if (step === 'repo') { + return ( + { + if (!v) return 'Required'; + if (!/^[^/]+\/[^/]+$/.test(v)) return 'Must be owner/repo format'; + return null; + }} + onSubmit={(v) => { + setRepoName(v); + setStep('fetch-tags'); + }} + /> + ); + } + + if (step === 'fetch-tags') { + return ( + { + const { values, labels } = await fetchRepoMeta(repoName); + if (values.length === 0) throw new Error('No tags or branches found.'); + setTagOptions(values); + setTagLabels(labels); + setStep('tag'); + }} + onError={handleError} + /> + ); + } + + if (step === 'tag') { + return ( + { + setTag(v); + setStep('resolve-commit'); + }} + /> + ); + } + + if (step === 'resolve-commit') { + return ( + { + const sha = await fetchCommitSha(repoName, tag); + setCommitSha(sha); + setBaseUrl(`https://raw.githubusercontent.com/${repoName}/${sha}/`); + setStep('fetch-files'); + }} + onError={handleError} + /> + ); + } + + if (step === 'fetch-files') { + return ( + { + const files = await fetchLuaFiles(repoName, commitSha); + if (files.length === 0) throw new Error('No .lua files found at this ref.'); + setLuaFiles(files); + setStep('files'); + }} + onError={handleError} + /> + ); + } + + if (step === 'files') { + return ( + { + setSelectedFiles(chosen); + setStep('targets'); + }} + /> + ); + } + + if (step === 'targets') { + return ( + { + setTargetMap(map); + setStep('require'); + }} + /> + ); + } + + if (step === 'require') { + const firstTarget = + mode === 'repo' ? (Object.values(targetMap)[0] ?? `lib/${id}.lua`) : (urlFiles[0]?.target ?? `lib/${id}.lua`); + const suggested = firstTarget.replace(/\.lua$/, '').replace(/\//g, '.'); + return ( + (v ? null : 'Required')} + onSubmit={(v) => { + setRequirePath(v); + setStep('confirm'); + }} + /> + ); + } + + if (step === 'confirm') { + if (mode === 'repo') { + return ( + + finishWithPlan({ + kind: 'repo', + id, + requirePath, + repoName, + tag, + commitSha, + baseUrl, + selectedFiles, + targetMap, + }) + } + onAbort={() => finishWithPlan(null)} + /> + ); + } + return ( + + finishWithPlan({ + kind: 'url', + id, + requirePath, + urlFiles, + }) + } + onAbort={() => finishWithPlan(null)} + /> + ); + } + + if (step === 'file-url') { + const n = urlFiles.length; + return ( + { + if (!v) return 'Required'; + try { + new URL(v); + } catch { + return 'Must be a valid URL'; + } + return null; + }} + onSubmit={(url) => { + setCurrentUrl(url); + setStep('file-fetch'); + }} + /> + ); + } + + if (step === 'file-fetch') { + return ( + { + setCurrentSha(sha256); + setCurrentBuffer(buffer); + setStep('file-target'); + }} + onError={handleError} + /> + ); + } + + if (step === 'file-target') { + const name = fileNameFromUrl(currentUrl); + const suggested = urlFiles.length === 0 ? `lib/${name}` : `lib/${id}/${name}`; + return ( + { + if (!v) return 'Required'; + if (!v.endsWith('.lua')) return 'Must end in .lua'; + return null; + }} + onSubmit={(target) => { + setUrlFiles((fs) => [...fs, { name, url: currentUrl, sha256: currentSha, target, buffer: currentBuffer }]); + setStep('file-more'); + }} + /> + ); + } + + if (step === 'file-more') { + return ( + setStep('file-url')} + onNo={() => setStep('require')} + /> + ); + } + + return ( + + + ✖ Error + + {errorMsg} + + ); +} + +export async function showAddWizard(opts: { projectDir: string; lockfile: Lockfile }): Promise { + let plan: PackageAddPlan | null = null; + const { waitUntilExit } = render( (plan = nextPlan)} />, { + alternateScreen: true, + }); + await waitUntilExit(); + return plan; +} diff --git a/cli/src/ui/package/index.ts b/cli/src/ui/package/index.ts new file mode 100644 index 00000000..c4f954b3 --- /dev/null +++ b/cli/src/ui/package/index.ts @@ -0,0 +1,3 @@ +export { showAddWizard } from './add.js'; +export { showInstallProgress } from './progress.js'; +export { showPackageBrowser, type PackageWorkflowResult } from './workflow.js'; diff --git a/cli/src/ui/package-progress.tsx b/cli/src/ui/package/progress.tsx similarity index 92% rename from cli/src/ui/package-progress.tsx rename to cli/src/ui/package/progress.tsx index 8093348c..91b540bf 100644 --- a/cli/src/ui/package-progress.tsx +++ b/cli/src/ui/package/progress.tsx @@ -1,10 +1,11 @@ import React, { useState, useEffect } from "react"; import { Box, Text, render, useApp } from "ink"; -import { installPackage } from "../lib/package/install.js"; -import { formatRequireHint } from "../lib/package/resolve.js"; -import type { ResolvedPackage } from "../lib/package/resolve.js"; -import type { Lockfile } from "../lib/package/lockfile.js"; -import type { InstallResult } from "../lib/package/install.js"; +import { SPINNER_FRAMES } from "../components.js"; +import { installPackage } from "../../lib/package/install.js"; +import { formatRequireHint } from "../../lib/package/resolve.js"; +import type { ResolvedPackage } from "../../lib/package/resolve.js"; +import type { Lockfile } from "../../lib/package/lockfile.js"; +import type { InstallResult } from "../../lib/package/install.js"; type FileStatus = "pending" | "downloading" | "ok" | "error"; type PkgStatus = "pending" | "installing" | "ok" | "error"; @@ -26,15 +27,13 @@ type PkgState = { error?: string; }; -const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - function useSpinner() { const [frame, setFrame] = useState(0); useEffect(() => { - const t = setInterval(() => setFrame((f) => (f + 1) % SPINNER.length), 80); + const t = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80); return () => clearInterval(t); }, []); - return SPINNER[frame]; + return SPINNER_FRAMES[frame]; } function FileRow({ file, spinner }: { file: FileState; spinner: string }) { @@ -126,7 +125,7 @@ function InstallProgress({ const [states, setStates] = useState( packages.map((pkg) => ({ id: pkg.id, - version: pkg.versionOverride ?? pkg.entry.source.tag, + version: pkg.versionOverride ?? pkg.entry.source.tag ?? 'url', liveComputed: !!pkg.versionOverride, status: "pending", files: pkg.files.map((f) => ({ diff --git a/cli/src/ui/package-workflow.tsx b/cli/src/ui/package/workflow.tsx similarity index 94% rename from cli/src/ui/package-workflow.tsx rename to cli/src/ui/package/workflow.tsx index e22457ba..917bcfb3 100644 --- a/cli/src/ui/package-workflow.tsx +++ b/cli/src/ui/package/workflow.tsx @@ -1,7 +1,8 @@ import { useState, useMemo, useEffect } from 'react'; import { Box, Text, render, useApp, useInput } from 'ink'; -import type { Registry, RegistryEntry } from '../lib/package/registry.js'; -import type { Lockfile, LockfileEntry } from '../lib/package/lockfile.js'; +import type { Registry, RegistryEntry } from '../../lib/package/registry.js'; +import type { Lockfile, LockfileEntry } from '../../lib/package/lockfile.js'; +import { trustLabel, trustColor } from '../../lib/trust.js'; export type PackageWorkflowResult = { action: 'install' | 'update' | 'remove'; id: string } | { action: 'cancel' }; @@ -18,17 +19,6 @@ const VISIBLE = 14; const VER_W = 10; const TRUST_W = 13; -function trustLabel(trust: string) { - if (trust === 'verified') return 'verified'; - if (trust === 'known') return 'known'; - return 'experimental'; -} - -function trustColor(trust: string): string { - if (trust === 'verified') return 'green'; - if (trust === 'known') return 'yellow'; - return 'red'; -} function PackageWorkflow({ registry, @@ -186,6 +176,7 @@ function PackageWorkflow({ {entry.description} github.com/{entry.source.repo} + {entry.license ? license: {entry.license} : null} {entry.subpackages?.length ? modules: {entry.subpackages.join(', ')} : null} {actions.map((act, i) => ( @@ -235,7 +226,7 @@ function PackageWorkflow({ const prefix = active ? '>> ' : installed ? ' * ' : ' '; const nameColor = active ? 'cyan' : hasUpdate ? 'yellow' : installed ? 'green' : undefined; const nameText = pkg.id.padEnd(NAME_W); - const verText = entry.source.tag.padEnd(VER_W); + const verText = (entry.source.tag ?? 'url').padEnd(VER_W); const trustText = trustLabel(entry.trust).padEnd(TRUST_W); return ( diff --git a/cli/src/ui/plugin-workflow.tsx b/cli/src/ui/plugin-workflow.tsx index 87101808..0b3ee1e2 100644 --- a/cli/src/ui/plugin-workflow.tsx +++ b/cli/src/ui/plugin-workflow.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useState } from "react"; -import { Box, Text, render, useApp, useInput } from "ink"; +import { Text, render, useApp } from "ink"; import { pluginCatalog } from "../generated/plugin-catalog.js"; +import { SelectStep, MultiSelectStep, TextInputStep, BooleanStep } from "./components.js"; type PluginAction = "list" | "install" | "remove" | "update" | "cancel"; type PluginSource = "local" | "remote"; @@ -49,100 +50,6 @@ function pluginOption(id: string): Option { }; } -function SingleSelect({ - title, - options, - cursor, -}: { - title: string; - options: Option[]; - cursor: number; -}) { - return ( - - {title} - - {options.map((option, index) => ( - - - {index === cursor ? "›" : " "} {index + 1}. {option.label} - - {option.description ? {option.description} : null} - - ))} - - Use ↑/↓, j/k, 1-{options.length}, then Enter. - - ); -} - -function MultiSelect({ - title, - options, - selected, - cursor, -}: { - title: string; - options: Option[]; - selected: Set; - cursor: number; -}) { - return ( - - {title} - - {options.length === 0 ? Nothing to choose here. : null} - {options.map((option, index) => ( - - - {index === cursor ? "›" : " "} {selected.has(option.value) ? "●" : "○"} {option.label} - - {option.description ? {option.description} : null} - - ))} - - Space toggles, Enter continues. - - ); -} - -function TextInput({ title, value, placeholder }: { title: string; value: string; placeholder?: string }) { - return ( - - {title} - {value || placeholder || " "} - Enter to continue. Backspace edits. - - ); -} - -function Confirm({ - title, - rows, - yes, -}: { - title: string; - rows: string[]; - yes: boolean; -}) { - return ( - - {title} - - {rows.map((row) => ( - {row} - ))} - - - Yes - / - No - - Use y/n, ←/→, then Enter. - - ); -} - function PluginWorkflow({ installedIds, defaultBranch, @@ -151,19 +58,15 @@ function PluginWorkflow({ onComplete, }: PluginWorkflowInput & { onComplete: (result: PluginWorkflowResult) => void }) { const { exit } = useApp(); - const initialActionIndex = initialAction ? Math.max(0, actions.findIndex((option) => option.value === initialAction)) : 0; - const [phase, setPhase] = useState(initialAction === "install" || initialAction === "update" ? "source" : "action"); - const [actionCursor, setActionCursor] = useState(initialActionIndex); - const [sourceCursor, setSourceCursor] = useState(0); - const [pluginCursor, setPluginCursor] = useState(0); + const initialPhase: Phase = initialAction === "install" || initialAction === "update" ? "source" : "action"; + const [phase, setPhase] = useState(initialPhase); + const [action, setAction] = useState(initialAction ?? "install"); + const [source, setSource] = useState("local"); const [branch, setBranch] = useState(defaultBranch); - const [selected, setSelected] = useState>( - new Set(defaultSelectAll && initialAction === "update" ? installedIds : []), + const [selectedIds, setSelectedIds] = useState( + defaultSelectAll && initialAction === "update" ? installedIds : [], ); - const [confirmed, setConfirmed] = useState(true); - const action = actions[actionCursor].value; - const source = sources[sourceCursor].value; const installed = useMemo(() => new Set(installedIds), [installedIds]); const pluginOptions = useMemo(() => { @@ -181,112 +84,112 @@ function PluginWorkflow({ exit(); }; - const move = (delta: number, count: number, setter: (value: number | ((value: number) => number)) => void) => { - if (count <= 0) return; - setter((value: number) => (value + count + delta) % count); - }; - - const completeSelection = () => { - if (action === "remove") { - setPhase("confirm"); - return; - } - if (action === "install" || action === "update") { - if (source === "remote") { - setPhase("branch"); - } else { - setPhase("confirm"); - } - return; - } - finish({ action: "cancel" }); - }; - - useInput((input, key) => { - if (key.escape) { - finish({ action: "cancel" }); - return; - } - - if (phase === "action") { - if (key.upArrow || input === "k") move(-1, actions.length, setActionCursor); - else if (key.downArrow || input === "j") move(1, actions.length, setActionCursor); - else if (Number(input) >= 1 && Number(input) <= actions.length) setActionCursor(Number(input) - 1); - else if (key.return) { - const next = actions[actionCursor].value; - setSelected(new Set()); - setPluginCursor(0); - if (next === "list" || next === "cancel") finish({ action: next }); - else if (next === "install" || next === "update") setPhase("source"); - else setPhase("plugins"); - } - return; - } + const cancel = () => finish({ action: "cancel" }); - if (phase === "source") { - if (key.upArrow || input === "k") move(-1, sources.length, setSourceCursor); - else if (key.downArrow || input === "j") move(1, sources.length, setSourceCursor); - else if (Number(input) >= 1 && Number(input) <= sources.length) setSourceCursor(Number(input) - 1); - else if (key.return) setPhase("plugins"); - return; - } - - if (phase === "plugins") { - if (key.upArrow || input === "k") move(-1, pluginOptions.length, setPluginCursor); - else if (key.downArrow || input === "j") move(1, pluginOptions.length, setPluginCursor); - else if (input === "a") setSelected(new Set(pluginOptions.map((option) => option.value))); - else if (input === " ") { - const option = pluginOptions[pluginCursor]; - if (!option) return; - setSelected((current) => { - const next = new Set(current); - if (next.has(option.value)) next.delete(option.value); - else next.add(option.value); - return next; - }); - } else if (key.return) completeSelection(); - return; - } + if (phase === "action") { + return ( + a.value)} + labels={actions.map((a) => a.label)} + descriptions={actions.map((a) => a.description ?? "")} + initialIndex={initialAction ? Math.max(0, actions.findIndex((a) => a.value === initialAction)) : 0} + onSelect={(value) => { + const act = value as PluginAction; + setAction(act); + if (act === "list" || act === "cancel") { + finish({ action: act }); + } else if (act === "install" || act === "update") { + setPhase("source"); + } else { + setPhase("plugins"); + } + }} + onCancel={cancel} + /> + ); + } - if (phase === "branch") { - if (key.return) setPhase("confirm"); - else if (key.backspace || key.delete) setBranch((value) => value.slice(0, -1)); - else if (input && !key.ctrl && !key.meta) setBranch((value) => value + input); - return; - } + if (phase === "source") { + return ( + s.value)} + labels={sources.map((s) => s.label)} + descriptions={sources.map((s) => s.description ?? "")} + onSelect={(value) => { + setSource(value as PluginSource); + setPhase("plugins"); + }} + onCancel={cancel} + /> + ); + } - if (phase === "confirm") { - if (input === "y" || key.leftArrow) setConfirmed(true); - else if (input === "n" || key.rightArrow) setConfirmed(false); - else if (key.return) { - if (!confirmed) { - finish({ action: "cancel" }); - return; - } - const pluginIds = [...selected]; - if (action === "remove") finish({ action, pluginIds }); - else if (action === "install" || action === "update") finish({ action, pluginIds, source, branch: branch.trim() || "main" }); - else finish({ action: "cancel" }); - } - } - }); + if (phase === "branch") { + return ( + { + setBranch(value || "main"); + setPhase("confirm"); + }} + /> + ); + } - if (phase === "action") return ; - if (phase === "source") return ; - if (phase === "branch") return ; if (phase === "plugins") { + const allSelected = defaultSelectAll && action === "update"; + const pluginTitle = + action === "install" ? "Choose plugins to install" + : action === "update" ? "Choose plugins to update" + : "Choose plugins to remove"; return ( - p.value)} + labels={pluginOptions.map((p) => p.label)} + descriptions={pluginOptions.map((p) => p.description ?? "")} + initialSelected={allSelected ? undefined : new Set()} + onSubmit={(chosen) => { + setSelectedIds(chosen); + if (action === "remove") { + setPhase("confirm"); + } else if (source === "remote") { + setPhase("branch"); + } else { + setPhase("confirm"); + } + }} + onCancel={cancel} /> ); } - const rows = selected.size > 0 ? [...selected].map((id) => `- ${id}`) : ["No plugins selected."]; - return ; + const rows = selectedIds.length > 0 ? selectedIds : ["No plugins selected."]; + return ( + { + if (action === "remove") { + finish({ action, pluginIds: selectedIds }); + } else if (action === "install" || action === "update") { + finish({ action, pluginIds: selectedIds, source, branch: branch.trim() || "main" }); + } else { + finish({ action: "cancel" }); + } + }} + onCancel={cancel} + > + {rows.map((row) => ( + + {" - "} + {row} + + ))} + + ); } export async function choosePluginWorkflow(input: PluginWorkflowInput): Promise { diff --git a/cli/src/ui/remove-workflow.tsx b/cli/src/ui/remove-workflow.tsx index 01d28567..2ccc976f 100644 --- a/cli/src/ui/remove-workflow.tsx +++ b/cli/src/ui/remove-workflow.tsx @@ -1,5 +1,6 @@ import React, { useMemo, useState } from "react"; import { Box, Text, render, useApp, useInput } from "ink"; +import { BooleanStep } from "./components.js"; export type RemoveTarget = { id: string; @@ -7,6 +8,7 @@ export type RemoveTarget = { path: string; description: string; defaultSelected: boolean; + dangerous?: boolean; }; export type RemoveWorkflowResult = @@ -23,7 +25,6 @@ function RemoveWorkflow({ const { exit } = useApp(); const [cursor, setCursor] = useState(0); const [confirm, setConfirm] = useState(false); - const [yes, setYes] = useState(true); const [selected, setSelected] = useState>( new Set(targets.filter((target) => target.defaultSelected).map((target) => target.id)), ); @@ -41,20 +42,13 @@ function RemoveWorkflow({ }; useInput((input, key) => { + if (confirm) return; + if (key.escape) { finish({ cancelled: true }); return; } - if (confirm) { - if (input === "y" || key.leftArrow) setYes(true); - else if (input === "n" || key.rightArrow) setYes(false); - else if (key.return) { - finish(yes ? { cancelled: false, targetIds: [...selected] } : { cancelled: true }); - } - return; - } - if (key.upArrow || input === "k") move(-1); else if (key.downArrow || input === "j") move(1); else if (input === "a") setSelected(new Set(rows.map((target) => target.id))); @@ -73,22 +67,20 @@ function RemoveWorkflow({ }); if (confirm) { + const selectedTargets = [...selected].map((id) => rows.find((item) => item.id === id)).filter(Boolean); return ( - - Remove selected Feather files and markers? - - {[...selected].map((id) => { - const target = rows.find((item) => item.id === id); - return target ? - {target.label}: {target.path} : null; - })} - - - Yes - / - No - - Use y/n, ←/→, then Enter. - + finish({ cancelled: false, targetIds: [...selected] })} + onCancel={() => finish({ cancelled: true })} + > + {selectedTargets.map((target) => ( + {" - "}{target!.label}: {target!.path} + ))} + ); } @@ -99,14 +91,14 @@ function RemoveWorkflow({ {rows.length === 0 ? No managed Feather files or markers were found. : null} {rows.map((target, index) => ( - - {index === cursor ? "›" : " "} {selected.has(target.id) ? "●" : "○"} {target.label}: {target.path} + + {index === cursor ? "❯" : " "} {selected.has(target.id) ? "◉" : "○"} {target.label}: {target.path} {target.description} ))} - Space toggles, a selects all, Enter continues, Esc cancels. + {"↑↓ or j/k navigate · Space toggle · a select all · Enter confirm · Esc cancel"} ); } diff --git a/cli/src/ui/update-workflow.tsx b/cli/src/ui/update-workflow.tsx index ef94fa29..e869a488 100644 --- a/cli/src/ui/update-workflow.tsx +++ b/cli/src/ui/update-workflow.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; -import { Box, Text, render, useApp, useInput } from "ink"; +import { render, useApp } from "ink"; +import { SelectStep, TextInputStep, BooleanStep } from "./components.js"; export type UpdateSource = "local" | "remote"; @@ -7,20 +8,14 @@ export type CoreUpdateWorkflowResult = | { cancelled: true } | { cancelled: false; source: UpdateSource; branch: string }; -type SourceOption = { - value: UpdateSource; - label: string; - description: string; -}; - -const sources: SourceOption[] = [ +const sources = [ { - value: "local", + value: "local" as UpdateSource, label: "Bundled/local copy", description: "Use the CLI-bundled Lua runtime, or --local-src when provided.", }, { - value: "remote", + value: "remote" as UpdateSource, label: "GitHub download", description: "Download core files from GitHub using a branch or tag.", }, @@ -35,90 +30,52 @@ function CoreUpdateWorkflow({ }) { const { exit } = useApp(); const [phase, setPhase] = useState<"source" | "branch" | "confirm">("source"); - const [sourceCursor, setSourceCursor] = useState(0); + const [source, setSource] = useState("local"); const [branch, setBranch] = useState(defaultBranch); - const [confirmed, setConfirmed] = useState(true); - - const source = sources[sourceCursor].value; const finish = (result: CoreUpdateWorkflowResult) => { onComplete(result); exit(); }; - const move = (delta: number) => { - setSourceCursor((value) => (value + sources.length + delta) % sources.length); - }; - - useInput((input, key) => { - if (key.escape) { - finish({ cancelled: true }); - return; - } - - if (phase === "source") { - if (key.upArrow || input === "k") move(-1); - else if (key.downArrow || input === "j") move(1); - else if (Number(input) >= 1 && Number(input) <= sources.length) setSourceCursor(Number(input) - 1); - else if (key.return) setPhase(source === "remote" ? "branch" : "confirm"); - return; - } - - if (phase === "branch") { - if (key.return) setPhase("confirm"); - else if (key.backspace || key.delete) setBranch((value) => value.slice(0, -1)); - else if (input && !key.ctrl && !key.meta) setBranch((value) => value + input); - return; - } - - if (input === "y" || key.leftArrow) setConfirmed(true); - else if (input === "n" || key.rightArrow) setConfirmed(false); - else if (key.return) { - if (!confirmed) finish({ cancelled: true }); - else finish({ cancelled: false, source, branch: branch.trim() || "main" }); - } - }); + const cancel = () => finish({ cancelled: true }); if (phase === "source") { return ( - - Update Feather core from - - {sources.map((option, index) => ( - - - {index === sourceCursor ? "›" : " "} {index + 1}. {option.label} - - {option.description} - - ))} - - Use ↑/↓, j/k, 1-{sources.length}, then Enter. Esc cancels. - + s.value)} + labels={sources.map((s) => s.label)} + descriptions={sources.map((s) => s.description)} + onSelect={(value) => { + setSource(value as UpdateSource); + setPhase(value === "remote" ? "branch" : "confirm"); + }} + onCancel={cancel} + /> ); } if (phase === "branch") { return ( - - GitHub branch or tag - {branch || "main"} - Enter to continue. Backspace edits. - + { + setBranch(value || "main"); + setPhase("confirm"); + }} + /> ); } return ( - - Update Feather core? - Source: {source === "remote" ? `GitHub ${branch.trim() || "main"}` : "bundled/local copy"} - - Yes - / - No - - Use y/n, ←/→, then Enter. - + finish({ cancelled: false, source, branch: branch.trim() || "main" })} + onCancel={cancel} + /> ); } diff --git a/cli/src/ui/upload-workflow.tsx b/cli/src/ui/upload-workflow.tsx new file mode 100644 index 00000000..8b14a785 --- /dev/null +++ b/cli/src/ui/upload-workflow.tsx @@ -0,0 +1,326 @@ +import React, { useMemo, useState } from 'react'; +import { Box, Text, render, useApp, useInput } from 'ink'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { buildTargets, uploadTargets, type BuildTarget, type UploadTarget, loadBuildConfig } from '../lib/build/config.js'; +import { readLatestManifest } from '../lib/build/files.js'; +import { BooleanStep, SelectStep, TextInputStep } from './components.js'; + +export type UploadWorkflowResult = + | { cancelled: true } + | { + cancelled: false; + target: UploadTarget; + buildTarget: BuildTarget; + dir: string; + config?: string; + buildDir?: string; + project?: string; + channel?: string; + userVersion?: string; + build: boolean; + dryRun: boolean; + allowFeatherRuntime: boolean; + confirmed: boolean; + }; + +type UploadWorkflowInput = { + dir?: string; + config?: string; + buildDir?: string; + project?: string; + channel?: string; + userVersion?: string; +}; + +type Phase = + | 'dir' + | 'target' + | 'buildTarget' + | 'project' + | 'channel' + | 'version' + | 'build' + | 'dryRun' + | 'allowRuntime' + | 'confirm'; + +const title = 'feather upload'; +const total = 10; + +function defaultBuildTarget(dir: string, buildDir?: string): BuildTarget { + const config = loadConfigSafe(dir); + const outDir = buildDir ?? config?.outDir ?? 'builds'; + const manifest = readLatestManifest(join(dir, outDir)); + const target = manifest?.target; + return buildTargets.includes(target as BuildTarget) ? (target as BuildTarget) : 'web'; +} + +function loadConfigSafe(dir: string, config?: string) { + try { + return loadBuildConfig({ projectDir: dir, configPath: config }); + } catch { + return null; + } +} + +function Summary({ + values, + onConfirm, + onCancel, +}: { + values: Extract; + onConfirm: () => void; + onCancel: () => void; +}) { + useInput((input, key) => { + if (key.escape || input === 'n' || input === 'N') onCancel(); + if (key.return || input === 'y' || input === 'Y') onConfirm(); + }); + + return ( + + {' '}feather upload + {' '}Review upload plan + + {' '}Target: {values.target} + {' '}Build: {values.buildTarget} + {' '}Project dir: {values.dir} + {' '}Itch project: {values.project || '(from feather.build.json)'} + {' '}Channel: {values.channel || '(from feather.build.json/default)'} + {' '}Version: {values.userVersion || '(from feather.build.json)'} + {' '}Build first: {values.build ? 'yes' : 'no'} + {' '}Action: {values.dryRun ? 'dry run' : 'upload'} + {' '}Runtime override: {values.allowFeatherRuntime ? 'yes' : 'no'} + + + {' '}Enter/y upload plan · n/Esc cancel + + + ); +} + +function UploadWorkflow({ input, onComplete }: { input: UploadWorkflowInput; onComplete: (result: UploadWorkflowResult) => void }) { + const { exit } = useApp(); + const [phase, setPhase] = useState('dir'); + const [dir, setDir] = useState(input.dir ?? '.'); + const [target, setTarget] = useState('itch'); + const [buildTarget, setBuildTarget] = useState(() => defaultBuildTarget(input.dir ?? '.', input.buildDir)); + const [project, setProject] = useState(input.project ?? ''); + const [channel, setChannel] = useState(input.channel ?? ''); + const [version, setVersion] = useState(input.userVersion ?? ''); + const [build, setBuild] = useState(false); + const [dryRun, setDryRun] = useState(true); + const [allowRuntime, setAllowRuntime] = useState(false); + + const config = useMemo(() => loadConfigSafe(dir, input.config), [dir, input.config]); + const configProject = config?.upload.itch?.project ?? ''; + const configChannel = config?.upload.itch?.channels?.[buildTarget] ?? buildTarget; + const configVersion = config?.version ?? ''; + const manifestExists = useMemo(() => { + const outDir = input.buildDir ?? config?.outDir ?? 'builds'; + return existsSync(join(dir, outDir, 'feather-build-manifest.json')); + }, [dir, input.buildDir, config?.outDir]); + + const finish = (result: UploadWorkflowResult) => { + onComplete(result); + exit(); + }; + + const cancel = () => finish({ cancelled: true }); + const result = (): Extract => ({ + cancelled: false, + target, + buildTarget, + dir, + config: input.config, + buildDir: input.buildDir, + project: project || configProject || undefined, + channel: channel || configChannel || undefined, + userVersion: version || configVersion || undefined, + build, + dryRun, + allowFeatherRuntime: allowRuntime, + confirmed: true, + }); + + if (phase === 'dir') { + return ( + { + setDir(value || '.'); + setBuildTarget(defaultBuildTarget(value || '.', input.buildDir)); + setPhase('target'); + }} + /> + ); + } + + if (phase === 'target') { + return ( + value === 'itch' ? 'Upload with butler.' : 'Planned, not supported yet.')} + onSelect={(value) => { + setTarget(value as UploadTarget); + setPhase('buildTarget'); + }} + onCancel={cancel} + /> + ); + } + + if (phase === 'buildTarget') { + return ( + { + setBuildTarget(value as BuildTarget); + setPhase('project'); + }} + onCancel={cancel} + /> + ); + } + + if (phase === 'project') { + return ( + { + setProject(value); + setPhase('channel'); + }} + /> + ); + } + + if (phase === 'channel') { + return ( + { + setChannel(value); + setPhase('version'); + }} + /> + ); + } + + if (phase === 'version') { + return ( + { + setVersion(value); + setPhase('build'); + }} + /> + ); + } + + if (phase === 'build') { + return ( + { + setBuild(true); + setPhase('dryRun'); + }} + onCancel={() => { + setBuild(false); + setPhase('dryRun'); + }} + /> + ); + } + + if (phase === 'dryRun') { + return ( + { + setDryRun(true); + setPhase('allowRuntime'); + }} + onCancel={() => { + setDryRun(false); + setPhase('allowRuntime'); + }} + /> + ); + } + + if (phase === 'allowRuntime') { + return ( + { + setAllowRuntime(true); + setPhase('confirm'); + }} + onCancel={() => { + setAllowRuntime(false); + setPhase('confirm'); + }} + /> + ); + } + + return ( + finish(result())} + onCancel={cancel} + /> + ); +} + +export async function chooseUploadWorkflow(input: UploadWorkflowInput = {}): Promise { + return new Promise((resolve) => { + render(); + }); +} diff --git a/cli/test/commands/build-android.test.mjs b/cli/test/commands/build-android.test.mjs new file mode 100644 index 00000000..565f1b80 --- /dev/null +++ b/cli/test/commands/build-android.test.mjs @@ -0,0 +1,401 @@ +/* eslint-disable no-undef */ +import { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppleLibrariesZip, + writeFakeCommand, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +} from './helpers.mjs'; + +test('build android: invalid config fails before staging or writing artifacts', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Bad Mobile Game', + version: '1.0.0', + productId: 'bad product id', + targets: { android: { loveAndroidDir: 'love-android', versionCode: 0 } }, + }); + + const result = run(['build', 'android', '--dir', dir, '--allow-unsafe', '--json']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Invalid android build config')); + assert.ok(outputOf(result).includes('productId')); + assert.equal(existsSync(join(dir, 'builds', 'bad-mobile-game-1.0.0.love')), false); +}); + +test('build android: injects game.love, runs Gradle, copies APK, and writes manifest', () => { + const dir = makeTmp(); + writeGame(dir); + const { recordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Mobile Game', + version: '1.2.3', + productId: 'com.example.mobilegame', + targets: { + android: { + loveAndroidDir: 'love-android', + displayName: 'Mobile Game Dev', + orientation: 'landscape', + recordAudio: true, + versionCode: 7, + versionName: '1.2.3-dev', + }, + }, + }); + + const result = run(['build', 'android', '--dir', dir, '--allow-unsafe', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.target, 'android'); + assert.equal(existsSync(join(dir, 'builds', 'mobile-game-1.2.3.love')), true); + assert.equal(existsSync(join(dir, 'builds', 'mobile-game-1.2.3-android.apk')), true); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'apk'), true); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.deepEqual(record.argv, ['assembleEmbedRecordDebug']); + assert.equal(record.embeddedLoveExists, true); + assert.ok(record.gradle.includes('applicationId "com.example.mobilegame"')); + assert.ok(record.gradle.includes('versionCode 7')); + assert.ok(record.gradle.includes('versionName "1.2.3-dev"')); + assert.ok(record.gradleProperties.includes('app.application_id=com.example.mobilegame')); + assert.ok(record.gradleProperties.includes('app.orientation=landscape')); + assert.ok(record.gradleProperties.includes('app.version_code=7')); + assert.ok(record.gradleProperties.includes('app.version_name=1.2.3-dev')); + assert.equal(record.gradleProperties.includes('app.name='), false); + assert.ok(record.gradleProperties.includes('app.name_byte_array=')); + assert.ok(record.manifest.includes('android:label="Mobile Game Dev"')); + assert.ok(record.manifest.includes('android:screenOrientation="landscape"')); + assert.ok(record.manifest.includes('android.permission.RECORD_AUDIO')); + assert.ok(record.gameActivity.includes('Feather: forcing embedded game.love from assets')); + const manifest = JSON.parse(readFileSync(join(dir, 'builds', 'feather-build-manifest.json'), 'utf8')); + assert.equal(manifest.target, 'android'); + assert.equal(manifest.artifacts.some((artifact) => artifact.type === 'apk'), true); +}); + +test('build android: embeds Feather debugger runtime and raw config in dev love archive', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + sessionName = "Nested Config", + __DANGEROUS_INSECURE_CONNECTION__ = true, + debugOverlay = { + enabled = true, + visible = false, + }, +} +`, + ); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Embedded Android', + version: '1.0.0', + productId: 'com.example.embeddedandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const result = run(['build', 'android', '--dir', dir, '--allow-unsafe', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const entries = readStoredZipEntries(join(dir, 'builds', 'embedded-android-1.0.0.love')); + assert.equal(entries.has('main.lua'), true); + assert.equal(entries.has('.feather-main.lua'), true); + assert.equal(entries.has('feather/auto.lua'), true); + assert.equal(entries.has('feather/core/debug_overlay.lua'), true); + assert.equal(entries.has('plugins/profiler/manifest.lua'), true); + assert.match(entries.get('main.lua').toString('utf8'), /require\("feather\.auto"\)/); + assert.match(entries.get('feather.config.lua').toString('utf8'), /debugOverlay\s*=\s*\{/); + assert.match(entries.get('feather.config.lua').toString('utf8'), /visible\s*=\s*false/); +}); + +test('build android --no-debugger: builds raw source without Feather embed', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Raw Android', + version: '1.0.0', + productId: 'com.example.rawandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const result = run(['build', 'android', '--dir', dir, '--no-debugger', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const entries = readStoredZipEntries(join(dir, 'builds', 'raw-android-1.0.0.love')); + assert.equal(entries.has('main.lua'), true); + assert.equal(entries.has('.feather-main.lua'), false); + assert.equal(entries.has('feather/auto.lua'), false); + assert.equal(entries.has('feather.config.lua'), false); +}); + +test('build android --release: does not auto-embed Feather debugger runtime', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Release Raw Android', + version: '1.0.0', + productId: 'com.example.releaserawandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const result = run(['build', 'android', '--dir', dir, '--release', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const entries = readStoredZipEntries(join(dir, 'builds', 'release-raw-android-1.0.0.love')); + assert.equal(entries.has('.feather-main.lua'), false); + assert.equal(entries.has('feather/auto.lua'), false); + assert.equal(entries.has('feather/core/debug_overlay.lua'), false); + assert.equal(entries.has('feather.config.lua'), false); +}); + +test('build android: reuses dev native cache between builds', () => { + const dir = makeTmp(); + writeGame(dir); + const { recordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Cached Android', + version: '1.0.0', + productId: 'com.example.cachedandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const first = run(['build', 'android', '--dir', dir, '--json']); + assert.equal(first.exitCode, 0, outputOf(first)); + const firstParsed = JSON.parse(first.stdout); + assert.equal(firstParsed.cache.enabled, true); + assert.equal(firstParsed.cache.hit, false); + assert.ok(firstParsed.cache.path.includes(join('builds', '.feather-cache', 'android'))); + assert.equal(existsSync(firstParsed.cache.path), true); + + const second = run(['build', 'android', '--dir', dir, '--json']); + assert.equal(second.exitCode, 0, outputOf(second)); + const secondParsed = JSON.parse(second.stdout); + assert.equal(secondParsed.cache.enabled, true); + assert.equal(secondParsed.cache.hit, true); + assert.equal(secondParsed.cache.key, firstParsed.cache.key); + assert.equal(secondParsed.cache.path, firstParsed.cache.path); + + const records = JSON.parse(readFileSync(recordPath, 'utf8')).records; + assert.equal(records.length, 2); + assert.equal(records[0].cwd, records[1].cwd); + assert.ok(records[0].cwd.includes(join('builds', '.feather-cache', 'android'))); +}); + +test('build android --no-cache uses fresh native workspaces', () => { + const dir = makeTmp(); + writeGame(dir); + const { recordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Uncached Android', + version: '1.0.0', + productId: 'com.example.uncachedandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const first = run(['build', 'android', '--dir', dir, '--no-cache', '--json']); + const second = run(['build', 'android', '--dir', dir, '--no-cache', '--json']); + assert.equal(first.exitCode, 0, outputOf(first)); + assert.equal(second.exitCode, 0, outputOf(second)); + assert.equal(JSON.parse(first.stdout).cache.enabled, false); + assert.equal(JSON.parse(second.stdout).cache.enabled, false); + assert.equal(existsSync(join(dir, 'builds', '.feather-cache')), false); + const records = JSON.parse(readFileSync(recordPath, 'utf8')).records; + assert.equal(records.length, 2); + assert.notEqual(records[0].cwd, records[1].cwd); + assert.equal(records[0].cwd.includes('.feather-cache'), false); + assert.equal(records[1].cwd.includes('.feather-cache'), false); +}); + +test('build android: stale native cache missing Gradle wrapper is recopied', () => { + const dir = makeTmp(); + writeGame(dir); + const { recordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Stale Cached Android', + version: '1.0.0', + productId: 'com.example.stalecachedandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const first = run(['build', 'android', '--dir', dir, '--json']); + assert.equal(first.exitCode, 0, outputOf(first)); + const firstParsed = JSON.parse(first.stdout); + rmSync(join(firstParsed.cache.path, 'love-android', process.platform === 'win32' ? 'gradlew.bat' : 'gradlew'), { force: true }); + + const second = run(['build', 'android', '--dir', dir, '--json']); + assert.equal(second.exitCode, 0, outputOf(second)); + const secondParsed = JSON.parse(second.stdout); + assert.equal(secondParsed.cache.enabled, true); + assert.equal(secondParsed.cache.hit, false); + assert.equal(secondParsed.cache.path, firstParsed.cache.path); + assert.equal(existsSync(join(secondParsed.cache.path, 'love-android', process.platform === 'win32' ? 'gradlew.bat' : 'gradlew')), true); + const records = JSON.parse(readFileSync(recordPath, 'utf8')).records; + assert.equal(records.length, 2); +}); + +test('build android --clean resets dev native cache', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Clean Cached Android', + version: '1.0.0', + productId: 'com.example.cleancachedandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const first = run(['build', 'android', '--dir', dir, '--json']); + const second = run(['build', 'android', '--dir', dir, '--json']); + const cleaned = run(['build', 'android', '--dir', dir, '--clean', '--json']); + assert.equal(first.exitCode, 0, outputOf(first)); + assert.equal(second.exitCode, 0, outputOf(second)); + assert.equal(cleaned.exitCode, 0, outputOf(cleaned)); + assert.equal(JSON.parse(first.stdout).cache.hit, false); + assert.equal(JSON.parse(second.stdout).cache.hit, true); + assert.equal(JSON.parse(cleaned.stdout).cache.hit, false); +}); + +test('build android --verbose: shows native build steps and Gradle output', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Verbose Android', + version: '1.0.0', + productId: 'com.example.verboseandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const result = run(['build', 'android', '--dir', dir, '--verbose']); + + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('Building android in verbose mode')); + assert.ok(result.stdout.includes('Staged')); + assert.ok(result.stdout.includes('Android workspace')); + assert.ok(result.stdout.includes('assembleEmbedNoRecordDebug')); + assert.ok(result.stdout.includes('fake gradle assembleEmbedNoRecordDebug')); +}); + +test('build android: honors gradleTask, custom artifactPath, and removes microphone permission', () => { + const dir = makeTmp(); + writeGame(dir); + const { recordPath } = writeFakeLoveAndroid(dir, { + recordAudioPermission: true, + apkRel: 'app/build/outputs/custom/custom-debug.apk', + }); + writeBuildConfig(dir, { + name: 'Custom Android', + version: '2.0.0', + productId: 'com.example.customandroid', + targets: { + android: { + loveAndroidDir: 'love-android', + gradleTask: ':app:assembleCustomDebug', + artifactPath: 'app/build/outputs/custom/custom-debug.apk', + recordAudio: false, + }, + }, + }); + + const result = run(['build', 'android', '--dir', dir, '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'builds', 'custom-android-2.0.0-android.apk')), true); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.deepEqual(record.argv, [':app:assembleCustomDebug']); + assert.equal(record.manifest.includes('android.permission.RECORD_AUDIO'), false); + assert.equal(record.manifest.includes('android.permission.INTERNET'), true); +}); + +test('build android --release: runs bundle/apk tasks, copies artifacts, and keeps signing secrets out of output', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'release.keystore'), 'fake keystore'); + const { recordPath } = writeFakeLoveAndroid(dir, { + recordAudioPermission: true, + aabRel: 'app/build/outputs/custom/store-release.aab', + apkRel: 'app/build/outputs/custom/store-release.apk', + }); + writeBuildConfig(dir, { + name: 'Store Android', + version: '3.0.0', + productId: 'com.example.storeandroid', + targets: { + android: { + loveAndroidDir: 'love-android', + recordAudio: true, + release: { + bundleTask: ':app:bundleStoreRelease', + apkTask: ':app:assembleStoreRelease', + bundleArtifactPath: 'app/build/outputs/custom/store-release.aab', + apkArtifactPath: 'app/build/outputs/custom/store-release.apk', + keystorePath: 'release.keystore', + keyAlias: 'release-key', + storePasswordEnv: 'FEATHER_TEST_STORE_PASSWORD', + keyPasswordEnv: 'FEATHER_TEST_KEY_PASSWORD', + }, + }, + }, + }); + + const result = run(['build', 'android', '--dir', dir, '--release', '--json'], { + env: { + ...process.env, + NO_COLOR: '1', + FORCE_COLOR: '0', + FEATHER_TEST_STORE_PASSWORD: 'store-secret', + FEATHER_TEST_KEY_PASSWORD: 'key-secret', + }, + }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + assert.equal(outputOf(result).includes('store-secret'), false); + assert.equal(outputOf(result).includes('key-secret'), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.cache.enabled, false); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'aab'), true); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'apk'), true); + assert.equal(existsSync(join(dir, 'builds', 'store-android-3.0.0-android.aab')), true); + assert.equal(existsSync(join(dir, 'builds', 'store-android-3.0.0-android.apk')), true); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.deepEqual(record.records.map((entry) => entry.argv[0]), [':app:bundleStoreRelease', ':app:assembleStoreRelease']); + assert.ok(record.signingProperties.includes('keyAlias=release-key')); + assert.ok(record.signingProperties.includes('storePassword=store-secret')); +}); diff --git a/cli/test/commands/build-ios.test.mjs b/cli/test/commands/build-ios.test.mjs new file mode 100644 index 00000000..fac3581c --- /dev/null +++ b/cli/test/commands/build-ios.test.mjs @@ -0,0 +1,444 @@ +/* eslint-disable no-undef */ +import { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppleLibrariesZip, + writeFakeCommand, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +} from './helpers.mjs'; + +test('build ios: invalid bundle id fails before xcodebuild', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Bad iOS Game', + version: '1.0.0', + targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'bad id' } }, + }); + const recordPath = join(dir, 'xcodebuild-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +const fs = require('node:fs'); +fs.writeFileSync(${JSON.stringify(recordPath)}, 'ran'); +process.exit(0); +`); + + const result = run(['build', 'ios', '--dir', dir, '--json'], { env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }) }); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Invalid ios build config')); + assert.equal(existsSync(recordPath), false); +}); + +test('build ios: injects game.love, runs xcodebuild, copies app, and writes manifest', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'iOS Game', + version: '5.0.0', + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.iosgame', + displayName: 'iOS Game Dev', + scheme: 'FeatherGame', + configuration: 'Release', + sdk: 'iphonesimulator', + derivedDataPath: 'builds/ios-derived-data', + teamId: 'ABC123XYZ', + }, + }, + }); + const recordPath = join(dir, 'xcodebuild-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +if (process.argv.includes('-version')) { + console.log('Xcode 99.0'); + process.exit(0); +} +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const derivedData = args[args.indexOf('-derivedDataPath') + 1]; +const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; +const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; +const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; +const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); +fs.mkdirSync(app, { recursive: true }); +fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); +const project = fs.readFileSync(path.join(process.cwd(), 'platform', 'xcode', 'love.xcodeproj', 'project.pbxproj'), 'utf8'); +const plist = fs.readFileSync(path.join(process.cwd(), 'platform', 'xcode', 'ios', 'love-ios.plist'), 'utf8'); +const iosResources = project.match(/666666666666666666666666 \\/\\* Resources \\*\\/ = \\{[\\s\\S]*?\\n \\};/)?.[0] || ''; +const macosResources = project.match(/222222222222222222222222 \\/\\* Resources \\*\\/ = \\{[\\s\\S]*?\\n \\};/)?.[0] || ''; +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ + argv: args, + gameLoveExists: fs.existsSync(path.join(process.cwd(), 'platform', 'xcode', 'game.love')), + projectContainsGameLove: project.includes('game.love'), + iosResourcesContainGameLove: iosResources.includes('FEATHERGAMELOVE000000000001 /* game.love in Resources */'), + macosResourcesContainGameLove: macosResources.includes('FEATHERGAMELOVE000000000001 /* game.love in Resources */'), + plistSupportsIndirectInput: plist.includes('UIApplicationSupportsIndirectInputEvents') && plist.includes(''), +}, null, 2)); +console.log('fake xcodebuild ' + args.join(' ')); +process.exit(0); +`); + + const result = run(['build', 'ios', '--dir', dir, '--no-cache', '--json'], { env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }) }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.target, 'ios'); + assert.equal(existsSync(join(dir, 'builds', 'ios-game-5.0.0.love')), true); + assert.equal(existsSync(join(dir, 'builds', 'ios-game-5.0.0-ios.app')), true); + assert.equal(existsSync(join(dir, 'builds', 'ios-game-5.0.0-ios.app', 'game.love')), true); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(record.argv.includes('-scheme')); + assert.ok(record.argv.includes('FeatherGame')); + assert.ok(record.argv.includes('-configuration')); + assert.ok(record.argv.includes('Release')); + assert.ok(record.argv.includes('-sdk')); + assert.ok(record.argv.includes('iphonesimulator')); + assert.ok(record.argv.includes(join(dir, 'builds', 'ios-derived-data'))); + assert.ok(record.argv.includes('PRODUCT_BUNDLE_IDENTIFIER=com.example.iosgame')); + assert.ok(record.argv.includes('INFOPLIST_KEY_CFBundleDisplayName=iOS Game Dev')); + assert.ok(record.argv.includes('ARCHS=x86_64')); + assert.ok(record.argv.includes('ONLY_ACTIVE_ARCH=NO')); + assert.ok(record.argv.includes('DEVELOPMENT_TEAM=ABC123XYZ')); + assert.equal(record.gameLoveExists, true); + assert.equal(record.projectContainsGameLove, true); + assert.equal(record.iosResourcesContainGameLove, true); + assert.equal(record.macosResourcesContainGameLove, false); + assert.equal(record.plistSupportsIndirectInput, true); + const entries = readStoredZipEntries(join(dir, 'builds', 'ios-game-5.0.0.love')); + assert.equal(entries.has('main.lua'), true); + assert.equal(entries.has('.feather-main.lua'), true); + assert.equal(entries.has('feather/auto.lua'), true); + assert.equal(entries.has('feather/core/debug_overlay.lua'), true); + const manifest = JSON.parse(readFileSync(join(dir, 'builds', 'feather-build-manifest.json'), 'utf8')); + assert.equal(manifest.target, 'ios'); + assert.equal(manifest.artifacts.some((artifact) => artifact.type === 'app'), true); +}); + +test('build ios: reuses dev native cache and cached DerivedData between builds', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Cached iOS', + version: '1.0.0', + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.cachedios', + }, + }, + }); + const recordPath = join(dir, 'xcodebuild-cache-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const derivedData = args[args.indexOf('-derivedDataPath') + 1]; +const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; +const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; +const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; +const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); +fs.mkdirSync(app, { recursive: true }); +fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); +const previous = fs.existsSync(${JSON.stringify(recordPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) + : { records: [] }; +previous.records.push({ + argv: args, + cwd: process.cwd(), + derivedData, + gameLoveExists: fs.existsSync(path.join(process.cwd(), 'platform', 'xcode', 'game.love')), +}); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); +process.exit(0); +`); + const env = envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }); + + const first = run(['build', 'ios', '--dir', dir, '--json'], { env }); + assert.equal(first.exitCode, 0, outputOf(first)); + const firstParsed = JSON.parse(first.stdout); + assert.equal(firstParsed.cache.enabled, true); + assert.equal(firstParsed.cache.hit, false); + + const second = run(['build', 'ios', '--dir', dir, '--json'], { env }); + assert.equal(second.exitCode, 0, outputOf(second)); + const secondParsed = JSON.parse(second.stdout); + assert.equal(secondParsed.cache.enabled, true); + assert.equal(secondParsed.cache.hit, true); + assert.equal(secondParsed.cache.path, firstParsed.cache.path); + + const records = JSON.parse(readFileSync(recordPath, 'utf8')).records; + assert.equal(records.length, 2); + assert.equal(records[0].cwd, records[1].cwd); + assert.equal(records[0].derivedData, records[1].derivedData); + assert.ok(records[0].argv.includes('Debug')); + assert.ok(records[1].argv.includes('Debug')); + assert.ok(records[0].argv.includes('ARCHS=x86_64')); + assert.ok(records[1].argv.includes('ARCHS=x86_64')); + assert.ok(records[0].cwd.includes(join('builds', '.feather-cache', 'ios'))); + assert.ok(records[0].derivedData.includes(join('builds', '.feather-cache', 'ios'))); + assert.equal(records[0].gameLoveExists, true); + assert.equal(records[1].gameLoveExists, true); +}); + +test('build ios: buffers noisy xcodebuild output in non-verbose mode', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Noisy iOS', + version: '1.0.0', + targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.noisyios' } }, + }); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const derivedData = args[args.indexOf('-derivedDataPath') + 1]; +const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; +const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; +const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; +const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); +fs.mkdirSync(app, { recursive: true }); +fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); +process.stdout.write('x'.repeat(2 * 1024 * 1024)); +process.exit(0); +`); + + const result = run(['build', 'ios', '--dir', dir, '--no-cache', '--json'], { + env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }), + }); + + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(JSON.parse(result.stdout).ok, true); +}); + +test('build ios --verbose: shows native build steps and xcodebuild output', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Verbose iOS', + version: '1.0.0', + targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.verboseios' } }, + }); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const derivedData = args[args.indexOf('-derivedDataPath') + 1]; +const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; +const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; +const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; +const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); +fs.mkdirSync(app, { recursive: true }); +fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); +console.log('fake xcodebuild ' + args.join(' ')); +process.exit(0); +`); + + const result = run(['build', 'ios', '--dir', dir, '--verbose'], { + env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }), + }); + + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('Building ios in verbose mode')); + assert.ok(result.stdout.includes('iOS workspace')); + assert.ok(result.stdout.includes('xcodebuild')); + assert.ok(result.stdout.includes('fake xcodebuild')); +}); + +test('build ios --release: archives, exports IPA, and writes release manifest artifacts', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Release iOS', + version: '6.0.0', + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.releaseios', + displayName: 'Release iOS', + scheme: 'ReleaseGame', + release: { + archivePath: 'builds/native-release.xcarchive', + exportPath: 'builds/native-export', + exportMethod: 'app-store-connect', + signingStyle: 'manual', + provisioningProfileSpecifier: 'Release Profile', + teamId: 'TEAM12345', + configuration: 'Release', + sdk: 'iphoneos', + }, + }, + }, + }); + const recordPath = join(dir, 'xcodebuild-release-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +if (process.argv.includes('-version')) { + console.log('Xcode 99.0'); + process.exit(0); +} +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const previous = fs.existsSync(${JSON.stringify(recordPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) + : { records: [] }; +if (args.includes('archive')) { + const archivePath = args[args.indexOf('-archivePath') + 1]; + fs.mkdirSync(archivePath, { recursive: true }); + fs.writeFileSync(path.join(archivePath, 'Info.plist'), 'fake archive'); +} +if (args.includes('-exportArchive')) { + const exportPath = args[args.indexOf('-exportPath') + 1]; + const exportOptions = args[args.indexOf('-exportOptionsPlist') + 1]; + fs.mkdirSync(exportPath, { recursive: true }); + fs.writeFileSync(path.join(exportPath, 'Release iOS.ipa'), 'fake ipa'); + previous.exportOptions = fs.readFileSync(exportOptions, 'utf8'); +} +previous.records.push({ argv: args }); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); +process.exit(0); +`); + + const result = run(['build', 'ios', '--dir', dir, '--release', '--json'], { + env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.cache.enabled, false); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'xcarchive'), true); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'ipa'), true); + assert.equal(existsSync(join(dir, 'builds', 'release-ios-6.0.0-ios.xcarchive')), true); + assert.equal(existsSync(join(dir, 'builds', 'release-ios-6.0.0-ios.ipa')), true); + const loveEntries = readStoredZipEntries(join(dir, 'builds', 'release-ios-6.0.0.love')); + assert.equal(loveEntries.has('.feather-main.lua'), false); + assert.equal(loveEntries.has('feather/auto.lua'), false); + assert.equal(loveEntries.has('feather/core/debug_overlay.lua'), false); + assert.equal(loveEntries.has('feather.config.lua'), false); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(record.records[0].argv.includes('archive')); + assert.ok(record.records[0].argv.includes('DEVELOPMENT_TEAM=TEAM12345')); + assert.ok(record.records[1].argv.includes('-exportArchive')); + assert.ok(record.exportOptions.includes('app-store-connect')); + assert.ok(record.exportOptions.includes('manual')); + assert.ok(record.exportOptions.includes('Release Profile')); +}); + +test('build ios --release: packages unsigned IPA from archive when no signing team is configured', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Unsigned iOS', + version: '1.2.3', + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.unsignedios', + release: { + archivePath: 'builds/unsigned.xcarchive', + exportPath: 'builds/unsigned-export', + }, + }, + }, + }); + const recordPath = join(dir, 'xcodebuild-unsigned-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const previous = fs.existsSync(${JSON.stringify(recordPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) + : { records: [] }; +if (args.includes('archive')) { + const archivePath = args[args.indexOf('-archivePath') + 1]; + const app = path.join(archivePath, 'Products', 'Applications', 'love-ios.app'); + fs.mkdirSync(app, { recursive: true }); + fs.writeFileSync(path.join(archivePath, 'Info.plist'), 'fake archive'); + fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); + fs.writeFileSync(path.join(app, 'love-ios'), 'fake executable'); +} +if (args.includes('-exportArchive')) { + throw new Error('unsigned release should not call -exportArchive'); +} +previous.records.push({ argv: args }); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); +process.exit(0); +`); + + const result = run(['build', 'ios', '--dir', dir, '--release', '--json'], { + env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }), + }); + + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'ipa'), true); + assert.equal(existsSync(join(dir, 'builds', 'unsigned-ios-1.2.3-ios.ipa')), true); + const loveEntries = readStoredZipEntries(join(dir, 'builds', 'unsigned-ios-1.2.3.love')); + assert.equal(loveEntries.has('.feather-main.lua'), false); + assert.equal(loveEntries.has('feather/auto.lua'), false); + assert.equal(loveEntries.has('feather/core/debug_overlay.lua'), false); + assert.equal(loveEntries.has('feather.config.lua'), false); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(record.records.length, 1); + assert.ok(record.records[0].argv.includes('archive')); + assert.ok(record.records[0].argv.includes('CODE_SIGNING_ALLOWED=NO')); +}); + +test('build ios: non-macOS hosts fail with setup guidance', { skip: process.platform === 'darwin' }, () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Non Mac iOS', + version: '1.0.0', + targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.nonmacios' } }, + }); + + const result = run(['build', 'ios', '--dir', dir, '--json']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('iOS builds require macOS with Xcode')); +}); diff --git a/cli/test/commands/build-vendor.test.mjs b/cli/test/commands/build-vendor.test.mjs new file mode 100644 index 00000000..7cf64802 --- /dev/null +++ b/cli/test/commands/build-vendor.test.mjs @@ -0,0 +1,321 @@ +/* eslint-disable no-undef */ +import { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppleLibrariesZip, + writeFakeAppImageTool, + writeFakeCommand, + writeFakeDesktopRuntimeVendors, + writeFakeLoveLinuxAppImage, + writeFakeLoveMacosZip, + writeFakeLoveWindowsZip, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +} from './helpers.mjs'; + +test('build vendor add android --json: clones vendor and updates config', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor Android', version: '1.0.0', loveVersion: '11.5' }); + const { binDir, recordPath } = writeFakeVendorGit(dir); + + const result = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].target, 'android'); + assert.equal(parsed.vendors[0].relativePath, 'vendor/love-android'); + assert.equal(parsed.vendors[0].configUpdated, true); + assert.equal(existsSync(join(dir, 'vendor', 'love-android', 'gradlew')), true); + const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); + assert.equal(config.targets.android.loveAndroidDir, 'vendor/love-android'); + const records = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(records.some((record) => record.args.includes('--recurse-submodules'))); + assert.ok( + records.some((record) => + record.args.some((arg) => { + try { + const url = new URL(arg); + return ( + url.hostname === 'github.com' && + /^\/love2d\/love-android(?:\.git)?\/?$/.test(url.pathname) + ); + } catch { + return false; + } + }), + ), + ); +}); + +test('build vendor add web --json: clones love.js vendor and updates config', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor Web', version: '1.0.0', loveVersion: '11.5' }); + const { binDir, recordPath } = writeFakeVendorGit(dir); + + const result = run(['build', 'vendor', 'add', 'web', '--dir', dir, '--json'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].target, 'web'); + assert.equal(parsed.vendors[0].relativePath, 'vendor/love.js'); + assert.equal(parsed.vendors[0].ref, 'main'); + assert.equal(parsed.vendors[0].configUpdated, true); + assert.equal(existsSync(join(dir, 'vendor', 'love.js', 'index.html')), true); + assert.equal(existsSync(join(dir, 'vendor', 'love.js', 'player.js')), true); + const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); + assert.equal(config.targets.web.loveJsDir, 'vendor/love.js'); + const records = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(records.some((record) => record.args.includes('https://github.com/2dengine/love.js'))); +}); + +test('build vendor add ios --json: clones vendor, installs Apple libraries, and updates config', async () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor iOS', version: '1.0.0', loveVersion: '11.5' }); + const { binDir, recordPath } = writeFakeVendorGit(dir); + const zipPath = await writeFakeAppleLibrariesZip(dir); + + const result = run(['build', 'vendor', 'add', 'ios', '--dir', dir, '--json'], { + env: envWithPath(binDir, { FEATHER_TEST_LOVE_APPLE_LIBRARIES_ZIP: zipPath }), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].target, 'ios'); + assert.equal(parsed.vendors[0].relativePath, 'vendor/love-ios'); + assert.equal(existsSync(join(dir, 'vendor', 'love-ios', 'platform', 'xcode', 'ios', 'libraries', 'liblove-test.a')), true); + assert.equal(existsSync(join(dir, 'vendor', 'love-ios', 'platform', 'xcode', 'macosx', 'Frameworks', 'test.framework', 'test')), true); + assert.equal(existsSync(join(dir, 'vendor', 'love-ios', 'platform', 'xcode', 'ios', 'libraries', '._ignored')), false); + const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); + assert.equal(config.targets.ios.loveIosDir, 'vendor/love-ios'); + const records = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok( + records.some((record) => + record.args.some((arg) => { + try { + const url = new URL(arg); + return ( + url.protocol === 'https:' && + url.hostname === 'github.com' && + (url.pathname === '/love2d/love' || url.pathname === '/love2d/love.git' || url.pathname === '/love2d/love/') + ); + } catch { + return false; + } + }) + ) + ); +}); + +test('build vendor add mobile --dry-run --json: reports planned vendors without writing', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['build', 'vendor', 'add', 'mobile', '--dir', dir, '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.deepEqual(parsed.vendors.map((vendor) => vendor.target), ['android', 'ios']); + assert.equal(existsSync(join(dir, 'vendor')), false); + assert.equal(existsSync(join(dir, 'feather.build.json')), false); +}); + +test('build vendor add --dry-run --json: defaults to mobile vendors', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['build', 'vendor', 'add', '--dir', dir, '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.deepEqual(parsed.vendors.map((vendor) => vendor.target), ['android', 'ios']); +}); + +test('build vendor add desktop --dry-run --json: includes desktop runtime vendors', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['build', 'vendor', 'add', 'desktop', '--dir', dir, '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.deepEqual(parsed.vendors.map((vendor) => vendor.target), ['windows', 'macos', 'linux']); + assert.equal(existsSync(join(dir, 'vendor')), false); +}); + +test('build vendor add all --dry-run --json: includes web, mobile, and desktop vendors', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['build', 'vendor', 'add', 'all', '--dir', dir, '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.deepEqual(parsed.vendors.map((vendor) => vendor.target), ['android', 'ios', 'web', 'windows', 'macos', 'linux']); + assert.equal(existsSync(join(dir, 'vendor')), false); +}); + +test('build vendor add desktop --json: installs runtime archives and updates config', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor Desktop', version: '1.0.0', loveVersion: '11.5' }); + const windowsZip = writeFakeLoveWindowsZip(dir); + const macosZip = writeFakeLoveMacosZip(dir); + const linuxAppImage = writeFakeLoveLinuxAppImage(dir); + const appImageTool = writeFakeAppImageTool(dir); + + const result = run(['build', 'vendor', 'add', 'desktop', '--dir', dir, '--json'], { + env: { + ...process.env, + NO_COLOR: '1', + FORCE_COLOR: '0', + FEATHER_TEST_LOVE_WINDOWS_ZIP: windowsZip, + FEATHER_TEST_LOVE_MACOS_ZIP: macosZip, + FEATHER_TEST_LOVE_LINUX_APPIMAGE: linuxAppImage, + FEATHER_TEST_APPIMAGETOOL: appImageTool, + }, + }); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.deepEqual(parsed.vendors.map((vendor) => vendor.target), ['windows', 'macos', 'linux']); + assert.equal(existsSync(join(dir, 'vendor', 'love-windows', 'love.exe')), true); + assert.equal(existsSync(join(dir, 'vendor', 'love-macos', 'love.app', 'Contents', 'Info.plist')), true); + assert.equal(existsSync(join(dir, 'vendor', 'love-linux', 'squashfs-root', 'bin', 'love')), true); + assert.equal(existsSync(join(dir, 'vendor', 'love-linux', 'appimagetool.AppImage')), true); + const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); + assert.equal(config.targets.windows.loveRuntimeDir, 'vendor/love-windows'); + assert.equal(config.targets.macos.loveRuntimeDir, 'vendor/love-macos'); + assert.equal(config.targets.linux.loveRuntimeDir, 'vendor/love-linux'); +}); + +test('build vendor add steamos --json: reuses configured Linux runtime vendor', () => { + const dir = makeTmp(); + writeGame(dir); + const vendors = writeFakeDesktopRuntimeVendors(dir); + writeBuildConfig(dir, { + name: 'Vendor SteamOS', + version: '1.0.0', + targets: { linux: { loveRuntimeDir: vendors.linux } }, + }); + + const result = run(['build', 'vendor', 'add', 'steamos', '--dir', dir, '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].target, 'steamos'); + assert.equal(parsed.vendors[0].skipped, true); + assert.equal(parsed.vendors[0].installed, false); + const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); + assert.equal(config.targets.steamos.loveRuntimeDir, 'vendor/love-linux'); +}); + +test('build vendor add --no-config: fetches vendor without writing build config', () => { + const dir = makeTmp(); + writeGame(dir); + const { binDir } = writeFakeVendorGit(dir); + + const result = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--no-config', '--json'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'vendor', 'love-android', 'gradlew')), true); + assert.equal(existsSync(join(dir, 'feather.build.json')), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].configUpdated, false); +}); + +test('build vendor add: existing directories and conflicting config require --force', () => { + const dir = makeTmp(); + writeGame(dir); + mkdirSync(join(dir, 'vendor', 'love-android'), { recursive: true }); + + const existing = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json']); + assert.equal(existing.exitCode, 1); + assert.ok(outputOf(existing).includes('--force')); + + writeBuildConfig(dir, { targets: { android: { loveAndroidDir: 'native/love-android' } } }); + const conflict = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--dry-run', '--json']); + assert.equal(conflict.exitCode, 1); + assert.ok(outputOf(conflict).includes('already configured')); + + const { binDir } = writeFakeVendorGit(dir); + const forced = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--force', '--json'], { env: envWithPath(binDir) }); + assert.equal(forced.exitCode, 0, outputOf(forced)); + const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); + assert.equal(config.targets.android.loveAndroidDir, 'vendor/love-android'); +}); + +test('build vendor list --json: reports configured, missing, and valid vendors', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeFakeLoveAndroid(dir); + const vendors = writeFakeDesktopRuntimeVendors(dir); + writeBuildConfig(dir, { + targets: { + web: { loveJsDir: 'love.js' }, + android: { loveAndroidDir: 'love-android' }, + ios: { loveIosDir: 'vendor/love-ios' }, + windows: { loveRuntimeDir: vendors.windows }, + macos: { loveRuntimeDir: vendors.macos }, + linux: { loveRuntimeDir: vendors.linux }, + }, + }); + + const result = run(['build', 'vendor', 'list', '--dir', dir, '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.vendors.map((vendor) => [vendor.target, vendor])); + assert.equal(labels.get('web').valid, true); + assert.equal(labels.get('android').valid, true); + assert.equal(labels.get('ios').exists, false); + assert.equal(labels.get('ios').detail, 'missing'); + assert.equal(labels.get('windows').valid, true); + assert.equal(labels.get('macos').valid, true); + assert.equal(labels.get('linux').valid, true); + assert.equal(labels.get('steamos').valid, true); +}); + +test('build vendor add: missing git produces compact actionable error', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json'], { + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', PATH: '' }, + }); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('git is required')); +}); diff --git a/cli/test/commands/build.test.mjs b/cli/test/commands/build.test.mjs new file mode 100644 index 00000000..f85a9b7c --- /dev/null +++ b/cli/test/commands/build.test.mjs @@ -0,0 +1,278 @@ +/* eslint-disable no-undef */ +import { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppleLibrariesZip, + writeFakeCommand, + writeFakeDesktopRuntimeVendors, + writeFakeDesktopTools, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +} from './helpers.mjs'; + +test('build web: creates love archive, love.js html package, zip, and manifest', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Command Game', + version: '1.2.3', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/command-game', channels: { web: 'html5' } } }, + }); + + const result = run(['build', 'web', '--dir', dir, '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.target, 'web'); + assert.equal( + parsed.artifacts.some((artifact) => artifact.type === 'love'), + true, + ); + assert.equal( + parsed.artifacts.some((artifact) => artifact.type === 'zip'), + true, + ); + assert.equal(existsSync(join(dir, 'builds', 'command-game-1.2.3.love')), true); + assert.equal(existsSync(join(dir, 'builds', 'command-game-1.2.3-html.zip')), true); + const index = readFileSync(join(dir, 'builds', 'command-game-1.2.3-html', 'index.html'), 'utf8'); + assert.ok(index.includes('Command Game')); + assert.ok(index.includes('player.min.js?g=game.love')); + assert.equal(index.includes('href="/play/"'), false); + const manifest = JSON.parse(readFileSync(join(dir, 'builds', 'feather-build-manifest.json'), 'utf8')); + assert.equal(manifest.target, 'web'); +}); + +test('build love: creates only a .love package and writes manifest', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Love Package', version: '2.0.0' }); + + const result = run(['build', 'love', '--dir', dir, '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.target, 'love'); + assert.deepEqual( + parsed.artifacts.map((artifact) => artifact.type), + ['love'], + ); + assert.equal(existsSync(join(dir, 'builds', 'love-package-2.0.0.love')), true); + const manifest = JSON.parse(readFileSync(join(dir, 'builds', 'feather-build-manifest.json'), 'utf8')); + assert.equal(manifest.target, 'love'); +}); + +for (const target of ['windows', 'macos', 'linux', 'steamos']) { + test(`build ${target}: packages with configured local LÖVE runtime vendor`, () => { + const dir = makeTmp(); + writeGame(dir); + const vendors = writeFakeDesktopRuntimeVendors(dir); + const { binDir } = writeFakeDesktopTools(dir); + writeBuildConfig(dir, { + name: 'Desktop Game', + version: '2.0.0', + targets: { + windows: { loveRuntimeDir: vendors.windows }, + macos: { loveRuntimeDir: vendors.macos }, + linux: { loveRuntimeDir: vendors.linux }, + }, + }); + + const result = run(['build', target, '--dir', dir, '--json'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.target, target); + assert.equal(existsSync(join(dir, 'builds', 'desktop-game-2.0.0.love')), true); + if (target === 'windows') { + assert.deepEqual( + parsed.artifacts.map((artifact) => artifact.type), + ['love', 'zip', 'installer'], + ); + assert.equal(existsSync(join(dir, 'builds', 'desktop-game-2.0.0-windows.zip')), true); + assert.equal(existsSync(join(dir, 'builds', 'desktop-game-2.0.0-windows-installer.exe')), true); + const entries = readStoredZipEntries(join(dir, 'builds', 'desktop-game-2.0.0-windows.zip')); + assert.equal(entries.has('desktop-game.exe'), true); + assert.equal(entries.has('love.exe'), false); + } else if (target === 'macos') { + assert.deepEqual( + parsed.artifacts.map((artifact) => artifact.type), + ['love', 'zip', 'dmg'], + ); + assert.equal(existsSync(join(dir, 'builds', 'desktop-game-2.0.0-macos.app.zip')), true); + assert.equal(existsSync(join(dir, 'builds', 'desktop-game-2.0.0-macos.dmg')), true); + } else { + assert.deepEqual( + parsed.artifacts.map((artifact) => artifact.type), + ['love', 'appimage', 'tar.gz'], + ); + assert.equal(existsSync(join(dir, 'builds', `desktop-game-2.0.0-${target}.AppImage`)), true); + assert.equal(existsSync(join(dir, 'builds', `desktop-game-2.0.0-${target}.tar.gz`)), true); + } + }); +} + +test('build desktop: missing runtime vendor fails with vendor add guidance', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Missing Desktop Runtime', version: '1.0.0' }); + + const result = run(['build', 'windows', '--dir', dir, '--json']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('feather build vendor add windows')); +}); + +test('build validation: rejects bad mobile config values and unsafe native paths', async () => { + const { validateAndroidBuildConfig, validateIosBuildConfig } = await import('../../dist/lib/build/validation.js'); + const { resolveWorkspacePath } = await import('../../dist/lib/build/native.js'); + const baseConfig = { + configPath: '/tmp/feather.build.json', + projectDir: '/tmp/game', + sourceDir: '/tmp/game', + outDir: '/tmp/game/builds', + name: 'Validation Game', + version: '1.0.0', + include: [], + exclude: [], + includeRuntime: false, + targets: {}, + upload: {}, + }; + + const androidIssues = validateAndroidBuildConfig({ + ...baseConfig, + productId: 'not a product id', + targets: { android: { versionCode: 0, orientation: 'sideways', gradleTask: 'assemble debug' } }, + }); + assert.deepEqual( + androidIssues.map((issue) => issue.field), + ['productId', 'targets.android.versionCode', 'targets.android.orientation', 'targets.android.gradleTask'], + ); + const fakeTestStorePasswordEnv = '1BAD_ENV'; + const fakeTestKeyPasswordEnv = 'GOOD_ENV'; + + const androidReleaseIssues = validateAndroidBuildConfig( + { + ...baseConfig, + targets: { + android: { + release: { + bundleTask: 'bundle release', + apkArtifactPath: '', + storePasswordEnv: fakeTestStorePasswordEnv, + keyPasswordEnv: fakeTestKeyPasswordEnv, + }, + }, + }, + }, + true, + ); + assert.ok(androidReleaseIssues.some((issue) => issue.field === 'targets.android.release.bundleTask')); + assert.ok(androidReleaseIssues.some((issue) => issue.field === 'targets.android.release.apkArtifactPath')); + assert.ok(androidReleaseIssues.some((issue) => issue.field === 'targets.android.release.storePasswordEnv')); + + const iosIssues = validateIosBuildConfig({ + ...baseConfig, + targets: { + ios: { bundleIdentifier: 'bad id', scheme: 'bad;', simulatorArch: 'bad;', derivedDataPath: '../escape' }, + }, + }); + assert.deepEqual( + iosIssues.map((issue) => issue.field), + ['bundleIdentifier', 'targets.ios.scheme', 'targets.ios.simulatorArch', 'targets.ios.derivedDataPath'], + ); + const iosReleaseIssues = validateIosBuildConfig( + { + ...baseConfig, + targets: { ios: { release: { exportMethod: 'side-load', signingStyle: 'sometimes', teamId: 'BAD TEAM' } } }, + }, + true, + ); + assert.ok(iosReleaseIssues.some((issue) => issue.field === 'targets.ios.release.exportMethod')); + assert.ok(iosReleaseIssues.some((issue) => issue.field === 'targets.ios.release.signingStyle')); + assert.ok(iosReleaseIssues.some((issue) => issue.field === 'targets.ios.release.teamId')); + assert.throws(() => resolveWorkspacePath('/tmp/native-work', '../escape.apk', 'Artifact'), /native build workspace/); +}); + +test('build release: non-mobile targets fail cleanly', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { name: 'Web Release', version: '1.0.0', targets: { web: { loveJsDir: 'love.js' } } }); + + const result = run(['build', 'web', '--dir', dir, '--release', '--json']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Release mode is currently supported only for android and ios')); +}); + +test('build mobile: missing native template paths fail with actionable errors', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { + name: 'Missing Mobile Template', + version: '1.0.0', + productId: 'com.example.missingmobiletemplate', + }); + + const android = run(['build', 'android', '--dir', dir, '--allow-unsafe', '--json']); + assert.equal(android.exitCode, 1); + assert.ok(outputOf(android).includes('targets.android.loveAndroidDir')); + + const ios = run(['build', 'ios', '--dir', dir, '--allow-unsafe', '--json'], { + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', FEATHER_TEST_ALLOW_IOS_BUILD: '1' }, + }); + assert.equal(ios.exitCode, 1); + assert.ok(outputOf(ios).includes('targets.ios.loveIosDir')); +}); + +test('build: production preflight blocks unsafe Feather config unless explicitly allowed', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { name: 'Unsafe Game', version: '1.0.0', targets: { web: { loveJsDir: 'love.js' } } }); + writeFileSync(join(dir, 'feather.config.lua'), 'return { include = { "console" }, apiKey = "dev" }\n'); + + const blocked = run(['build', 'web', '--dir', dir, '--json']); + assert.equal(blocked.exitCode, 1); + assert.ok(outputOf(blocked).includes('Production build preflight failed')); + + const allowed = run(['build', 'web', '--dir', dir, '--json', '--allow-unsafe']); + assert.equal(allowed.exitCode, 0, outputOf(allowed)); + assert.equal(JSON.parse(allowed.stdout).ok, true); +}); diff --git a/cli/test/commands/doctor.test.mjs b/cli/test/commands/doctor.test.mjs new file mode 100644 index 00000000..778dad32 --- /dev/null +++ b/cli/test/commands/doctor.test.mjs @@ -0,0 +1,534 @@ +/* eslint-disable no-undef */ +import { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppleLibrariesZip, + writeFakeCommand, + writeFakeDesktopRuntimeVendors, + writeFakeDesktopTools, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +} from './helpers.mjs'; + +test('doctor --json reports unknown installed plugin trust', () => { + const dir = makeTmp(); + writeGame(dir); + writeMinimalRuntime(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + writeLocalPluginSource(dir, 'custom-plugin'); + + const parsed = parseDoctorJson(dir); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + const trustCheck = labels.get('Plugin custom-plugin'); + assert.equal(trustCheck.severity, 'warn'); + assert.ok(trustCheck.detail.includes('unknown trust')); + assert.ok(trustCheck.fix.includes('feather plugin remove custom-plugin')); +}); + +test('doctor --json reports dangerous bundled plugin trust', () => { + const dir = makeTmp(); + writeGame(dir); + writeMinimalRuntime(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + + const parsed = parseDoctorJson(dir); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + const trustCheck = labels.get('Plugin console trust'); + assert.equal(trustCheck.severity, 'warn'); + assert.ok(trustCheck.detail.includes('development-only')); +}); + +test('doctor --json warns about runtime symlinks escaping project', () => { + const dir = makeTmp(); + const outside = join(makeTmp(), 'outside-runtime'); + writeGame(dir); + writeMinimalRuntime(outside); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + symlinkSync(join(outside, 'feather'), join(dir, 'feather'), 'dir'); + + const parsed = parseDoctorJson(dir); + const symlinkCheck = parsed.checks.find((check) => check.label === 'Symlink escape'); + assert.equal(symlinkCheck.severity, 'warn'); + assert.ok(symlinkCheck.detail.includes('outside-runtime')); +}); + +test('doctor --json remains decoration-free and reports missing plugin directory', () => { + const dir = makeTmp(); + writeGame(dir); + writeMinimalRuntime(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + const parsed = parseDoctorJson(dir); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Plugin directory').severity, 'info'); + assert.equal(labels.get('Plugin directory').detail, 'not installed'); +}); + +test('doctor --json reports malformed plugin manifests with recovery text', () => { + const dir = makeTmp(); + writeGame(dir); + writeMinimalRuntime(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + mkdirSync(join(dir, 'feather', 'plugins', 'bad-plugin'), { recursive: true }); + writeFileSync(join(dir, 'feather', 'plugins', 'bad-plugin', 'manifest.lua'), 'return { name = "Bad Plugin" }\n'); + + const parsed = parseDoctorJson(dir); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + const manifestCheck = labels.get('Plugin manifests'); + assert.equal(manifestCheck.severity, 'warn'); + assert.ok(manifestCheck.fix.includes('feather plugin update')); +}); + +test('doctor: human output honors NO_COLOR', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test", include = { "console" } }\n'); + const result = run(['doctor', dir]); + assert.equal(result.exitCode, 0); + assert.equal(ANSI_RE.test(result.stdout), false); + assert.ok(result.stdout.includes('Plugin console')); + assert.ok(result.stdout.includes('feather plugin install console')); +}); + +test('doctor --production fails unsafe remote-control and production settings', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + __DANGEROUS_INSECURE_CONNECTION__ = true, + host = "0.0.0.0", + include = { "console", "hot-reload" }, + apiKey = "dev", + captureScreenshot = true, + writeToDisk = true, + debugger = { + enabled = true, + hotReload = { + enabled = true, + allow = { "game.*" }, + persistToDisk = true, + }, + }, +} +`, + ); + + const { result, parsed } = parseDoctorJsonResult(dir, ['--production']); + assert.equal(result.exitCode, 1); + assert.equal(parsed.production, true); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + for (const label of [ + '__DANGEROUS_INSECURE_CONNECTION__', + 'Desktop App ID', + 'captureScreenshot', + 'Hot reload', + 'Hot reload allowlist', + 'Hot reload persistence', + 'Console API key', + 'Step debugger', + 'Disk logging', + 'Network host exposure', + ]) { + assert.equal(labels.get(label)?.severity, 'fail', `${label} should fail in production`); + } +}); + +test('doctor --json makes missing Desktop App ID fail outside production', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + __DANGEROUS_INSECURE_CONNECTION__ = true, + host = "0.0.0.0", + include = { "console" }, + apiKey = "dev", +} +`, + ); + + const { result, parsed } = parseDoctorJsonResult(dir); + assert.equal(result.exitCode, 1, outputOf(result)); + assert.equal(parsed.production, false); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Desktop App ID')?.severity, 'fail'); + assert.equal(labels.get('__DANGEROUS_INSECURE_CONNECTION__')?.severity, 'warn'); + assert.equal(labels.get('Console API key')?.severity, 'warn'); + assert.equal(labels.get('Network host exposure')?.severity, 'warn'); +}); + +test('doctor --json keeps missing Desktop App ID non-failing in disk mode', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { mode = "disk" }\n'); + + const { result, parsed } = parseDoctorJsonResult(dir); + assert.equal(result.exitCode, 0, outputOf(result)); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Desktop App ID')?.severity, 'pass'); + assert.equal(labels.get('Desktop App ID')?.detail, 'not needed for disk mode'); +}); + +test('doctor --security --json emits a sterile security report without secrets', () => { + const dir = makeTmp(); + const secret = 'StrongSecretValue1234567890!'; + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + appId = "feather-app-test-1234567890", + host = "127.0.0.1", + include = { "console" }, + apiKey = "${secret}", +} +`, + ); + + const result = run(['doctor', dir, '--security', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + assert.equal(result.stdout.includes('✔'), false); + assert.equal(result.stdout.includes(secret), false); + + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.security, true); + assert.equal(parsed.report.config.apiKeyStatus, 'configured'); + assert.equal(parsed.report.config.consoleIncluded, true); + assert.equal(parsed.report.network.exposure, 'loopback'); + assert.ok(parsed.checks.every((check) => ['Safety', 'Plugins', 'Packages', 'Runtime', 'Project'].includes(check.group))); + assert.equal(parsed.checks.some((check) => check.label === 'Node.js'), false); +}); + +test('doctor --production fails unmanaged embedded runtime', () => { + const dir = makeTmp(); + writeGame(dir); + writeMinimalRuntime(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890", mode = "socket" }\n'); + + const { result, parsed } = parseDoctorJsonResult(dir, ['--production']); + assert.equal(result.exitCode, 1); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Managed runtime')?.severity, 'fail'); + assert.ok(labels.get('Managed runtime')?.fix.includes('feather init')); +}); + +test('doctor: build and upload target checks report missing and configured dependencies', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + writeBuildConfig(dir, { + name: 'Doctor Build Game', + version: '1.0.0', + upload: { itch: { project: 'tester/doctor-build-game' } }, + }); + const { parsed: missing } = parseDoctorJsonResult(dir, ['--build-target', 'web', '--upload-target', 'itch']); + const missingLabels = new Map(missing.checks.map((check) => [check.label, check])); + assert.equal(missingLabels.get('love.js player')?.severity, 'fail'); + assert.equal(missingLabels.get('butler')?.severity, 'fail'); + assert.ok(missingLabels.get('love.js player')?.fix.includes('targets.web.loveJsDir')); + + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Doctor Build Game', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/doctor-build-game' } }, + }); + const { binDir } = writeFakeCommand(dir, 'butler', `console.log('butler test'); process.exit(0);`); + const configured = run(['doctor', dir, '--json', '--build-target', 'web', '--upload-target', 'itch'], { env: envWithPath(binDir, { BUTLER_API_KEY: 'test-key' }) }); + assert.equal(configured.exitCode, 0, outputOf(configured)); + const parsed = JSON.parse(configured.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('love.js player')?.severity, 'pass'); + assert.equal(labels.get('butler')?.severity, 'pass'); + assert.equal(labels.get('BUTLER_API_KEY')?.severity, 'pass'); +}); + +test('doctor: desktop build targets report runtime vendors and packaging tools', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + writeBuildConfig(dir, { name: 'Desktop Doctor Game', version: '1.0.0' }); + + const { parsed: missing } = parseDoctorJsonResult(dir, ['--build-target', 'windows']); + const missingLabels = new Map(missing.checks.map((check) => [check.label, check])); + assert.equal(missingLabels.get('Windows LÖVE runtime')?.severity, 'fail'); + assert.ok(missingLabels.get('Windows LÖVE runtime')?.fix.includes('feather build vendor add windows')); + + const vendors = writeFakeDesktopRuntimeVendors(dir); + writeBuildConfig(dir, { + name: 'Desktop Doctor Game', + version: '1.0.0', + targets: { + windows: { loveRuntimeDir: vendors.windows }, + macos: { loveRuntimeDir: vendors.macos }, + linux: { loveRuntimeDir: vendors.linux }, + }, + }); + const { binDir } = writeFakeDesktopTools(dir); + + for (const target of ['windows', 'macos', 'linux', 'steamos']) { + const result = run(['doctor', dir, '--json', '--build-target', target], { env: envWithPath(binDir) }); + assert.equal(result.stdout.trim().startsWith('{'), true, outputOf(result)); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + const runtimeLabel = target === 'macos' + ? 'macOS LÖVE runtime' + : target === 'steamos' + ? 'SteamOS LÖVE runtime' + : `${target[0].toUpperCase()}${target.slice(1)} LÖVE runtime`; + assert.equal(labels.get(runtimeLabel)?.severity, 'pass'); + if (target === 'windows') assert.equal(labels.get('NSIS makensis')?.severity, 'pass'); + if (target === 'macos') assert.equal(labels.get('hdiutil')?.severity, 'pass'); + if (target === 'linux' || target === 'steamos') assert.equal(labels.get('appimagetool')?.severity, 'pass'); + } +}); + +test('doctor: build target all reports every platform with prefixed labels', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + writeFakeLoveJs(dir); + writeFakeLoveAndroid(dir); + writeFakeLoveIos(dir); + const vendors = writeFakeDesktopRuntimeVendors(dir); + writeBuildConfig(dir, { + name: 'All Platforms Doctor Game', + version: '1.0.0', + productId: 'com.example.allplatformsdoctor', + targets: { + web: { loveJsDir: 'love.js' }, + android: { loveAndroidDir: 'love-android' }, + ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.allplatformsdoctor.ios' }, + windows: { loveRuntimeDir: vendors.windows }, + macos: { loveRuntimeDir: vendors.macos }, + linux: { loveRuntimeDir: vendors.linux }, + }, + }); + const { binDir } = writeFakeDesktopTools(dir); + writeFakeCommand(dir, 'java', `console.error('java version "17.0.0"'); process.exit(0);`); + writeFakeCommand(dir, 'xcodebuild', `console.log('Xcode 99.0'); process.exit(0);`); + + const result = run(['doctor', dir, '--json', '--build-target', 'all'], { + env: envWithPath(binDir, { ANDROID_HOME: join(dir, 'android-sdk') }), + }); + assert.equal(result.stdout.trim().startsWith('{'), true, outputOf(result)); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Build target')?.detail, 'all'); + assert.equal(labels.get('Build target')?.fix, 'checking love, web, android, ios, windows, macos, linux, steamos'); + assert.equal(labels.get('LÖVE package')?.severity, 'pass'); + assert.equal(labels.get('Web love.js player')?.severity, 'pass'); + assert.equal(labels.get('Android config')?.severity, 'pass'); + assert.equal(labels.get('Android love-android template')?.severity, 'pass'); + assert.equal(labels.get('Android JDK')?.severity, 'pass'); + assert.equal(labels.get('Android SDK')?.severity, 'pass'); + assert.equal(labels.get('iOS config')?.severity, 'pass'); + assert.equal(labels.get('iOS LÖVE template')?.severity, 'pass'); + assert.equal(labels.get('iOS xcodebuild')?.severity, 'pass'); + assert.equal(labels.get('Windows LÖVE runtime')?.severity, 'pass'); + assert.equal(labels.get('Windows NSIS makensis')?.severity, 'pass'); + assert.equal(labels.get('macOS LÖVE runtime')?.severity, 'pass'); + assert.equal(labels.get('macOS hdiutil')?.severity, 'pass'); + assert.equal(labels.get('Linux LÖVE runtime')?.severity, 'pass'); + assert.equal(labels.get('Linux appimagetool')?.severity, 'pass'); + assert.equal(labels.get('SteamOS LÖVE runtime')?.severity, 'pass'); + assert.equal(labels.get('SteamOS appimagetool')?.severity, 'pass'); +}); + +test('doctor: invalid build target mentions doctor-only all target', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + + const { result, parsed } = parseDoctorJsonResult(dir, ['--build-target', 'badtarget']); + assert.equal(result.exitCode, 1); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Build target')?.severity, 'fail'); + assert.ok(labels.get('Build target')?.fix.includes('all')); +}); + +test('doctor: android build target reports template and local tool setup', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + writeBuildConfig(dir, { name: 'Android Doctor Game', version: '1.0.0' }); + const { parsed: missing } = parseDoctorJsonResult(dir, ['--build-target', 'android']); + const missingLabels = new Map(missing.checks.map((check) => [check.label, check])); + assert.equal(missingLabels.get('love-android template')?.severity, 'fail'); + assert.equal(missingLabels.get('Android Gradle wrapper')?.severity, 'fail'); + assert.ok(missingLabels.get('love-android template')?.fix.includes('targets.android.loveAndroidDir')); + assert.ok(missingLabels.get('love-android template')?.fix.includes('feather build vendor add android')); + + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Android Doctor Game', + version: '1.0.0', + productId: 'com.example.androiddoctor', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir } = writeFakeCommand(dir, 'java', `console.error('java version "17.0.0"'); process.exit(0);`); + const configured = run(['doctor', dir, '--json', '--build-target', 'android'], { + env: envWithPath(binDir, { ANDROID_HOME: join(dir, 'android-sdk') }), + }); + assert.equal(configured.stdout.trim().startsWith('{'), true, outputOf(configured)); + const parsed = JSON.parse(configured.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('love-android template')?.severity, 'pass'); + assert.equal(labels.get('Android Gradle wrapper')?.severity, 'pass'); + assert.equal(labels.get('JDK')?.severity, 'pass'); + assert.equal(labels.get('Android SDK')?.severity, 'pass'); + assert.equal(labels.get('Android product id')?.severity, 'pass'); + assert.equal(labels.get('Android signing')?.severity, 'warn'); +}); + +test('doctor: mobile build target reports config validation failures', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Doctor Bad Android', + version: '1.0.0', + productId: 'bad product id', + targets: { android: { loveAndroidDir: 'love-android', versionCode: 0 } }, + }); + + const { result, parsed } = parseDoctorJsonResult(dir, ['--build-target', 'android']); + assert.equal(result.exitCode, 1); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Android config')?.severity, 'fail'); + assert.ok(labels.get('Android config')?.detail.includes('versionCode')); + assert.equal(labels.get('Android product id')?.severity, 'fail'); +}); + +test('doctor: android release reports missing signing environment', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + writeFakeLoveAndroid(dir); + writeFileSync(join(dir, 'release.keystore'), 'fake keystore'); + writeBuildConfig(dir, { + name: 'Doctor Android Release', + version: '1.0.0', + productId: 'com.example.doctorandroidrelease', + targets: { + android: { + loveAndroidDir: 'love-android', + release: { + keystorePath: 'release.keystore', + keyAlias: 'release-key', + storePasswordEnv: 'FEATHER_MISSING_STORE_PASSWORD', + keyPasswordEnv: 'FEATHER_MISSING_KEY_PASSWORD', + }, + }, + }, + }); + + const { result, parsed } = parseDoctorJsonResult(dir, ['--build-target', 'android', '--release']); + assert.equal(result.exitCode, 1); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Android config')?.severity, 'pass'); + assert.equal(labels.get('Android signing')?.severity, 'fail'); + assert.ok(labels.get('Android signing')?.detail.includes('FEATHER_MISSING_STORE_PASSWORD')); +}); + +test('doctor: ios build target reports platform, template, Xcode, and signing hints', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + writeBuildConfig(dir, { name: 'iOS Doctor Game', version: '1.0.0' }); + const { parsed: missing } = parseDoctorJsonResult(dir, ['--build-target', 'ios']); + const missingLabels = new Map(missing.checks.map((check) => [check.label, check])); + assert.equal(missingLabels.get('LÖVE iOS template')?.severity, 'fail'); + assert.equal(missingLabels.get('LÖVE iOS Xcode project')?.severity, 'fail'); + assert.ok(missingLabels.get('LÖVE iOS template')?.fix.includes('feather build vendor add ios')); + + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'iOS Doctor Game', + version: '1.0.0', + targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.iosdoctor' } }, + }); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', `console.log('Xcode 99.0'); process.exit(0);`); + const configured = run(['doctor', dir, '--json', '--build-target', 'ios'], { env: envWithPath(binDir) }); + assert.equal(configured.stdout.trim().startsWith('{'), true, outputOf(configured)); + const parsed = JSON.parse(configured.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('xcodebuild')?.severity, 'pass'); + assert.equal(labels.get('LÖVE iOS template')?.severity, 'pass'); + assert.equal(labels.get('LÖVE iOS Xcode project')?.severity, 'pass'); + assert.equal(labels.get('iOS bundle id')?.severity, 'pass'); + assert.equal(labels.get('iOS signing team')?.severity, 'warn'); + if (process.platform === 'darwin') { + assert.equal(labels.get('macOS host')?.severity, 'pass'); + } else { + assert.equal(labels.get('macOS host')?.severity, 'fail'); + assert.equal(configured.exitCode, 1, outputOf(configured)); + } +}); + +test('doctor: ios release reports export options and release signing hints', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Doctor iOS Release', + version: '1.0.0', + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.doctoriosrelease', + release: { + exportMethod: 'app-store-connect', + signingStyle: 'manual', + teamId: 'TEAM12345', + }, + }, + }, + }); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', `console.log('Xcode 99.0'); process.exit(0);`); + const result = run(['doctor', dir, '--json', '--build-target', 'ios', '--release'], { env: envWithPath(binDir) }); + assert.equal(result.stdout.trim().startsWith('{'), true, outputOf(result)); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('iOS config')?.severity, 'pass'); + assert.equal(labels.get('iOS signing team')?.severity, 'pass'); + assert.equal(labels.get('iOS export options')?.severity, 'pass'); +}); diff --git a/cli/test/commands/help.test.mjs b/cli/test/commands/help.test.mjs new file mode 100644 index 00000000..9f7a54fd --- /dev/null +++ b/cli/test/commands/help.test.mjs @@ -0,0 +1,15 @@ +/* eslint-disable no-undef */ +import { assert, outputOf, run, test } from './helpers.mjs'; + +function runOk(args) { + const result = run(args); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.length > 0); + return result.stdout; +} + +test('help: core commands render help text', () => { + assert.match(runOk(['--help']), /Usage:/); + assert.match(runOk(['doctor', '--help']), /doctor/); + assert.match(runOk(['plugin', '--help']), /plugin/); +}); diff --git a/cli/test/commands/helpers.mjs b/cli/test/commands/helpers.mjs new file mode 100644 index 00000000..42b72036 --- /dev/null +++ b/cli/test/commands/helpers.mjs @@ -0,0 +1,767 @@ +/* eslint-disable no-undef */ +/** + * Focused compiled-CLI coverage for non-package commands. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { delimiter, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const CLI = fileURLToPath(new URL('../../dist/index.js', import.meta.url)); +const ROOT = fileURLToPath(new URL('../../..', import.meta.url)); +const LOCAL_SRC = join(ROOT, 'src-lua'); +// eslint-disable-next-line no-control-regex +const ANSI_RE = /\x1B\[[0-?]*[ -/]*[@-~]/; +const sha256 = (value) => createHash('sha256').update(value).digest('hex'); + +function makeTmp() { + return mkdtempSync(join(tmpdir(), 'feather-command-test-')); +} + +function run(args, extra = {}) { + const result = spawnSync(process.execPath, [CLI, ...args], { + encoding: 'utf8', + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + ...extra, + }); + return { + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + exitCode: result.status ?? 1, + }; +} + +function spawnCli(args, extra = {}) { + const child = spawn(process.execPath, [CLI, ...args], { + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + ...extra, + }); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + return child; +} + +function waitForOutput(child, pattern, timeoutMs = 10000) { + return new Promise((resolveWait, rejectWait) => { + let output = ''; + const timer = setTimeout(() => { + rejectWait(new Error(`Timed out waiting for ${pattern}. Output:\n${output}`)); + }, timeoutMs); + const onData = (chunk) => { + output += chunk; + if (pattern.test(output)) { + clearTimeout(timer); + cleanup(); + resolveWait(output); + } + }; + const onExit = (code) => { + clearTimeout(timer); + cleanup(); + rejectWait(new Error(`Process exited with ${code} before ${pattern}. Output:\n${output}`)); + }; + const cleanup = () => { + child.stdout.off('data', onData); + child.stderr.off('data', onData); + child.off('exit', onExit); + }; + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.once('exit', onExit); + }); +} + +function stopChild(child) { + return new Promise((resolveStop) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolveStop(); + return; + } + child.once('exit', () => resolveStop()); + child.kill('SIGTERM'); + setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + }, 2000).unref(); + }); +} + +function outputOf(result) { + return `${result.stdout}\n${result.stderr}`; +} + +function writeGame(dir) { + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'main.lua'), + `function love.update(dt) +end + +function love.draw() +end +`, + ); +} + +function writeMinimalRuntime(dir) { + mkdirSync(join(dir, 'feather', 'lib'), { recursive: true }); + writeFileSync(join(dir, 'feather', 'init.lua'), 'FEATHER_VERSION_NAME = "test"\nreturn {}\n'); + writeFileSync(join(dir, 'feather', 'auto.lua'), 'return {}\n'); + writeFileSync(join(dir, 'feather', 'lib', 'ws.lua'), 'return {}\n'); + writeFileSync(join(dir, 'feather', 'plugin_manager.lua'), 'return {}\n'); +} + +function writeLocalPluginSource(root, id, options = {}) { + const sourceDir = options.sourceDir ?? id.replace(/\./g, '/'); + const pluginDir = join(root, 'plugins', sourceDir); + mkdirSync(pluginDir, { recursive: true }); + const manifestId = options.manifestId ?? id; + const versionLine = options.version === null ? '' : ` version = "${options.version ?? '1.0.0'}",\n`; + writeFileSync( + join(pluginDir, 'manifest.lua'), + `return { + id = "${manifestId}", + name = "${options.name ?? manifestId}", +${versionLine}} +`, + ); + writeFileSync(join(pluginDir, 'init.lua'), 'return {}\n'); + return pluginDir; +} + +function writeLock(dir, packages) { + writeFileSync(join(dir, 'feather.lock.json'), JSON.stringify({ lockfileVersion: 1, packages }, null, 2)); +} + +function writeFakeLove(dir, options = {}) { + const recordPath = options.recordPath ?? join(dir, 'fake-love-record.json'); + const exitCode = options.exitCode ?? 0; + const fakePath = join(dir, 'fake-love.cjs'); + writeFileSync( + fakePath, + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const shimDir = process.argv[2] || ''; +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ + argv: process.argv.slice(2), + env: { + FEATHER_GAME_PATH: process.env.FEATHER_GAME_PATH, + FEATHER_SESSION_NAME: process.env.FEATHER_SESSION_NAME, + }, + shimMainExists: shimDir ? fs.existsSync(path.join(shimDir, 'main.lua')) : false, + featherAutoExists: shimDir ? fs.existsSync(path.join(shimDir, 'feather', 'auto.lua')) : false, + shimMain: shimDir ? fs.readFileSync(path.join(shimDir, 'main.lua'), 'utf8') : '', +}, null, 2)); +process.exit(${JSON.stringify(exitCode)}); +`, + ); + chmodSync(fakePath, 0o755); + return { fakePath, recordPath }; +} + +function writeFakeAdb(dir, options = {}) { + const recordPath = options.recordPath ?? join(dir, 'adb-record.json'); + const { binDir } = writeFakeCommand(dir, 'adb', ` +const fs = require('node:fs'); +const args = process.argv.slice(2); +const previous = fs.existsSync(${JSON.stringify(recordPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) + : []; +previous.push({ args }); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); +if (${JSON.stringify(options.failInstall ?? false)} && args.includes('install')) { + console.error('install failed'); + process.exit(42); +} +process.exit(0); +`); + return { binDir, recordPath }; +} + +function writeFakeXcrun(dir) { + const recordPath = join(dir, 'xcrun-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcrun', ` +const fs = require('node:fs'); +const args = process.argv.slice(2); +const previous = fs.existsSync(${JSON.stringify(recordPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) + : []; +previous.push({ args }); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); +process.exit(0); +`); + return { binDir, recordPath }; +} + +function writeFakeCommand(dir, name, script) { + const binDir = join(dir, 'bin'); + mkdirSync(binDir, { recursive: true }); + const commandPath = join(binDir, name); + writeFileSync(commandPath, `#!/usr/bin/env node\n${script}\n`); + chmodSync(commandPath, 0o755); + return { binDir, commandPath }; +} + +function writeFakeVendorGit(dir) { + const recordPath = join(dir, 'git-record.json'); + const { binDir } = writeFakeCommand(dir, 'git', ` +const fs = require('fs'); +const path = require('path'); +const args = process.argv.slice(2); +const recordPath = ${JSON.stringify(recordPath)}; +const records = fs.existsSync(recordPath) ? JSON.parse(fs.readFileSync(recordPath, 'utf8')) : []; +records.push({ args }); +fs.writeFileSync(recordPath, JSON.stringify(records, null, 2)); +if (args[0] === '--version') { + console.log('git version test'); + process.exit(0); +} +if (args[0] !== 'clone') { + console.error('unexpected git args ' + args.join(' ')); + process.exit(1); +} +const target = args[args.length - 1]; +const repo = args[args.length - 2]; +fs.mkdirSync(target, { recursive: true }); +if (repo.includes('love.js')) { + fs.writeFileSync(path.join(target, 'index.html'), ''); + fs.writeFileSync(path.join(target, 'player.js'), 'console.log("love.js");\\n'); +} else if (repo.includes('love-android')) { + fs.writeFileSync(path.join(target, 'gradlew'), '#!/bin/sh\\n'); + fs.mkdirSync(path.join(target, 'app', 'src', 'embed', 'assets'), { recursive: true }); +} else if (repo.includes('love')) { + fs.mkdirSync(path.join(target, 'platform', 'xcode', 'love.xcodeproj'), { recursive: true }); + fs.writeFileSync(path.join(target, 'platform', 'xcode', 'love.xcodeproj', 'project.pbxproj'), ''); +} +process.exit(0); +`); + return { binDir, recordPath }; +} + +function envWithPath(binDir, extra = {}) { + return { + ...process.env, + NO_COLOR: '1', + FORCE_COLOR: '0', + PATH: `${binDir}${delimiter}${process.env.PATH ?? ''}`, + ...extra, + }; +} + +function writeFakeLoveJs(dir) { + const loveJsDir = join(dir, 'love.js'); + mkdirSync(loveJsDir, { recursive: true }); + writeFileSync( + join(loveJsDir, 'index.html'), + 'löve.js', + ); + writeFileSync(join(loveJsDir, 'player.min.js'), 'console.log("love.js");\n'); + return loveJsDir; +} + +function writeFakeLoveAndroid(dir, options = {}) { + const recordPath = options.recordPath ?? join(dir, 'gradle-record.json'); + const apkRel = options.apkRel ?? 'app/build/outputs/apk/embed/debug/app-embed-debug.apk'; + const aabRel = options.aabRel ?? 'app/build/outputs/bundle/embedRelease/app-embed-release.aab'; + const manifestPermission = options.recordAudioPermission + ? ' \n' + : ''; + const root = join(dir, 'love-android'); + mkdirSync(join(root, 'app', 'src', 'main', 'res', 'values'), { recursive: true }); + mkdirSync(join(root, 'app', 'src', 'main'), { recursive: true }); + mkdirSync(join(root, 'love', 'src', 'main', 'java', 'org', 'love2d', 'android'), { recursive: true }); + writeFileSync( + join(root, 'app', 'build.gradle'), + `android { + namespace "org.love2d.android" + defaultConfig { + applicationId "org.love2d.android" + versionCode 1 + versionName "0.0.0" + } +} +`, + ); + writeFileSync( + join(root, 'gradle.properties'), + `app.name_byte_array=76,195,150,86,69 +app.application_id=org.love2d.android +app.orientation=landscape +app.version_code=31 +app.version_name=11.5 +`, + ); + writeFileSync( + join(root, 'app', 'src', 'main', 'AndroidManifest.xml'), + ` +${manifestPermission} + + + + +`, + ); + writeFileSync(join(root, 'app', 'src', 'main', 'res', 'values', 'strings.xml'), 'LÖVE\n'); + writeFileSync( + join(root, 'love', 'src', 'main', 'java', 'org', 'love2d', 'android', 'GameActivity.java'), + `package org.love2d.android; +import java.io.IOException; +import java.io.InputStream; +class GameActivity { + boolean embed; + boolean needToCopyGameInArchive; + Object getResources() { return null; } + Object getAssets() { return null; } + void onCreate() { + embed = getResources().getBoolean(R.bool.embed); + } +} +`, + ); + const gradlew = join(root, 'gradlew'); + writeFileSync( + gradlew, + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const cwd = process.cwd(); +const apk = path.join(cwd, ${JSON.stringify(apkRel)}); +fs.mkdirSync(path.dirname(apk), { recursive: true }); +fs.writeFileSync(apk, 'fake apk'); +const aab = path.join(cwd, ${JSON.stringify(aabRel)}); +fs.mkdirSync(path.dirname(aab), { recursive: true }); +fs.writeFileSync(aab, 'fake aab'); +const previous = fs.existsSync(${JSON.stringify(recordPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) + : { records: [] }; +const entry = { + argv: process.argv.slice(2), + cwd, + embeddedLoveExists: fs.existsSync(path.join(cwd, 'app', 'src', 'embed', 'assets', 'game.love')), + gradle: fs.readFileSync(path.join(cwd, 'app', 'build.gradle'), 'utf8'), + gradleProperties: fs.readFileSync(path.join(cwd, 'gradle.properties'), 'utf8'), + manifest: fs.readFileSync(path.join(cwd, 'app', 'src', 'main', 'AndroidManifest.xml'), 'utf8'), + gameActivity: fs.existsSync(path.join(cwd, 'love', 'src', 'main', 'java', 'org', 'love2d', 'android', 'GameActivity.java')) + ? fs.readFileSync(path.join(cwd, 'love', 'src', 'main', 'java', 'org', 'love2d', 'android', 'GameActivity.java'), 'utf8') + : '', + signingProperties: fs.existsSync(path.join(cwd, 'feather-signing.properties')) + ? fs.readFileSync(path.join(cwd, 'feather-signing.properties'), 'utf8') + : '', +}; +previous.records.push(entry); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ ...entry, records: previous.records }, null, 2)); +console.log('fake gradle ' + process.argv.slice(2).join(' ')); +process.exit(0); +`, + ); + chmodSync(gradlew, 0o755); + return { root, recordPath }; +} + +function writeFakeLoveIos(dir) { + const root = join(dir, 'love-ios'); + const projectDir = join(root, 'platform', 'xcode', 'love.xcodeproj'); + const plistDir = join(root, 'platform', 'xcode', 'ios'); + mkdirSync(projectDir, { recursive: true }); + mkdirSync(plistDir, { recursive: true }); + writeFileSync( + join(plistDir, 'love-ios.plist'), + ` + + + + CFBundleExecutable + love + CFBundleIdentifier + org.love2d.love + CFBundleName + love + CFBundleShortVersionString + 11.5 + CFBundleVersion + 11.5 + + +`, + ); + writeFileSync( + join(projectDir, 'project.pbxproj'), + `// !$*UTF8*$! +{ +/* Begin PBXBuildFile section */ +/* End PBXBuildFile section */ +/* Begin PBXFileReference section */ +/* End PBXFileReference section */ +/* Begin PBXNativeTarget section */ + 111111111111111111111111 /* love-macosx */ = { + isa = PBXNativeTarget; + buildPhases = ( + 222222222222222222222222 /* Resources */, + ); + name = "love-macosx"; + }; + 333333333333333333333333 /* love-ios */ = { + isa = PBXNativeTarget; + buildPhases = ( + 444444444444444444444444 /* Sources */, + 555555555555555555555555 /* Frameworks */, + 666666666666666666666666 /* Resources */, + ); + name = "love-ios"; + }; +/* End PBXNativeTarget section */ +/* Begin PBXResourcesBuildPhase section */ + 222222222222222222222222 /* Resources */ = { + isa = PBXResourcesBuildPhase; + files = ( + ); + }; + 666666666666666666666666 /* Resources */ = { + isa = PBXResourcesBuildPhase; + files = ( + 777777777777777777777777 /* Launch Screen.xib in Resources */, + ); + }; +/* End PBXResourcesBuildPhase section */ +} +`, + ); + return root; +} + +function writeBuildConfig(dir, config) { + writeFileSync(join(dir, 'feather.build.json'), `${JSON.stringify(config, null, 2)}\n`); +} + +function readStoredZipEntries(zipPath) { + const buffer = readFileSync(zipPath); + const entries = new Map(); + let offset = 0; + while (offset + 30 < buffer.length && buffer.readUInt32LE(offset) === 0x04034b50) { + const method = buffer.readUInt16LE(offset + 8); + const compressedSize = buffer.readUInt32LE(offset + 18); + const fileNameLength = buffer.readUInt16LE(offset + 26); + const extraLength = buffer.readUInt16LE(offset + 28); + const nameStart = offset + 30; + const dataStart = nameStart + fileNameLength + extraLength; + const name = buffer.subarray(nameStart, nameStart + fileNameLength).toString('utf8'); + const data = buffer.subarray(dataStart, dataStart + compressedSize); + if (method !== 0) throw new Error(`Test zip reader only supports stored entries: ${name}`); + entries.set(name, data); + offset = dataStart + compressedSize; + } + return entries; +} + +function createDataDescriptorZipBuffer(entries) { + const chunks = []; + const central = []; + let offset = 0; + + for (const entry of entries) { + const name = Buffer.from(entry.name); + const data = Buffer.from(entry.data); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x08, 6); + local.writeUInt16LE(0, 8); + local.writeUInt32LE(0, 10); + local.writeUInt32LE(0, 14); + local.writeUInt32LE(0, 18); + local.writeUInt32LE(0, 22); + local.writeUInt16LE(name.length, 26); + local.writeUInt16LE(0, 28); + + const descriptor = Buffer.alloc(16); + descriptor.writeUInt32LE(0x08074b50, 0); + descriptor.writeUInt32LE(0, 4); + descriptor.writeUInt32LE(data.length, 8); + descriptor.writeUInt32LE(data.length, 12); + + const centralEntry = Buffer.alloc(46); + centralEntry.writeUInt32LE(0x02014b50, 0); + centralEntry.writeUInt16LE(20, 4); + centralEntry.writeUInt16LE(20, 6); + centralEntry.writeUInt16LE(0x08, 8); + centralEntry.writeUInt16LE(0, 10); + centralEntry.writeUInt32LE(0, 12); + centralEntry.writeUInt32LE(0, 16); + centralEntry.writeUInt32LE(data.length, 20); + centralEntry.writeUInt32LE(data.length, 24); + centralEntry.writeUInt16LE(name.length, 28); + centralEntry.writeUInt16LE(0, 30); + centralEntry.writeUInt16LE(0, 32); + centralEntry.writeUInt16LE(0, 34); + centralEntry.writeUInt16LE(0, 36); + centralEntry.writeUInt32LE(0, 38); + centralEntry.writeUInt32LE(offset, 42); + + chunks.push(local, name, data, descriptor); + central.push(centralEntry, name); + offset += local.length + name.length + data.length + descriptor.length; + } + + const centralOffset = offset; + const centralSize = central.reduce((sum, chunk) => sum + chunk.length, 0); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralSize, 12); + end.writeUInt32LE(centralOffset, 16); + end.writeUInt16LE(0, 20); + + return Buffer.concat([...chunks, ...central, end]); +} + +async function writeFakeAppleLibrariesZip(dir) { + const zipPath = join(dir, 'love-apple-libraries.zip'); + writeFileSync(zipPath, createDataDescriptorZipBuffer([ + { name: 'love-apple-dependencies/iOS/libraries/liblove-test.a', data: Buffer.from('ios lib') }, + { name: 'love-apple-dependencies/macOS/Frameworks/test.framework/test', data: Buffer.from('mac lib') }, + { name: '__MACOSX/love-apple-dependencies/iOS/libraries/._ignored', data: Buffer.from('metadata') }, + ])); + return zipPath; +} + +function writeFakeDesktopRuntimeVendors(dir) { + const windows = join(dir, 'vendor', 'love-windows'); + mkdirSync(windows, { recursive: true }); + writeFileSync(join(windows, 'love.exe'), 'fake love exe'); + writeFileSync(join(windows, 'SDL2.dll'), 'fake dll'); + + const macos = join(dir, 'vendor', 'love-macos', 'love.app', 'Contents'); + mkdirSync(join(macos, 'MacOS'), { recursive: true }); + mkdirSync(join(macos, 'Resources'), { recursive: true }); + writeFileSync(join(macos, 'Info.plist'), fakeMacosPlist()); + writeFileSync(join(macos, 'MacOS', 'love'), '#!/bin/sh\n'); + chmodSync(join(macos, 'MacOS', 'love'), 0o755); + + const linux = join(dir, 'vendor', 'love-linux'); + writeFakeLinuxRuntime(linux); + + return { + windows: 'vendor/love-windows', + macos: 'vendor/love-macos', + linux: 'vendor/love-linux', + }; +} + +function writeFakeLinuxRuntime(root) { + mkdirSync(join(root, 'squashfs-root', 'bin'), { recursive: true }); + mkdirSync(join(root, 'squashfs-root', 'share', 'applications'), { recursive: true }); + writeFileSync(join(root, 'squashfs-root', 'bin', 'love'), '#!/bin/sh\n'); + chmodSync(join(root, 'squashfs-root', 'bin', 'love'), 0o755); + writeFileSync(join(root, 'squashfs-root', 'AppRun'), '#!/bin/sh\nexec "$APPDIR/bin/love" "$@"\n'); + chmodSync(join(root, 'squashfs-root', 'AppRun'), 0o755); + writeFileSync(join(root, 'squashfs-root', 'love.desktop'), '[Desktop Entry]\nName=LÖVE\nType=Application\nExec=love\n'); + writeFileSync( + join(root, 'appimagetool.AppImage'), + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const out = process.argv[3]; +fs.mkdirSync(path.dirname(out), { recursive: true }); +fs.writeFileSync(out, 'fake appimage'); +`, + ); + chmodSync(join(root, 'appimagetool.AppImage'), 0o755); +} + +function writeFakeDesktopTools(dir) { + const { binDir } = writeFakeCommand(dir, 'makensis', ` +const fs = require('node:fs'); +if (process.argv.includes('/VERSION')) { + console.log('makensis test'); + process.exit(0); +} +const script = fs.readFileSync(process.argv[2], 'utf8'); +const out = script.match(/OutFile "([^"]+)"/)?.[1]; +if (!out) process.exit(2); +fs.mkdirSync(require('node:path').dirname(out), { recursive: true }); +fs.writeFileSync(out, 'fake installer'); +process.exit(0); +`); + writeFakeCommand(dir, 'hdiutil', ` +const fs = require('node:fs'); +const path = require('node:path'); +if (process.argv[2] === 'help') { + console.log('hdiutil test'); + process.exit(0); +} +const out = process.argv[process.argv.length - 1]; +fs.mkdirSync(path.dirname(out), { recursive: true }); +fs.writeFileSync(out, 'fake dmg'); +process.exit(0); +`); + writeFakeCommand(dir, 'plutil', ` +console.log('plutil test'); +process.exit(0); +`); + writeFakeCommand(dir, 'ditto', ` +const fs = require('node:fs'); +const path = require('node:path'); +const out = process.argv[process.argv.length - 1]; +fs.mkdirSync(path.dirname(out), { recursive: true }); +fs.writeFileSync(out, 'fake app zip'); +process.exit(0); +`); + return { binDir }; +} + +function writeFakeLoveWindowsZip(dir) { + const zipPath = join(dir, 'love-windows.zip'); + writeFileSync(zipPath, createDataDescriptorZipBuffer([ + { name: 'love-11.5-win64/love.exe', data: Buffer.from('fake love exe') }, + { name: 'love-11.5-win64/SDL2.dll', data: Buffer.from('fake dll') }, + ])); + return zipPath; +} + +function writeFakeLoveMacosZip(dir) { + const zipPath = join(dir, 'love-macos.zip'); + writeFileSync(zipPath, createDataDescriptorZipBuffer([ + { name: 'love.app/Contents/Info.plist', data: Buffer.from(fakeMacosPlist()) }, + { name: 'love.app/Contents/MacOS/love', data: Buffer.from('#!/bin/sh\n') }, + ])); + return zipPath; +} + +function writeFakeLoveLinuxAppImage(dir) { + const appImage = join(dir, 'love.AppImage'); + writeFileSync( + appImage, + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +if (process.argv.includes('--appimage-extract')) { + const root = path.join(process.cwd(), 'squashfs-root'); + fs.mkdirSync(path.join(root, 'bin'), { recursive: true }); + fs.mkdirSync(path.join(root, 'share', 'applications'), { recursive: true }); + fs.writeFileSync(path.join(root, 'bin', 'love'), '#!/bin/sh\\n'); + fs.chmodSync(path.join(root, 'bin', 'love'), 0o755); + fs.writeFileSync(path.join(root, 'AppRun'), '#!/bin/sh\\nexec "$APPDIR/bin/love" "$@"\\n'); + fs.chmodSync(path.join(root, 'AppRun'), 0o755); + fs.writeFileSync(path.join(root, 'love.desktop'), '[Desktop Entry]\\nName=LÖVE\\nType=Application\\nExec=love\\n'); + process.exit(0); +} +console.log('fake love appimage'); +process.exit(0); +`, + ); + chmodSync(appImage, 0o755); + return appImage; +} + +function writeFakeAppImageTool(dir) { + const appImageTool = join(dir, 'appimagetool.AppImage'); + writeFileSync( + appImageTool, + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const out = process.argv[3]; +fs.mkdirSync(path.dirname(out), { recursive: true }); +fs.writeFileSync(out, 'fake appimage'); +`, + ); + chmodSync(appImageTool, 0o755); + return appImageTool; +} + +function fakeMacosPlist() { + return ` + + + + CFBundleExecutable + love + CFBundleIdentifier + org.love2d.love + CFBundleName + love + + +`; +} + +function parseDoctorJson(dir, extra = []) { + const result = run(['doctor', dir, '--json', ...extra]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + assert.equal(result.stdout.trim().startsWith('{'), true); + return JSON.parse(result.stdout); +} + +function parseDoctorJsonResult(dir, extra = []) { + const result = run(['doctor', dir, '--json', ...extra]); + assert.equal(ANSI_RE.test(result.stdout), false); + assert.equal(result.stdout.trim().startsWith('{'), true, outputOf(result)); + return { result, parsed: JSON.parse(result.stdout) }; +} + +export { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppImageTool, + writeFakeAppleLibrariesZip, + writeFakeCommand, + writeFakeDesktopRuntimeVendors, + writeFakeDesktopTools, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveLinuxAppImage, + writeFakeLoveMacosZip, + writeFakeLoveWindowsZip, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +}; diff --git a/cli/test/commands/init.test.mjs b/cli/test/commands/init.test.mjs new file mode 100644 index 00000000..27e9e8f0 --- /dev/null +++ b/cli/test/commands/init.test.mjs @@ -0,0 +1,117 @@ +/* eslint-disable no-undef */ +import { + LOCAL_SRC, + assert, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + readFileSync, + rmSync, + run, + test, + writeFileSync, +} from './helpers.mjs'; + +function runOk(args) { + const result = run(args); + assert.equal(result.exitCode, 0, outputOf(result)); + return result.stdout.trim(); +} + +function writeE2eGame(dir) { + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'main.lua'), + `local t = 0 + +function love.update(dt) + t = t + dt +end + +function love.draw() + love.graphics.print("CLI E2E " .. tostring(t), 10, 10) +end +`, + ); +} + +function doctorJson(dir, extra = []) { + const raw = runOk(['doctor', dir, '--json', ...extra]); + return JSON.parse(raw); +} + +test('init/remove e2e: auto mode installs runtime, doctor passes, and remove cleans up', () => { + const workspace = makeTmp(); + const project = join(workspace, 'auto-game'); + + try { + writeE2eGame(project); + + runOk([ + 'init', + project, + '--mode', + 'auto', + '--local-src', + LOCAL_SRC, + '--install-dir', + 'feather', + '--no-plugins', + '--yes', + ]); + + assert.equal(existsSync(join(project, 'feather', 'init.lua')), true); + assert.equal(existsSync(join(project, 'feather.config.lua')), true); + assert.match(readFileSync(join(project, 'main.lua'), 'utf8'), /FEATHER-INIT-BEGIN require/); + assert.match(readFileSync(join(project, 'main.lua'), 'utf8'), /USE_DEBUGGER/); + + const report = doctorJson(project); + assert.equal(report.failures, 0, JSON.stringify(report, null, 2)); + assert.ok(report.checks.some((check) => check.label === 'Embedded Feather runtime' && check.severity === 'pass')); + assert.ok(report.checks.some((check) => check.label === 'USE_DEBUGGER guard' && check.severity === 'pass')); + + runOk(['remove', project, '--yes']); + assert.equal(existsSync(join(project, 'feather')), false); + assert.equal(existsSync(join(project, 'feather.config.lua')), false); + assert.equal(readFileSync(join(project, 'main.lua'), 'utf8').includes('FEATHER-INIT'), false); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: cli mode creates config without embedding runtime', () => { + const workspace = makeTmp(); + const project = join(workspace, 'cli-game'); + + try { + writeE2eGame(project); + + runOk([ + 'init', + project, + '--mode', + 'cli', + '--local-src', + LOCAL_SRC, + '--install-dir', + 'feather', + '--no-plugins', + '--yes', + ]); + + assert.equal(existsSync(join(project, 'feather.config.lua')), true); + assert.equal(existsSync(join(project, 'feather')), false); + + const report = doctorJson(project); + assert.equal(report.failures, 0, JSON.stringify(report, null, 2)); + assert.ok(report.checks.some((check) => check.label === 'Embedded Feather runtime' && check.severity === 'info')); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); diff --git a/cli/test/commands/package.test.mjs b/cli/test/commands/package.test.mjs new file mode 100644 index 00000000..70e38cc1 --- /dev/null +++ b/cli/test/commands/package.test.mjs @@ -0,0 +1,1578 @@ +/* eslint-disable no-undef */ +/** + * E2E tests for `feather package` subcommands. + * + * Runs the compiled CLI binary (dist/index.js) so build must be current. + * Uses --offline everywhere registry access is needed to avoid network I/O. + * Pre-populates temp dirs with lockfiles / files for audit & remove scenarios. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const CLI = fileURLToPath(new URL('../../dist/index.js', import.meta.url)); +const ROOT = fileURLToPath(new URL('../../..', import.meta.url)); +const LOCAL_SRC = join(ROOT, 'src-lua'); +const sha256 = (s) => createHash('sha256').update(s).digest('hex'); + +/** Run the CLI and return { stdout, stderr, exitCode }. Never throws. */ +function run(args, extra = {}) { + try { + const stdout = execFileSync('node', [CLI, ...args], { + encoding: 'utf8', + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + ...extra, + }); + return { stdout, stderr: '', exitCode: 0 }; + } catch (err) { + return { stdout: err.stdout ?? '', stderr: err.stderr ?? '', exitCode: err.status ?? 1 }; + } +} + +function makeTmp() { + return mkdtempSync(join(tmpdir(), 'feather-e2e-')); +} + +function outputOf(result) { + return `${result.stdout}\n${result.stderr}`; +} + +/** + * Write a minimal feather.lock.json to dir. + * @param {string} dir + * @param {Record} packages + */ +function writeLock(dir, packages) { + writeFileSync(join(dir, 'feather.lock.json'), JSON.stringify({ lockfileVersion: 1, packages }, null, 2)); +} + +function writeGame(dir) { + writeFileSync( + join(dir, 'main.lua'), + `function love.update(dt) +end + +function love.draw() +end +`, + ); +} + +function sourceFiles(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + return /\.(ts|tsx)$/.test(entry.name) ? [path] : []; + }); +} + +test('search: no query lists all registry packages', () => { + const { stdout, exitCode } = run(['package', 'search', '--offline']); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('anim8'), 'should include anim8'); + assert.ok(stdout.includes('bump'), 'should include bump'); + assert.ok(stdout.includes('package(s)'), 'should print count'); +}); + +test('search: query filters by package name', () => { + const { stdout, exitCode } = run(['package', 'search', 'anim', '--offline']); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('anim8')); + assert.ok(!stdout.includes('bump')); +}); + +test('search: query matches tags', () => { + const { stdout, exitCode } = run(['package', 'search', 'animation', '--offline']); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('anim8')); +}); + +test('search: unmatched query reports no results', () => { + const { stdout, exitCode } = run(['package', 'search', 'zzz_no_such_pkg_xyz', '--offline']); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('No packages found')); +}); + +test('list --installed: empty project prints hint', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'list', '--installed', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('No packages installed')); +}); + +test('list --installed: shows installed package with version', () => { + const dir = makeTmp(); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [], + }, + }); + const { stdout, exitCode } = run(['package', 'list', '--installed', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('anim8')); + assert.ok(stdout.includes('v2.3.1')); +}); + +test('info: shows package details for known package', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'info', 'anim8', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('anim8')); + assert.ok(stdout.includes('kikito/anim8')); + assert.ok(stdout.includes('anim8.lua')); + assert.ok(stdout.includes('lib/anim8.lua')); +}); + +test('info: unknown package exits 1 with message', () => { + const dir = makeTmp(); + const result = run(['package', 'info', 'zzz_no_such_pkg', '--offline', '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('not found')); +}); + +test('install: no names + no lockfile prints empty hint and exits 0', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'install', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('empty')); +}); + +test('install: no names + empty lockfile prints hint', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'install', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('empty')); +}); + +test('install: no names + all verified skips restore', () => { + const dir = makeTmp(); + const content = 'return {}'; + const hash = sha256(content); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'anim8.lua'), content); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: hash }], + }, + }); + const { stdout, exitCode } = run(['package', 'install', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('up to date')); +}); + +test('install: unknown package exits 1', () => { + const dir = makeTmp(); + const { exitCode } = run(['package', 'install', 'zzz_no_such_pkg', '--offline', '--dir', dir]); + assert.equal(exitCode, 1); +}); + +test('install: dry-run shows plan without writing any files', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'install', 'anim8', '--dry-run', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('anim8')); + assert.ok(stdout.includes('Dry run')); + assert.ok(!existsSync(join(dir, 'lib', 'anim8.lua')), 'must not write any file'); + assert.ok(!existsSync(join(dir, 'feather.lock.json')), 'must not write lockfile'); +}); + +test('install: already-installed package is skipped', () => { + const dir = makeTmp(); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [], + }, + }); + const { stdout, exitCode } = run(['package', 'install', 'anim8', '--dry-run', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('already installed')); +}); + +test('install: version override without --allow-untrusted exits 1', () => { + const dir = makeTmp(); + const result = run(['package', 'install', 'anim8@v2.2.0', '--offline', '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('allow-untrusted')); +}); + +test('install --from-url: missing --allow-untrusted exits 1 with warning', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run([ + 'package', + 'install', + '--from-url', + 'https://example.com/helper.lua', + '--target', + 'lib/helper.lua', + '--dir', + dir, + ]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('NOT been reviewed') || stdout.includes('allow-untrusted')); +}); + +test('add: non-interactive terminal exits cleanly', () => { + const dir = makeTmp(); + const { stdout, stderr, exitCode } = run(['package', 'add', '--dir', dir]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('interactive terminal')); + assert.equal(stderr.includes('Raw mode is not supported'), false); +}); + +test('install --from-url: --yes alone does not bypass untrusted source', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run([ + 'package', + 'install', + '--from-url', + 'https://example.com/helper.lua', + '--target', + 'lib/helper.lua', + '--yes', + '--dir', + dir, + ]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('allow-untrusted') || stdout.includes('untrusted')); +}); + +test('install --from-url: missing --target exits 1', () => { + const dir = makeTmp(); + const result = run([ + 'package', + 'install', + '--from-url', + 'https://example.com/helper.lua', + '--allow-untrusted', + '--dir', + dir, + ]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('--target')); +}); + +test('audit: no packages installed prints hint', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('No packages installed')); +}); + +test('audit: correct file exits 0 and reports verified', () => { + const dir = makeTmp(); + const content = 'return {}'; + const hash = sha256(content); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'anim8.lua'), content); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: hash }], + }, + }); + const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); + assert.equal(exitCode, 0, `unexpected failure:\n${stdout}`); + assert.ok(stdout.includes('verified')); +}); + +test('audit: missing file exits 1 and reports missing', () => { + const dir = makeTmp(); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: 'abc123' }], + }, + }); + const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('missing')); +}); + +test('audit: tampered file exits 1 and reports MODIFIED', () => { + const dir = makeTmp(); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'anim8.lua'), 'tampered content'); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: sha256('original content') }], + }, + }); + const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('MODIFIED')); +}); + +test('audit --json: outputs valid JSON', () => { + const dir = makeTmp(); + const content = 'return {}'; + const hash = sha256(content); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'anim8.lua'), content); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: hash }], + }, + }); + const { stdout, exitCode } = run(['package', 'audit', '--json', '--dir', dir]); + assert.equal(exitCode, 0); + const parsed = JSON.parse(stdout); + assert.ok(Array.isArray(parsed)); + assert.equal(parsed[0].status, 'verified'); +}); + +test('remove: not installed exits 1 with message', () => { + const dir = makeTmp(); + const result = run(['package', 'remove', 'anim8', '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('not installed')); +}); + +test('remove: installed package requires --yes in non-interactive mode', () => { + const dir = makeTmp(); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'anim8.lua'), 'return {}'); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: 'any' }], + }, + }); + const result = run(['package', 'remove', 'anim8', '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('--yes')); + assert.ok(existsSync(join(dir, 'lib', 'anim8.lua')), 'file should remain'); +}); + +test('remove: deletes file and removes lockfile entry', () => { + const dir = makeTmp(); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'anim8.lua'), 'return {}'); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: 'any' }], + }, + }); + const { exitCode } = run(['package', 'remove', 'anim8', '--dir', dir, '--yes']); + assert.equal(exitCode, 0); + assert.ok(!existsSync(join(dir, 'lib', 'anim8.lua')), 'file should be deleted'); + const lock = JSON.parse(readFileSync(join(dir, 'feather.lock.json'), 'utf8')); + assert.ok(!lock.packages.anim8, 'lockfile entry should be removed'); +}); + +test('remove: does not touch files belonging to other packages', () => { + const dir = makeTmp(); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'anim8.lua'), 'return {}'); + writeFileSync(join(dir, 'lib', 'bump.lua'), 'return {}'); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: 'any' }], + }, + bump: { + version: 'v3.1.7', + trust: 'verified', + source: { repo: 'kikito/bump.lua', tag: 'v3.1.7' }, + files: [{ name: 'bump.lua', target: 'lib/bump.lua', sha256: 'any' }], + }, + }); + run(['package', 'remove', 'anim8', '--dir', dir, '--yes']); + assert.ok(existsSync(join(dir, 'lib', 'bump.lua')), 'bump.lua must not be touched'); + const lock = JSON.parse(readFileSync(join(dir, 'feather.lock.json'), 'utf8')); + assert.ok(lock.packages.bump, 'bump lockfile entry must remain'); +}); + +test('update: no packages installed prints hint', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'update', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('No packages installed')); +}); + +test('update: specific name not in lockfile exits 1', () => { + const dir = makeTmp(); + // lockfile has another package so the empty-lockfile early-return is bypassed + writeLock(dir, { + bump: { + version: 'v3.1.7', + trust: 'verified', + source: { repo: 'kikito/bump.lua', tag: 'v3.1.7' }, + files: [], + }, + }); + const result = run(['package', 'update', 'anim8', '--offline', '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('not installed')); +}); + +test('update: already up-to-date package is reported', () => { + const dir = makeTmp(); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [], + }, + }); + const { stdout, exitCode } = run(['package', 'update', 'anim8', '--offline', '--dry-run', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('up to date')); +}); + +test('update: experimental packages are skipped', () => { + const dir = makeTmp(); + writeLock(dir, { + 'my-helper': { + version: '0.0.0', + trust: 'experimental', + source: { url: 'https://example.com/my-helper.lua' }, + files: [], + }, + }); + const { stdout, exitCode } = run(['package', 'update', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('Skipping') || stdout.includes('experimental')); +}); + +/** + * Write a lockfile entry as produced by `feather package add`: + * trust: experimental, source: { url }, per-file url in each file entry. + */ +function writeUrlLock(dir, id, files) { + writeLock(dir, { + [id]: { + version: 'url', + trust: 'experimental', + source: { url: files[0].url }, + files, + installedAt: new Date().toISOString(), + }, + }); +} + +test('list --installed: shows url package with experimental trust', () => { + const dir = makeTmp(); + writeUrlLock(dir, 'my-helper', [ + { name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: 'abc' }, + ]); + const { stdout, exitCode } = run(['package', 'list', '--installed', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('my-helper')); + assert.ok(stdout.includes('experimental')); +}); + +test('audit: url package with correct file reports verified', () => { + const dir = makeTmp(); + const content = 'return {}'; + const hash = sha256(content); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'helper.lua'), content); + writeUrlLock(dir, 'my-helper', [ + { name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: hash }, + ]); + const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); + assert.equal(exitCode, 0, `unexpected failure:\n${stdout}`); + assert.ok(stdout.includes('verified')); +}); + +test('audit: url package with missing file exits 1 and reports missing', () => { + const dir = makeTmp(); + writeUrlLock(dir, 'my-helper', [ + { name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: 'abc' }, + ]); + const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('missing')); +}); + +test('audit: url package with tampered file exits 1 and reports MODIFIED', () => { + const dir = makeTmp(); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'helper.lua'), 'tampered'); + writeUrlLock(dir, 'my-helper', [ + { name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: sha256('original') }, + ]); + const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('MODIFIED')); +}); + +test('audit: multi-file url package all verified', () => { + const dir = makeTmp(); + const c1 = 'return "a"'; + const c2 = 'return "b"'; + mkdirSync(join(dir, 'lib', 'mypkg'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'mypkg', 'a.lua'), c1); + writeFileSync(join(dir, 'lib', 'mypkg', 'b.lua'), c2); + writeUrlLock(dir, 'mypkg', [ + { name: 'a.lua', url: 'https://example.com/a.lua', target: 'lib/mypkg/a.lua', sha256: sha256(c1) }, + { name: 'b.lua', url: 'https://example.com/b.lua', target: 'lib/mypkg/b.lua', sha256: sha256(c2) }, + ]); + const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); + assert.equal(exitCode, 0, `unexpected failure:\n${stdout}`); + assert.match(stdout, /lib\/mypkg\/a\.lua\s+verified/); + assert.match(stdout, /lib\/mypkg\/b\.lua\s+verified/); +}); + +test('install (no args): url package already on disk with correct hash is skipped', () => { + const dir = makeTmp(); + const content = 'return {}'; + const hash = sha256(content); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'helper.lua'), content); + writeUrlLock(dir, 'my-helper', [ + { name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: hash }, + ]); + const { stdout, exitCode } = run(['package', 'install', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('up to date')); +}); + +test('install (no args): broken untrusted url package requires explicit repair consent', () => { + const dir = makeTmp(); + const content = 'return {}\n'; + writeLock(dir, { + helper: { + version: 'url', + trust: 'experimental', + source: { kind: 'url', url: 'data:text/plain,return%20%7B%7D%0A', urls: ['data:text/plain,return%20%7B%7D%0A'] }, + files: [{ name: 'helper.lua', target: 'lib/helper.lua', sha256: sha256(content) }], + }, + }); + + const result = run(['package', 'install', '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('--allow-untrusted')); + assert.equal(existsSync(join(dir, 'lib', 'helper.lua')), false); +}); + +test('install (no args): --allow-untrusted repairs broken url package', () => { + const dir = makeTmp(); + const content = 'return {}\n'; + writeLock(dir, { + helper: { + version: 'url', + trust: 'experimental', + source: { kind: 'url', url: 'data:text/plain,return%20%7B%7D%0A', urls: ['data:text/plain,return%20%7B%7D%0A'] }, + files: [{ name: 'helper.lua', target: 'lib/helper.lua', sha256: sha256(content) }], + }, + }); + + const result = run(['package', 'install', '--dir', dir, '--allow-untrusted']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(readFileSync(join(dir, 'lib', 'helper.lua'), 'utf8'), content); +}); + +test('remove: url package removes file and lockfile entry', () => { + const dir = makeTmp(); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'helper.lua'), 'return {}'); + writeUrlLock(dir, 'my-helper', [ + { name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: 'any' }, + ]); + const { exitCode } = run(['package', 'remove', 'my-helper', '--dir', dir, '--yes']); + assert.equal(exitCode, 0); + assert.ok(!existsSync(join(dir, 'lib', 'helper.lua')), 'file should be deleted'); + const lock = JSON.parse(readFileSync(join(dir, 'feather.lock.json'), 'utf8')); + assert.ok(!lock.packages['my-helper'], 'lockfile entry should be removed'); +}); + +test('remove: url package with multiple files removes all of them', () => { + const dir = makeTmp(); + mkdirSync(join(dir, 'lib', 'mypkg'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'mypkg', 'a.lua'), 'return "a"'); + writeFileSync(join(dir, 'lib', 'mypkg', 'b.lua'), 'return "b"'); + writeUrlLock(dir, 'mypkg', [ + { name: 'a.lua', url: 'https://example.com/a.lua', target: 'lib/mypkg/a.lua', sha256: 'any' }, + { name: 'b.lua', url: 'https://example.com/b.lua', target: 'lib/mypkg/b.lua', sha256: 'any' }, + ]); + const { exitCode } = run(['package', 'remove', 'mypkg', '--dir', dir, '--yes']); + assert.equal(exitCode, 0); + assert.ok(!existsSync(join(dir, 'lib', 'mypkg', 'a.lua'))); + assert.ok(!existsSync(join(dir, 'lib', 'mypkg', 'b.lua'))); +}); + +test('plugin remove: --yes removes plugin in non-interactive mode', () => { + const dir = makeTmp(); + const pluginDir = join(dir, 'feather', 'plugins', 'my-plugin'); + mkdirSync(pluginDir, { recursive: true }); + writeFileSync( + join(pluginDir, 'manifest.lua'), + 'return { id = "my-plugin", name = "My Plugin", version = "1.0.0" }\n', + ); + + const { stdout, exitCode } = run(['plugin', 'remove', 'my-plugin', '--dir', dir, '--yes']); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('Removed my-plugin')); + assert.ok(!existsSync(pluginDir), 'plugin directory should be deleted'); +}); + +test('output: NO_COLOR keeps package search readable without ANSI escapes', () => { + const { stdout, exitCode } = run(['package', 'search', 'anim', '--offline'], { + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + }); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('anim8')); + // eslint-disable-next-line no-control-regex + assert.equal(/\x1B\[[0-?]*[ -/]*[@-~]/.test(stdout), false); +}); + +test('output: command and lib sources route terminal writes through output helpers', () => { + const allowed = new Set([ + join(ROOT, 'cli', 'src', 'lib', 'output.ts'), + join(ROOT, 'cli', 'src', 'lib', 'command.ts'), + ]); + const files = [ + ...sourceFiles(join(ROOT, 'cli', 'src', 'commands')), + ...sourceFiles(join(ROOT, 'cli', 'src', 'lib')), + ].filter((file) => !allowed.has(file)); + + const offenders = files.flatMap((file) => { + const source = readFileSync(file, 'utf8'); + return source.match(/console\.(?:log|error)\s*\(/) ? [file.replace(`${ROOT}/`, '')] : []; + }); + + assert.deepEqual(offenders, []); +}); + +test('update: url package is skipped with experimental message', () => { + const dir = makeTmp(); + writeUrlLock(dir, 'my-helper', [ + { name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: 'abc' }, + ]); + const { stdout, exitCode } = run(['package', 'update', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('Skipping') || stdout.includes('experimental')); +}); + +test('install: subpackage dry-run installs only its files', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'install', 'hump.camera', '--dry-run', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('camera.lua'), 'should list camera.lua'); + assert.ok(stdout.includes('Dry run')); + assert.ok(!stdout.includes('timer.lua'), 'must not include timer.lua'); + assert.ok(!stdout.includes('signal.lua'), 'must not include signal.lua'); +}); + +test('install: parent package dry-run includes all files', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'install', 'hump', '--dry-run', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('camera.lua')); + assert.ok(stdout.includes('timer.lua')); + assert.ok(stdout.includes('Dry run')); +}); + +test('info: package with subpackages lists module names', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'info', 'hump', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('hump.camera')); + assert.ok(stdout.includes('hump.timer')); +}); + +test('search: matches package description', () => { + const { stdout, exitCode } = run(['package', 'search', 'sprite', '--offline']); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('anim8')); +}); + +test('search: subpackages not shown at top level', () => { + const { stdout, exitCode } = run(['package', 'search', '--offline']); + assert.equal(exitCode, 0); + // hump.camera should not appear as a top-level entry (it's a subpackage) + const lines = stdout.split('\n').filter((l) => l.trim().startsWith('hump')); + assert.ok(lines.length > 0, 'hump should appear'); + assert.ok(!lines.some((l) => l.startsWith(' hump.camera')), 'hump.camera should not be a top-level entry'); +}); + +test('audit --json: url package included in output', () => { + const dir = makeTmp(); + const content = 'return {}'; + const hash = sha256(content); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'helper.lua'), content); + writeUrlLock(dir, 'my-helper', [ + { name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: hash }, + ]); + const { stdout, exitCode } = run(['package', 'audit', '--json', '--dir', dir]); + assert.equal(exitCode, 0); + const parsed = JSON.parse(stdout); + assert.ok(Array.isArray(parsed)); + const entry = parsed.find((r) => r.id === 'my-helper'); + assert.ok(entry, 'my-helper should appear in audit output'); + assert.equal(entry.status, 'verified'); +}); + +test('command errors: central handler writes compact stderr and exit code', () => { + const dir = makeTmp(); + const result = run(['package', 'info', 'zzz_no_such_pkg', '--offline', '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(result.stderr.includes('not found')); + assert.equal(result.stderr.includes('Error:'), false); +}); + +test('registry validation rejects targets outside the project', async () => { + const { validateRegistry } = await import('../../dist/lib/package/registry.js'); + assert.throws( + () => + validateRegistry({ + version: 1, + updatedAt: '2026-05-16', + packages: { + escape: { + type: 'love2d-library', + trust: 'verified', + description: 'bad target', + tags: [], + source: { + repo: 'owner/repo', + tag: 'main', + baseUrl: 'https://raw.githubusercontent.com/owner/repo/0123456789abcdef0123456789abcdef01234567/', + commitSha: '0123456789abcdef0123456789abcdef01234567', + }, + install: { + files: [{ name: 'escape.lua', target: '../escape.lua', sha256: 'a'.repeat(64) }], + }, + require: 'escape', + }, + }, + }), + /escapes project root/, + ); +}); + +test('init: --yes --mode auto patches main.lua with guarded markers', () => { + const dir = makeTmp(); + writeGame(dir); + const { exitCode } = run([ + 'init', + dir, + '--mode', + 'auto', + '--local-src', + LOCAL_SRC, + '--install-dir', + 'feather', + '--no-plugins', + '--yes', + ]); + assert.equal(exitCode, 0); + const main = readFileSync(join(dir, 'main.lua'), 'utf8'); + assert.ok(main.includes('FEATHER-INIT-BEGIN require')); + assert.ok(main.includes('USE_DEBUGGER')); +}); + +test('remove: non-interactive destructive remove requires --yes', () => { + const dir = makeTmp(); + writeGame(dir); + run(['init', dir, '--mode', 'auto', '--local-src', LOCAL_SRC, '--install-dir', 'feather', '--no-plugins', '--yes']); + const result = run(['remove', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('--yes')); + assert.ok(existsSync(join(dir, 'feather.config.lua'))); +}); + +test('remove: dry-run does not delete files or edit main.lua', () => { + const dir = makeTmp(); + writeGame(dir); + run(['init', dir, '--mode', 'auto', '--local-src', LOCAL_SRC, '--install-dir', 'feather', '--no-plugins', '--yes']); + const beforeMain = readFileSync(join(dir, 'main.lua'), 'utf8'); + const { exitCode } = run(['remove', dir, '--dry-run']); + assert.equal(exitCode, 0); + assert.equal(readFileSync(join(dir, 'main.lua'), 'utf8'), beforeMain); + assert.ok(existsSync(join(dir, 'feather'))); + assert.ok(existsSync(join(dir, 'feather.config.lua'))); +}); + +test('doctor --json reports package audit problems and unsafe config flags', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + include = { "console" }, + apiKey = "dev", + __DANGEROUS_INSECURE_CONNECTION__ = true, + debugger = true, + captureScreenshot = true, + writeToDisk = true, +} +`, + ); + writeLock(dir, { + helper: { + version: 'url', + trust: 'experimental', + source: { url: 'https://example.com/helper.lua' }, + files: [ + { + name: 'helper.lua', + url: 'https://example.com/helper.lua', + target: 'lib/helper.lua', + sha256: sha256('expected'), + }, + ], + }, + }); + const result = run(['doctor', dir, '--json']); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Package file integrity').severity, 'warn'); + assert.equal(labels.get('__DANGEROUS_INSECURE_CONNECTION__').severity, 'warn'); + assert.equal(labels.get('Console API key').severity, 'warn'); + assert.equal(labels.get('Step debugger').severity, 'warn'); + assert.equal(labels.get('captureScreenshot').severity, 'warn'); + assert.equal(labels.get('Disk logging').severity, 'warn'); +}); + +test('doctor --json reports included plugins missing or unknown', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { include = { "console", "missing-plugin" } }\n'); + + const result = run(['doctor', dir, '--json']); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + const consoleCheck = labels.get('Plugin console'); + const unknownCheck = labels.get('Plugin missing-plugin'); + + assert.equal(consoleCheck.severity, 'warn'); + assert.equal(consoleCheck.detail, 'included but not installed'); + assert.ok(consoleCheck.fix.includes(`feather plugin install console --dir ${dir} --install-dir feather`)); + assert.equal(unknownCheck.severity, 'warn'); + assert.equal(unknownCheck.detail, 'included but unknown'); + assert.ok(unknownCheck.fix.includes('Remove or correct "missing-plugin"')); +}); + +test('doctor --json reports malformed installed plugin manifests with exact update fix', () => { + const dir = makeTmp(); + writeGame(dir); + mkdirSync(join(dir, 'feather', 'plugins', 'bad-plugin'), { recursive: true }); + writeFileSync(join(dir, 'feather', 'init.lua'), 'return {}\n'); + writeFileSync(join(dir, 'feather', 'plugins', 'bad-plugin', 'manifest.lua'), 'return { name = "Bad Plugin" }\n'); + + const result = run(['doctor', dir, '--json']); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + const manifestCheck = labels.get('Plugin manifests'); + + assert.equal(manifestCheck.severity, 'warn'); + assert.equal(manifestCheck.detail, '1 missing id'); + assert.ok(manifestCheck.fix.includes(`feather plugin update --dir ${dir} --install-dir feather --yes`)); +}); + +test('doctor --json reports package file recovery and stale bundled registry versions', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test" }\n'); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'anim8.lua'), 'tampered'); + writeLock(dir, { + anim8: { + version: 'v0.0.0', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v0.0.0' }, + files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: sha256('original') }], + }, + helper: { + version: 'url', + trust: 'experimental', + source: { url: 'https://example.com/helper.lua' }, + files: [ + { + name: 'helper.lua', + url: 'https://example.com/helper.lua', + target: 'lib/helper.lua', + sha256: sha256('expected'), + }, + ], + }, + }); + + const result = run(['doctor', dir, '--json']); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + + assert.equal(labels.get('Package file integrity').severity, 'warn'); + assert.ok(labels.get('Package file integrity').fix.includes(`feather package install --dir ${dir}`)); + assert.equal(labels.get('Package anim8 files').detail, '1 modified'); + assert.ok(labels.get('Package anim8 files').fix.includes(`feather package install --dir ${dir}`)); + assert.equal(labels.get('Package helper files').detail, '1 missing'); + assert.equal(labels.get('Package anim8 version').severity, 'warn'); + assert.ok(labels.get('Package anim8 version').fix.includes(`feather package update anim8 --dir ${dir}`)); + assert.equal(labels.has('Package helper version'), false); +}); + +test('doctor --json reports untrusted lockfile source URLs', () => { + const dir = makeTmp(); + writeGame(dir); + const commitSha = '0123456789abcdef0123456789abcdef01234567'; + writeLock(dir, { + helper: { + version: 'url', + trust: 'experimental', + source: { kind: 'url', url: 'https://example.com/helper.lua', urls: ['https://example.com/helper.lua'] }, + files: [ + { + name: 'helper.lua', + url: 'https://example.com/helper.lua', + target: 'lib/helper.lua', + sha256: sha256('expected'), + }, + ], + }, + 'raw-helper': { + version: 'main', + trust: 'experimental', + source: { repo: 'me/pkg', tag: 'main', resolvedRef: commitSha, commitSha }, + files: [{ name: 'raw.lua', target: 'lib/raw.lua', sha256: sha256('expected') }], + }, + }); + + const result = run(['doctor', dir, '--json']); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + const sourceCheck = labels.get('Package helper source'); + + assert.equal(sourceCheck.severity, 'warn'); + const detailUrls = (sourceCheck.detail.match(/https?:\/\/[^\s)]+/g) || []) + .map((value) => { + try { + return new URL(value); + } catch { + return null; + } + }) + .filter(Boolean); + assert.ok(detailUrls.some((parsedUrl) => parsedUrl.hostname === 'example.com')); + assert.ok(sourceCheck.fix.includes('--allow-untrusted')); + assert.equal(labels.has('Package raw-helper source'), false); +}); + +test('doctor --json reports non-experimental packages missing from bundled registry', () => { + const dir = makeTmp(); + writeGame(dir); + writeLock(dir, { + 'local-legacy': { + version: 'v1.0.0', + trust: 'known', + source: { repo: 'me/local-legacy', tag: 'v1.0.0' }, + files: [], + }, + }); + + const result = run(['doctor', dir, '--json']); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + const registryCheck = labels.get('Package local-legacy registry'); + + assert.equal(registryCheck.severity, 'warn'); + assert.equal(registryCheck.detail, 'not found in bundled registry'); + assert.ok(registryCheck.fix.includes(`feather package remove local-legacy --dir ${dir} --yes`)); +}); + +test('doctor: human report includes grouped checks and summary', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test", include = { "console" } }\n'); + const result = run(['doctor', dir]); + assert.match(result.stdout, /Feather doctor/); + assert.match(result.stdout, /Environment/); + assert.match(result.stdout, /Project/); + assert.match(result.stdout, /Plugin console/); + assert.match(result.stdout, /feather plugin install console/); + assert.match(result.stdout, /passed, .* warnings, .* failures/); +}); + +async function withFetchMock(mock, runTest) { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock; + try { + await runTest(); + } finally { + globalThis.fetch = originalFetch; + } +} + +function emptyLockfile() { + return { lockfileVersion: 1, generatedAt: new Date(0).toISOString(), packages: {} }; +} + +function initSetupState(overrides = {}) { + return { + mode: 'auto', + installSource: 'local', + branch: 'main', + installDir: 'feather', + installPlugins: true, + pluginPromptsEnabled: true, + include: new Set(), + exclude: new Set(['console']), + advanced: false, + sessionName: 'My Game', + host: '127.0.0.1', + port: '4004', + socketModeIndex: 0, + baseDir: '', + sampleRate: '1', + updateInterval: '0.1', + maxTempLogs: '200', + outputDir: 'logs', + retryInterval: '5', + connectTimeout: '2', + errorWait: '3', + binaryTextThreshold: '4096', + deviceId: '', + capabilities: 'all', + toggles: new Set(['debug', 'wrapPrint', 'defaultObservers']), + needsApiKey: false, + apiKey: '', + appIdInput: 'feather-app-test', + ...overrides, + }; +} + +test('package add: repo plan converts to custom install input', async () => { + const { packageAddPlanFiles, toCustomRepoPackageInput } = await import('../../dist/lib/package/add-plan.js'); + const lockfile = emptyLockfile(); + const commitSha = '0123456789abcdef0123456789abcdef01234567'; + const plan = { + kind: 'repo', + id: 'my-pkg', + requirePath: 'lib.my-pkg.init', + repoName: 'me/pkg', + tag: 'v1.0.0', + commitSha, + baseUrl: `https://raw.githubusercontent.com/me/pkg/${commitSha}/`, + selectedFiles: ['init.lua', 'util.lua'], + targetMap: { 'init.lua': 'lib/my-pkg/init.lua', 'util.lua': 'lib/my-pkg/util.lua' }, + }; + + assert.deepEqual(packageAddPlanFiles(plan), [ + { name: 'init.lua', target: 'lib/my-pkg/init.lua' }, + { name: 'util.lua', target: 'lib/my-pkg/util.lua' }, + ]); + assert.deepEqual(toCustomRepoPackageInput({ plan, projectDir: '/tmp/game', lockfile }), { + id: 'my-pkg', + repoName: 'me/pkg', + tag: 'v1.0.0', + commitSha, + baseUrl: `https://raw.githubusercontent.com/me/pkg/${commitSha}/`, + selectedFiles: ['init.lua', 'util.lua'], + targetMap: { 'init.lua': 'lib/my-pkg/init.lua', 'util.lua': 'lib/my-pkg/util.lua' }, + projectDir: '/tmp/game', + lockfile, + onFileStart: undefined, + }); +}); + +test('package add: url plan converts to custom install input', async () => { + const { packageAddPlanFiles, toCustomUrlPackageInput } = await import('../../dist/lib/package/add-plan.js'); + const lockfile = emptyLockfile(); + const urlFiles = [ + { + name: 'helper.lua', + url: 'https://example.com/helper.lua', + sha256: sha256('return {}'), + target: 'lib/helper.lua', + buffer: Buffer.from('return {}'), + }, + ]; + const plan = { kind: 'url', id: 'helper', requirePath: 'lib.helper', urlFiles }; + + assert.deepEqual(packageAddPlanFiles(plan), [{ name: 'helper.lua', target: 'lib/helper.lua' }]); + assert.deepEqual(toCustomUrlPackageInput({ plan, projectDir: '/tmp/game', lockfile }), { + id: 'helper', + urlFiles, + projectDir: '/tmp/game', + lockfile, + }); +}); + +test('package add: failed plan install does not write lockfile', async () => { + const dir = makeTmp(); + const { installPackageAddPlan } = await import('../../dist/lib/package/add-plan.js'); + + await withFetchMock( + async () => new Response('missing', { status: 404 }), + async () => { + const result = await installPackageAddPlan({ + projectDir: dir, + lockfile: emptyLockfile(), + plan: { + kind: 'repo', + id: 'my-pkg', + requirePath: 'lib.my-pkg.init', + repoName: 'me/pkg', + tag: 'v1.0.0', + commitSha: '0123456789abcdef0123456789abcdef01234567', + baseUrl: 'https://raw.githubusercontent.com/me/pkg/abc123/', + selectedFiles: ['init.lua'], + targetMap: { 'init.lua': 'lib/my-pkg/init.lua' }, + }, + }); + + assert.equal(result.ok, false); + assert.equal(existsSync(join(dir, 'feather.lock.json')), false); + }, + ); +}); + +test('init mode: config builder preserves cli and advanced setup values', async () => { + const { buildInitSetup } = await import('../../dist/ui/init/config.js'); + const setup = buildInitSetup( + initSetupState({ + mode: 'cli', + installSource: 'remote', + branch: 'dev', + installDir: '', + installPlugins: true, + include: new Set(['console']), + exclude: new Set(), + advanced: true, + port: '5000', + socketModeIndex: 1, + capabilities: 'logs, assets', + toggles: new Set(['debug', 'captureScreenshot']), + needsApiKey: true, + apiKey: 'StrongSecret123!', + appIdInput: '', + }), + ); + + assert.equal(setup.mode, 'cli'); + assert.equal(setup.source, 'remote'); + assert.equal(setup.branch, 'dev'); + assert.equal(setup.installDir, 'feather'); + assert.deepEqual(setup.config.include, ['console']); + assert.equal(setup.config.port, 5000); + assert.equal(setup.config.mode, 'disk'); + assert.deepEqual(setup.config.capabilities, ['logs', 'assets']); + assert.equal(setup.config.captureScreenshot, true); + assert.equal(setup.config.apiKey, 'StrongSecret123!'); + assert.deepEqual(setup.config.pluginOptions, { console: { evalEnabled: true } }); + assert.equal(setup.config.__DANGEROUS_INSECURE_CONNECTION__, true); +}); + +test('custom add: repo install writes selected files and lockfile metadata', async () => { + const dir = makeTmp(); + const { installCustomRepoPackage } = await import('../../dist/lib/package/custom-add.js'); + const commitSha = '0123456789abcdef0123456789abcdef01234567'; + const files = new Map([ + [`https://raw.githubusercontent.com/me/pkg/${commitSha}/init.lua`, 'return "init"'], + [`https://raw.githubusercontent.com/me/pkg/${commitSha}/util.lua`, 'return "util"'], + ]); + + await withFetchMock( + async (url) => { + const body = files.get(String(url)); + return body === undefined ? new Response('missing', { status: 404 }) : new Response(body); + }, + async () => { + const result = await installCustomRepoPackage({ + id: 'my-pkg', + repoName: 'me/pkg', + tag: 'v1.0.0', + commitSha, + baseUrl: `https://raw.githubusercontent.com/me/pkg/${commitSha}/`, + selectedFiles: ['init.lua', 'util.lua'], + targetMap: { 'init.lua': 'lib/my-pkg/init.lua', 'util.lua': 'lib/my-pkg/util.lua' }, + projectDir: dir, + lockfile: emptyLockfile(), + }); + + assert.equal(result.ok, true); + assert.equal(readFileSync(join(dir, 'lib', 'my-pkg', 'init.lua'), 'utf8'), 'return "init"'); + assert.equal(readFileSync(join(dir, 'lib', 'my-pkg', 'util.lua'), 'utf8'), 'return "util"'); + + const lock = JSON.parse(readFileSync(join(dir, 'feather.lock.json'), 'utf8')); + assert.equal(lock.packages['my-pkg'].version, 'v1.0.0'); + assert.equal(lock.packages['my-pkg'].trust, 'experimental'); + assert.deepEqual(lock.packages['my-pkg'].source, { + repo: 'me/pkg', + tag: 'v1.0.0', + resolvedRef: commitSha, + commitSha, + }); + assert.equal(lock.packages['my-pkg'].files.length, 2); + assert.equal( + lock.packages['my-pkg'].files[0].url, + `https://raw.githubusercontent.com/me/pkg/${commitSha}/init.lua`, + ); + }, + ); +}); + +test('custom add: URL install writes buffered files and lockfile metadata', async () => { + const dir = makeTmp(); + const { installCustomUrlPackage } = await import('../../dist/lib/package/custom-add.js'); + const buffer = Buffer.from('return "helper"'); + const otherBuffer = Buffer.from('return "other"'); + const result = await installCustomUrlPackage({ + id: 'my-helper', + urlFiles: [ + { + name: 'helper.lua', + url: 'https://example.com/helper.lua', + sha256: 'stale-sha-is-recomputed', + target: 'lib/helper.lua', + buffer, + }, + { + name: 'other.lua', + url: 'https://example.com/other.lua', + sha256: 'stale-sha-is-recomputed', + target: 'lib/other.lua', + buffer: otherBuffer, + }, + ], + projectDir: dir, + lockfile: emptyLockfile(), + }); + + assert.equal(result.ok, true); + assert.equal(readFileSync(join(dir, 'lib', 'helper.lua'), 'utf8'), 'return "helper"'); + const lock = JSON.parse(readFileSync(join(dir, 'feather.lock.json'), 'utf8')); + assert.equal(lock.packages['my-helper'].version, 'url'); + assert.equal(lock.packages['my-helper'].trust, 'experimental'); + assert.deepEqual(lock.packages['my-helper'].source, { + kind: 'url', + url: 'https://example.com/helper.lua', + urls: ['https://example.com/helper.lua', 'https://example.com/other.lua'], + }); + assert.equal(lock.packages['my-helper'].files[0].sha256, sha256(buffer)); + assert.equal(lock.packages['my-helper'].files[1].url, 'https://example.com/other.lua'); +}); + +test('custom add: lockfile source validation rejects malformed optional provenance', async () => { + const { validateLockfileSource } = await import('../../dist/lib/package/lockfile.js'); + + assert.throws(() => validateLockfileSource({ repo: 'me/pkg', tag: 'main', commitSha: 'abc123' }), /commitSha/); + assert.throws( + () => validateLockfileSource({ kind: 'url', url: 'https://example.com/helper.lua', urls: [] }), + /source\.urls/, + ); + assert.throws( + () => validateLockfileSource({ kind: 'url', url: 'https://example.com/helper.lua', urls: [''] }), + /source\.urls/, + ); +}); + +test('custom add: invalid repo commit provenance is rejected before fetch or write', async () => { + const dir = makeTmp(); + const { installCustomRepoPackage } = await import('../../dist/lib/package/custom-add.js'); + let fetchCalled = false; + + await withFetchMock( + async () => { + fetchCalled = true; + return new Response('return {}'); + }, + async () => { + const result = await installCustomRepoPackage({ + id: 'bad-sha', + repoName: 'me/pkg', + tag: 'main', + commitSha: 'abc123', + baseUrl: 'https://raw.githubusercontent.com/me/pkg/abc123/', + selectedFiles: ['init.lua'], + targetMap: { 'init.lua': 'lib/init.lua' }, + projectDir: dir, + lockfile: emptyLockfile(), + }); + + assert.equal(result.ok, false); + assert.equal(fetchCalled, false); + assert.match(result.error, /commitSha/); + assert.equal(existsSync(join(dir, 'feather.lock.json')), false); + }, + ); +}); + +test('restore: old url source-only lockfiles remain compatible', async () => { + const dir = makeTmp(); + const { restorePackage } = await import('../../dist/lib/package/install.js'); + const content = 'return "old url"'; + + await withFetchMock( + async (url) => { + assert.equal(String(url), 'https://example.com/helper.lua'); + return new Response(content); + }, + async () => { + const result = await restorePackage( + 'my-helper', + { + version: 'url', + trust: 'experimental', + source: { url: 'https://example.com/helper.lua' }, + files: [{ name: 'helper.lua', target: 'lib/helper.lua', sha256: sha256(content) }], + installedAt: new Date(0).toISOString(), + }, + { projectDir: dir }, + ); + + assert.equal(result.ok, true); + assert.equal(readFileSync(join(dir, 'lib', 'helper.lua'), 'utf8'), content); + }, + ); +}); + +test('restore: enriched url lockfiles still prefer per-file URLs', async () => { + const dir = makeTmp(); + const { restorePackage } = await import('../../dist/lib/package/install.js'); + const files = new Map([ + ['https://example.com/a.lua', 'return "a"'], + ['https://example.com/b.lua', 'return "b"'], + ]); + const fetched = []; + + await withFetchMock( + async (url) => { + fetched.push(String(url)); + const body = files.get(String(url)); + return body === undefined ? new Response('missing', { status: 404 }) : new Response(body); + }, + async () => { + const result = await restorePackage( + 'mypkg', + { + version: 'url', + trust: 'experimental', + source: { + kind: 'url', + url: 'https://example.com/primary.lua', + urls: ['https://example.com/a.lua', 'https://example.com/b.lua'], + }, + files: [ + { + name: 'a.lua', + url: 'https://example.com/a.lua', + target: 'lib/mypkg/a.lua', + sha256: sha256('return "a"'), + }, + { + name: 'b.lua', + url: 'https://example.com/b.lua', + target: 'lib/mypkg/b.lua', + sha256: sha256('return "b"'), + }, + ], + installedAt: new Date(0).toISOString(), + }, + { projectDir: dir }, + ); + + assert.equal(result.ok, true); + assert.deepEqual(fetched, ['https://example.com/a.lua', 'https://example.com/b.lua']); + assert.equal(readFileSync(join(dir, 'lib', 'mypkg', 'a.lua'), 'utf8'), 'return "a"'); + assert.equal(readFileSync(join(dir, 'lib', 'mypkg', 'b.lua'), 'utf8'), 'return "b"'); + }, + ); +}); + +test('restore: old repo lockfiles without commitSha remain compatible', async () => { + const dir = makeTmp(); + const { restorePackage } = await import('../../dist/lib/package/install.js'); + const content = 'return "repo"'; + + await withFetchMock( + async (url) => { + assert.equal(String(url), 'https://raw.githubusercontent.com/me/pkg/main/init.lua'); + return new Response(content); + }, + async () => { + const result = await restorePackage( + 'my-pkg', + { + version: 'main', + trust: 'experimental', + source: { repo: 'me/pkg', tag: 'main' }, + files: [{ name: 'init.lua', target: 'lib/init.lua', sha256: sha256(content) }], + installedAt: new Date(0).toISOString(), + }, + { projectDir: dir }, + ); + + assert.equal(result.ok, true); + assert.equal(readFileSync(join(dir, 'lib', 'init.lua'), 'utf8'), content); + }, + ); +}); + +test('custom add: escaping target is rejected before fetch or write', async () => { + const dir = makeTmp(); + const { installCustomRepoPackage } = await import('../../dist/lib/package/custom-add.js'); + let fetchCalled = false; + + await withFetchMock( + async () => { + fetchCalled = true; + return new Response('return {}'); + }, + async () => { + const result = await installCustomRepoPackage({ + id: 'escape', + repoName: 'me/pkg', + tag: 'v1.0.0', + baseUrl: 'https://raw.githubusercontent.com/me/pkg/abc123/', + selectedFiles: ['escape.lua'], + targetMap: { 'escape.lua': '../escape.lua' }, + projectDir: dir, + lockfile: emptyLockfile(), + }); + + assert.equal(result.ok, false); + assert.equal(fetchCalled, false); + assert.ok(result.error.includes('escapes project root')); + assert.equal(existsSync(join(dir, 'feather.lock.json')), false); + assert.equal(existsSync(join(dir, '..', 'escape.lua')), false); + }, + ); +}); + +test('custom add: failed repo fetch does not write lockfile', async () => { + const dir = makeTmp(); + const { installCustomRepoPackage } = await import('../../dist/lib/package/custom-add.js'); + + await withFetchMock( + async () => new Response('missing', { status: 500 }), + async () => { + const result = await installCustomRepoPackage({ + id: 'broken', + repoName: 'me/pkg', + tag: 'v1.0.0', + baseUrl: 'https://raw.githubusercontent.com/me/pkg/abc123/', + selectedFiles: ['broken.lua'], + targetMap: { 'broken.lua': 'lib/broken.lua' }, + projectDir: dir, + lockfile: emptyLockfile(), + }); + + assert.equal(result.ok, false); + assert.ok(result.error.includes('HTTP 500')); + assert.equal(existsSync(join(dir, 'lib', 'broken.lua')), false); + assert.equal(existsSync(join(dir, 'feather.lock.json')), false); + }, + ); +}); + +test('package registry: top-level packages resolve offline in dry-run mode', () => { + const registryPath = join(ROOT, 'cli', 'dist', 'generated', 'registry.json'); + assert.ok(existsSync(registryPath), 'cli/dist/generated/registry.json missing; run build first'); + const registry = JSON.parse(readFileSync(registryPath, 'utf8')); + const topLevel = Object.entries(registry.packages).filter(([, entry]) => !entry.parent); + assert.ok(topLevel.length > 0, 'registry should include top-level packages'); + + for (const [pkgId] of topLevel) { + const pkgDir = makeTmp(); + + const install = run(['package', 'install', pkgId, '--dry-run', '--offline', '--dir', pkgDir]); + assert.equal(install.exitCode, 0, `${pkgId} dry-run failed:\n${outputOf(install)}`); + assert.ok(install.stdout.includes(pkgId), `${pkgId}: dry-run output should mention package`); + assert.ok(install.stdout.includes('Dry run'), `${pkgId}: dry-run output should label the plan`); + assert.equal(existsSync(join(pkgDir, 'feather.lock.json')), false, `${pkgId}: dry-run must not write lockfile`); + + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(pkgDir, { recursive: true, force: true }); + } + } +}); + +test('remove: refuses runtime symlink escaping project', () => { + const dir = makeTmp(); + const outside = join(makeTmp(), 'outside-runtime'); + writeGame(dir); + mkdirSync(join(outside, 'feather', 'lib'), { recursive: true }); + writeFileSync(join(outside, 'feather', 'init.lua'), 'FEATHER_VERSION_NAME = "test"\nreturn {}\n'); + symlinkSync(join(outside, 'feather'), join(dir, 'feather'), 'dir'); + writeFileSync( + join(dir, 'feather.config.lua'), + [ + '-- FEATHER-MANAGED-BEGIN', + '-- mode: auto', + '-- installDir: feather', + '-- manualEntrypoint: (none)', + '-- FEATHER-MANAGED-END', + 'return { appId = "feather-app-test" }', + '', + ].join('\n'), + ); + + const result = run(['remove', dir, '--yes']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Runtime remove target resolves outside project root')); + assert.equal(existsSync(join(outside, 'feather', 'init.lua')), true); +}); + +test('package remove: refuses lockfile target through symlink escaping project', () => { + const dir = makeTmp(); + const outside = join(makeTmp(), 'outside-lib'); + mkdirSync(outside, { recursive: true }); + writeFileSync(join(outside, 'helper.lua'), 'return {}\n'); + symlinkSync(outside, join(dir, 'lib'), 'dir'); + writeLock(dir, { + helper: { + version: 'url', + trust: 'experimental', + source: { url: 'https://example.com/helper.lua' }, + files: [ + { + name: 'helper.lua', + url: 'https://example.com/helper.lua', + target: 'lib/helper.lua', + sha256: sha256('return {}\n'), + }, + ], + }, + }); + + const result = run(['package', 'remove', 'helper', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Refusing to remove unsafe package target: lib/helper.lua')); + assert.equal(existsSync(join(outside, 'helper.lua')), true); +}); diff --git a/cli/test/commands/plugins.test.mjs b/cli/test/commands/plugins.test.mjs new file mode 100644 index 00000000..7981922d --- /dev/null +++ b/cli/test/commands/plugins.test.mjs @@ -0,0 +1,169 @@ +/* eslint-disable no-undef */ +import { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppleLibrariesZip, + writeFakeCommand, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +} from './helpers.mjs'; + +test('plugin list: missing plugin directory is a clean empty state', () => { + const dir = makeTmp(); + const result = run(['plugin', 'list', dir]); + assert.equal(result.exitCode, 0); + assert.ok(result.stdout.includes('No plugins directory found')); +}); + +test('plugin install: local source copies console manifest', () => { + const dir = makeTmp(); + const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + const manifest = readFileSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua'), 'utf8'); + assert.ok(manifest.includes('id = "console"')); + assert.ok(outputOf(result).includes('Installed console')); +}); + +test('plugin update: explicit local update refreshes damaged files', () => { + const dir = makeTmp(); + run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + const installedInit = join(dir, 'feather', 'plugins', 'console', 'init.lua'); + writeFileSync(installedInit, 'damaged'); + + const result = run(['plugin', 'update', 'console', '--local-src', LOCAL_SRC, '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(readFileSync(installedInit, 'utf8'), readFileSync(join(LOCAL_SRC, 'plugins', 'console', 'init.lua'), 'utf8')); + assert.ok(outputOf(result).includes('Updated console')); +}); + +test('plugin update: local --yes updates all installed plugins without selection', () => { + const dir = makeTmp(); + run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + run(['plugin', 'install', 'hot-reload', '--local-src', LOCAL_SRC, '--dir', dir]); + const consoleInit = join(dir, 'feather', 'plugins', 'console', 'init.lua'); + const hotReloadInit = join(dir, 'feather', 'plugins', 'hot-reload', 'init.lua'); + writeFileSync(consoleInit, 'damaged console'); + writeFileSync(hotReloadInit, 'damaged hot reload'); + + const result = run(['plugin', 'update', '--local-src', LOCAL_SRC, '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(readFileSync(consoleInit, 'utf8'), readFileSync(join(LOCAL_SRC, 'plugins', 'console', 'init.lua'), 'utf8')); + assert.equal(readFileSync(hotReloadInit, 'utf8'), readFileSync(join(LOCAL_SRC, 'plugins', 'hot-reload', 'init.lua'), 'utf8')); +}); + +test('plugin install: unknown local plugin exits 1', () => { + const dir = makeTmp(); + const result = run(['plugin', 'install', 'zzz-missing', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Unknown plugin: zzz-missing')); +}); + +test('plugin install: local manifest is validated before copying', () => { + const dir = makeTmp(); + const source = join(makeTmp(), 'src-lua'); + writeLocalPluginSource(source, 'bad-plugin', { version: null }); + + const result = run(['plugin', 'install', 'bad-plugin', '--local-src', source, '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Plugin manifest is missing version: bad-plugin')); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'bad-plugin')), false); +}); + +test('plugin install: local manifest id must match plugin path', () => { + const dir = makeTmp(); + const source = join(makeTmp(), 'src-lua'); + writeLocalPluginSource(source, 'console', { manifestId: 'other-plugin' }); + + const result = run(['plugin', 'install', 'console', '--local-src', source, '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Plugin manifest id mismatch: expected console, found other-plugin')); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console')), false); +}); + +test('plugin install: rejects path traversal plugin ids', () => { + const dir = makeTmp(); + const result = run(['plugin', 'install', '../escape', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Invalid plugin id: ../escape')); +}); + +test('plugin install: refuses install directory symlink escaping project', () => { + const dir = makeTmp(); + const outside = join(makeTmp(), 'outside-runtime'); + mkdirSync(outside, { recursive: true }); + symlinkSync(outside, join(dir, 'feather'), 'dir'); + + const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Plugin install target resolves outside project root')); + assert.equal(existsSync(join(outside, 'plugins', 'console')), false); +}); + +test('plugin update: explicit local update fails on invalid manifest', () => { + const dir = makeTmp(); + const source = join(makeTmp(), 'src-lua'); + writeLocalPluginSource(source, 'bad-plugin', { version: 'not valid' }); + + const result = run(['plugin', 'update', 'bad-plugin', '--local-src', source, '--dir', dir, '--yes']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Plugin manifest has invalid version: bad-plugin')); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'bad-plugin')), false); +}); + +test('plugin remove: refuses plugin directory symlink escaping project', () => { + const dir = makeTmp(); + const outside = join(makeTmp(), 'outside-runtime'); + writeLocalPluginSource(outside, 'console'); + symlinkSync(outside, join(dir, 'feather'), 'dir'); + + const result = run(['plugin', 'remove', 'console', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Plugin remove target resolves outside project root')); + assert.equal(existsSync(join(outside, 'plugins', 'console', 'manifest.lua')), true); +}); + +test('plugin list: malformed manifests do not crash and use directory fallback id', () => { + const dir = makeTmp(); + const pluginDir = join(dir, 'feather', 'plugins', 'bad-plugin'); + mkdirSync(pluginDir, { recursive: true }); + writeFileSync(join(pluginDir, 'manifest.lua'), 'return { name = "Bad Plugin", version = "0.0.1" }\n'); + + const result = run(['plugin', 'list', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('bad-plugin')); + assert.ok(result.stdout.includes('Bad Plugin')); +}); diff --git a/cli/test/commands/run.test.mjs b/cli/test/commands/run.test.mjs new file mode 100644 index 00000000..1f6b6ae3 --- /dev/null +++ b/cli/test/commands/run.test.mjs @@ -0,0 +1,559 @@ +/* eslint-disable no-undef */ +import { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppleLibrariesZip, + writeFakeCommand, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +} from './helpers.mjs'; + +test('run: non-interactive missing game path exits with compact error', () => { + const result = run(['run']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Game path is required')); + assert.equal(outputOf(result).includes('Raw mode is not supported'), false); + assert.equal(outputOf(result).includes('Error:'), false); +}); + +test('run: missing game path and missing main.lua render compact errors', () => { + const dir = makeTmp(); + const missingPath = join(dir, 'missing-game'); + const emptyGame = join(dir, 'empty-game'); + mkdirSync(emptyGame, { recursive: true }); + + const missing = run(['run', missingPath]); + assert.equal(missing.exitCode, 1); + assert.ok(outputOf(missing).includes(`Game path not found: ${resolve(missingPath)}`)); + assert.equal(outputOf(missing).includes('Error:'), false); + + const noMain = run(['run', emptyGame]); + assert.equal(noMain.exitCode, 1); + assert.ok(outputOf(noMain).includes(`No main.lua found in: ${resolve(emptyGame)}`)); + assert.equal(outputOf(noMain).includes('Error:'), false); +}); + +test('run: fake love receives shim, args, env, and exit code is propagated', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const { fakePath, recordPath } = writeFakeLove(dir, { exitCode: 7 }); + + const result = run([ + 'run', + '--love', + fakePath, + '--session-name', + 'Command Test', + '--no-plugins', + '--feather-path', + join(LOCAL_SRC, 'feather'), + gameDir, + '--', + '--level', + '2', + ]); + + assert.equal(result.exitCode, 7); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(record.env.FEATHER_GAME_PATH, resolve(gameDir)); + assert.equal(record.env.FEATHER_SESSION_NAME, 'Command Test'); + assert.equal(record.shimMainExists, true); + assert.equal(record.featherAutoExists, true); + assert.equal(existsSync(record.argv[0]), false, 'shim should be cleaned after love exits'); + assert.deepEqual(record.argv.slice(1), ['--level', '2']); +}); + +test('run: source checkout build exposes feather.auto without a bundled cli/lua directory', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const { fakePath, recordPath } = writeFakeLove(dir); + + const result = run(['run', '--love', fakePath, gameDir]); + + assert.equal(result.exitCode, 0, outputOf(result)); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(record.featherAutoExists, true); +}); + +test('run: accepts configPath aliases and recovers npm-stripped config path argument', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const configPath = join(gameDir, 'feather.config.lua'); + writeFileSync(configPath, 'return { sessionName = "From Config Alias" }\n'); + + const alias = writeFakeLove(dir, { recordPath: join(dir, 'alias-record.json') }); + const aliasResult = run(['run', '--love', alias.fakePath, '--configPath', configPath, gameDir]); + assert.equal(aliasResult.exitCode, 0, outputOf(aliasResult)); + const aliasRecord = JSON.parse(readFileSync(alias.recordPath, 'utf8')); + assert.equal(aliasRecord.env.FEATHER_SESSION_NAME, 'From Config Alias'); + assert.deepEqual(aliasRecord.argv.slice(1), []); + + const stripped = writeFakeLove(dir, { recordPath: join(dir, 'stripped-record.json') }); + const strippedResult = run(['run', '--love', stripped.fakePath, gameDir, configPath]); + assert.equal(strippedResult.exitCode, 0, outputOf(strippedResult)); + const strippedRecord = JSON.parse(readFileSync(stripped.recordPath, 'utf8')); + assert.equal(strippedRecord.env.FEATHER_SESSION_NAME, 'From Config Alias'); + assert.deepEqual(strippedRecord.argv.slice(1), []); +}); + +test('run: shim preloads config before requiring feather.auto', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const configPath = join(gameDir, 'feather.config.lua'); + writeFileSync(configPath, 'return { __DANGEROUS_INSECURE_CONNECTION__ = true, sessionName = "Security Config" }\n'); + const { fakePath, recordPath } = writeFakeLove(dir); + + const result = run(['run', '--love', fakePath, gameDir, '--config', configPath]); + + assert.equal(result.exitCode, 0, outputOf(result)); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(record.shimMain.includes('FEATHER_AUTO_CONFIG = {')); + assert.ok(record.shimMain.includes('__DANGEROUS_INSECURE_CONNECTION__ = true')); + assert.equal(record.shimMain.includes('require("feather.auto").setup'), false); + assert.ok(record.shimMain.includes('require("feather.auto")')); +}); + +test('run --no-debugger: launches game directly without Feather shim', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const { fakePath, recordPath } = writeFakeLove(dir); + + const result = run(['run', '--love', fakePath, '--no-debugger', gameDir, '--', '--plain']); + + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('Debugger disabled')); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(record.argv[0], resolve(gameDir)); + assert.deepEqual(record.argv.slice(1), ['--plain']); + assert.equal(record.featherAutoExists, false); + assert.equal(record.shimMain, readFileSync(join(gameDir, 'main.lua'), 'utf8')); +}); + +test('run --disable-debugger: aliases debugger-disabled desktop launch', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const { fakePath, recordPath } = writeFakeLove(dir); + + const result = run(['run', '--love', fakePath, '--disable-debugger', gameDir]); + + assert.equal(result.exitCode, 0, outputOf(result)); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(record.argv[0], resolve(gameDir)); +}); + +test('run --target android: builds, installs, sets adb reverse, launches, and supports device selection', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Run Android', + version: '1.0.0', + productId: 'com.example.runandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + writeFileSync(join(dir, 'feather.config.lua'), 'return { port = 4010 }\n'); + const { binDir, recordPath } = writeFakeAdb(dir); + + const result = run(['run', dir, '--target', 'android', '--device', 'emulator-5554'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('Launched android')); + assert.ok(outputOf(result).includes('com.example.runandroid')); + const records = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.deepEqual(records.map((entry) => entry.args), [ + ['-s', 'emulator-5554', 'version'], + ['-s', 'emulator-5554', 'install', '-r', join(dir, 'builds', 'run-android-1.0.0-android.apk')], + ['-s', 'emulator-5554', 'shell', 'am', 'force-stop', 'com.example.runandroid'], + ['-s', 'emulator-5554', 'reverse', 'tcp:4010', 'tcp:4010'], + ['-s', 'emulator-5554', 'shell', 'monkey', '-p', 'com.example.runandroid', '-c', 'android.intent.category.LAUNCHER', '1'], + ]); + const entries = readStoredZipEntries(join(dir, 'builds', 'run-android-1.0.0.love')); + assert.equal(entries.has('feather/auto.lua'), true); + assert.match(entries.get('feather.config.lua').toString('utf8'), /port\s*=\s*4010/); +}); + +test('run --target android --config: embeds selected raw Feather config in mobile love archive', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const configPath = join(gameDir, 'custom-feather.config.lua'); + writeFileSync(configPath, `return { + sessionName = "Mobile Custom", + __DANGEROUS_INSECURE_CONNECTION__ = true, + debugger = { + hotReload = { + enabled = true, + allow = { "game.*" }, + }, + }, +} +`); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Run Android Config', + version: '1.0.0', + productId: 'com.example.runandroidconfig', + sourceDir: 'game', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir } = writeFakeAdb(dir); + + const result = run(['run', gameDir, '--target', 'android', '--config', configPath, '--no-adb-reverse'], { + cwd: dir, + env: envWithPath(binDir), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + const entries = readStoredZipEntries(join(dir, 'builds', 'run-android-config-1.0.0.love')); + assert.equal(entries.has('main.lua'), true); + assert.equal(entries.has('.feather-main.lua'), true); + assert.equal(entries.has('feather/core/debug_overlay.lua'), true); + const config = entries.get('feather.config.lua').toString('utf8'); + assert.match(config, /sessionName\s*=\s*"Mobile Custom"/); + assert.match(config, /hotReload\s*=\s*\{/); + assert.match(config, /allow\s*=\s*\{\s*"game\.\*"\s*\}/); +}); + +test('run --target android: uses root build config for a nested game path', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'src-lua', 'example', 'test_cli'); + writeGame(gameDir); + const { recordPath: gradleRecordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Nested Android', + version: '1.0.0', + productId: 'com.example.nestedandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir } = writeFakeAdb(dir); + + const result = run(['run', gameDir, '--target', 'android', '--no-adb-reverse'], { + cwd: dir, + env: envWithPath(binDir), + }); + + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('Launched android')); + assert.ok(outputOf(result).includes(gameDir)); + assert.ok(existsSync(join(dir, 'builds', 'nested-android-1.0.0-android.apk'))); + const gradleRecord = JSON.parse(readFileSync(gradleRecordPath, 'utf8')); + assert.equal(gradleRecord.embeddedLoveExists, true); +}); + +test('run --target android --no-adb-reverse skips reverse setup', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'No Reverse', + version: '1.0.0', + productId: 'com.example.noreverse', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir, recordPath } = writeFakeAdb(dir); + + const result = run(['run', dir, '--target', 'android', '--no-adb-reverse'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + const records = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(records.some((entry) => entry.args.includes('reverse')), false); +}); + +test('run --target android --no-debugger skips adb reverse setup', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'No Debugger Android', + version: '1.0.0', + productId: 'com.example.nodebuggerandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir, recordPath } = writeFakeAdb(dir); + + const result = run(['run', dir, '--target', 'android', '--no-debugger'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + const records = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(records.some((entry) => entry.args.includes('reverse')), false); + assert.equal(outputOf(result).includes('ADB reverse disabled'), true); + const entries = readStoredZipEntries(join(dir, 'builds', 'no-debugger-android-1.0.0.love')); + assert.equal(entries.has('feather/auto.lua'), false); + assert.equal(entries.has('.feather-main.lua'), false); +}); + +test('run --target android --no-cache forwards cache option to mobile build', () => { + const dir = makeTmp(); + writeGame(dir); + const { recordPath: gradleRecordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Run No Cache', + version: '1.0.0', + productId: 'com.example.runnocache', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir } = writeFakeAdb(dir); + + const first = run(['run', dir, '--target', 'android', '--no-adb-reverse', '--no-cache'], { env: envWithPath(binDir) }); + const second = run(['run', dir, '--target', 'android', '--no-adb-reverse', '--no-cache'], { env: envWithPath(binDir) }); + assert.equal(first.exitCode, 0, outputOf(first)); + assert.equal(second.exitCode, 0, outputOf(second)); + assert.equal(existsSync(join(dir, 'builds', '.feather-cache')), false); + const records = JSON.parse(readFileSync(gradleRecordPath, 'utf8')).records; + assert.equal(records.length, 2); + assert.notEqual(records[0].cwd, records[1].cwd); +}); + +test('run --target android: missing adb produces doctor guidance', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Missing Adb', + version: '1.0.0', + productId: 'com.example.missingadb', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const result = run(['run', dir, '--target', 'android'], { + env: { + ...process.env, + NO_COLOR: '1', + FORCE_COLOR: '0', + PATH: dirname(process.execPath), + }, + }); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('adb not found')); + assert.ok(outputOf(result).includes('feather doctor --build-target android')); +}); + +test('run --target android: failed install exits with compact error', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Install Fail', + version: '1.0.0', + productId: 'com.example.installfail', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir } = writeFakeAdb(dir, { failInstall: true }); + + const result = run(['run', dir, '--target', 'android'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Android install failed')); + assert.ok(outputOf(result).includes('install failed')); +}); + +test('run --target ios: builds app, installs simulator app, and launches bundle id', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Run iOS', + version: '1.0.0', + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.runios', + derivedDataPath: 'builds/ios-run-derived-data', + }, + }, + }); + const recordPath = join(dir, 'xcodebuild-run-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +if (process.argv.includes('-version')) { + console.log('Xcode 99.0'); + process.exit(0); +} +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const derivedData = args[args.indexOf('-derivedDataPath') + 1]; +const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; +const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; +const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; +const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); +fs.mkdirSync(app, { recursive: true }); +fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ argv: args }, null, 2)); +process.exit(0); +`); + const xcrun = writeFakeXcrun(dir); + + const result = run(['run', dir, '--target', 'ios', '--device', 'SIM-123'], { + env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('Launched ios')); + const records = JSON.parse(readFileSync(xcrun.recordPath, 'utf8')); + assert.deepEqual(records.map((entry) => entry.args), [ + ['simctl', 'terminate', 'SIM-123', 'com.example.runios'], + ['simctl', 'uninstall', 'SIM-123', 'com.example.runios'], + ['simctl', 'install', 'SIM-123', join(dir, 'builds', 'run-ios-1.0.0-ios.app')], + ['simctl', 'launch', 'SIM-123', 'com.example.runios'], + ]); + assert.equal(existsSync(join(dir, 'builds', 'run-ios-1.0.0-ios.app', 'game.love')), true); +}); + +test('run --target ios: missing xcrun produces doctor guidance', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Missing Xcrun', + version: '1.0.0', + targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.missingxcrun' } }, + }); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const derivedData = args[args.indexOf('-derivedDataPath') + 1]; +const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; +const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; +const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; +const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); +fs.mkdirSync(app, { recursive: true }); +fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); +process.exit(0); +`); + + const result = run(['run', dir, '--target', 'ios'], { + env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1', PATH: `${binDir}${delimiter}${dirname(process.execPath)}` }), + }); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('xcrun not found')); + assert.ok(outputOf(result).includes('feather doctor --build-target ios')); +}); + +test('run mobile: forwarded game arguments are rejected', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['run', dir, '--target', 'android', '--', '--level', 'dev']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Mobile run does not support forwarded game arguments yet')); +}); + +test('run --target web: builds, embeds Feather, serves generated html, and stays running', async () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeFileSync(join(dir, 'feather.config.lua'), `return { + sessionName = "Web Custom", + __DANGEROUS_INSECURE_CONNECTION__ = true, + debugOverlay = { visible = false }, +} +`); + writeBuildConfig(dir, { + name: 'Run Web', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + }); + + const child = spawnCli(['run', dir, '--target', 'web', '--web-port', '0', '--config', join(dir, 'feather.config.lua')], { + env: envWithPath('', { FEATHER_TEST_WEB_RUN_NO_SERVER: '1' }), + }); + try { + const output = await waitForOutput(child, /Debugger\s+enabled/); + assert.match(output, /Serving web build/); + assert.match(output, /Debugger\s+enabled/); + assert.equal(existsSync(join(dir, 'builds', 'run-web-1.0.0-html', 'index.html')), true); + const entries = readStoredZipEntries(join(dir, 'builds', 'run-web-1.0.0.love')); + assert.equal(entries.has('main.lua'), true); + assert.equal(entries.has('.feather-main.lua'), true); + assert.equal(entries.has('feather/auto.lua'), true); + assert.equal(entries.has('feather/core/debug_overlay.lua'), true); + assert.match(entries.get('feather.config.lua').toString('utf8'), /sessionName\s*=\s*"Web Custom"/); + assert.match(entries.get('feather.config.lua').toString('utf8'), /debugOverlay\s*=\s*\{/); + } finally { + await stopChild(child); + } +}); + +test('run --target web --no-debugger: builds and serves raw source', async () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Run Web Raw', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + }); + + const child = spawnCli(['run', dir, '--target', 'web', '--web-port', '0', '--no-debugger'], { + env: envWithPath('', { FEATHER_TEST_WEB_RUN_NO_SERVER: '1' }), + }); + try { + const output = await waitForOutput(child, /Debugger\s+disabled/); + assert.match(output, /Debugger\s+disabled/); + const entries = readStoredZipEntries(join(dir, 'builds', 'run-web-raw-1.0.0.love')); + assert.equal(entries.has('main.lua'), true); + assert.equal(entries.has('.feather-main.lua'), false); + assert.equal(entries.has('feather/auto.lua'), false); + } finally { + await stopChild(child); + } +}); + +test('run --target web: rejects forwarded game arguments', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Run Web Args', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + }); + + const result = run(['run', dir, '--target', 'web', '--', '--foo']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Web run does not support forwarded game arguments yet.')); +}); + +test('run --target web: missing love.js config exits with web build guidance', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['run', dir, '--target', 'web']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Web build requires targets.web.loveJsDir')); +}); diff --git a/cli/test/commands/runtime.test.mjs b/cli/test/commands/runtime.test.mjs new file mode 100644 index 00000000..e892735e --- /dev/null +++ b/cli/test/commands/runtime.test.mjs @@ -0,0 +1,146 @@ +/* eslint-disable no-undef */ +import { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppleLibrariesZip, + writeFakeCommand, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +} from './helpers.mjs'; + +test('command runtime redacts API keys from compact and debug errors', async () => { + const { runCliAction } = await import('../../dist/lib/command.js'); + const originalError = console.error; + const previousExitCode = process.exitCode; + const previousDebug = process.env.FEATHER_DEBUG; + const secret = 'StrongSecretValue1234567890!'; + const lines = []; + process.env.FEATHER_DEBUG = '1'; + process.exitCode = undefined; + console.error = (line = '') => lines.push(String(line)); + try { + await runCliAction(async () => { + throw new Error(`Failed to parse config: apiKey = "${secret}"`); + }); + const output = lines.join('\n'); + assert.equal(process.exitCode, 1); + assert.equal(output.includes(secret), false); + assert.ok(output.includes('apiKey = "[redacted]"')); + } finally { + console.error = originalError; + process.exitCode = previousExitCode; + if (previousDebug === undefined) delete process.env.FEATHER_DEBUG; + else process.env.FEATHER_DEBUG = previousDebug; + } +}); + +test('command runtime: unexpected errors render compact stderr and exit 1', async () => { + const { runCliAction } = await import('../../dist/lib/command.js'); + const originalError = console.error; + const lines = []; + const previousExitCode = process.exitCode; + const previousDebug = process.env.FEATHER_DEBUG; + delete process.env.FEATHER_DEBUG; + process.exitCode = undefined; + console.error = (line = '') => lines.push(String(line)); + try { + await runCliAction(async () => { + throw new Error('surprise failure'); + }); + assert.equal(process.exitCode, 1); + assert.ok(lines.join('\n').includes('surprise failure')); + assert.equal(lines.join('\n').includes('Error: surprise failure'), false); + } finally { + console.error = originalError; + process.exitCode = previousExitCode; + if (previousDebug === undefined) delete process.env.FEATHER_DEBUG; + else process.env.FEATHER_DEBUG = previousDebug; + } +}); + +test('command runtime: FEATHER_DEBUG includes stack for unexpected errors', async () => { + const { runCliAction } = await import('../../dist/lib/command.js'); + const originalError = console.error; + const lines = []; + const previousExitCode = process.exitCode; + const previousDebug = process.env.FEATHER_DEBUG; + process.env.FEATHER_DEBUG = '1'; + process.exitCode = undefined; + console.error = (line = '') => lines.push(String(line)); + try { + await runCliAction(async () => { + throw new Error('debuggable failure'); + }); + assert.equal(process.exitCode, 1); + assert.ok(lines.join('\n').includes('debuggable failure')); + assert.ok(lines.join('\n').includes('Error: debuggable failure')); + } finally { + console.error = originalError; + process.exitCode = previousExitCode; + if (previousDebug === undefined) delete process.env.FEATHER_DEBUG; + else process.env.FEATHER_DEBUG = previousDebug; + } +}); + +test('json commands used by scripts stay parseable and decoration-free', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + const content = 'return {}'; + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'helper.lua'), content); + writeLock(dir, { + helper: { + version: 'url', + trust: 'experimental', + source: { url: 'https://example.com/helper.lua' }, + files: [{ name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: sha256(content) }], + }, + }); + + const audit = run(['package', 'audit', '--json', '--dir', dir]); + assert.equal(audit.exitCode, 0); + assert.equal(ANSI_RE.test(audit.stdout), false); + const auditParsed = JSON.parse(audit.stdout); + assert.equal(auditParsed[0].status, 'verified'); + + const doctor = run(['doctor', dir, '--json']); + assert.equal(doctor.exitCode, 0); + assert.equal(ANSI_RE.test(doctor.stdout), false); + assert.equal(doctor.stdout.trim().startsWith('{'), true); + JSON.parse(doctor.stdout); +}); diff --git a/cli/test/commands/upload.test.mjs b/cli/test/commands/upload.test.mjs new file mode 100644 index 00000000..6b9c159a --- /dev/null +++ b/cli/test/commands/upload.test.mjs @@ -0,0 +1,317 @@ +/* eslint-disable no-undef */ +import { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppleLibrariesZip, + writeFakeCommand, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +} from './helpers.mjs'; + +function writeUnsafeUploadManifest(dir, { name = 'Unsafe Upload', project = 'tester/unsafe-upload', channel = 'html5' } = {}) { + const artifact = join(dir, 'builds', 'unsafe-web'); + mkdirSync(join(artifact, 'feather'), { recursive: true }); + writeFileSync(join(artifact, 'index.html'), 'Unsafe'); + writeFileSync(join(artifact, 'feather', 'debugger.lua'), 'return {}\n'); + writeBuildConfig(dir, { + name, + version: '1.0.0', + upload: { itch: { project, channels: { web: channel } } }, + }); + writeFileSync(join(dir, 'builds', 'feather-build-manifest.json'), `${JSON.stringify({ + name, + version: '1.0.0', + target: 'web', + createdAt: '2026-05-17T00:00:00.000Z', + artifacts: [{ target: 'web', type: 'html', path: artifact }], + }, null, 2)}\n`); + return artifact; +} + +test('upload itch: dry-run uses build manifest and configured channel', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Upload Game', + version: '3.4.5', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/upload-game', channels: { web: 'html5' } } }, + }); + const build = run(['build', 'web', '--dir', dir, '--json']); + assert.equal(build.exitCode, 0, outputOf(build)); + + const result = run(['upload', 'itch', 'web', '--dir', dir, '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.dryRun, true); + assert.equal(parsed.project, 'tester/upload-game'); + assert.equal(parsed.channel, 'html5'); + assert.equal(parsed.userVersion, '3.4.5'); + assert.deepEqual(parsed.command.slice(0, 2), ['butler', 'push']); +}); + +test('upload itch: fake butler receives artifact, channel, version, and flags', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Butler Game', + version: '4.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/butler-game', channels: { web: 'html5' } } }, + }); + const build = run(['build', 'web', '--dir', dir, '--json']); + assert.equal(build.exitCode, 0, outputOf(build)); + const recordPath = join(dir, 'butler-record.json'); + const { binDir } = writeFakeCommand(dir, 'butler', ` +if (process.argv.includes('--version')) { + console.log('butler test'); + process.exit(0); +} +const fs = require('node:fs'); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ argv: process.argv.slice(2) }, null, 2)); +process.exit(0); +`); + + const result = run(['upload', 'itch', 'web', '--dir', dir, '--if-changed', '--hidden', '--yes', '--json'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(record.argv[0], 'push'); + assert.ok(record.argv[1].endsWith('butler-game-4.0.0.love') || record.argv[1].endsWith('butler-game-4.0.0-html.zip')); + assert.equal(record.argv[2], 'tester/butler-game:html5'); + assert.ok(record.argv.includes('--userversion')); + assert.ok(record.argv.includes('4.0.0')); + assert.ok(record.argv.includes('--if-changed')); + assert.ok(record.argv.includes('--hidden')); +}); + +test('upload itch: desktop targets prefer installer-style artifacts over .love', () => { + const dir = makeTmp(); + writeGame(dir); + const builds = join(dir, 'builds'); + mkdirSync(builds, { recursive: true }); + writeBuildConfig(dir, { + name: 'Desktop Upload', + version: '5.0.0', + upload: { + itch: { + project: 'tester/desktop-upload', + channels: { windows: 'win', macos: 'mac', linux: 'linux' }, + }, + }, + }); + const artifacts = [ + { target: 'windows', type: 'love', path: join(builds, 'desktop-upload-5.0.0.love') }, + { target: 'windows', type: 'zip', path: join(builds, 'desktop-upload-5.0.0-windows.zip') }, + { target: 'windows', type: 'installer', path: join(builds, 'desktop-upload-5.0.0-windows-installer.exe') }, + { target: 'macos', type: 'love', path: join(builds, 'desktop-upload-5.0.0.love') }, + { target: 'macos', type: 'zip', path: join(builds, 'desktop-upload-5.0.0-macos.app.zip') }, + { target: 'macos', type: 'dmg', path: join(builds, 'desktop-upload-5.0.0-macos.dmg') }, + { target: 'linux', type: 'love', path: join(builds, 'desktop-upload-5.0.0.love') }, + { target: 'linux', type: 'tar.gz', path: join(builds, 'desktop-upload-5.0.0-linux.tar.gz') }, + { target: 'linux', type: 'appimage', path: join(builds, 'desktop-upload-5.0.0-linux.AppImage') }, + ]; + for (const artifact of artifacts) writeFileSync(artifact.path, artifact.type); + writeFileSync(join(builds, 'feather-build-manifest.json'), `${JSON.stringify({ + name: 'Desktop Upload', + version: '5.0.0', + target: 'windows', + createdAt: '2026-05-16T00:00:00.000Z', + artifacts, + }, null, 2)}\n`); + + for (const [target, expected] of [ + ['windows', 'desktop-upload-5.0.0-windows-installer.exe'], + ['macos', 'desktop-upload-5.0.0-macos.dmg'], + ['linux', 'desktop-upload-5.0.0-linux.AppImage'], + ]) { + const result = run(['upload', 'itch', target, '--dir', dir, '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.artifact.endsWith(expected), true); + } +}); + +test('upload steam: planned target fails cleanly', () => { + const dir = makeTmp(); + writeGame(dir); + const result = run(['upload', 'steam', '--dir', dir, '--yes', '--json']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('planned but not supported yet')); +}); + +test('upload itch: non-interactive real upload requires --yes', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Confirm Upload', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/confirm-upload', channels: { web: 'html5' } } }, + }); + const build = run(['build', 'web', '--dir', dir, '--json']); + assert.equal(build.exitCode, 0, outputOf(build)); + + const result = run(['upload', 'itch', 'web', '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('--yes')); +}); + +test('upload itch: --build --dry-run builds an artifact and returns upload JSON without uploading', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Build Upload', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/build-upload', channels: { web: 'html5' } } }, + }); + + const result = run(['upload', 'itch', 'web', '--dir', dir, '--build', '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.dryRun, true); + assert.equal(parsed.project, 'tester/build-upload'); + assert.equal(parsed.safety.status, 'safe'); +}); + +test('upload itch: missing project reports a clear headless error', () => { + const dir = makeTmp(); + writeGame(dir); + const builds = join(dir, 'builds'); + mkdirSync(builds, { recursive: true }); + const artifact = join(builds, 'game.love'); + writeFileSync(artifact, 'not-a-real-upload'); + writeBuildConfig(dir, { name: 'Missing Project', version: '1.0.0' }); + writeFileSync(join(builds, 'feather-build-manifest.json'), `${JSON.stringify({ + name: 'Missing Project', + version: '1.0.0', + target: 'love', + createdAt: '2026-05-17T00:00:00.000Z', + artifacts: [{ target: 'love', type: 'love', path: artifact }], + }, null, 2)}\n`); + + const result = run(['upload', 'itch', 'love', '--dir', dir, '--dry-run']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('upload.itch.project')); +}); + +test('upload itch: Feather runtime blocks headless upload unless explicitly allowed', () => { + const dir = makeTmp(); + writeGame(dir); + writeUnsafeUploadManifest(dir); + + const blocked = run(['upload', 'itch', 'web', '--dir', dir, '--yes', '--json']); + assert.equal(blocked.exitCode, 1); + const parsed = JSON.parse(blocked.stdout); + assert.equal(parsed.ok, false); + assert.equal(parsed.safety.status, 'unsafe'); + assert.ok(parsed.safety.detectedFiles.some((file) => file.startsWith('feather/'))); +}); + +test('upload itch: uninspectable artifact blocks headless upload unless explicitly allowed', () => { + const dir = makeTmp(); + writeGame(dir); + const builds = join(dir, 'builds'); + mkdirSync(builds, { recursive: true }); + const artifact = join(builds, 'game.exe'); + writeFileSync(artifact, 'opaque'); + writeBuildConfig(dir, { + name: 'Opaque Upload', + version: '1.0.0', + upload: { itch: { project: 'tester/opaque-upload', channels: { windows: 'windows' } } }, + }); + writeFileSync(join(builds, 'feather-build-manifest.json'), `${JSON.stringify({ + name: 'Opaque Upload', + version: '1.0.0', + target: 'windows', + createdAt: '2026-05-17T00:00:00.000Z', + artifacts: [{ target: 'windows', type: 'installer', path: artifact }], + }, null, 2)}\n`); + + const blocked = run(['upload', 'itch', 'windows', '--dir', dir, '--yes', '--json']); + assert.equal(blocked.exitCode, 1); + const parsed = JSON.parse(blocked.stdout); + assert.equal(parsed.ok, false); + assert.equal(parsed.safety.status, 'unknown'); +}); + +test('upload itch: unsafe override uploads and prints a large warning in text mode', () => { + const dir = makeTmp(); + writeGame(dir); + writeUnsafeUploadManifest(dir, { + name: 'Warn Upload', + project: 'tester/warn-upload', + channel: 'html5', + }); + const recordPath = join(dir, 'butler-record.json'); + const { binDir } = writeFakeCommand(dir, 'butler', ` +const fs = require('node:fs'); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ argv: process.argv.slice(2) }, null, 2)); +process.exit(0); +`); + + const result = run(['upload', 'itch', 'web', '--dir', dir, '--yes', '--allow-feather-runtime'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('UPLOAD SAFETY WARNING')); + assert.ok(outputOf(result).includes('feather/')); +}); + +test('upload itch: unsafe override in json includes warning without non-json stdout', () => { + const dir = makeTmp(); + writeGame(dir); + writeUnsafeUploadManifest(dir, { + name: 'Json Warn Upload', + project: 'tester/json-warn-upload', + channel: 'html5', + }); + const { binDir } = writeFakeCommand(dir, 'butler', 'process.exit(0);'); + + const result = run(['upload', 'itch', 'web', '--dir', dir, '--yes', '--allow-feather-runtime', '--json'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.safety.status, 'unsafe'); + assert.ok(parsed.warning.includes('Feather runtime')); +}); diff --git a/cli/test/e2e.mjs b/cli/test/e2e.mjs deleted file mode 100644 index 91631dcd..00000000 --- a/cli/test/e2e.mjs +++ /dev/null @@ -1,219 +0,0 @@ -#!/usr/bin/env node -/* eslint-disable no-undef */ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); -const cli = join(root, 'cli', 'dist', 'index.js'); -const localSrc = join(root, 'src-lua'); - -function log(message) { - console.log(`cli-e2e: ${message}`); -} - -function fail(message) { - console.error(`cli-e2e: ${message}`); - process.exit(1); -} - -function assert(condition, message) { - if (!condition) fail(message); -} - -function run(args, options = {}) { - const result = spawnSync(process.execPath, [cli, ...args], { - cwd: root, - encoding: 'utf8', - env: { ...process.env, NO_COLOR: '1' }, - ...options, - }); - - if (result.status !== 0) { - console.error(result.stdout); - console.error(result.stderr); - fail(`command failed: feather ${args.join(' ')}`); - } - - return result.stdout.trim(); -} - -function read(path) { - return readFileSync(path, 'utf8'); -} - -function writeGame(dir) { - writeFileSync( - join(dir, 'main.lua'), - `local t = 0 - -function love.update(dt) - t = t + dt -end - -function love.draw() - love.graphics.print("CLI E2E " .. tostring(t), 10, 10) -end -`, - ); -} - -function doctorJson(dir, extra = []) { - const raw = run(['doctor', dir, '--json', ...extra]); - try { - return JSON.parse(raw); - } catch (err) { - console.error(raw); - throw err; - } -} - -assert(existsSync(cli), 'cli/dist/index.js is missing. Run npm run cli:build first.'); -assert(existsSync(join(localSrc, 'feather', 'init.lua')), 'src-lua Feather runtime is missing.'); - -const workspace = mkdtempSync(join(tmpdir(), 'kyonru-feather-e2e-')); -log(`workspace ${workspace}`); - -try { - const autoProject = join(workspace, 'auto-game'); - const cliProject = join(workspace, 'cli-game'); - rmSync(autoProject, { recursive: true, force: true }); - rmSync(cliProject, { recursive: true, force: true }); - writeFileSync(join(workspace, '.keep'), ''); - mkdirSync(autoProject, { recursive: true }); - mkdirSync(cliProject, { recursive: true }); - - writeGame(autoProject); - writeGame(cliProject); - - log('init auto project'); - run([ - 'init', - autoProject, - '--mode', - 'auto', - '--local-src', - localSrc, - '--install-dir', - 'feather', - '--no-plugins', - '--yes', - ]); - - assert(existsSync(join(autoProject, 'feather', 'init.lua')), 'auto init did not install feather/init.lua'); - assert(existsSync(join(autoProject, 'feather.config.lua')), 'auto init did not create feather.config.lua'); - assert( - read(join(autoProject, 'main.lua')).includes('FEATHER-INIT-BEGIN require'), - 'auto init did not patch main.lua', - ); - assert(read(join(autoProject, 'main.lua')).includes('USE_DEBUGGER'), 'auto init did not add USE_DEBUGGER guard'); - - log('doctor auto project'); - const autoDoctor = doctorJson(autoProject); - assert(autoDoctor.failures === 0, `doctor found blockers for auto project: ${JSON.stringify(autoDoctor, null, 2)}`); - assert( - autoDoctor.checks.some((check) => check.label === 'Embedded Feather runtime' && check.severity === 'pass'), - 'doctor did not detect embedded runtime', - ); - assert( - autoDoctor.checks.some((check) => check.label === 'USE_DEBUGGER guard' && check.severity === 'pass'), - 'doctor did not detect USE_DEBUGGER guard', - ); - - log('remove auto project'); - run(['remove', autoProject, '--yes']); - assert(!existsSync(join(autoProject, 'feather')), 'remove did not delete feather runtime'); - assert(!existsSync(join(autoProject, 'feather.config.lua')), 'remove did not delete managed feather.config.lua'); - assert(!read(join(autoProject, 'main.lua')).includes('FEATHER-INIT'), 'remove did not strip FEATHER-INIT markers'); - - log('init cli-mode project'); - run([ - 'init', - cliProject, - '--mode', - 'cli', - '--local-src', - localSrc, - '--install-dir', - 'feather', - '--no-plugins', - '--yes', - ]); - - assert(existsSync(join(cliProject, 'feather.config.lua')), 'cli init did not create feather.config.lua'); - assert(!existsSync(join(cliProject, 'feather')), 'cli mode should not install embedded runtime'); - - log('doctor cli-mode project'); - const cliDoctor = doctorJson(cliProject); - assert(cliDoctor.failures === 0, `doctor found blockers for cli-mode project: ${JSON.stringify(cliDoctor, null, 2)}`); - assert( - cliDoctor.checks.some((check) => check.label === 'Embedded Feather runtime' && check.severity === 'info'), - 'doctor should treat missing runtime as info in cli mode', - ); - - log('help commands'); - run(['--help']); - run(['doctor', '--help']); - run(['plugin', '--help']); - - log('package: loading registry'); - const registryPath = join(root, 'cli', 'dist', 'generated', 'registry.json'); - assert(existsSync(registryPath), 'cli/dist/generated/registry.json missing — run build first'); - const registry = JSON.parse(read(registryPath)); - - const topLevel = Object.entries(registry.packages).filter(([, e]) => !e.parent); - log(`package: ${topLevel.length} top-level packages to test`); - - for (const [pkgId, entry] of topLevel) { - log(`package install ${pkgId} @ ${entry.source.tag}`); - const pkgDir = mkdtempSync(join(tmpdir(), `feather-pkg-${pkgId}-`)); - - // install - run(['package', 'install', pkgId, '--dir', pkgDir]); - - // every file must exist on disk - for (const file of entry.install.files) { - assert(existsSync(join(pkgDir, file.target)), `${pkgId}: ${file.target} not on disk after install`); - } - - // lockfile entry must be correct - const lock = JSON.parse(read(join(pkgDir, 'feather.lock.json'))); - assert(lock.packages[pkgId], `${pkgId}: missing lockfile entry`); - assert( - lock.packages[pkgId].version === entry.source.tag, - `${pkgId}: lockfile version ${lock.packages[pkgId].version} !== registry ${entry.source.tag}`, - ); - assert( - lock.packages[pkgId].trust === entry.trust, - `${pkgId}: lockfile trust ${lock.packages[pkgId].trust} !== registry ${entry.trust}`, - ); - - // audit must pass (--json gives clean stdout even when piped) - const auditOut = run(['package', 'audit', '--json', '--dir', pkgDir]); - const auditResults = JSON.parse(auditOut); - for (const r of auditResults) { - assert(r.status === 'verified', `${pkgId}: audit ${r.target} → ${r.status}`); - } - - // remove - run(['package', 'remove', pkgId, '--dir', pkgDir]); - for (const file of entry.install.files) { - assert(!existsSync(join(pkgDir, file.target)), `${pkgId}: ${file.target} still on disk after remove`); - } - const lockAfter = JSON.parse(read(join(pkgDir, 'feather.lock.json'))); - assert(!lockAfter.packages[pkgId], `${pkgId}: lockfile entry remains after remove`); - - if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { - rmSync(pkgDir, { recursive: true, force: true }); - } - log(`package install ${pkgId} ✔`); - } - - log('passed'); -} finally { - if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { - rmSync(workspace, { recursive: true, force: true }); - } -} diff --git a/cli/test/package.test.mjs b/cli/test/package.test.mjs deleted file mode 100644 index 834a81e2..00000000 --- a/cli/test/package.test.mjs +++ /dev/null @@ -1,399 +0,0 @@ -/* eslint-disable no-undef */ -/** - * E2E tests for `feather package` subcommands. - * - * Runs the compiled CLI binary (dist/index.js) so build must be current. - * Uses --offline everywhere registry access is needed to avoid network I/O. - * Pre-populates temp dirs with lockfiles / files for audit & remove scenarios. - */ - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { fileURLToPath } from 'node:url'; - -const CLI = fileURLToPath(new URL('../dist/index.js', import.meta.url)); -const sha256 = (s) => createHash('sha256').update(s).digest('hex'); - -/** Run the CLI and return { stdout, stderr, exitCode }. Never throws. */ -function run(args, extra = {}) { - try { - const stdout = execFileSync('node', [CLI, ...args], { - encoding: 'utf8', - env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, - ...extra, - }); - return { stdout, stderr: '', exitCode: 0 }; - } catch (err) { - return { stdout: err.stdout ?? '', stderr: err.stderr ?? '', exitCode: err.status ?? 1 }; - } -} - -function makeTmp() { - return mkdtempSync(join(tmpdir(), 'feather-e2e-')); -} - -/** - * Write a minimal feather.lock.json to dir. - * @param {string} dir - * @param {Record} packages - */ -function writeLock(dir, packages) { - writeFileSync(join(dir, 'feather.lock.json'), JSON.stringify({ lockfileVersion: 1, packages }, null, 2)); -} - -test('search: no query lists all registry packages', () => { - const { stdout, exitCode } = run(['package', 'search', '--offline']); - assert.equal(exitCode, 0); - assert.ok(stdout.includes('anim8'), 'should include anim8'); - assert.ok(stdout.includes('bump'), 'should include bump'); - assert.ok(stdout.includes('package(s)'), 'should print count'); -}); - -test('search: query filters by package name', () => { - const { stdout, exitCode } = run(['package', 'search', 'anim', '--offline']); - assert.equal(exitCode, 0); - assert.ok(stdout.includes('anim8')); - assert.ok(!stdout.includes('bump')); -}); - -test('search: query matches tags', () => { - const { stdout, exitCode } = run(['package', 'search', 'animation', '--offline']); - assert.equal(exitCode, 0); - assert.ok(stdout.includes('anim8')); -}); - -test('search: unmatched query reports no results', () => { - const { stdout, exitCode } = run(['package', 'search', 'zzz_no_such_pkg_xyz', '--offline']); - assert.equal(exitCode, 0); - assert.ok(stdout.includes('No packages found')); -}); - -test('list --installed: empty project prints hint', () => { - const dir = makeTmp(); - const { stdout, exitCode } = run(['package', 'list', '--installed', '--dir', dir]); - assert.equal(exitCode, 0); - assert.ok(stdout.includes('No packages installed')); -}); - -test('list --installed: shows installed package with version', () => { - const dir = makeTmp(); - writeLock(dir, { - anim8: { - version: 'v2.3.1', - trust: 'verified', - source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, - files: [], - }, - }); - const { stdout, exitCode } = run(['package', 'list', '--installed', '--dir', dir]); - assert.equal(exitCode, 0); - assert.ok(stdout.includes('anim8')); - assert.ok(stdout.includes('v2.3.1')); -}); - -test('info: shows package details for known package', () => { - const dir = makeTmp(); - const { stdout, exitCode } = run(['package', 'info', 'anim8', '--offline', '--dir', dir]); - assert.equal(exitCode, 0); - assert.ok(stdout.includes('anim8')); - assert.ok(stdout.includes('kikito/anim8')); - assert.ok(stdout.includes('anim8.lua')); - assert.ok(stdout.includes('lib/anim8.lua')); -}); - -test('info: unknown package exits 1 with message', () => { - const dir = makeTmp(); - const { stdout, exitCode } = run(['package', 'info', 'zzz_no_such_pkg', '--offline', '--dir', dir]); - assert.equal(exitCode, 1); - assert.ok(stdout.includes('not found')); -}); - -test('install: no names + no lockfile prints empty hint and exits 0', () => { - const dir = makeTmp(); - const { stdout, exitCode } = run(['package', 'install', '--offline', '--dir', dir]); - assert.equal(exitCode, 0); - assert.ok(stdout.includes('empty')); -}); - -test('install: no names + empty lockfile prints hint', () => { - const dir = makeTmp(); - const { stdout, exitCode } = run(['package', 'install', '--offline', '--dir', dir]); - assert.equal(exitCode, 0); - assert.ok(stdout.includes('empty')); -}); - -test('install: no names + all verified skips restore', () => { - const dir = makeTmp(); - const content = 'return {}'; - const hash = sha256(content); - mkdirSync(join(dir, 'lib'), { recursive: true }); - writeFileSync(join(dir, 'lib', 'anim8.lua'), content); - writeLock(dir, { - anim8: { - version: 'v2.3.1', - trust: 'verified', - source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, - files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: hash }], - }, - }); - const { stdout, exitCode } = run(['package', 'install', '--dir', dir]); - assert.equal(exitCode, 0); - assert.ok(stdout.includes('up to date')); -}); - -test('install: unknown package exits 1', () => { - const dir = makeTmp(); - const { exitCode } = run(['package', 'install', 'zzz_no_such_pkg', '--offline', '--dir', dir]); - assert.equal(exitCode, 1); -}); - -test('install: dry-run shows plan without writing any files', () => { - const dir = makeTmp(); - const { stdout, exitCode } = run(['package', 'install', 'anim8', '--dry-run', '--offline', '--dir', dir]); - assert.equal(exitCode, 0); - assert.ok(stdout.includes('anim8')); - assert.ok(stdout.includes('Dry run')); - assert.ok(!existsSync(join(dir, 'lib', 'anim8.lua')), 'must not write any file'); - assert.ok(!existsSync(join(dir, 'feather.lock.json')), 'must not write lockfile'); -}); - -test('install: already-installed package is skipped', () => { - const dir = makeTmp(); - writeLock(dir, { - anim8: { - version: 'v2.3.1', - trust: 'verified', - source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, - files: [], - }, - }); - const { stdout, exitCode } = run(['package', 'install', 'anim8', '--dry-run', '--offline', '--dir', dir]); - assert.equal(exitCode, 0); - assert.ok(stdout.includes('already installed')); -}); - -test('install: version override without --allow-untrusted exits 1', () => { - const dir = makeTmp(); - const { stdout, exitCode } = run(['package', 'install', 'anim8@v2.2.0', '--offline', '--dir', dir]); - assert.equal(exitCode, 1); - assert.ok(stdout.includes('allow-untrusted')); -}); - -test('install --from-url: missing --allow-untrusted exits 1 with warning', () => { - const dir = makeTmp(); - const { stdout, exitCode } = run([ - 'package', - 'install', - '--from-url', - 'https://example.com/helper.lua', - '--target', - 'lib/helper.lua', - '--dir', - dir, - ]); - assert.equal(exitCode, 1); - assert.ok(stdout.includes('NOT been reviewed') || stdout.includes('allow-untrusted')); -}); - -test('install --from-url: missing --target exits 1', () => { - const dir = makeTmp(); - const { stdout, exitCode } = run([ - 'package', - 'install', - '--from-url', - 'https://example.com/helper.lua', - '--allow-untrusted', - '--dir', - dir, - ]); - assert.equal(exitCode, 1); - assert.ok(stdout.includes('--target')); -}); - -test('audit: no packages installed prints hint', () => { - const dir = makeTmp(); - const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); - assert.equal(exitCode, 0); - assert.ok(stdout.includes('No packages installed')); -}); - -test('audit: correct file exits 0 and reports verified', () => { - const dir = makeTmp(); - const content = 'return {}'; - const hash = sha256(content); - mkdirSync(join(dir, 'lib'), { recursive: true }); - writeFileSync(join(dir, 'lib', 'anim8.lua'), content); - writeLock(dir, { - anim8: { - version: 'v2.3.1', - trust: 'verified', - source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, - files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: hash }], - }, - }); - const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); - assert.equal(exitCode, 0, `unexpected failure:\n${stdout}`); - assert.ok(stdout.includes('verified')); -}); - -test('audit: missing file exits 1 and reports missing', () => { - const dir = makeTmp(); - writeLock(dir, { - anim8: { - version: 'v2.3.1', - trust: 'verified', - source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, - files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: 'abc123' }], - }, - }); - const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); - assert.equal(exitCode, 1); - assert.ok(stdout.includes('missing')); -}); - -test('audit: tampered file exits 1 and reports MODIFIED', () => { - const dir = makeTmp(); - mkdirSync(join(dir, 'lib'), { recursive: true }); - writeFileSync(join(dir, 'lib', 'anim8.lua'), 'tampered content'); - writeLock(dir, { - anim8: { - version: 'v2.3.1', - trust: 'verified', - source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, - files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: sha256('original content') }], - }, - }); - const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); - assert.equal(exitCode, 1); - assert.ok(stdout.includes('MODIFIED')); -}); - -test('audit --json: outputs valid JSON', () => { - const dir = makeTmp(); - const content = 'return {}'; - const hash = sha256(content); - mkdirSync(join(dir, 'lib'), { recursive: true }); - writeFileSync(join(dir, 'lib', 'anim8.lua'), content); - writeLock(dir, { - anim8: { - version: 'v2.3.1', - trust: 'verified', - source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, - files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: hash }], - }, - }); - const { stdout, exitCode } = run(['package', 'audit', '--json', '--dir', dir]); - assert.equal(exitCode, 0); - const parsed = JSON.parse(stdout); - assert.ok(Array.isArray(parsed)); - assert.equal(parsed[0].status, 'verified'); -}); - -test('remove: not installed exits 1 with message', () => { - const dir = makeTmp(); - const { stdout, exitCode } = run(['package', 'remove', 'anim8', '--dir', dir]); - assert.equal(exitCode, 1); - assert.ok(stdout.includes('not installed')); -}); - -test('remove: deletes file and removes lockfile entry', () => { - const dir = makeTmp(); - mkdirSync(join(dir, 'lib'), { recursive: true }); - writeFileSync(join(dir, 'lib', 'anim8.lua'), 'return {}'); - writeLock(dir, { - anim8: { - version: 'v2.3.1', - trust: 'verified', - source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, - files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: 'any' }], - }, - }); - const { exitCode } = run(['package', 'remove', 'anim8', '--dir', dir]); - assert.equal(exitCode, 0); - assert.ok(!existsSync(join(dir, 'lib', 'anim8.lua')), 'file should be deleted'); - const lock = JSON.parse(readFileSync(join(dir, 'feather.lock.json'), 'utf8')); - assert.ok(!lock.packages.anim8, 'lockfile entry should be removed'); -}); - -test('remove: does not touch files belonging to other packages', () => { - const dir = makeTmp(); - mkdirSync(join(dir, 'lib'), { recursive: true }); - writeFileSync(join(dir, 'lib', 'anim8.lua'), 'return {}'); - writeFileSync(join(dir, 'lib', 'bump.lua'), 'return {}'); - writeLock(dir, { - anim8: { - version: 'v2.3.1', - trust: 'verified', - source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, - files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: 'any' }], - }, - bump: { - version: 'v3.1.7', - trust: 'verified', - source: { repo: 'kikito/bump.lua', tag: 'v3.1.7' }, - files: [{ name: 'bump.lua', target: 'lib/bump.lua', sha256: 'any' }], - }, - }); - run(['package', 'remove', 'anim8', '--dir', dir]); - assert.ok(existsSync(join(dir, 'lib', 'bump.lua')), 'bump.lua must not be touched'); - const lock = JSON.parse(readFileSync(join(dir, 'feather.lock.json'), 'utf8')); - assert.ok(lock.packages.bump, 'bump lockfile entry must remain'); -}); - -test('update: no packages installed prints hint', () => { - const dir = makeTmp(); - const { stdout, exitCode } = run(['package', 'update', '--offline', '--dir', dir]); - assert.equal(exitCode, 0); - assert.ok(stdout.includes('No packages installed')); -}); - -test('update: specific name not in lockfile exits 1', () => { - const dir = makeTmp(); - // lockfile has another package so the empty-lockfile early-return is bypassed - writeLock(dir, { - bump: { - version: 'v3.1.7', - trust: 'verified', - source: { repo: 'kikito/bump.lua', tag: 'v3.1.7' }, - files: [], - }, - }); - const { stdout, exitCode } = run(['package', 'update', 'anim8', '--offline', '--dir', dir]); - assert.equal(exitCode, 1); - assert.ok(stdout.includes('not installed')); -}); - -test('update: already up-to-date package is reported', () => { - const dir = makeTmp(); - writeLock(dir, { - anim8: { - version: 'v2.3.1', - trust: 'verified', - source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, - files: [], - }, - }); - const { stdout, exitCode } = run(['package', 'update', 'anim8', '--offline', '--dry-run', '--dir', dir]); - assert.equal(exitCode, 0); - assert.ok(stdout.includes('up to date')); -}); - -test('update: experimental packages are skipped', () => { - const dir = makeTmp(); - writeLock(dir, { - 'my-helper': { - version: '0.0.0', - trust: 'experimental', - source: { url: 'https://example.com/my-helper.lua' }, - files: [], - }, - }); - const { stdout, exitCode } = run(['package', 'update', '--offline', '--dir', dir]); - assert.equal(exitCode, 0); - assert.ok(stdout.includes('Skipping') || stdout.includes('experimental')); -}); diff --git a/docs/assets.md b/docs/assets.md index b4f6ebd3..2b7196a8 100644 --- a/docs/assets.md +++ b/docs/assets.md @@ -14,28 +14,13 @@ Assets appear in the desktop app after they are loaded by the game. ## Setup -No extra setup is required beyond running Feather in debug mode: +No extra setup is required beyond running the game through the Feather CLI: -```lua -require("feather.auto") - -function love.update(dt) - DEBUGGER:update(dt) -end -``` - -Or with manual setup: - -```lua -local FeatherDebugger = require("feather") - -local debugger = FeatherDebugger({ - debug = true, -}) - -function love.update(dt) - debugger:update(dt) -end +```bash +feather run path/to/my-game +feather run path/to/my-game --target web +feather run path/to/my-game --target android +feather run path/to/my-game --target ios ``` Open the **Assets** tab in the desktop app to view the tracked catalog. @@ -45,18 +30,10 @@ Open the **Assets** tab in the desktop app to view the tracked catalog. Asset tracking is enabled by default. To disable it for a session: ```lua -require("feather.auto").setup({ - assetPreview = false, -}) -``` - -Or with manual setup: - -```lua -local debugger = FeatherDebugger({ - debug = true, +-- feather.config.lua +return { assetPreview = false, -}) +} ``` When disabled, Feather does not hook `love.graphics.newImage`, `love.graphics.newFont`, `love.audio.newSource`, or `love.draw` for asset previews. @@ -176,7 +153,7 @@ When zoom is above 100%, Feather draws a pixel grid over the preview. This is us ### The texture list is empty > [!IMPORTANT] -> Make sure the game loads images after Feather starts. If assets are loaded before `require("feather.auto")` or before creating `FeatherDebugger`, Feather cannot track those calls. +> Make sure the game loads images after Feather starts. When using `feather run`, the CLI starts Feather before your game's `main.lua` is loaded. ### Preview says the file is not available diff --git a/docs/configuration.md b/docs/configuration.md index 78900057..673f707a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,6 +31,7 @@ | `appId` | `string` | `""` | Desktop App ID that is allowed to send commands to this game. Copy from **Feather → Settings → Security → Desktop App ID**. Required unless `__DANGEROUS_INSECURE_CONNECTION__` is set. | | `__DANGEROUS_INSECURE_CONNECTION__` | `boolean` | `false` | Explicit opt-in to skip the appId check and accept commands from any Feather desktop. Must be set to acknowledge the security risk (e.g. in a LAN jam build without a fixed App ID). | | `debugger` | `boolean` or `table` | `false` | Enable the step debugger, or pass debugger options such as `debugger.hotReload`. | +| `debugOverlay` | `table` or `false` | enabled in debug | Small in-game “Feather debugger enabled” badge. Use `enabled`, `visible`, `hideKey`, `touchToggle`, and `corner` to customize it. Press `F12` or double-tap the top-right corner to hide/show by default. | | `assetPreview` | `boolean` | `true` | Enable the core Assets tab tracking and previews. Set to `false` to avoid hooking asset loaders and `love.draw`. | | `binaryTextThreshold` | `number` | `4096` | Observer, time-travel, debugger, and console text values longer than this many bytes are sent through the hybrid binary protocol instead of JSON. | @@ -73,6 +74,15 @@ See [Hot Reload](hot-reload.md) for the full workflow. Feather uses a **nonce-based challenge-response handshake** to authenticate each connection before any game data is exchanged. +The CLI can audit these settings: + +```bash +feather doctor path/to/my-game --production +feather doctor path/to/my-game --security --json +``` + +`--production` fails on unsafe release settings. `--security --json` emits a machine-readable security report and redacts sensitive values such as `apiKey`. + ### How it works 1. When the game connects, the desktop immediately sends a one-time challenge nonce. @@ -112,6 +122,19 @@ The desktop will accept the connection and show an insecure badge on the session > [!CAUTION] > Insecure mode allows **any** Feather desktop on the network to send commands to your game, including triggering the console plugin if installed. Do not ship production builds with insecure mode enabled. +### Console API key + +The Console plugin can evaluate Lua inside the running game. If `console` is included, configure a strong `apiKey` and match it in Feather desktop Settings: + +```lua +return { + include = { "console" }, + apiKey = "a-long-random-per-session-secret", +} +``` + +Doctor reports whether the key is configured or weak, but does not print the key value. + --- ## Connecting diff --git a/docs/debugger.md b/docs/debugger.md index 951d0cce..1be47474 100644 --- a/docs/debugger.md +++ b/docs/debugger.md @@ -11,18 +11,27 @@ It also hosts Feather's opt-in [Hot Reload](hot-reload.md) controls for selected ## Setup -### With auto.lua +### CLI-managed setup -The debugger is controlled by the `debugger` config option, not the plugin system. Enable it in auto setup: +The debugger is controlled by the `debugger` config option, not the plugin system. Enable it in `feather.config.lua`, then run through the CLI: ```lua -require("feather.auto").setup({ +return { debugger = true, -}) +} +``` + +```bash +feather run path/to/my-game +feather run path/to/my-game --target web +feather run path/to/my-game --target android +feather run path/to/my-game --target ios ``` ### Manual setup +Manual Lua integration is only needed for projects that intentionally vendor Feather themselves: + ```lua local debugger = FeatherDebugger({ debug = true, diff --git a/docs/index.md b/docs/index.md index e8b95b63..172253fe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,7 @@ **Real-time debug & inspect tool for [LÖVE (love2d)](https://love2d.org) games.** -Like Flipper or React DevTools, but for your game. Inspect logs, variables, performance metrics, and errors in real-time over a WebSocket connection — with a built-in plugin system, step debugger, and zero-config setup. +Like Flipper or React DevTools, but for LÖVE game. Inspect logs, variables, performance metrics, and errors in real time over a WebSocket connection with a built-in plugin system, step debugger, and zero-config setup. --- @@ -19,77 +19,76 @@ Like Flipper or React DevTools, but for your game. Inspect logs, variables, perf - 🐛 **Step Debugger** — Breakpoints, step over/into/out, call stack, and local variable inspection. - 🖼️ **Asset inspector** — Browse loaded textures, fonts, and audio sources with previews, zoom, pan, and pixel grid. - 📁 **Log file viewer** — Open `.featherlog` files for offline inspection. -- 🖥️ **CLI-first workflow** — `feather init`, `feather run`, and `feather remove` manage setup and cleanup. -- ⚡ **Guarded in-game setup** — Generated imports load only when `USE_DEBUGGER` is enabled. +- 🖥️ **CLI-first workflow** — `feather init`, `feather run`, and `feather remove` manage setup and cleanup with no manual Lua integration. +- 🚢 **Build/upload helpers** — `feather build` creates `.love`, web, mobile, and desktop artifacts and `feather upload itch` pushes them with Butler. +- 🧹 **Self-cleaning setup** — Generated files are managed by Feather and can be previewed or removed before release. - 📦 **Config file support** — `feather.config.lua` keeps project settings outside game code. --- ## Quick Start -> [!IMPORTANT] -> For quick local desktop iteration, you can also use `feather run path/to/my-game` without changing game code. For mobile, handhelds, and remote devices like Android, iOS, or Steam Deck, use the embedded library from `feather init --mode auto` so the game carries Feather with it on the device. +Install the Feather desktop app and CLI: -### Option A — CLI injection (no game-side changes) +1. Download the desktop app from [Releases](https://github.com/Kyonru/feather/releases). +2. Install the CLI: ```bash npm install -g @kyonru/feather +``` + +Initialize your project, open the Feather app, then run the game: + +```bash feather init path/to/my-game feather run path/to/my-game ``` -Feather is injected automatically. No `require` needed in the game. See [CLI](cli.md). +Feather is injected by the CLI for dev runs and debug builds, so your game code does not need a manual `require` for any target. -> [!NOTE] -> This is best for local desktop development where the CLI launches LÖVE directly. +> [!CAUTION] +> `feather run` is for development. Do not publish builds created from a run session; create user-facing builds with `feather build --release` so Feather debugging tools are not included. -### Option B — Managed in-game setup +### Optional Vendors -```bash -npm install -g @kyonru/feather -feather init path/to/my-game --mode auto -USE_DEBUGGER=1 love path/to/my-game -``` +Vendor setup downloads the local LÖVE runtimes/templates needed by web, mobile, and packaged desktop targets, then updates `feather.build.json`. -> [!IMPORTANT] -> Use this for mobile, handheld, and remote devices such as Android, iOS, Steam Deck, or a second computer. Those builds need the embedded Feather library because the CLI is not launching the game process on that device. +```bash +feather build vendor add web --dir path/to/my-game +feather run path/to/my-game --target web -`feather init` creates `feather.config.lua`: +feather build vendor add android --dir path/to/my-game +feather run path/to/my-game --target android -```lua -return { - sessionName = "My RPG", - -- Set to the desktop app machine's LAN IP for remote devices. - host = "192.168.1.50", - exclude = { "network-inspector" }, -} +feather build vendor add ios --dir path/to/my-game +feather run path/to/my-game --target ios ``` -> [!TIP] -> The generated `main.lua` integration is guarded by `USE_DEBUGGER`, so Feather is not imported unless you opt in for a dev run. - -When you access `DEBUGGER` in your own code, guard it: +For all build vendors, including desktop packaging runtimes: -```lua -function love.update(dt) - if DEBUGGER then - DEBUGGER:update(dt) - end -end +```bash +feather build vendor add all --dir path/to/my-game ``` -Before shipping a production build: +Build release artifacts from the same CLI flow: ```bash -feather remove --dry-run -feather remove --yes +feather build love --dir path/to/my-game --release +feather build android --dir path/to/my-game --release +feather build ios --dir path/to/my-game --release +feather build windows --dir path/to/my-game --release +feather build macos --dir path/to/my-game --release +feather build linux --dir path/to/my-game --release +feather build steamos --dir path/to/my-game --release ``` +See [CLI](cli.md) for `feather run`, `feather doctor`, `feather build`, and `feather upload` options. + --- ## Documentation -- [CLI](cli.md) — Run games without touching their code, `feather run`, `feather init`, `feather doctor` +- [CLI](cli.md) — Run games without touching their code, `feather run`, `feather init`, `feather doctor`, `feather build`, `feather upload` - [Installation](installation.md) — Download, install script, LuaRocks, custom paths - [Configuration](configuration.md) — All config options, connecting, mobile debugging - [Usage](usage.md) — Observers, logging, console / REPL, step debugger diff --git a/docs/installation.md b/docs/installation.md index 9507c411..bc0efdc3 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,57 +1,81 @@ # Installation -## Option 1: CLI (Recommended — no game changes needed) +## CLI (Recommended — no game changes needed) -Install the `@kyonru/feather` npm package globally, then use `feather run` to inject Feather into any love2d game without touching its source: +Install the Feather desktop app, install the `@kyonru/feather` npm package globally, then use the CLI for desktop, web, mobile, and packaged desktop workflows: ```bash npm install -g @kyonru/feather +feather init path/to/my-game feather run path/to/my-game ``` -A new session tab appears in the Feather desktop app automatically. No `require` calls, no `DEBUGGER:update(dt)` — the CLI handles everything. +A new session tab appears in the Feather desktop app automatically. No `require` calls, no `DEBUGGER:update(dt)` — the CLI handles runtime injection and setup. > [!NOTE] -> Use `feather run` for local desktop iteration where the CLI launches LÖVE directly. +> Use plain `feather run` for local desktop iteration where the CLI launches LÖVE directly. Use `feather run --target web|android|ios` when you want the CLI to build, serve, install, or launch a configured platform target. -> [!IMPORTANT] -> For mobile, handheld, and remote devices such as Android, iOS, Steam Deck, or a second computer, embed Feather into the game instead. Those devices run the game themselves, so the CLI cannot inject Feather at launch time. +### Vendors and Platform Runs -### Embedded library for devices - -Use auto mode for device builds: +Add local LÖVE runtimes/templates once per project, then run or build those targets: ```bash -cd path/to/my-game -feather init --mode auto +feather build vendor add web --dir path/to/my-game +feather run path/to/my-game --target web + +feather build vendor add android --dir path/to/my-game +feather run path/to/my-game --target android + +feather build vendor add ios --dir path/to/my-game +feather run path/to/my-game --target ios + +feather build vendor add all --dir path/to/my-game ``` -Then set the desktop app machine as the connection target in `feather.config.lua`: +`feather build vendor add all` also installs desktop runtime vendors for Windows, macOS, Linux, and SteamOS packaging. Vendor fetching does not install Android SDK, JDK, Xcode, NSIS, or signing assets. -```lua -return { - sessionName = "Steam Deck Test", +Check all platform readiness in one pass: - -- IP address of the computer running the Feather desktop app. - host = "192.168.1.50", -} +```bash +feather doctor path/to/my-game --build-target all ``` -Run the game with Feather enabled: +Common run and build commands: ```bash -# macOS / Linux / Steam Deck shell -USE_DEBUGGER=1 love . +feather run path/to/my-game --target web +feather run path/to/my-game --target android +feather run path/to/my-game --target ios + +feather build love --dir path/to/my-game +feather build android --dir path/to/my-game --release +feather build ios --dir path/to/my-game --release +feather build windows --dir path/to/my-game +feather build macos --dir path/to/my-game +feather build linux --dir path/to/my-game +feather build steamos --dir path/to/my-game ``` -> [!TIP] -> For Android over USB with ADB reverse, the default `host = "127.0.0.1"` can still work because ADB routes the device port back to your computer. For Wi-Fi devices, Steam Deck, or another computer, use the LAN IP shown in Feather Settings. +Release builds do not auto-embed Feather's debugger runtime. If you used a managed init mode while experimenting, Feather can clean up its own generated blocks/files before packaging: + +```bash +feather remove path/to/my-game --dry-run +feather remove path/to/my-game --yes +``` See [CLI](cli.md) for all commands, flags, and `feather.config.lua` options. +For CI or release scripts that need a security-only JSON report: + +```bash +feather doctor path/to/my-game --security --json +``` + --- -## Option 2: Install Script +## Advanced: Install Script + +The install script is an advanced/manual path. Prefer the CLI above unless you intentionally want to vendor the Lua runtime yourself. Download the core library and all plugins with a single command: @@ -83,7 +107,7 @@ FEATHER_INCLUDE_CONSOLE=1 bash -c "$(curl -sSf https://raw.githubusercontent.com FEATHER_INCLUDE_HOT_RELOAD=1 bash -c "$(curl -sSf https://raw.githubusercontent.com/Kyonru/feather/main/scripts/install-feather.sh)" ``` -## Option 3: Direct Download +## Advanced: Direct Download 1. Go to the [releases page](https://github.com/Kyonru/feather/releases) and download `feather-x.x.x.zip`. 2. Unzip and copy the `feather/` folder into your project, e.g. `lib/feather/`. @@ -93,7 +117,7 @@ FEATHER_INCLUDE_HOT_RELOAD=1 bash -c "$(curl -sSf https://raw.githubusercontent. local Feather = require "lib.feather" ``` -## Option 4: LuaRocks +## Advanced: LuaRocks ```bash luarocks install feather @@ -204,7 +228,7 @@ Run without arguments to see the full list of available plugins: bash install-plugin.sh ``` -After installing, register the plugins in your setup: +After installing, register the plugins in your manual setup: ```lua require("feather.auto").setup({ diff --git a/docs/recommendations.md b/docs/recommendations.md index 39bbe9e0..3cc84fc5 100644 --- a/docs/recommendations.md +++ b/docs/recommendations.md @@ -25,11 +25,11 @@ The game rejects commands from any other Feather desktop instance. Only the matc return { -- Any Feather desktop on the network can send commands to this game. -- Set this only when you cannot use appId (e.g. shared dev machine, CI). - insecureConnection = true, + __DANGEROUS_INSECURE_CONNECTION__ = true, } ``` -Setting `insecureConnection = true` is your acknowledgment that the game accepts commands from any Feather desktop on the network. Feather will not start without one of these two options — there is no silent fallback. +Setting `__DANGEROUS_INSECURE_CONNECTION__ = true` is your acknowledgment that the game accepts commands from any Feather desktop on the network. Feather will not start without one of these two options — there is no silent fallback. `feather init` prompts for the App ID during setup and writes it to `feather.config.lua` automatically. To find it: open the Feather desktop app → **Settings** → **Security** → **Desktop App ID**. @@ -43,9 +43,11 @@ Setting `insecureConnection = true` is your acknowledgment that the game accepts Set `apiKey` in the game config and match it in the Feather desktop app Settings. This gates access to the Console / REPL plugin (remote Lua execution): ```lua -local debugger = FeatherDebugger({ +-- feather.config.lua +return { + include = { "console" }, apiKey = "your-api-key", -}) +} ``` ### Console plugin @@ -55,124 +57,96 @@ local debugger = FeatherDebugger({ --- -## Safe `DEBUGGER` Usage +## CLI-Managed Debugging > [!IMPORTANT] -> `DEBUGGER` only exists when Feather actually loads. With CLI-managed setup, generated imports are guarded by `USE_DEBUGGER`, so production-like runs can leave Feather unloaded entirely. +> The CLI is the preferred workflow for desktop, web, Android, iOS, and packaged desktop builds. You do not need to add `require("feather.auto")`, call `DEBUGGER:update(dt)`, or keep Feather-specific code in your game. -Always guard direct `DEBUGGER` usage: +Use the CLI for local and platform runs: -```lua -function love.update(dt) - if DEBUGGER then - DEBUGGER:update(dt) - end -end +```bash +feather init path/to/my-game +feather run path/to/my-game +feather run path/to/my-game --target web +feather run path/to/my-game --target android +feather run path/to/my-game --target ios ``` -For custom actions: +Add vendors for platform targets once per project: -```lua -if DEBUGGER then - DEBUGGER:log("Player spawned") -end +```bash +feather build vendor add all --dir path/to/my-game ``` -Avoid this in game code: +Run doctor as a release gate before packaging: -```lua -DEBUGGER:update(dt) +```bash +feather doctor path/to/my-game --production +feather doctor path/to/my-game --build-target all ``` -> [!CAUTION] -> That direct call will fail when `USE_DEBUGGER` is unset, when `debug = false`, or after running `feather remove`. +`--production` exits with code `1` for release blockers such as insecure connections, weak Console auth, hot reload, debugger/screenshot/disk persistence settings, network exposure with weak auth, or unmanaged embedded Feather runtime. -For local/dev runs, enable Feather explicitly: +For CI systems that need a security-only machine-readable report: ```bash -USE_DEBUGGER=1 love . +feather doctor path/to/my-game --security --json ``` -For production builds, leave `USE_DEBUGGER` unset and run `feather remove --yes` before packaging. - ---- - -## Performance - -> [!WARNING] -> Feather is a development tool, not something you should ship in production builds. Use one of these approaches, from least to most thorough. - -### Level 1 — Disable at runtime (`debug = false`) +The security report includes config posture, plugin trust, package provenance, and network exposure. It redacts sensitive values such as `apiKey`. -The `debug` flag makes Feather a no-op: no WebSocket connection, no hooks, `update()` returns immediately. The library is still bundled but consumes no CPU. +If you use Feather's local release helpers, keep release metadata in `feather.build.json` and let doctor check the target-specific tools before CI runs the build: -```lua -local debugger = FeatherDebugger({ - debug = Config.IS_DEBUG, -}) +```bash +feather build vendor add all --dir path/to/my-game --json +feather build web --dir path/to/my-game --json +feather build android --dir path/to/my-game --release --json +feather build ios --dir path/to/my-game --release --json +feather build windows --dir path/to/my-game --json +feather upload itch web --dir path/to/my-game --dry-run --json ``` -The files are dormant but present in the bundle. - -### Level 2 — Keep Feather managed by the CLI +Web builds package a local love.js player; mobile targets use official LÖVE templates; desktop targets use local LÖVE runtime vendors. Mobile release mode is opt-in with `--release` and produces Android AAB/APK or iOS archive/IPA artifacts. Release builds do not auto-embed Feather's debugger runtime. -Prefer initializing Feather through the CLI so the generated files are easy to remove later: +--- -```bash -feather init --mode auto -# or -feather init --mode manual -``` +## Performance -Auto mode inserts marked `FEATHER-INIT` blocks in `main.lua`. Manual mode creates `feather.debugger.lua` and loads it from a marked block in `main.lua`. +> [!WARNING] +> Feather is a development tool, not something you should ship in production builds. Prefer CLI-managed runs and builds so release artifacts stay clean by default. -Both modes guard Feather imports with the `USE_DEBUGGER` environment variable. Run local/dev builds with: +### Release builds ```bash -# macOS / Linux -USE_DEBUGGER=1 love . +feather build android --dir path/to/my-game --release +feather build ios --dir path/to/my-game --release ``` -```powershell -# Windows PowerShell -$env:USE_DEBUGGER = "1" -love . -``` +`--release` disables automatic debugger embedding for mobile builds. The generated `.love` used for the release artifact contains the game source, not Feather's injected `.feather-main.lua`, `feather/auto.lua`, plugin files, or generated debug config. -```bat -:: Windows cmd.exe -set USE_DEBUGGER=1 && love . -``` +### Self-cleaning managed files -> [!NOTE] -> Leave `USE_DEBUGGER` unset, `0`, or `false` in production-like runs so Feather is not loaded at all. - -Before packaging a release, run: +If you used `feather init` while experimenting, Feather can preview and remove its own generated files: ```bash -feather remove -``` - -Use `--dry-run` first to preview: - -```bash -feather remove --dry-run +feather remove path/to/my-game --dry-run +feather remove path/to/my-game --yes ``` The remove command only edits generated `FEATHER-INIT` blocks and removes generated Feather files it can identify. If you want to keep part of the setup: ```bash -feather remove --keep-config -feather remove --keep-runtime -feather remove --keep-main +feather remove path/to/my-game --keep-config +feather remove path/to/my-game --keep-runtime +feather remove path/to/my-game --keep-main ``` > [!TIP] > This is the recommended workflow for most projects because Feather can clean up after itself before production packaging. -### Level 3 — Guard manual requires +### Advanced manual integration -If you wire Feather by hand, wrap the entire setup in a conditional so the library is never loaded in production: +Manual Lua integration is still available for unusual projects, but it is no longer the recommended path. If you wire Feather by hand, wrap the entire setup in a conditional so the library is never loaded in production: ```lua if Config.IS_DEBUG then @@ -186,9 +160,9 @@ end No Lua code runs and no globals are created in release builds. -### Level 4 — Exclude from the release build +### Manual exclusion -Since Feather installs into a single directory, excluding it is a single glob: +If you manually vendored Feather, excluding it is a single glob: **Manual zip:** @@ -198,13 +172,6 @@ zip -r MyGame.love . \ -x "lib/feather/*" ``` -**[love-release](https://github.com/MisterDA/love-release)** — add to `.love-release.yml`: - -```yaml -exclude: - - feather/ -``` - **[makelove](https://github.com/pfirsich/makelove)** — add to `makelove.toml`: ```toml @@ -215,7 +182,7 @@ exclude = ["feather/**"] > [!IMPORTANT] > No Feather code or assets are present in the release build. This also eliminates the Console plugin as an attack surface entirely. -If you use manual mode, also exclude or remove: +If you used manual mode, also exclude or remove: ```txt feather.debugger.lua diff --git a/docs/usage.md b/docs/usage.md index a3965c25..eb825220 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,8 +1,21 @@ # Usage -## Manual Setup +## Setup -For full control over which plugins are loaded: +Use the CLI for normal development. It injects Feather for desktop runs and embeds a temporary debug runtime for web/mobile dev runs without requiring game-side Lua integration: + +```bash +feather init path/to/my-game +feather run path/to/my-game +feather run path/to/my-game --target web +feather run path/to/my-game --target android +feather run path/to/my-game --target ios +``` + +The direct Lua API is still available for unusual projects that intentionally vendor Feather themselves. + +> [!WARNING] +> Manual setup can leave Feather code, remote debugging hooks, or powerful plugins such as Console in places you did not intend to ship. Use it only if you understand the security consequences of accidental or unintended use. Prefer the CLI-managed workflow for normal development and releases. ```lua local FeatherDebugger = require "feather" @@ -72,11 +85,13 @@ debugger:error("Something went wrong") The Console is an **opt-in plugin** for evaluating Lua code directly inside the running game. It is not included by default. +Enable it from `feather.config.lua`: + ```lua -require("feather.auto").setup({ +return { include = { "console" }, pluginOptions = { console = { evalEnabled = true } }, -}) +} ``` Once enabled, open the **Console** tab in the desktop app and type any Lua expression. Return values are shown inline; `print()` output is captured automatically. @@ -95,8 +110,12 @@ return love.graphics.getStats() The step debugger pauses game execution at any line and lets you inspect local variables, closure values, and the call stack from the **Debugger** tab. +Enable it from `feather.config.lua`: + ```lua -require("feather.auto").setup({ debugger = true }) +return { + debugger = true, +} ``` Click any line number in the source view to add a breakpoint. While paused, use **Continue**, **Step Over**, **Step Into**, and **Step Out** to navigate execution. @@ -119,8 +138,12 @@ Use **Select Folder** in the Assets tab to set the session's Game Root when the Time Travel records per-frame observer snapshots into a ring buffer and lets you scrub backwards through history to find exactly when a value changed. +Enable it from `feather.config.lua`: + ```lua -require("feather.auto").setup({ include = { "time-travel" } }) +return { + include = { "time-travel" }, +} ``` Open the **Time Travel** tab, click **Start Recording**, reproduce the bug, then click **Stop & Load** to fetch and scrub through the captured frames. diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 18b72b9f..cd252895 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -28,7 +28,7 @@ test('shows no-session empty state and opens settings', async ({ page }) => { await expect(page.getByText('No session connected')).toBeVisible(); await expect(page.getByText('Start a game with Feather enabled')).toBeVisible(); - await page.getByRole('button', { name: 'Open Settings' }).click(); + await page.getByRole('button', { name: 'Connect a LÖVE project' }).click(); await expect(page.getByRole('dialog', { name: 'Settings' })).toBeVisible(); await expect(page.getByLabel('WebSocket Port')).toHaveValue('4004'); await expect(page.getByLabel('Connection Timeout (seconds)')).toHaveValue('15'); @@ -36,21 +36,22 @@ test('shows no-session empty state and opens settings', async ({ page }) => { test('persists settings changes across reloads', async ({ page }) => { await page.goto('/'); - await page.getByRole('button', { name: 'Open Settings' }).click(); + await page.getByRole('button', { name: 'Connect a LÖVE project' }).click(); - const port = page.getByLabel('WebSocket Port'); - const timeout = page.getByLabel('Connection Timeout (seconds)'); - const assetRoot = page.getByLabel('Asset Source Directory'); + await page.getByLabel('WebSocket Port').fill('4111'); + await page.getByLabel('Connection Timeout (seconds)').fill('22'); + + await page.getByRole('tab', { name: 'General' }).click(); + await page.getByLabel('Asset Source Directory').fill('/tmp/feather-assets'); - await port.fill('4111'); - await timeout.fill('22'); - await assetRoot.fill('/tmp/feather-assets'); await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'Close' }).first().click(); await page.reload(); - await page.getByRole('button', { name: 'Open Settings' }).click(); + await page.getByRole('button', { name: 'Connect a LÖVE project' }).click(); await expect(page.getByLabel('WebSocket Port')).toHaveValue('4111'); await expect(page.getByLabel('Connection Timeout (seconds)')).toHaveValue('22'); + + await page.getByRole('tab', { name: 'General' }).click(); await expect(page.getByLabel('Asset Source Directory')).toHaveValue('/tmp/feather-assets'); }); diff --git a/mkdocs.yml b/mkdocs.yml index b02717c1..9dd5cdb0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,7 +42,7 @@ nav: - Recommendations: recommendations.md - Plugins: - Overview: plugins.md - - Plugin UI: plugin-ui.md + - Plugin UI: plugins-ui.md - Animation Inspector: plugins/animation-inspector.md - Audio Debug: plugins/audio-debug.md - Bookmark: plugins/bookmark.md diff --git a/package-lock.json b/package-lock.json index 53c8f3e7..6db495db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "feather", - "version": "0.10.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "feather", - "version": "0.10.0", + "version": "1.0.0", "workspaces": [ "cli" ], @@ -38,7 +38,6 @@ "@tauri-apps/plugin-dialog": "2.7.1", "@tauri-apps/plugin-fs": "2.5.1", "@tauri-apps/plugin-opener": "2.5.4", - "@tauri-apps/plugin-shell": "2.3.5", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "gif.js": "0.2.0", @@ -82,7 +81,7 @@ }, "cli": { "name": "@kyonru/feather", - "version": "0.10.0", + "version": "1.0.0", "license": "EPL-2.0", "dependencies": { "chalk": "5.3.0", @@ -96,6 +95,7 @@ }, "devDependencies": { "@types/node": "24.2.0", + "tsx": "4.21.0", "typescript": "6.0.3" }, "engines": { @@ -572,6 +572,422 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -4819,15 +5235,6 @@ "@tauri-apps/api": "^2.11.0" } }, - "node_modules/@tauri-apps/plugin-shell": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", - "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.10.1" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -6246,6 +6653,19 @@ "node": ">=6" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/gif.js": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/gif.js/-/gif.js-0.2.0.tgz", @@ -7635,6 +8055,16 @@ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "license": "MIT" }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/rolldown": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", @@ -7981,6 +8411,82 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", diff --git a/package.json b/package.json index 21685e1e..f6c5fcde 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "feather", - "description": "Feather is a Love2D devtool to debug and inspect your game in real-time. It provides a web-based interface to view and manipulate game state, visualize physics, and more.", + "description": "Feather is a LÖVE game development toolkit with a desktop inspector, zero-config CLI, plugin system, build/upload helpers, and package management.", "private": true, - "version": "0.10.0", + "version": "1.0.0", "type": "module", "workspaces": [ "cli" @@ -10,7 +10,7 @@ "scripts": { "cli:build": "npm run build --workspace=cli", "cli:dev": "npm run dev --workspace=cli", - "test:cli:e2e": "npm run cli:build && node cli/test/e2e.mjs && node cli/test/package.test.mjs", + "test:cli:e2e": "npm run cli:build && node --test cli/test/commands/*.test.mjs", "test:lua:e2e": "node scripts/lua-e2e.mjs", "test:app:e2e": "playwright test", "test:tauri:e2e": "cargo test --manifest-path src-tauri/Cargo.toml ws_server", @@ -28,6 +28,10 @@ "typecheck": "npm run typecheck:web && npm run typecheck:lua", "generate:plugin-catalog": "node scripts/generate-plugin-catalog.mjs", "check:plugin-catalog": "npm run generate:plugin-catalog && git diff --exit-code cli/src/generated/plugin-catalog.ts", + "package:add": "npm run package:add --workspace=cli", + "package:add-url": "npm run package:add-url --workspace=cli", + "package:update": "npm run package:update --workspace=cli", + "package:remove": "npm run package:remove --workspace=cli", "generate:registry": "node scripts/generate-registry.mjs", "check:registry": "npm run generate:registry && git diff --exit-code cli/src/generated/registry.json", "verify:packages": "node scripts/compute-checksums.mjs --all", @@ -63,7 +67,6 @@ "@tauri-apps/plugin-dialog": "2.7.1", "@tauri-apps/plugin-fs": "2.5.1", "@tauri-apps/plugin-opener": "2.5.4", - "@tauri-apps/plugin-shell": "2.3.5", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "gif.js": "0.2.0", diff --git a/packages/README.md b/packages/README.md index 57d6e580..f4753495 100644 --- a/packages/README.md +++ b/packages/README.md @@ -1,6 +1,8 @@ # Package Manager -Feather includes a curated package installer for LÖVE libraries. It is not a general package manager. It is a hand-curated catalog of known-good libraries with verified checksums. +Feather includes a curated package installer for LÖVE libraries. It is **not a general package manager** — it is a set of utilities to manage libraries that would otherwise require manual copy-paste. There is no central registry in the npm or Cargo sense. All catalog packages are sourced from GitHub repositories; if a repository is deleted, renamed, or made private, that package will no longer be installable. **You are responsible for your own copies.** Committing installed files to your repo rather than adding them to `.gitignore` is strongly recommended if long-term reproducibility matters to you. + +The catalog is hand-curated and pinned to exact commit SHAs with verified checksums. ## Concepts @@ -10,7 +12,7 @@ Feather includes a curated package installer for LÖVE libraries. It is not a ge | -------------- | ----------------------------------------------------------------------------------------------------------------------- | | `verified` | Feather-reviewed. Pinned release + per-file SHA-256. | | `known` | Popular community library. Checksum-pinned but not audited in depth. | -| `experimental` | Installed via `--from-url` or a version override (`name@version`). SHA-256 computed live. Requires `--allow-untrusted`. | +| `experimental` | Installed via `feather package add` or `--from-url`, or a version override (`name@version`). SHA-256 computed live. Requires `--allow-untrusted` for the CLI flag form. | By default, `verified` and `known` packages install without extra flags. `experimental` requires `--allow-untrusted`. @@ -20,6 +22,14 @@ Version overrides (`feather package install anim8@v2.2.0`) are treated as `exper Every install, update, or remove updates `feather.lock.json` in your project root. Commit this file — it records the exact version and SHA-256 of every installed file so anyone cloning your project can restore dependencies with `feather package install` and verify them with `feather package audit`. +Custom GitHub repo installs also record the resolved commit SHA when available. Custom URL installs record the primary URL plus the selected URL list, and every custom file records its own URL and SHA-256. Older lockfiles remain compatible. + +`feather doctor` includes lockfile verification and warns when package file URLs point outside trusted raw GitHub HTTPS sources: + +```sh +feather doctor --security --json +``` + --- ## Commands @@ -63,6 +73,14 @@ feather package install This is the command to run after cloning a project that has a lockfile. +If a missing or modified lockfile entry uses an experimental or untrusted source, non-interactive repair requires explicit consent: + +```sh +feather package install --allow-untrusted +``` + +Without `--allow-untrusted`, Feather exits before downloading from that source. `--yes` does not bypass this trust check. + ### `feather package install [name2...]` Install one or more packages. Files are verified against their SHA-256 before being written. The lockfile is updated on success. @@ -129,11 +147,50 @@ If the requested version does not exist on GitHub, the install aborts with a cle > [!NOTE] > To go back to the Feather-reviewed version, run `feather package install anim8` (no version pin). This re-installs the registry-pinned release and resets the lockfile entry to `trust: verified`. +### `feather package add` + +Install a Lua library that is not in the Feather catalog. Launches an interactive wizard and first asks how you want to add the package: + +- **From GitHub repository** — commit-SHA pinned, reproducible. The wizard fetches the repo's tags and branches, lets you pick one, resolves it to an exact commit SHA, shows all `.lua` files in the tree, and lets you select which ones to install and where. +- **From direct URL(s)** — for libraries not on GitHub or without versioned releases. Each URL is downloaded immediately and its SHA-256 is computed before you confirm. + +Both flows share the same steps for naming the package, setting install paths, entering a require path, and reviewing before anything is written. + +```sh +feather package add +feather package add --dir path/to/my-game +``` + +**GitHub repo flow (step by step):** + +1. Choose mode → "From GitHub repository" +2. Package name — how it appears in `feather.lock.json` +3. GitHub repo (`owner/repo`) +4. Select a tag or branch — tags listed first, then branches +5. Select which `.lua` files to install +6. Set install target path for each file +7. Enter the require path for your game code +8. Review summary and confirm — files are written only after this step + +**Direct URL flow (step by step):** + +1. Choose mode → "From direct URL(s)" +2. Package name +3. Enter a file URL — downloaded and SHA-256 computed immediately +4. Confirm install path, optionally add more files to the same package +5. Enter the require path +6. Review summary and confirm + +> [!CAUTION] +> Packages installed this way have `trust: experimental` — they have not been reviewed by the Feather team. Only install files from sources you trust. The SHA-256 of each file is recorded in the lockfile so future `feather package audit` runs will detect any tampering. +> +> Repo installs store the selected repo/tag and resolved commit SHA when available. URL installs store the primary URL, all selected URLs, per-file URLs, and per-file SHA-256 values. + ### `feather package install --from-url --target ` -Install a single Lua file from an arbitrary URL. The SHA-256 is computed and shown before the file is written. The package is stored in the lockfile with `trust: experimental`. +Non-interactive equivalent of `feather package add`. Use this in scripts or CI where stdin is not a TTY. Installs a single Lua file from a URL. -**Requires `--allow-untrusted`** — this is intentional. The flag name makes the risk explicit. +**Requires `--allow-untrusted`** — the flag name makes the risk explicit. ```sh feather package install --from-url https://example.com/mylib.lua \ @@ -141,8 +198,7 @@ feather package install --from-url https://example.com/mylib.lua \ --allow-untrusted ``` -> [!CAUTION] -> Experimental packages have not been reviewed by the Feather team. Only install files from sources you trust. The SHA-256 shown at install time is what will be verified by future `feather package audit` runs. +`--yes` alone never confirms an untrusted URL. In scripts and CI, pass `--allow-untrusted` only after reviewing the source and target path. ### `feather package update [name]` @@ -154,7 +210,7 @@ feather package update # update all feather package update --dry-run ``` -`experimental` packages (installed via `--from-url`) cannot be updated — re-install with `--from-url` to replace them. +`experimental` packages (installed via `feather package add` or `--from-url`) cannot be updated through the registry — use `feather package add` again to replace them. ### `feather package remove ` @@ -189,7 +245,7 @@ Auditing 4 installed packages… --- -## Available Packages +## Some Available Packages | Package | Trust | Description | | ---------------- | -------- | ----------------------------------------------- | @@ -218,11 +274,12 @@ Run `feather package list` for the most up-to-date listing including any newly a ## Security model - **SHA-256 is verified before any file is written.** For registry installs, the checksum is checked against the Feather-pinned value before anything touches disk. A mismatch aborts the install. -- **Version overrides and `--from-url` compute SHA-256 live.** The hash is shown at install time and stored in the lockfile, so future `audit` runs detect tampering — but the initial download is not checked against a known-good value. +- **Registry packages are pinned to an exact commit SHA.** Each package in the catalog records the specific Git commit the files were fetched from at the time of curation — not just a tag or branch name. This means downloads are reproducible: the same bytes are fetched on every install, even if a tag is later moved or a branch advances. +- **Version overrides and `feather package add` / `--from-url` compute SHA-256 live.** The hash is shown at install time and stored in the lockfile, so future `audit` runs detect tampering — but the initial download is not checked against a known-good value. - **No post-install scripts.** No code runs during install, update, or remove. - **Files stay inside your project.** Target paths are resolved and validated against the project root. Paths that would escape (e.g. `../../`) are rejected. - **The registry is fetched over HTTPS only.** -- **`experimental` packages are flagged everywhere** — in install output, `list --installed`, and `audit` results. This includes version overrides. +- **`experimental` packages are flagged everywhere** — in install output, `list --installed`, and `audit` results. This includes version overrides and all URL installs. --- @@ -231,15 +288,58 @@ Run `feather package list` for the most up-to-date listing including any newly a To propose adding a package to the Feather catalog, open an issue at [github.com/Kyonru/feather](https://github.com/Kyonru/feather) with: - The library name and GitHub repo -- The specific release tag or commit to pin +- The specific release tag or branch to pin - Whether it meets the criteria for `verified` or `known` trust -Maintainers will compute checksums, review the library, and add it to `packages/`. +Maintainers use interactive dev scripts to add, update, and remove catalog entries. All scripts open an ink TUI wizard and regenerate the registry automatically. -To compute checksums locally while developing: +### Adding a GitHub-hosted package ```sh -node scripts/compute-checksums.mjs --all # verify all package files +npm run package:add # add from GitHub repo (tag or branch) +npm run package:add-url # add from direct file URL(s) +npm run package:update # edit an existing package entry +npm run package:remove # remove a package from the catalog +``` + +`package:add` fetches the repo's tags and branches from GitHub, lets you select one, resolves it to an exact commit SHA (for reproducible downloads), walks through trust level, description, tags, file selection, install targets, require path, and optional submodule definitions, then writes `packages/.json` and regenerates the registry. + +`package:add-url` follows the same wizard flow for libraries not hosted on GitHub or without versioned releases — you provide direct file URLs and the wizard computes checksums and handles everything else. + +### Verifying checksums + +```sh +node scripts/compute-checksums.mjs --all # re-verify all package files against their stored SHA-256 node scripts/compute-checksums.mjs https://raw.githubusercontent.com/example/lib/main/lib.lua node scripts/compute-checksums.mjs --package packages/anim8.json ``` + +### Package format + +Each file in `packages/` is a standalone JSON manifest: + +```json +{ + "type": "love2d-library", + "trust": "verified", + "description": "A 2D animation library for LÖVE", + "tags": ["animation", "sprites"], + "homepage": "https://github.com/kikito/anim8", + "license": "MIT", + "source": { + "repo": "kikito/anim8", + "tag": "v2.3.1", + "commitSha": "c1c12ec...", + "baseUrl": "https://raw.githubusercontent.com/kikito/anim8/c1c12ec.../" + }, + "install": { + "files": [ + { "name": "anim8.lua", "sha256": "abc123...", "target": "lib/anim8.lua" } + ] + }, + "require": "lib.anim8", + "example": "local anim8 = require('lib.anim8')" +} +``` + +The `commitSha` field pins downloads to the exact commit SHA of the selected tag or branch at curation time. `baseUrl` uses this SHA so fetches are immutable even if the tag is later moved. `scripts/generate-registry.mjs` assembles all package files into `cli/src/generated/registry.json`, which is bundled into the published CLI. diff --git a/packages/anim8.json b/packages/anim8.json index 7fe0b3ce..c6a54557 100644 --- a/packages/anim8.json +++ b/packages/anim8.json @@ -2,16 +2,25 @@ "type": "love2d-library", "trust": "verified", "description": "A 2D animation library for LÖVE. Handles sprite sheets, frames, tags, and playback.", - "tags": ["animation", "sprites"], + "tags": [ + "animation", + "sprites" + ], "homepage": "https://github.com/kikito/anim8", + "license": "MIT", "source": { "repo": "kikito/anim8", "tag": "v2.3.1", - "baseUrl": "https://raw.githubusercontent.com/kikito/anim8/v2.3.1/" + "baseUrl": "https://raw.githubusercontent.com/kikito/anim8/c1c12ec45fde28ffd2d6967675adcb3c3e501fa7/", + "commitSha": "c1c12ec45fde28ffd2d6967675adcb3c3e501fa7" }, "install": { "files": [ - { "name": "anim8.lua", "sha256": "c6581c30225a4297924a4f27e56edbc12894fb2e168232fa3a19d21ac149e83f", "target": "lib/anim8.lua" } + { + "name": "anim8.lua", + "sha256": "c6581c30225a4297924a4f27e56edbc12894fb2e168232fa3a19d21ac149e83f", + "target": "lib/anim8.lua" + } ] }, "require": "lib.anim8", diff --git a/packages/baton.json b/packages/baton.json new file mode 100644 index 00000000..f637a3eb --- /dev/null +++ b/packages/baton.json @@ -0,0 +1,31 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "An input library for LÖVE.", + "tags": [ + "input", + "controller", + "keyboard", + "mouse", + "touch" + ], + "homepage": "https://github.com/tesselode/baton", + "license": "MIT", + "source": { + "repo": "tesselode/baton", + "tag": "v1.0.2", + "baseUrl": "https://raw.githubusercontent.com/tesselode/baton/3defe19f8f0da1a457ba86095e6f6a3c7ee6a8e9/", + "commitSha": "3defe19f8f0da1a457ba86095e6f6a3c7ee6a8e9" + }, + "install": { + "files": [ + { + "name": "baton.lua", + "sha256": "5dee8e0984badef516d1a42f5996b8fae332363399940241fb82abeb5ae648f7", + "target": "lib/baton.lua" + } + ] + }, + "require": "lib.baton", + "example": "local baton = require('lib.baton')" +} diff --git a/packages/beehive.json b/packages/beehive.json new file mode 100644 index 00000000..62ea9707 --- /dev/null +++ b/packages/beehive.json @@ -0,0 +1,54 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "A behavior tree implementation in lua.", + "tags": [ + "ai", + "behavior", + "tree" + ], + "homepage": "https://github.com/drhayes/beehive.lua", + "license": "MIT", + "source": { + "repo": "drhayes/beehive.lua", + "tag": "main", + "baseUrl": "https://raw.githubusercontent.com/drhayes/beehive.lua/00877150688ce42fffbaeeb76d7a4cb87e88da60/", + "commitSha": "00877150688ce42fffbaeeb76d7a4cb87e88da60" + }, + "install": { + "files": [ + { + "name": "beehive/fail.lua", + "sha256": "8ffc086030857f311398ce2abc578f574c0303364e3f68a3e78e84fb00a9e41e", + "target": "lib/beehive/fail.lua" + }, + { + "name": "beehive/invert.lua", + "sha256": "cb11cc9564409a5419ad113155e357c968e8c6f38ec10cae55403e5ac488040e", + "target": "lib/beehive/invert.lua" + }, + { + "name": "beehive/parallel.lua", + "sha256": "fdeeadcf6e437a0d11bfcd4019a50aaac34cb969d72bfa10cf6cb69ee570fe4f", + "target": "lib/beehive/parallel.lua" + }, + { + "name": "beehive/repeat.lua", + "sha256": "fd4a75cfbba982ba4436716b12ca5a102e13b1bcf6f630a3c162ee7ad1afff38", + "target": "lib/beehive/repeat.lua" + }, + { + "name": "beehive/selector.lua", + "sha256": "0b46e7085065e89bd8488a0c1edaf335640c01c8d04b4fe302e8b02556f16b83", + "target": "lib/beehive/selector.lua" + }, + { + "name": "beehive/sequence.lua", + "sha256": "80ee98a5a1f8fd302bb2b0452b145051c4094cd4f776453eb0df7a10aef2ae15", + "target": "lib/beehive/sequence.lua" + } + ] + }, + "require": "lib.beehive.fail", + "example": "local beehive = require('lib.beehive.fail')" +} diff --git a/packages/bump.json b/packages/bump.json index 92b51e9e..281d1b99 100644 --- a/packages/bump.json +++ b/packages/bump.json @@ -2,16 +2,25 @@ "type": "love2d-library", "trust": "verified", "description": "A Lua library for simple AABB collision detection and resolution.", - "tags": ["collision", "physics"], + "tags": [ + "collision", + "physics" + ], "homepage": "https://github.com/kikito/bump.lua", + "license": "MIT", "source": { "repo": "kikito/bump.lua", "tag": "v3.1.7", - "baseUrl": "https://raw.githubusercontent.com/kikito/bump.lua/v3.1.7/" + "baseUrl": "https://raw.githubusercontent.com/kikito/bump.lua/72d6b13dfa525564f6e1a533d71eaa8235a5e96c/", + "commitSha": "72d6b13dfa525564f6e1a533d71eaa8235a5e96c" }, "install": { "files": [ - { "name": "bump.lua", "sha256": "b947cc4449554816831d5c45b61a169e1559548139478d980c8eef14c7b362a6", "target": "lib/bump.lua" } + { + "name": "bump.lua", + "sha256": "b947cc4449554816831d5c45b61a169e1559548139478d980c8eef14c7b362a6", + "target": "lib/bump.lua" + } ] }, "require": "lib.bump", diff --git a/packages/cargo.json b/packages/cargo.json new file mode 100644 index 00000000..551e0eee --- /dev/null +++ b/packages/cargo.json @@ -0,0 +1,29 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "LÖVE asset manager", + "tags": [ + "asset", + "manager", + "loader" + ], + "homepage": "https://github.com/bjornbytes/cargo", + "license": "MIT", + "source": { + "repo": "bjornbytes/cargo", + "tag": "v0.1.1", + "baseUrl": "https://raw.githubusercontent.com/bjornbytes/cargo/8014bc0ad7d4639c112e5d00fac6c9ac68b0d696/", + "commitSha": "8014bc0ad7d4639c112e5d00fac6c9ac68b0d696" + }, + "install": { + "files": [ + { + "name": "cargo.lua", + "sha256": "50749991281b9052e8f449c55d5bda0c5855f73c9264adb3f42077cf3ff0ab96", + "target": "lib/cargo.lua" + } + ] + }, + "require": "lib.cargo", + "example": "local cargo = require('lib.cargo')" +} diff --git a/packages/classic.json b/packages/classic.json index 7fe3b2c0..d8847495 100644 --- a/packages/classic.json +++ b/packages/classic.json @@ -2,16 +2,25 @@ "type": "love2d-library", "trust": "verified", "description": "A tiny class module for Lua. Minimal OOP with inheritance.", - "tags": ["oop", "utilities"], + "tags": [ + "oop", + "utilities" + ], "homepage": "https://github.com/rxi/classic", + "license": "MIT", "source": { "repo": "rxi/classic", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/rxi/classic/master/" + "baseUrl": "https://raw.githubusercontent.com/rxi/classic/e5610756c98ac2f8facd7ab90c94e1a097ecd2c6/", + "commitSha": "e5610756c98ac2f8facd7ab90c94e1a097ecd2c6" }, "install": { "files": [ - { "name": "classic.lua", "sha256": "52827561440f22ff3f34f926b2f4bdc1b74cbbaafa3b3b8252b530b12387d48b", "target": "lib/classic.lua" } + { + "name": "classic.lua", + "sha256": "52827561440f22ff3f34f926b2f4bdc1b74cbbaafa3b3b8252b530b12387d48b", + "target": "lib/classic.lua" + } ] }, "require": "lib.classic", diff --git a/packages/flux.json b/packages/flux.json index 1fbd41ce..3ab0a477 100644 --- a/packages/flux.json +++ b/packages/flux.json @@ -2,16 +2,25 @@ "type": "love2d-library", "trust": "verified", "description": "A fast, lightweight tweening library for Lua with chainable API and easing functions.", - "tags": ["animation", "tweening"], + "tags": [ + "animation", + "tweening" + ], "homepage": "https://github.com/rxi/flux", + "license": "MIT", "source": { "repo": "rxi/flux", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/rxi/flux/master/" + "baseUrl": "https://raw.githubusercontent.com/rxi/flux/bb330231b87eabf84fbd68322f13a6320db30a41/", + "commitSha": "bb330231b87eabf84fbd68322f13a6320db30a41" }, "install": { "files": [ - { "name": "flux.lua", "sha256": "11542ab78001f99d94d0b9ca3f99072494e1e4f4ce395db203bf4f7e6f7889d5", "target": "lib/flux.lua" } + { + "name": "flux.lua", + "sha256": "11542ab78001f99d94d0b9ca3f99072494e1e4f4ce395db203bf4f7e6f7889d5", + "target": "lib/flux.lua" + } ] }, "require": "lib.flux", diff --git a/packages/g3d.json b/packages/g3d.json new file mode 100644 index 00000000..b68b0a1d --- /dev/null +++ b/packages/g3d.json @@ -0,0 +1,58 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "Simple and easy 3D engine for LÖVE.", + "tags": [ + "3d", + "engine" + ], + "homepage": "https://github.com/groverburger/g3d", + "license": "MIT", + "source": { + "repo": "groverburger/g3d", + "tag": "master", + "commitSha": "639120acd754dc5c34402e41eb1687b1a5a3ffa8", + "baseUrl": "https://raw.githubusercontent.com/groverburger/g3d/639120acd754dc5c34402e41eb1687b1a5a3ffa8/" + }, + "install": { + "files": [ + { + "name": "g3d/camera.lua", + "sha256": "4aa73b340ef09168ff22574bc2e9f5ea8c0f6f4561f38215ed9d0ae268897dc6", + "target": "lib/g3d/camera.lua" + }, + { + "name": "g3d/collisions.lua", + "sha256": "f93b0806e75f570a41e35d1a45978bf3217e7ca970d442ef9b18dea8aeef0850", + "target": "lib/g3d/collisions.lua" + }, + { + "name": "g3d/init.lua", + "sha256": "02f5c867264b8ca1d4501a4b7537aba35600d013950bc3d05e9b3b372e6a4dc6", + "target": "lib/g3d/init.lua" + }, + { + "name": "g3d/matrices.lua", + "sha256": "c6579f4c87032bfd0562a679ce261e51c6c42e4a6a69e05102de087e15625f9a", + "target": "lib/g3d/matrices.lua" + }, + { + "name": "g3d/model.lua", + "sha256": "c52054e0d5db5688417712449606bfd1eaedcdc85a5a4fc3eebf482f2d38360b", + "target": "lib/g3d/model.lua" + }, + { + "name": "g3d/objloader.lua", + "sha256": "208002fe5edd9ef05ebd710f0a07701f2fbadfc3cf74059cd701832f195c633e", + "target": "lib/g3d/objloader.lua" + }, + { + "name": "g3d/vectors.lua", + "sha256": "4d23c3334fa1e6b43fbf7bf4fe00969480ee6a4c82a7007f87b3e10df42c411e", + "target": "lib/g3d/vectors.lua" + } + ] + }, + "require": "lib.g3d", + "example": "local g3d = require('lib.g3d')" +} diff --git a/packages/hump.json b/packages/hump.json index 9c334255..81e6f7dc 100644 --- a/packages/hump.json +++ b/packages/hump.json @@ -2,30 +2,92 @@ "type": "love2d-library", "trust": "verified", "description": "Helper utilities for LÖVE: camera, timer, signal, class, vector, gamestate.", - "tags": ["utilities", "camera", "timer", "oop", "math"], + "tags": [ + "utilities", + "camera", + "timer", + "oop", + "math" + ], "homepage": "https://github.com/vrld/hump", + "license": "MIT", "source": { "repo": "vrld/hump", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/08937cc0ecf72d1a964a8de6cd552c5e136bf0d4/", + "commitSha": "08937cc0ecf72d1a964a8de6cd552c5e136bf0d4" }, "install": { "files": [ - { "name": "camera.lua", "sha256": "b5f0c086fc652f7f97eef84b6be358f889ab9b8d28cf7f295b46d48638014099", "target": "lib/hump/camera.lua" }, - { "name": "timer.lua", "sha256": "8931203b027ecce889692e67323e7f333881fb230ed9dd735c785f5e3a7def03", "target": "lib/hump/timer.lua" }, - { "name": "signal.lua", "sha256": "4f311197119177453d8759add39531905da2d3590a7e3a7befc618e46f31b051", "target": "lib/hump/signal.lua" }, - { "name": "class.lua", "sha256": "b0b97bb7cc51b9234dfe3dce0f6da12beaebbb6335eaeaa38de8e537a157cb4e", "target": "lib/hump/class.lua" }, - { "name": "vector.lua", "sha256": "70a4d9437e0c7bdf718160eb4a3464f54705129b854f1c9bd60a5adea8d197cc", "target": "lib/hump/vector.lua" }, - { "name": "gamestate.lua", "sha256": "7e11c9f4909ececc3b61fa4313b001b25f33b5ac36bc96b641aedff0328a4406", "target": "lib/hump/gamestate.lua" } + { + "name": "camera.lua", + "sha256": "b5f0c086fc652f7f97eef84b6be358f889ab9b8d28cf7f295b46d48638014099", + "target": "lib/hump/camera.lua" + }, + { + "name": "timer.lua", + "sha256": "8931203b027ecce889692e67323e7f333881fb230ed9dd735c785f5e3a7def03", + "target": "lib/hump/timer.lua" + }, + { + "name": "signal.lua", + "sha256": "4f311197119177453d8759add39531905da2d3590a7e3a7befc618e46f31b051", + "target": "lib/hump/signal.lua" + }, + { + "name": "class.lua", + "sha256": "b0b97bb7cc51b9234dfe3dce0f6da12beaebbb6335eaeaa38de8e537a157cb4e", + "target": "lib/hump/class.lua" + }, + { + "name": "vector.lua", + "sha256": "70a4d9437e0c7bdf718160eb4a3464f54705129b854f1c9bd60a5adea8d197cc", + "target": "lib/hump/vector.lua" + }, + { + "name": "gamestate.lua", + "sha256": "7e11c9f4909ececc3b61fa4313b001b25f33b5ac36bc96b641aedff0328a4406", + "target": "lib/hump/gamestate.lua" + } ] }, "subpackages": { - "hump.camera": { "files": ["camera.lua"], "require": "lib.hump.camera" }, - "hump.timer": { "files": ["timer.lua"], "require": "lib.hump.timer" }, - "hump.signal": { "files": ["signal.lua"], "require": "lib.hump.signal" }, - "hump.class": { "files": ["class.lua"], "require": "lib.hump.class" }, - "hump.vector": { "files": ["vector.lua"], "require": "lib.hump.vector" }, - "hump.gamestate": { "files": ["gamestate.lua"], "require": "lib.hump.gamestate" } + "hump.camera": { + "files": [ + "camera.lua" + ], + "require": "lib.hump.camera" + }, + "hump.timer": { + "files": [ + "timer.lua" + ], + "require": "lib.hump.timer" + }, + "hump.signal": { + "files": [ + "signal.lua" + ], + "require": "lib.hump.signal" + }, + "hump.class": { + "files": [ + "class.lua" + ], + "require": "lib.hump.class" + }, + "hump.vector": { + "files": [ + "vector.lua" + ], + "require": "lib.hump.vector" + }, + "hump.gamestate": { + "files": [ + "gamestate.lua" + ], + "require": "lib.hump.gamestate" + } }, "require": "lib.hump", "example": "local Camera = require('lib.hump.camera')" diff --git a/packages/inspect.json b/packages/inspect.json index b5f48a85..f2c2f011 100644 --- a/packages/inspect.json +++ b/packages/inspect.json @@ -2,16 +2,25 @@ "type": "love2d-library", "trust": "verified", "description": "Human-readable table serializer and pretty-printer for Lua. Useful for debugging.", - "tags": ["debug", "utilities"], + "tags": [ + "debug", + "utilities" + ], "homepage": "https://github.com/kikito/inspect.lua", + "license": "MIT", "source": { "repo": "kikito/inspect.lua", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/kikito/inspect.lua/master/" + "baseUrl": "https://raw.githubusercontent.com/kikito/inspect.lua/a8ca3120dfec48801036eaeff9335ab7a096dd24/", + "commitSha": "a8ca3120dfec48801036eaeff9335ab7a096dd24" }, "install": { "files": [ - { "name": "inspect.lua", "sha256": "656e4be83802750cfe13156810f7990d49893c86518ed5d882f0709d2901dfc5", "target": "lib/inspect.lua" } + { + "name": "inspect.lua", + "sha256": "656e4be83802750cfe13156810f7990d49893c86518ed5d882f0709d2901dfc5", + "target": "lib/inspect.lua" + } ] }, "require": "lib.inspect", diff --git a/packages/knife.json b/packages/knife.json new file mode 100644 index 00000000..3a3b41f5 --- /dev/null +++ b/packages/knife.json @@ -0,0 +1,169 @@ +{ + "type": "love2d-library", + "trust": "known", + "description": "A collection of useful micro-modules for Lua.", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" + }, + "install": { + "files": [ + { + "name": "knife/base.lua", + "sha256": "9cdc89da1a37296aa0f57c1f28c4123be6052604ca2b60306a98e926666e2fd1", + "target": "lib/knife/base.lua" + }, + { + "name": "knife/behavior.lua", + "sha256": "9ee69bf0b7966061e75716811727138772dc766ba8d03ed6a5d98df3491967e4", + "target": "lib/knife/behavior.lua" + }, + { + "name": "knife/bind.lua", + "sha256": "01923de7538a127d4bdc23ec6dccea9c791928e88dcd61dd4cd7cc97287c639a", + "target": "lib/knife/bind.lua" + }, + { + "name": "knife/chain.lua", + "sha256": "70143d8618f7be18025da8cc073714fe1c3c3a2d173c2d04f415e9d0d85bd964", + "target": "lib/knife/chain.lua" + }, + { + "name": "knife/convoke.lua", + "sha256": "76c1862861999b8523cb060c964fded1986dbcd04c08610e9547bddcef751776", + "target": "lib/knife/convoke.lua" + }, + { + "name": "knife/event.lua", + "sha256": "962e2f6b47fc6a81cdc07d28d5fef6cd40132dcb28fd3d34c834616cd15be74f", + "target": "lib/knife/event.lua" + }, + { + "name": "knife/gun.lua", + "sha256": "9f0754e9a11fddb7c376b4e92d9d583a1720dc038e080957a598c43093a4064b", + "target": "lib/knife/gun.lua" + }, + { + "name": "knife/memoize.lua", + "sha256": "45c857737cf3b3c4e7bd3994c882a142761153932ba4695b57aa4eb9198ccfd0", + "target": "lib/knife/memoize.lua" + }, + { + "name": "knife/serialize.lua", + "sha256": "472901fa35c502af4bfddc2ee0cc723dc91fb5ffbd06ddde22fa512bd94c4870", + "target": "lib/knife/serialize.lua" + }, + { + "name": "knife/system.lua", + "sha256": "bc9d8981679bdb752f4e1b2031f42b6e08a40f411981f23f6f148588edb23ea2", + "target": "lib/knife/system.lua" + }, + { + "name": "knife/test.lua", + "sha256": "581c6c679cf5b711068880b36c242da9a4648c1525a9d615bbaeed0f79b4dbcd", + "target": "lib/knife/test.lua" + }, + { + "name": "knife/timer.lua", + "sha256": "6c0a1f59404f7c1e3612afc80fb443b543546a5aa14a1d200a679e8684b5aaac", + "target": "lib/knife/timer.lua" + } + ] + }, + "require": "lib.knife.base", + "example": "local knife = require('lib.knife.base')", + "subpackages": { + "knife.base": { + "files": [ + "knife/base.lua" + ], + "require": "lib.knife.base" + }, + "knife.behavior": { + "files": [ + "knife/behavior.lua" + ], + "require": "lib.knife.behavior" + }, + "knife.bind": { + "files": [ + "knife/bind.lua" + ], + "require": "lib.knife.bind" + }, + "knife.chain": { + "files": [ + "knife/chain.lua" + ], + "require": "lib.knife.chain" + }, + "knife.convoke": { + "files": [ + "knife/convoke.lua" + ], + "require": "lib.knife.convoke" + }, + "knife.event": { + "files": [ + "knife/event.lua" + ], + "require": "lib.knife.event" + }, + "knife.gun": { + "files": [ + "knife/gun.lua" + ], + "require": "lib.knife.gun" + }, + "knife.memoize": { + "files": [ + "knife/memoize.lua" + ], + "require": "lib.knife.memoize" + }, + "knife.serialize": { + "files": [ + "knife/serialize.lua" + ], + "require": "lib.knife.serialize" + }, + "knife.system": { + "files": [ + "knife/system.lua" + ], + "require": "lib.knife.system" + }, + "knife.test": { + "files": [ + "knife/test.lua" + ], + "require": "lib.knife.test" + }, + "knife.timer": { + "files": [ + "knife/timer.lua" + ], + "require": "lib.knife.timer" + } + } +} diff --git a/packages/love-dialogue.json b/packages/love-dialogue.json new file mode 100644 index 00000000..f8dc9d65 --- /dev/null +++ b/packages/love-dialogue.json @@ -0,0 +1,96 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "Dialogue based Game Engine for Love2d with custom scripting language for dialogues", + "tags": [ + "text", + "dialogue", + "system", + "tag", + "talk" + ], + "homepage": "https://github.com/Miisan-png/Love-Dialogue", + "license": "unknown", + "source": { + "repo": "Miisan-png/Love-Dialogue", + "tag": "main", + "baseUrl": "https://raw.githubusercontent.com/Miisan-png/Love-Dialogue/1d1cbd1f6730fbaf73fab18a2d33ee3525abfb75/", + "commitSha": "1d1cbd1f6730fbaf73fab18a2d33ee3525abfb75" + }, + "install": { + "files": [ + { + "name": "LoveDialogue/9patch.lua", + "sha256": "dd882490cab2a6850b4b5f95a3fc1845b090a37773fcd4347d5bb496efe2839b", + "target": "lib/love-dialogue/9patch.lua" + }, + { + "name": "LoveDialogue/DialogueConstants.lua", + "sha256": "9335210bfd61d9e39fcfdbd7ca6c7e55f5897565848260dffd3080e62ee9d0c5", + "target": "lib/love-dialogue/DialogueConstants.lua" + }, + { + "name": "LoveDialogue/Logic.lua", + "sha256": "85701aa89d7892f5a55095b70a992036a241504e3470caeb58caae7f886b434f", + "target": "lib/love-dialogue/Logic.lua" + }, + { + "name": "LoveDialogue/LoveCharacter.lua", + "sha256": "b606202a72f9b9c349bcee793bd6bfc7a36cc64059480a5a4071f81ba442d7d6", + "target": "lib/love-dialogue/LoveCharacter.lua" + }, + { + "name": "LoveDialogue/LoveDialogue.lua", + "sha256": "9bea894af34bca1db4f04e94c38fd8800a36093bac8730f5f32c5b963a9b7043", + "target": "lib/love-dialogue/LoveDialogue.lua" + }, + { + "name": "LoveDialogue/LoveDialogueParser.lua", + "sha256": "35548075414928e077b9d8136186444fbcbfb11c89c8b92eb92487e1cc78bddc", + "target": "lib/love-dialogue/LoveDialogueParser.lua" + }, + { + "name": "LoveDialogue/PluginManager.lua", + "sha256": "c66684888259a4eea25546369ffe9c8540f778b4e16fa5adad2a5fe3cda1dc8c", + "target": "lib/love-dialogue/PluginManager.lua" + }, + { + "name": "LoveDialogue/ResourceManager.lua", + "sha256": "5366b1bdb8a033fc1c2bfd787c326f1031aa9e285ff73c8c4695ac8e97dd32bb", + "target": "lib/love-dialogue/ResourceManager.lua" + }, + { + "name": "LoveDialogue/TextEffects.lua", + "sha256": "399aacf0ff2e8f8615692ef86a184f5e2b730f0ab1fc5961e24ee8a8564f11d6", + "target": "lib/love-dialogue/TextEffects.lua" + }, + { + "name": "LoveDialogue/ThemeParser.lua", + "sha256": "1303f4057eb47c9606c66560207308b7cc6b2ca12d4bcb98ee9c023d29e03af9", + "target": "lib/love-dialogue/ThemeParser.lua" + }, + { + "name": "LoveDialogue/Transition.lua", + "sha256": "296a3c8f33d184387487d46e6f5b22205a565293cdbb2226eaab6052e2ef5d84", + "target": "lib/love-dialogue/Transition.lua" + }, + { + "name": "LoveDialogue/Tween.lua", + "sha256": "2d5be30a9a4d3132373505c3ebde532ffe3009db2e7f8b9ce45680f01f5fef0f", + "target": "lib/love-dialogue/Tween.lua" + }, + { + "name": "LoveDialogue/init.lua", + "sha256": "23f6c1bf3ac8e9d9d99443caa750acee158a415242208e93f8e4f21893b5df79", + "target": "lib/love-dialogue/init.lua" + }, + { + "name": "LoveDialogue/plugins/DebugPlugin.lua", + "sha256": "5dc1d9f7a01d9f2e92aff9647ba43742cebb4095c17a4a48a2d698b9c638cebd", + "target": "lib/love-dialogue/plugins/DebugPlugin.lua" + } + ] + }, + "require": "lib.love-dialogue", + "example": "local love_dialogue = require('lib.love-dialogue')" +} diff --git a/packages/lovebpm.json b/packages/lovebpm.json new file mode 100644 index 00000000..836408f0 --- /dev/null +++ b/packages/lovebpm.json @@ -0,0 +1,32 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "A LÖVE library for syncing events to the BPM of an audio track", + "tags": [ + "music", + "sound", + "audio", + "track", + "bpm", + "events" + ], + "homepage": "https://github.com/rxi/lovebpm", + "license": "MIT", + "source": { + "repo": "rxi/lovebpm", + "tag": "master", + "commitSha": "22f8537083818ebfc2a0788fbec3e991894371f9", + "baseUrl": "https://raw.githubusercontent.com/rxi/lovebpm/22f8537083818ebfc2a0788fbec3e991894371f9/" + }, + "install": { + "files": [ + { + "name": "lovebpm.lua", + "sha256": "fe3bb0fa97f79c0ff8e32810a16473bbe7677b1d21b753aeec1e35ac8f86b00c", + "target": "lib/lovebpm.lua" + } + ] + }, + "require": "lib.lovebpm", + "example": "local lovebpm = require('lib.lovebpm')" +} diff --git a/packages/lua-state-machine.json b/packages/lua-state-machine.json new file mode 100644 index 00000000..bca71349 --- /dev/null +++ b/packages/lua-state-machine.json @@ -0,0 +1,29 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "A finite state machine lua micro framework", + "tags": [ + "state", + "machine", + "finite" + ], + "homepage": "https://github.com/kyleconroy/lua-state-machine", + "license": "MIT", + "source": { + "repo": "kyleconroy/lua-state-machine", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/kyleconroy/lua-state-machine/96fc10ee123479cf2e5fb8ffe9cdde2fd6df0877/", + "commitSha": "96fc10ee123479cf2e5fb8ffe9cdde2fd6df0877" + }, + "install": { + "files": [ + { + "name": "statemachine.lua", + "sha256": "95affa1ad7a9e2a4629cce4b321fbf49cf521cce6e9be5e92f035f7e64f9275c", + "target": "lib/statemachine.lua" + } + ] + }, + "require": "lib.statemachine", + "example": "local lua_state_machine = require('lib.statemachine')" +} diff --git a/packages/lume.json b/packages/lume.json index 589b2928..a03c4427 100644 --- a/packages/lume.json +++ b/packages/lume.json @@ -2,16 +2,25 @@ "type": "love2d-library", "trust": "verified", "description": "A collection of Lua functions for gamedev: math, table manipulation, string utilities, and more.", - "tags": ["utilities", "math"], + "tags": [ + "utilities", + "math" + ], "homepage": "https://github.com/rxi/lume", + "license": "MIT", "source": { "repo": "rxi/lume", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/rxi/lume/master/" + "baseUrl": "https://raw.githubusercontent.com/rxi/lume/98847e7812cf28d3d64b289b03fad71dc704547d/", + "commitSha": "98847e7812cf28d3d64b289b03fad71dc704547d" }, "install": { "files": [ - { "name": "lume.lua", "sha256": "fb2856d60eb48516c8aa04de1e8b58b355a56fb7a05fd1170cfe330f8ad69b1a", "target": "lib/lume.lua" } + { + "name": "lume.lua", + "sha256": "fb2856d60eb48516c8aa04de1e8b58b355a56fb7a05fd1170cfe330f8ad69b1a", + "target": "lib/lume.lua" + } ] }, "require": "lib.lume", diff --git a/packages/middleclass.json b/packages/middleclass.json index c2f06ac4..feaa673a 100644 --- a/packages/middleclass.json +++ b/packages/middleclass.json @@ -2,16 +2,25 @@ "type": "love2d-library", "trust": "verified", "description": "A simple OOP library for Lua. Supports inheritance, mixins, and interfaces.", - "tags": ["oop", "utilities"], + "tags": [ + "oop", + "utilities" + ], "homepage": "https://github.com/kikito/middleclass", + "license": "MIT", "source": { "repo": "kikito/middleclass", "tag": "v4.1.1", - "baseUrl": "https://raw.githubusercontent.com/kikito/middleclass/v4.1.1/" + "baseUrl": "https://raw.githubusercontent.com/kikito/middleclass/dc0fb3612da5d41866a2f06076a4ecea5614ef78/", + "commitSha": "dc0fb3612da5d41866a2f06076a4ecea5614ef78" }, "install": { "files": [ - { "name": "middleclass.lua", "sha256": "b4f0060e16daa070a0da33b6808f6e4cb9ee819205321d019a8e770af5e77c07", "target": "lib/middleclass.lua" } + { + "name": "middleclass.lua", + "sha256": "b4f0060e16daa070a0da33b6808f6e4cb9ee819205321d019a8e770af5e77c07", + "target": "lib/middleclass.lua" + } ] }, "require": "lib.middleclass", diff --git a/packages/push.json b/packages/push.json index d0b8b78b..b049f8f7 100644 --- a/packages/push.json +++ b/packages/push.json @@ -2,16 +2,25 @@ "type": "love2d-library", "trust": "verified", "description": "Resolution-independence library for LÖVE. Lets you draw at a fixed resolution and scale to any window size.", - "tags": ["rendering", "resolution"], + "tags": [ + "rendering", + "resolution" + ], "homepage": "https://github.com/Ulydev/push", + "license": "MIT", "source": { "repo": "Ulydev/push", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/Ulydev/push/master/" + "baseUrl": "https://raw.githubusercontent.com/Ulydev/push/9c165816a14c868339c3cd0d22eed65c313c8bf8/", + "commitSha": "9c165816a14c868339c3cd0d22eed65c313c8bf8" }, "install": { "files": [ - { "name": "push.lua", "sha256": "c5702771a3275ce3e3909a8414d1fe76024b30ca3943382d06a9b54f5ee3c517", "target": "lib/push.lua" } + { + "name": "push.lua", + "sha256": "c5702771a3275ce3e3909a8414d1fe76024b30ca3943382d06a9b54f5ee3c517", + "target": "lib/push.lua" + } ] }, "require": "lib.push", diff --git a/packages/shove.json b/packages/shove.json new file mode 100644 index 00000000..cae7ff18 --- /dev/null +++ b/packages/shove.json @@ -0,0 +1,43 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "A powerful resolution-handler and rendering library for LÖVE 📐", + "tags": [ + "resolution", + "gamedev", + "performance", + "lua", + "rendering", + "aspect-ratio", + "game-development", + "love2d", + "layers", + "pixel-perfect", + "scaling", + "love2d-framework" + ], + "homepage": "https://github.com/oval-tutu/shove", + "license": "MIT", + "source": { + "repo": "oval-tutu/shove", + "tag": "1.0.6", + "baseUrl": "https://raw.githubusercontent.com/oval-tutu/shove/ee1dedbca78ca988589edd4c5314d7e662223973/", + "commitSha": "ee1dedbca78ca988589edd4c5314d7e662223973" + }, + "install": { + "files": [ + { + "name": "shove-profiler.lua", + "sha256": "8c2c78a0d4dac5afaa3e487648c1e12fad632bd8971dd21424da765ac0135e88", + "target": "lib/shove/profiler.lua" + }, + { + "name": "shove.lua", + "sha256": "151b3bb65aa33114189bd7a2abe23b61acb58bd0530e4a3a92531ee0502e4903", + "target": "lib/shove/init.lua" + } + ] + }, + "require": "lib.shove", + "example": "local shove = require('lib.shove')" +} diff --git a/packages/sti.json b/packages/sti.json index 20166453..d330479b 100644 --- a/packages/sti.json +++ b/packages/sti.json @@ -1,18 +1,48 @@ { "type": "love2d-library", - "trust": "known", + "trust": "verified", "description": "Simple Tiled Implementation — loads and renders Tiled maps (.tmx) in LÖVE.", - "tags": ["maps", "tiled", "tilemaps"], + "tags": [ + "sprites", + "maps", + "tiles", + "tiled" + ], "homepage": "https://github.com/karai17/Simple-Tiled-Implementation", + "license": "NOASSERTION", "source": { "repo": "karai17/Simple-Tiled-Implementation", - "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/karai17/Simple-Tiled-Implementation/master/" + "tag": "v1.2.3.0", + "baseUrl": "https://raw.githubusercontent.com/karai17/Simple-Tiled-Implementation/69cdf8c7565f518c03967d0243cb54d1ee31da4e/", + "commitSha": "69cdf8c7565f518c03967d0243cb54d1ee31da4e" }, "install": { "files": [ - { "name": "sti/init.lua", "sha256": "b7542ac156b3c2ee24d256ce260f73dc01ab2d1245c4eb4b14c92fcdf487c814", "target": "lib/sti/init.lua" }, - { "name": "sti/utils.lua", "sha256": "ba8abf5a3f5680c6a020234afc248c4fb8945cda993d4cd82b292510e4dab17c", "target": "lib/sti/utils.lua" } + { + "name": "sti/graphics.lua", + "sha256": "c77ed8b4220b2e7c37fdf56612365ce3520ea8183deee92e2f337691f7d20b14", + "target": "lib/sti/graphics.lua" + }, + { + "name": "sti/init.lua", + "sha256": "b65f9cc816bd666bf1ba42b4f50ee14d5fa83f22b3f121eb0b0f07c14ecb8df7", + "target": "lib/sti/init.lua" + }, + { + "name": "sti/plugins/box2d.lua", + "sha256": "5206a9528eeaf19a12b9512f2b8ca2fa9e2af7e3a634eb084b4dd9c0ac070205", + "target": "lib/sti/plugins/box2d.lua" + }, + { + "name": "sti/plugins/bump.lua", + "sha256": "0c2bc5c882aeab0e2dc5ad1019a83aab72ccfe25ef6d9b105b8cb924d2feaab5", + "target": "lib/sti/plugins/bump.lua" + }, + { + "name": "sti/utils.lua", + "sha256": "6993cfde776ec0d1dae4ee53cec8df36fe37801dcf6858f51e443fe65f4ad4c9", + "target": "lib/sti/utils.lua" + } ] }, "require": "lib.sti", diff --git a/packages/sysl-text.json b/packages/sysl-text.json new file mode 100644 index 00000000..27214e84 --- /dev/null +++ b/packages/sysl-text.json @@ -0,0 +1,46 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "Text rendering with tag support", + "tags": [ + "text", + "draw", + "love2d", + "textboxes", + "sprite-text" + ], + "homepage": "https://github.com/sysl-dev/SYSL-Text", + "license": "MIT", + "source": { + "repo": "sysl-dev/SYSL-Text", + "tag": "v2.2", + "baseUrl": "https://raw.githubusercontent.com/sysl-dev/SYSL-Text/6c9b95047cb8650344e90224cd56bd00c5132d97/", + "commitSha": "6c9b95047cb8650344e90224cd56bd00c5132d97" + }, + "install": { + "files": [ + { + "name": "example/library/slog-frame.lua", + "sha256": "d37119014d865d4e829a2757d8ec2974c3991afdcbcafe558decafb651066c5e", + "target": "lib/sysl-text/slog-frame.lua" + }, + { + "name": "example/library/slog-icon.lua", + "sha256": "bf48f58f25930d20a367361ef5decceb4e8e377aed27093e3ea9f5a2c34dfc1a", + "target": "lib/sysl-text/slog-icon.lua" + }, + { + "name": "example/library/slog-pixel.lua", + "sha256": "c95586e30a45ea9b5b49dfca1089c61c9ec1a59981e9526febdc56ae5354e925", + "target": "lib/sysl-text/slog-pixel.lua" + }, + { + "name": "example/library/slog-text.lua", + "sha256": "10134b45499d925de5b177ae13874fd026dfc476f1eae5ba1b64874a49ec4ad9", + "target": "lib/sysl-text/slog-text.lua" + } + ] + }, + "require": "lib.sysl-text.slog-text", + "example": "local sysl_text = require('lib.sysl-text.slog-text')" +} diff --git a/packages/tiny-ecs.json b/packages/tiny-ecs.json new file mode 100644 index 00000000..8c1a976d --- /dev/null +++ b/packages/tiny-ecs.json @@ -0,0 +1,35 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "ECS for Lua", + "tags": [ + "ecs", + "entity", + "component", + "system" + ], + "homepage": "https://github.com/bakpakin/tiny-ecs", + "license": "MIT", + "source": { + "repo": "bakpakin/tiny-ecs", + "tag": "master", + "commitSha": "821914795db365d73dcb973b4a54741423650dc9", + "baseUrl": "https://raw.githubusercontent.com/bakpakin/tiny-ecs/821914795db365d73dcb973b4a54741423650dc9/" + }, + "install": { + "files": [ + { + "name": "init.lua", + "sha256": "73461b847b513ac10aeb0c65366de52940fd736d2c7026d06c0425ea78b2b175", + "target": "lib/tiny-ecs/init.lua" + }, + { + "name": "tiny.lua", + "sha256": "a766b6a35949546746d041e9f4806a4ea068605b9657e2c7dedbc636c2cdf805", + "target": "lib/tiny-ecs/tiny.lua" + } + ] + }, + "require": "lib.tiny-ecs", + "example": "local tiny_ecs = require('lib.tiny-ecs')" +} diff --git a/packages/windfield.json b/packages/windfield.json deleted file mode 100644 index 0aea9a9e..00000000 --- a/packages/windfield.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "type": "love2d-library", - "trust": "known", - "description": "A physics module for LÖVE that makes Box2D easier to use. Wraps love.physics with a simpler API.", - "tags": ["physics", "box2d"], - "homepage": "https://github.com/SSYGEN/windfield", - "source": { - "repo": "SSYGEN/windfield", - "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/SSYGEN/windfield/master/" - }, - "install": { - "files": [ - { "name": "windfield/init.lua", "sha256": "687c789dceeba3ebb6f5b152b94e9c085268695ed8cc578780d24ec1696f0388", "target": "lib/windfield/init.lua" } - ] - }, - "require": "lib.windfield", - "example": "local wf = require('lib.windfield')" -} diff --git a/scripts/backfill-commit-shas.mjs b/scripts/backfill-commit-shas.mjs new file mode 100644 index 00000000..037253b6 --- /dev/null +++ b/scripts/backfill-commit-shas.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/** + * One-shot: resolves each package's source.tag/branch to a commit SHA and + * rewrites source.commitSha + source.baseUrl in packages/*.json, then + * regenerates the registry. + * + * Skips packages that already have source.commitSha set. + * Uses --force to re-resolve and overwrite even if commitSha is already present. + * + * Usage: + * node scripts/backfill-commit-shas.mjs + * node scripts/backfill-commit-shas.mjs --force + */ + +import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); +const packagesDir = join(root, 'packages'); + +const force = process.argv.includes('--force'); +const GH_HEADERS = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }; + +async function resolveCommitSha(repo, ref) { + const url = `https://api.github.com/repos/${repo}/commits/${encodeURIComponent(ref)}`; + const res = await fetch(url, { headers: GH_HEADERS }); + if (!res.ok) throw new Error(`GitHub API ${res.status} for ${repo}@${ref}`); + const data = await res.json(); + return data.sha; +} + +const files = readdirSync(packagesDir).filter((f) => f.endsWith('.json')).sort(); +let updated = 0; +let skipped = 0; +let failed = 0; + +for (const file of files) { + const id = file.replace(/\.json$/, ''); + const path = join(packagesDir, file); + const pkg = JSON.parse(readFileSync(path, 'utf8')); + + if (!pkg.source?.repo || !pkg.source?.tag) { + console.log(` SKIP ${id}: missing source.repo or source.tag`); + skipped++; + continue; + } + + if (pkg.source.commitSha && !force) { + console.log(` SKIP ${id}: already has commitSha ${pkg.source.commitSha.slice(0, 7)}`); + skipped++; + continue; + } + + process.stdout.write(` ... ${id} (${pkg.source.repo}@${pkg.source.tag})`); + try { + const sha = await resolveCommitSha(pkg.source.repo, pkg.source.tag); + pkg.source.commitSha = sha; + pkg.source.baseUrl = `https://raw.githubusercontent.com/${pkg.source.repo}/${sha}/`; + writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n', 'utf8'); + process.stdout.write(` → ${sha.slice(0, 7)}\n`); + updated++; + } catch (err) { + process.stdout.write(`\n FAIL ${id}: ${err.message}\n`); + failed++; + } +} + +console.log(`\n${updated} updated, ${skipped} skipped, ${failed} failed`); + +if (updated > 0) { + process.stdout.write('\nRegenerating registry… '); + const result = spawnSync(process.execPath, [join(root, 'scripts', 'generate-registry.mjs')], { + cwd: root, + stdio: 'pipe', + encoding: 'utf8', + }); + if (result.status !== 0) { + console.error('FAILED\n' + result.stderr); + process.exit(1); + } + console.log('done'); +} diff --git a/scripts/generate-registry.mjs b/scripts/generate-registry.mjs index ebc1b0bb..711798e9 100644 --- a/scripts/generate-registry.mjs +++ b/scripts/generate-registry.mjs @@ -33,7 +33,7 @@ for (const file of packageFiles) { console.warn(` WARN ${id}: missing "trust" field`); warnings++; } - if (!pkg.source?.baseUrl) { + if (pkg.source?.type !== 'url' && !pkg.source?.baseUrl) { console.warn(` WARN ${id}: missing source.baseUrl`); warnings++; } @@ -56,6 +56,7 @@ for (const file of packageFiles) { description: sub.description ?? `${id} — ${subId.split(".").pop()} module`, tags: pkg.tags ?? [], homepage: pkg.homepage, + license: pkg.license, source: pkg.source, install: { files: (pkg.install?.files ?? []).filter((f) => @@ -74,6 +75,7 @@ for (const file of packageFiles) { description: pkg.description, tags: pkg.tags ?? [], homepage: pkg.homepage, + license: pkg.license, source: pkg.source, install: pkg.install, subpackages: pkg.subpackages ? Object.keys(pkg.subpackages) : undefined, diff --git a/src-lua/feather/auto.lua b/src-lua/feather/auto.lua index 372afe92..917af993 100644 --- a/src-lua/feather/auto.lua +++ b/src-lua/feather/auto.lua @@ -38,6 +38,27 @@ local function toFsPath(modulePath) return modulePath:gsub("%.$", ""):gsub("%.", "/") end +local function getInfo(fsPath) + if not love or not love.filesystem or not love.filesystem.getInfo then + return nil + end + local ok, info = pcall(love.filesystem.getInfo, fsPath) + if ok then + return info + end + return nil +end + +local function isDirectory(fsPath) + local info = getInfo(fsPath) + return info and info.type == "directory" +end + +local function isFile(fsPath) + local info = getInfo(fsPath) + return info and info.type == "file" +end + --- Load a manifest.lua from a filesystem path. ---@param fsPath string Absolute-ish path understood by love.filesystem ---@return table|nil @@ -45,6 +66,9 @@ local function loadManifest(fsPath) if not love or not love.filesystem then return nil end + if love.filesystem.getInfo and not isFile(fsPath) then + return nil + end local chunk, _ = love.filesystem.load(fsPath) if not chunk then return nil @@ -101,30 +125,36 @@ local function scanPlugins() if not love or not love.filesystem then return entries end - local items = love.filesystem.getDirectoryItems(fsDir) + local ok, items = pcall(love.filesystem.getDirectoryItems, fsDir) + if not ok then + return entries + end if not items then return entries end for _, dirName in ipairs(items) do - local manifest = loadManifest(fsDir .. "/" .. dirName .. "/manifest.lua") - if manifest and manifest.id then - local mod = tryRequire(pluginModPath .. manifest.id) - entries[#entries + 1] = { - mod = mod, - id = manifest.id, - opts = manifest.opts or {}, - optIn = manifest.optIn or false, - disabled = manifest.disabled ~= false, - capabilities = manifest.capabilities or {}, - compatibility = { - api = manifest.api, - minApi = manifest.minApi, - maxApi = manifest.maxApi, - name = manifest.name, - version = manifest.version, - }, - } + local pluginDir = fsDir .. "/" .. dirName + if isDirectory(pluginDir) then + local manifest = loadManifest(pluginDir .. "/manifest.lua") + if manifest and manifest.id then + local mod = tryRequire(pluginModPath .. manifest.id) + entries[#entries + 1] = { + mod = mod, + id = manifest.id, + opts = manifest.opts or {}, + optIn = manifest.optIn or false, + disabled = manifest.disabled ~= false, + capabilities = manifest.capabilities or {}, + compatibility = { + api = manifest.api, + minApi = manifest.minApi, + maxApi = manifest.maxApi, + name = manifest.name, + version = manifest.version, + }, + } + end end end @@ -166,10 +196,7 @@ function auto.setup(config) local plugins = {} for _, entry in ipairs(scanPlugins()) do - if entry.mod and not exclude[entry.id] then - if entry.optIn and not include[entry.id] then - goto continue - end + if entry.mod and not exclude[entry.id] and not (entry.optIn and not include[entry.id]) then local opts = entry.opts if pluginOptions[entry.id] then opts = {} @@ -190,7 +217,6 @@ function auto.setup(config) entry.compatibility ) end - ::continue:: end -- Append any extra user plugins @@ -215,7 +241,7 @@ function auto.setup(config) end if not DEBUGGER then - auto.setup() + auto.setup(rawget(_G, "FEATHER_AUTO_CONFIG")) end return auto diff --git a/src-lua/feather/core/debug_overlay.lua b/src-lua/feather/core/debug_overlay.lua new file mode 100644 index 00000000..454ef3d9 --- /dev/null +++ b/src-lua/feather/core/debug_overlay.lua @@ -0,0 +1,143 @@ +local Class = require(FEATHER_PATH .. ".lib.class") + +local DebugOverlay = Class({}) + +local DEFAULTS = { + enabled = true, + visible = true, + hideKey = "f12", + touchToggle = true, + corner = "top-right", + text = true, +} + +local function withDefault(value, fallback) + if value == nil then + return fallback + end + return value +end + +function DebugOverlay:init(feather, config) + config = config or {} + if config == false then + config = { enabled = false } + end + + self.feather = feather + self.enabled = withDefault(config.enabled, DEFAULTS.enabled) + self.visible = withDefault(config.visible, DEFAULTS.visible) + self.hideKey = config.hideKey or DEFAULTS.hideKey + self.touchToggle = withDefault(config.touchToggle, DEFAULTS.touchToggle) + self.corner = config.corner or DEFAULTS.corner + self.text = withDefault(config.text, DEFAULTS.text) + self._lastTouchTime = 0 + self._doubleTapWindow = config.doubleTapWindow or 0.35 + self._touchSize = config.touchSize or 96 +end + +function DebugOverlay:toggle() + self.visible = not self.visible +end + +function DebugOverlay:onKeypressed(key) + if not self.enabled then + return + end + if key == self.hideKey then + self:toggle() + end +end + +function DebugOverlay:_isInCorner(x, y) + if not love or not love.graphics then + return false + end + local width, height = love.graphics.getDimensions() + local size = self._touchSize + + if self.corner == "top-left" then + return x <= size and y <= size + elseif self.corner == "bottom-left" then + return x <= size and y >= height - size + elseif self.corner == "bottom-right" then + return x >= width - size and y >= height - size + end + + return x >= width - size and y <= size +end + +function DebugOverlay:onTouchpressed(_id, x, y) + if not self.enabled or not self.touchToggle then + return + end + if not self:_isInCorner(x, y) then + return + end + + local now = love and love.timer and love.timer.getTime and love.timer.getTime() or os.clock() + if now - self._lastTouchTime <= self._doubleTapWindow then + self:toggle() + self._lastTouchTime = 0 + else + self._lastTouchTime = now + end +end + +function DebugOverlay:_status() + local feather = self.feather + if feather.featherDebugger and feather.featherDebugger.paused then + return "paused", { 1.0, 0.78, 0.28, 1 } + end + if feather.mode == "disk" then + return "disk mode", { 0.52, 0.78, 1.0, 1 } + end + if feather.wsConnected then + return "connected", { 0.35, 1.0, 0.55, 1 } + end + if feather.__connState == "authenticating" or feather.__connState == "idle" then + return "connecting", { 0.52, 0.78, 1.0, 1 } + end + return "disconnected", { 1.0, 0.38, 0.38, 1 } +end + +function DebugOverlay:onDraw() + if not self.enabled or not self.visible then + return + end + if not love or not love.graphics then + return + end + + pcall(function() + local g = love.graphics + local text, accent = self:_status() + local label = "Feather debugger enabled · " .. text + local font = g.getFont() + local paddingX, paddingY = 8, 5 + local textWidth = self.text and font and font:getWidth(label) or (self.text and #label * 7 or 0) + local textHeight = self.text and font and font:getHeight() or 14 + local width = textWidth + paddingX * 2 + local height = textHeight + paddingY * 2 + local screenWidth = g.getWidth() + local x = math.max(8, screenWidth - width - 10) + local y = 10 + + if g.push then + g.push("all") + end + g.setColor(0.04, 0.05, 0.07, 0.82) + g.rectangle("fill", x, y, width, height, 5, 5) + g.setColor(accent) + g.rectangle("fill", x, y, 4, height, 5, 5) + if self.text then + g.setColor(1, 1, 1, 0.92) + g.print(label, x + paddingX, y + paddingY) + end + if g.pop then + g.pop() + end + end) +end + +return DebugOverlay diff --git a/src-lua/feather/init.lua b/src-lua/feather/init.lua index 2d4b3dbb..41ccbb53 100644 --- a/src-lua/feather/init.lua +++ b/src-lua/feather/init.lua @@ -14,12 +14,13 @@ local FeatherLogger = require(FEATHER_PATH .. ".core.logger") local FeatherObserver = require(FEATHER_PATH .. ".core.observer") local FeatherPerformance = require(FEATHER_PATH .. ".core.performance") local FeatherAssets = require(FEATHER_PATH .. ".core.assets") +local FeatherDebugOverlay = require(FEATHER_PATH .. ".core.debug_overlay") local FeatherDebugger = require(FEATHER_PATH .. ".debugger") local FeatherUI = require(FEATHER_PATH .. ".ui") local get_current_dir = require(FEATHER_PATH .. ".utils").get_current_dir local format = require(FEATHER_PATH .. ".utils").format -local FEATHER_VERSION_NAME = "0.10.0" +local FEATHER_VERSION_NAME = "1.0.0" local FEATHER_API = 5 local FEATHER_VERSION = { @@ -85,6 +86,7 @@ local customErrorHandler = errorhandler ---@field assetPreview? boolean Enable core asset tracking and previews (default true) ---@field binaryTextThreshold? number Observer/time-travel strings longer than this are sent as binary text (default 4096) ---@field hotReload? table Top-level hot reload options. Prefer debugger.hotReload for new configs. +---@field debugOverlay? table|boolean Small in-game debugger status badge (default enabled in debug builds) --- Feather constructor ---@param config FeatherConfig function Feather:init(config) @@ -199,6 +201,7 @@ function Feather:init(config) self.pluginManager = FeatherPluginManager(self, self.featherLogger, self.featherObserver) self.pluginManager:hookLoveCallbacks() + self.debugOverlay = FeatherDebugOverlay(self, conf.debugOverlay) ---@type FeatherDebugger self.featherDebugger = FeatherDebugger(self) diff --git a/src-lua/feather/lib/base64.lua b/src-lua/feather/lib/base64.lua index de8b484e..9335e77a 100644 --- a/src-lua/feather/lib/base64.lua +++ b/src-lua/feather/lib/base64.lua @@ -3,7 +3,7 @@ --- Optimised: builds 4-char chunks via pre-computed lookup table to avoid --- per-character string.sub calls (≈4× faster on large payloads). -local bit = require("bit") +local bit = require((FEATHER_PATH or "feather") .. ".lib.bit") local band = bit.band local shr = bit.rshift diff --git a/src-lua/feather/lib/bit.lua b/src-lua/feather/lib/bit.lua new file mode 100644 index 00000000..9fc10d16 --- /dev/null +++ b/src-lua/feather/lib/bit.lua @@ -0,0 +1,88 @@ +--- Small bit-operation compatibility layer for Feather. +--- Uses LuaJIT's bit module when available, then bit32, then a Lua 5.1 fallback. + +local ok, native = pcall(require, "bit") +if ok and native then + return native +end + +local ok32, native32 = pcall(require, "bit32") +if ok32 and native32 then + return native32 +end + +local floor = math.floor +local TWO_32 = 4294967296 + +local function normalize(n) + return (n or 0) % TWO_32 +end + +local function bitop(a, b, predicate) + a = normalize(a) + b = normalize(b) + + local result = 0 + local place = 1 + + while a > 0 or b > 0 do + local abit = a % 2 + local bbit = b % 2 + + if predicate(abit, bbit) then + result = result + place + end + + a = (a - abit) / 2 + b = (b - bbit) / 2 + place = place * 2 + end + + return result +end + +local function apply(predicate, a, b, ...) + if b == nil then + return normalize(a) + end + + local result = bitop(a, b, predicate) + for i = 1, select("#", ...) do + result = bitop(result, select(i, ...), predicate) + end + return result +end + +local function band(a, b, ...) + return apply(function(abit, bbit) + return abit == 1 and bbit == 1 + end, a, b, ...) +end + +local function bor(a, b, ...) + return apply(function(abit, bbit) + return abit == 1 or bbit == 1 + end, a, b, ...) +end + +local function bxor(a, b, ...) + return apply(function(abit, bbit) + return abit ~= bbit + end, a, b, ...) +end + +local function lshift(n, bits) + return floor(normalize(n) * (2 ^ bits)) % TWO_32 +end + +local function rshift(n, bits) + return floor(normalize(n) / (2 ^ bits)) +end + +return { + band = band, + bor = bor, + bxor = bxor, + lshift = lshift, + rshift = rshift, +} diff --git a/src-lua/feather/lib/ws.lua b/src-lua/feather/lib/ws.lua index 4952fce5..12bbc905 100644 --- a/src-lua/feather/lib/ws.lua +++ b/src-lua/feather/lib/ws.lua @@ -14,7 +14,7 @@ Usage: ]] local socket = require("socket") -local bit = require("bit") +local bit = require((FEATHER_PATH or "feather") .. ".lib.bit") local band, bor, bxor = bit.band, bit.bor, bit.bxor local shl, shr = bit.lshift, bit.rshift diff --git a/src-lua/feather/plugin_manager.lua b/src-lua/feather/plugin_manager.lua index 2c34d4d8..9d0c0e19 100644 --- a/src-lua/feather/plugin_manager.lua +++ b/src-lua/feather/plugin_manager.lua @@ -167,65 +167,63 @@ function FeatherPluginManager:init(feather, logger, observer) type = "error", str = "Plugin <" .. plugin.identifier .. "> is not compatible: " .. message, }) - goto continue - end - - local ok, pluginInstance = xpcall(plugin.plugin, function(err) - return type(err) == "string" and debug.traceback(err, 2) or debug.traceback(tostring(err), 2) - end, { - options = plugin.options, - feather = feather, - logger = logger, - observer = observer, - api = compatibility.api, - minApi = compatibility.minApi, - maxApi = compatibility.maxApi, - }) - - if ok then - local supported = true - if pluginInstance and pluginInstance.isSupported then - supported = pluginInstance:isSupported(feather.version) - end - table.insert(self.plugins, { - instance = pluginInstance, - identifier = plugin.identifier, - disabled = plugin.disabled or not supported or false, - incompatible = not supported, - incompatibilityReason = (not supported) and describeApiCompatibility(compatibility, feather.version) or nil, - capabilities = plugin.capabilities or {}, - compatibility = compatibility, - name = plugin.name, - version = plugin.version, + else + local ok, pluginInstance = xpcall(plugin.plugin, function(err) + return type(err) == "string" and debug.traceback(err, 2) or debug.traceback(tostring(err), 2) + end, { + options = plugin.options, + feather = feather, + logger = logger, + observer = observer, + api = compatibility.api, + minApi = compatibility.minApi, + maxApi = compatibility.maxApi, }) - if not supported then - self.logger:log({ - type = "error", - str = "Plugin <" .. plugin.identifier .. "> is not compatible: " .. describeApiCompatibility(compatibility, feather.version), + if ok then + local supported = true + if pluginInstance and pluginInstance.isSupported then + supported = pluginInstance:isSupported(feather.version) + end + table.insert(self.plugins, { + instance = pluginInstance, + identifier = plugin.identifier, + disabled = plugin.disabled or not supported or false, + incompatible = not supported, + incompatibilityReason = (not supported) and describeApiCompatibility(compatibility, feather.version) or nil, + capabilities = plugin.capabilities or {}, + compatibility = compatibility, + name = plugin.name, + version = plugin.version, }) - end - -- Warn if a plugin requests a capability not in the user's allowlist - if allowedPerms and allowedPerms ~= "all" then - for _, perm in ipairs(plugin.capabilities or {}) do - if not allowedPerms[perm] then - self.logger:log({ - type = "error", - str = "[Plugin " - .. plugin.identifier - .. "] requests capability '" - .. perm - .. "' which is not in the allowlist", - }) + if not supported then + self.logger:log({ + type = "error", + str = "Plugin <" .. plugin.identifier .. "> is not compatible: " .. describeApiCompatibility(compatibility, feather.version), + }) + end + + -- Warn if a plugin requests a capability not in the user's allowlist + if allowedPerms and allowedPerms ~= "all" then + for _, perm in ipairs(plugin.capabilities or {}) do + if not allowedPerms[perm] then + self.logger:log({ + type = "error", + str = "[Plugin " + .. plugin.identifier + .. "] requests capability '" + .. perm + .. "' which is not in the allowlist", + }) + end end end + else + -- pluginInstance is the formatted error+traceback string from the xpcall handler + self.logger:log({ type = "error", str = tostring(pluginInstance) }) end - else - -- pluginInstance is the formatted error+traceback string from the xpcall handler - self.logger:log({ type = "error", str = tostring(pluginInstance) }) end - ::continue:: end end @@ -397,6 +395,16 @@ function FeatherPluginManager:hookLoveCallbacks() original(...) end dispatch(method, ...) + local overlay = mgr.feather and mgr.feather.debugOverlay + if overlay then + if method == "onDraw" and overlay.onDraw then + overlay:onDraw() + elseif method == "onKeypressed" and overlay.onKeypressed then + overlay:onKeypressed(...) + elseif method == "onTouchpressed" and overlay.onTouchpressed then + overlay:onTouchpressed(...) + end + end end self._loveCallbackWrappers[name] = wrapper end diff --git a/src-lua/manifest.txt b/src-lua/manifest.txt index c0e58653..d4e9d417 100644 --- a/src-lua/manifest.txt +++ b/src-lua/manifest.txt @@ -1,6 +1,7 @@ core:feather/auto.lua core:feather/core/assets.lua core:feather/core/base.lua +core:feather/core/debug_overlay.lua core:feather/core/logger.lua core:feather/core/observer.lua core:feather/core/performance.lua @@ -8,6 +9,7 @@ core:feather/debugger.lua core:feather/error_handler.lua core:feather/init.lua core:feather/lib/base64.lua +core:feather/lib/bit.lua core:feather/lib/class.lua core:feather/lib/inspect.lua core:feather/lib/json.lua diff --git a/src-lua/plugins/animation-inspector/init.lua b/src-lua/plugins/animation-inspector/init.lua index fde10108..34329cf7 100644 --- a/src-lua/plugins/animation-inspector/init.lua +++ b/src-lua/plugins/animation-inspector/init.lua @@ -100,48 +100,45 @@ function AnimationInspectorPlugin:handleRequest() local ok, anim = pcall(entry.getter) if ok and anim then local status = anim.status or "?" - if not self.showPaused and status == "paused" then - goto continue - end - - local totalFrames = anim.frames and #anim.frames or 0 - local position = anim.position or 0 - local timer = anim.timer or 0 - local totalDuration = anim.totalDuration or 0 - local currentDuration = (anim.durations and anim.durations[position]) or 0 - local flipped = "" - if anim.flippedH then - flipped = flipped .. "H" - end - if anim.flippedV then - flipped = flipped .. (flipped ~= "" and "+V" or "V") - end - if flipped == "" then - flipped = "—" - end + if self.showPaused or status ~= "paused" then + local totalFrames = anim.frames and #anim.frames or 0 + local position = anim.position or 0 + local timer = anim.timer or 0 + local totalDuration = anim.totalDuration or 0 + local currentDuration = (anim.durations and anim.durations[position]) or 0 + local flipped = "" + if anim.flippedH then + flipped = flipped .. "H" + end + if anim.flippedV then + flipped = flipped .. (flipped ~= "" and "+V" or "V") + end + if flipped == "" then + flipped = "—" + end - -- Get frame dimensions if available - local dims = "?" - if anim.frames and anim.frames[position] then - local pok, w, h = pcall(function() - local _, _, fw, fh = anim.frames[position]:getViewport() - return fw, fh - end) - if pok then - dims = string.format("%dx%d", w, h) + -- Get frame dimensions if available + local dims = "?" + if anim.frames and anim.frames[position] then + local pok, w, h = pcall(function() + local _, _, fw, fh = anim.frames[position]:getViewport() + return fw, fh + end) + if pok then + dims = string.format("%dx%d", w, h) + end end - end - rows[#rows + 1] = { - name = entry.name, - status = status, - frame = string.format("%d/%d", position, totalFrames), - timer = string.format("%.2f/%.2fs", timer, totalDuration), - frameDur = string.format("%.3fs", currentDuration), - flip = flipped, - size = dims, - } - ::continue:: + rows[#rows + 1] = { + name = entry.name, + status = status, + frame = string.format("%d/%d", position, totalFrames), + timer = string.format("%.2f/%.2fs", timer, totalDuration), + frameDur = string.format("%.3fs", currentDuration), + flip = flipped, + size = dims, + } + end else rows[#rows + 1] = { name = entry.name, diff --git a/src-lua/plugins/audio-debug/init.lua b/src-lua/plugins/audio-debug/init.lua index edf0c2c5..40a0be7e 100644 --- a/src-lua/plugins/audio-debug/init.lua +++ b/src-lua/plugins/audio-debug/init.lua @@ -103,30 +103,27 @@ function AudioDebugPlugin:handleRequest() local status = playing and "playing" or "stopped" -- Skip stopped sources if filter is off - if not self.showStopped and not playing then - goto continue - end - - local channels = src:getChannelCount() - local vol = src:getVolume() - local pitch = src:getPitch() - local looping = src:isLooping() - local srcType = src:getType() -- static / stream / queue - local duration = src:getDuration("seconds") - local position = src:tell("seconds") + if self.showStopped or playing then + local channels = src:getChannelCount() + local vol = src:getVolume() + local pitch = src:getPitch() + local looping = src:isLooping() + local srcType = src:getType() -- static / stream / queue + local duration = src:getDuration("seconds") + local position = src:tell("seconds") - rows[#rows + 1] = { - name = entry.label, - type = srcType, - status = status, - volume = string.format("%.2f", vol), - pitch = string.format("%.2f", pitch), - looping = looping and "yes" or "no", - channels = channels == 1 and "mono" or "stereo", - duration = duration >= 0 and string.format("%.1fs", duration) or "?", - position = string.format("%.1fs", position), - } - ::continue:: + rows[#rows + 1] = { + name = entry.label, + type = srcType, + status = status, + volume = string.format("%.2f", vol), + pitch = string.format("%.2f", pitch), + looping = looping and "yes" or "no", + channels = channels == 1 and "mono" or "stereo", + duration = duration >= 0 and string.format("%.1fs", duration) or "?", + position = string.format("%.1fs", position), + } + end end local columns = { diff --git a/src-lua/plugins/coroutine-monitor/init.lua b/src-lua/plugins/coroutine-monitor/init.lua index 364fffcc..337b6b02 100644 --- a/src-lua/plugins/coroutine-monitor/init.lua +++ b/src-lua/plugins/coroutine-monitor/init.lua @@ -197,22 +197,19 @@ function CoroutineMonitorPlugin:handleRequest() end -- Skip dead coroutines if filter is off - if not self.showDead and status == "dead" then - goto continue - end - - local age = now - entry.created - local lastYield = entry.lastYieldTime > 0 and string.format("%.1fs ago", now - entry.lastYieldTime) or "—" + if self.showDead or status ~= "dead" then + local age = now - entry.created + local lastYield = entry.lastYieldTime > 0 and string.format("%.1fs ago", now - entry.lastYieldTime) or "—" - rows[#rows + 1] = { - name = entry.label, - status = status, - yields = tostring(entry.yields), - yieldsFrame = tostring(entry.yieldsThisFrame), - lastYield = lastYield, - age = string.format("%.1fs", age), - } - ::continue:: + rows[#rows + 1] = { + name = entry.label, + status = status, + yields = tostring(entry.yields), + yieldsFrame = tostring(entry.yieldsThisFrame), + lastYield = lastYield, + age = string.format("%.1fs", age), + } + end end local columns = { diff --git a/src-lua/plugins/entity-inspector/init.lua b/src-lua/plugins/entity-inspector/init.lua index 5846edec..a2677af0 100644 --- a/src-lua/plugins/entity-inspector/init.lua +++ b/src-lua/plugins/entity-inspector/init.lua @@ -285,30 +285,32 @@ function EntityInspectorPlugin:handleRequest(_request, _feather) for i, entity in ipairs(entityList) do -- Apply user filter + local includeEntity = true if source.filter then local ok, pass = pcall(source.filter, entity) if ok and not pass then - goto continue + includeEntity = false end end - local node = buildNode(entity, source, i, 1, self.maxDepth, self.maxValueLen, count, self.maxEntities) + if includeEntity then + local node = buildNode(entity, source, i, 1, self.maxDepth, self.maxValueLen, count, self.maxEntities) - if node then - -- Apply search filter (name match) - if filter then - if not node.name:lower():find(filter, 1, true) then - goto continue + if node then + -- Apply search filter (name match) + local matchesSearch = true + if filter then + matchesSearch = node.name:lower():find(filter, 1, true) ~= nil + end + if matchesSearch then + nodes[#nodes + 1] = node end end - nodes[#nodes + 1] = node - end - if count.n >= self.maxEntities then - break + if count.n >= self.maxEntities then + break + end end - - ::continue:: end return { diff --git a/src-lua/plugins/network-inspector/init.lua b/src-lua/plugins/network-inspector/init.lua index a775c2e5..6074371f 100644 --- a/src-lua/plugins/network-inspector/init.lua +++ b/src-lua/plugins/network-inspector/init.lua @@ -387,22 +387,29 @@ function NetworkInspectorPlugin:update() local matchEndpoint = p.endpoint:lower():find(filterLower, 1, true) local matchPayload = p.payload:lower():find(filterLower, 1, true) local matchDir = p.direction:lower():find(filterLower, 1, true) - if not matchEndpoint and not matchPayload and not matchDir then - goto continue + local matches = matchEndpoint or matchPayload or matchDir + if matches then + rows[#rows + 1] = { + id = tostring(p.id), + direction = p.direction == "out" and "→ OUT" or "← IN", + endpoint = p.endpoint, + size = formatSize(p.size), + payload = p.payload, + gameTime = string.format("%.2f", p.gameTime), + status = p.status == "error" and ("✗ " .. (p.error or "error")) or "✓", + } end + else + rows[#rows + 1] = { + id = tostring(p.id), + direction = p.direction == "out" and "→ OUT" or "← IN", + endpoint = p.endpoint, + size = formatSize(p.size), + payload = p.payload, + gameTime = string.format("%.2f", p.gameTime), + status = p.status == "error" and ("✗ " .. (p.error or "error")) or "✓", + } end - - rows[#rows + 1] = { - id = tostring(p.id), - direction = p.direction == "out" and "→ OUT" or "← IN", - endpoint = p.endpoint, - size = formatSize(p.size), - payload = p.payload, - gameTime = string.format("%.2f", p.gameTime), - status = p.status == "error" and ("✗ " .. (p.error or "error")) or "✓", - } - - ::continue:: end return { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index dc75af73..65dc2c72 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -957,15 +957,6 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "endi" version = "1.1.0" @@ -1057,7 +1048,7 @@ dependencies = [ [[package]] name = "feather" -version = "0.10.0" +version = "1.0.0" dependencies = [ "axum", "futures-util", @@ -1069,7 +1060,6 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-opener", - "tauri-plugin-shell", "tokio", "tokio-tungstenite", "uuid", @@ -2607,16 +2597,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "os_pipe" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db335f4760b14ead6290116f2427bf33a14d4f0617d49f78a246de10c1831224" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "pango" version = "0.18.3" @@ -3594,44 +3574,12 @@ dependencies = [ "digest", ] -[[package]] -name = "shared_child" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" -dependencies = [ - "libc", - "sigchld", - "windows-sys 0.60.2", -] - [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "sigchld" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" -dependencies = [ - "libc", - "os_pipe", - "signal-hook", -] - -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - [[package]] name = "signal-hook-registry" version = "1.4.6" @@ -4086,27 +4034,6 @@ dependencies = [ "zbus 4.0.1", ] -[[package]] -name = "tauri-plugin-shell" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b9ffadec5c3523f11e8273465cacb3d86ea7652a28e6e2a2e9b5c182f791d25" -dependencies = [ - "encoding_rs", - "log", - "open", - "os_pipe", - "regex", - "schemars 0.8.22", - "serde", - "serde_json", - "shared_child", - "tauri", - "tauri-plugin", - "thiserror 2.0.12", - "tokio", -] - [[package]] name = "tauri-runtime" version = "2.7.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b8374936..2a6f5b3f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "feather" -version = "0.10.0" +version = "1.0.0" description = "Feather is a Love2D devtool to debug and inspect your game in real-time. It provides a web-based interface to view and manipulate game state, visualize physics, and more." authors = ["kyonru"] edition = "2021" @@ -21,7 +21,6 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tauri-plugin-shell = "2" tauri-plugin-opener = "2" tauri-plugin-fs = "2" tauri-plugin-dialog = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 88f53879..2f4a9cdd 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,23 +7,6 @@ ], "permissions": [ "core:default", - "shell:allow-open", - { - "identifier": "shell:allow-execute", - "allow": [ - { - "name": "code", - "cmd": "sh", - "args": [ - "-c", - { - "validator": "\\S*\\/?code\\s+--goto\\s+\\S+" - } - ], - "sidecar": false - } - ] - }, "opener:default", { "identifier": "opener:allow-open-path", @@ -38,6 +21,9 @@ "allow": [ { "url": "https://github.com/Kyonru/feather/releases/*" + }, + { + "url": "https://kyonru.github.io/feather/*" } ] }, @@ -63,4 +49,4 @@ "dialog:allow-save", "fs:allow-write-text-file" ] -} \ No newline at end of file +} diff --git a/src-tauri/src/cli_status.rs b/src-tauri/src/cli_status.rs new file mode 100644 index 00000000..8397c633 --- /dev/null +++ b/src-tauri/src/cli_status.rs @@ -0,0 +1,330 @@ +use serde::Serialize; +use serde_json::Value; +use std::{ + env, + ffi::OsStr, + path::{Path, PathBuf}, + process::Command, +}; + +const INSTALL_DOCS_URL: &str = "https://kyonru.github.io/feather/installation/"; +const CLI_DOCS_URL: &str = "https://kyonru.github.io/feather/cli/"; + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CliStatus { + installed: bool, + path: Option, + version: Option, + source: Option, + node_version: Option, + npm_version: Option, + error: Option, + install_docs_url: String, + cli_docs_url: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CliProjectStatus { + cli: CliStatus, + project_dir: String, + doctor: Option, + build_doctor: Option, + vendors: Option, + errors: Vec, +} + +pub fn status(cli_path: Option) -> CliStatus { + let node_version = command_first_line("node", &["--version"]); + let npm_version = command_first_line("npm", &["--version"]); + + for (candidate, source) in cli_candidates(cli_path) { + let version = command_first_line(candidate.as_os_str(), &["--version"]); + if let Some(version) = version { + return CliStatus { + installed: true, + path: Some(candidate.to_string_lossy().to_string()), + version: Some(version), + source: Some(source), + node_version, + npm_version, + error: None, + install_docs_url: INSTALL_DOCS_URL.to_string(), + cli_docs_url: CLI_DOCS_URL.to_string(), + }; + } + } + + CliStatus { + installed: false, + path: None, + version: None, + source: None, + node_version, + npm_version, + error: Some( + "Feather CLI was not found on PATH or in common npm global locations.".to_string(), + ), + install_docs_url: INSTALL_DOCS_URL.to_string(), + cli_docs_url: CLI_DOCS_URL.to_string(), + } +} + +pub fn project_status(project_dir: String, cli_path: Option) -> CliProjectStatus { + let cli = status(cli_path); + let mut errors = Vec::new(); + let mut doctor = None; + let mut build_doctor = None; + let mut vendors = None; + + if let Some(path) = &cli.path { + doctor = run_json(path, &["doctor", &project_dir, "--json"], &mut errors); + build_doctor = run_json( + path, + &["doctor", &project_dir, "--json", "--build-target", "all"], + &mut errors, + ); + vendors = run_json( + path, + &["build", "vendor", "list", "--dir", &project_dir, "--json"], + &mut errors, + ); + } + + CliProjectStatus { + cli, + project_dir, + doctor, + build_doctor, + vendors, + errors, + } +} + +fn run_json(command: &str, args: &[&str], errors: &mut Vec) -> Option { + match Command::new(command).args(args).output() { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout.is_empty() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + errors.push(format!( + "{} returned no JSON output: {}", + args.join(" "), + stderr + )); + return None; + } + match serde_json::from_str::(&stdout) { + Ok(value) => Some(value), + Err(err) => { + errors.push(format!("{} returned invalid JSON: {}", args.join(" "), err)); + None + } + } + } + Err(err) => { + errors.push(format!("Failed to run {}: {}", args.join(" "), err)); + None + } + } +} + +fn command_first_line>(command: S, args: &[&str]) -> Option { + let output = Command::new(command).args(args).output().ok()?; + if !output.status.success() { + return None; + } + + let text = if output.stdout.is_empty() { + String::from_utf8_lossy(&output.stderr) + } else { + String::from_utf8_lossy(&output.stdout) + }; + text.lines() + .next() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) +} + +fn cli_candidates(cli_path: Option) -> Vec<(PathBuf, String)> { + let mut candidates = Vec::new(); + if let Some(path) = cli_path + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + candidates.push((PathBuf::from(path), "configured".to_string())); + } + + candidates.push((PathBuf::from("feather"), "PATH".to_string())); + for dir in common_bin_dirs() { + candidates.push(( + dir.join(executable_name("feather")), + "common npm bin".to_string(), + )); + } + + dedupe_candidates(candidates) +} + +fn common_bin_dirs() -> Vec { + let mut dirs = vec![ + PathBuf::from("/usr/local/bin"), + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/bin"), + ]; + if let Some(home) = env::var_os("HOME").map(PathBuf::from) { + dirs.push(home.join(".npm-global/bin")); + dirs.push(home.join(".volta/bin")); + dirs.push(home.join(".local/bin")); + } + dirs +} + +fn executable_name(name: &str) -> String { + if cfg!(windows) { + format!("{name}.cmd") + } else { + name.to_string() + } +} + +fn dedupe_candidates(candidates: Vec<(PathBuf, String)>) -> Vec<(PathBuf, String)> { + let mut seen = Vec::::new(); + let mut result = Vec::new(); + for (path, source) in candidates { + if seen.iter().any(|item| item == &path) { + continue; + } + seen.push(path.clone()); + result.push((path, source)); + } + result +} + +pub fn resolve_source_location(project_root: &str, relative_file: &str) -> Result { + let root = canonical_dir(project_root, "Project root")?; + let relative = Path::new(relative_file); + if relative.is_absolute() { + return Err("Source file must be relative to the project root.".to_string()); + } + if relative + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err("Source file cannot escape the project root.".to_string()); + } + + let candidate = root.join(relative); + let resolved = if candidate.exists() { + candidate + .canonicalize() + .map_err(|err| format!("Could not resolve source file: {err}"))? + } else { + let parent = candidate.parent().unwrap_or(&root); + let resolved_parent = canonical_dir(parent, "Source file parent")?; + resolved_parent.join( + candidate + .file_name() + .ok_or("Source file is missing a file name.")?, + ) + }; + + if !resolved.starts_with(&root) { + return Err("Source file is outside the project root.".to_string()); + } + + Ok(resolved) +} + +pub fn open_source_location( + editor_path: String, + project_root: String, + relative_file: String, + line: Option, +) -> Result<(), String> { + let editor = validate_editor_path(&editor_path)?; + let source = resolve_source_location(&project_root, &relative_file)?; + let line = line.unwrap_or(1).max(1); + let goto = format!("{}:{line}", source.to_string_lossy()); + + Command::new(editor) + .args(["--goto", &goto]) + .spawn() + .map_err(|err| format!("Could not open VS Code: {err}"))?; + + Ok(()) +} + +fn validate_editor_path(editor_path: &str) -> Result<&str, String> { + let editor = editor_path.trim(); + if editor.is_empty() { + return Err("Set a VS Code executable path before opening source files.".to_string()); + } + if editor.contains('\0') { + return Err("Editor executable path contains an invalid character.".to_string()); + } + if editor.split_whitespace().count() > 1 && !Path::new(editor).exists() { + return Err("Editor executable path must not include arguments.".to_string()); + } + Ok(editor) +} + +fn canonical_dir>(path: P, label: &str) -> Result { + let path = path.as_ref(); + if !path.exists() { + return Err(format!("{label} does not exist.")); + } + let canonical = path + .canonicalize() + .map_err(|err| format!("Could not resolve {label}: {err}"))?; + if !canonical.is_dir() { + return Err(format!("{label} must be a directory.")); + } + Ok(canonical) +} + +#[cfg(test)] +mod tests { + use super::{resolve_source_location, validate_editor_path}; + use std::{fs, path::PathBuf}; + + fn temp_dir(name: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("feather-cli-status-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn resolves_project_relative_file() { + let dir = temp_dir("valid"); + fs::write(dir.join("main.lua"), "print('hi')").unwrap(); + let resolved = resolve_source_location(dir.to_str().unwrap(), "main.lua").unwrap(); + assert!(resolved.ends_with("main.lua")); + } + + #[test] + fn rejects_parent_escape() { + let dir = temp_dir("escape"); + let err = resolve_source_location(dir.to_str().unwrap(), "../main.lua").unwrap_err(); + assert!(err.contains("escape")); + } + + #[test] + fn rejects_editor_arguments() { + let err = validate_editor_path("code --goto").unwrap_err(); + assert!(err.contains("arguments")); + } + + #[test] + fn accepts_existing_editor_path_with_spaces() { + let dir = temp_dir("editor spaces"); + let editor = dir.join("Code CLI"); + fs::write(&editor, "").unwrap(); + let validated = validate_editor_path(editor.to_str().unwrap()).unwrap(); + assert!(validated.ends_with("Code CLI")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f6e2a4f4..372871f3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod cli_status; mod ws_server; use std::{ @@ -39,6 +40,33 @@ fn get_local_ips() -> Vec { .collect() } +#[tauri::command] +async fn get_cli_status(cli_path: Option) -> Result { + tauri::async_runtime::spawn_blocking(move || cli_status::status(cli_path)) + .await + .map_err(|err| err.to_string()) +} + +#[tauri::command] +async fn get_cli_project_status( + project_dir: String, + cli_path: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || cli_status::project_status(project_dir, cli_path)) + .await + .map_err(|err| err.to_string()) +} + +#[tauri::command] +fn open_source_location( + editor_path: String, + project_root: String, + relative_file: String, + line: Option, +) -> Result<(), String> { + cli_status::open_source_location(editor_path, project_root, relative_file, line) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // Shared session map and app ID between the WS server and Tauri commands @@ -49,7 +77,6 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_shell::init()) .manage(sessions.clone()) .manage(app_id.clone()) .setup(move |app| { @@ -66,7 +93,10 @@ pub fn run() { ws_server::get_active_sessions, ws_server::close_session, ws_server::set_app_id, - get_local_ips + get_local_ips, + get_cli_status, + get_cli_project_status, + open_source_location ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 68c96647..b0160ff4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Feather", - "version": "0.10.0", + "version": "1.0.0", "identifier": "com.kyonru.love.feather", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.css b/src/App.css index f66e9a52..26d50f86 100644 --- a/src/App.css +++ b/src/App.css @@ -9,8 +9,8 @@ line-height: 24px; font-weight: 400; - color: #0f0f0f; - background-color: #f6f6f6; + color: #403f53; + background-color: #f0f4f8; font-synthesis: none; text-rendering: optimizeLegibility; @@ -18,37 +18,42 @@ -moz-osx-font-smoothing: grayscale; -webkit-text-size-adjust: 100%; --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --background: #f0f4f8; + --foreground: #403f53; + --card: #f8fbff; + --card-foreground: #403f53; + --popover: #f8fbff; + --popover-foreground: #403f53; + --primary: #ad5b68; + --primary-foreground: #ffffff; + --secondary: #e5ebf3; + --secondary-foreground: #403f53; + --muted: #e5ebf3; + --muted-foreground: #6c6f85; + --accent: #f3dfe4; + --accent-foreground: #873f4d; + --destructive: #d3423e; + --border: #d4dce8; + --input: #c9d3e3; + --ring: #ad5b68; + --chart-1: #ad5b68; + --chart-2: #0c969b; + --chart-3: #994cc3; + --chart-4: #c96765; + --chart-5: #d3423e; + --sidebar: #e8eef6; + --sidebar-foreground: #403f53; + --sidebar-primary: #ad5b68; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #f0dce3; + --sidebar-accent-foreground: #873f4d; + --sidebar-border: #d4dce8; + --sidebar-ring: #ad5b68; + --plugin-accent: #8a5a9f; + --plugin-active: #eee1f2; + --plugin-active-foreground: #563965; + --plugin-active-icon: #74498a; + --plugin-active-border: #d5bddf; } @theme inline { @@ -90,37 +95,42 @@ } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --background: #0d1117; + --foreground: #c9d1d9; + --card: #161b22; + --card-foreground: #c9d1d9; + --popover: #161b22; + --popover-foreground: #c9d1d9; + --primary: #8b949e; + --primary-foreground: #0d1117; + --secondary: #21262d; + --secondary-foreground: #c9d1d9; + --muted: #21262d; + --muted-foreground: #8b949e; + --accent: #30363d; + --accent-foreground: #c9d1d9; + --destructive: #f85149; + --border: #30363d; + --input: #30363d; + --ring: #8b949e; + --chart-1: #8b949e; + --chart-2: #3fb950; + --chart-3: #bc8cff; + --chart-4: #d29922; + --chart-5: #f85149; + --sidebar: #161b22; + --sidebar-foreground: #c9d1d9; + --sidebar-primary: #8b949e; + --sidebar-primary-foreground: #0d1117; + --sidebar-accent: #21262d; + --sidebar-accent-foreground: #c9d1d9; + --sidebar-border: #30363d; + --sidebar-ring: #ff9aa2; + --plugin-accent: #c9a0dc; + --plugin-active: #31263a; + --plugin-active-foreground: #f1def8; + --plugin-active-icon: #dfb7f0; + --plugin-active-border: #7f4d94; } @layer base { @@ -161,4 +171,4 @@ ::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); } -} \ No newline at end of file +} diff --git a/src/assets/theme/dark.ts b/src/assets/theme/dark.ts index 4a64d745..0003765b 100644 --- a/src/assets/theme/dark.ts +++ b/src/assets/theme/dark.ts @@ -1,90 +1,90 @@ export default { "hljs-comment": { - color: "#969896", + color: "#8b949e", }, "hljs-quote": { - color: "#969896", + color: "#8b949e", }, "hljs-variable": { - color: "#cc6666", + color: "#ff7b72", }, "hljs-template-variable": { - color: "#cc6666", + color: "#ff7b72", }, "hljs-tag": { - color: "#cc6666", + color: "#7ee787", }, "hljs-name": { - color: "#cc6666", + color: "#7ee787", }, "hljs-selector-id": { - color: "#cc6666", + color: "#ff7b72", }, "hljs-selector-class": { - color: "#cc6666", + color: "#ff7b72", }, "hljs-regexp": { - color: "#cc6666", + color: "#a5d6ff", }, "hljs-deletion": { - color: "#cc6666", + color: "#ffa198", }, "hljs-number": { - color: "#de935f", + color: "#79c0ff", }, "hljs-built_in": { - color: "#de935f", + color: "#79c0ff", }, "hljs-builtin-name": { - color: "#de935f", + color: "#79c0ff", }, "hljs-literal": { - color: "#de935f", + color: "#79c0ff", }, "hljs-type": { - color: "#de935f", + color: "#79c0ff", }, "hljs-params": { - color: "#de935f", + color: "#c9d1d9", }, "hljs-meta": { - color: "#de935f", + color: "#8b949e", }, "hljs-link": { - color: "#de935f", + color: "#58a6ff", }, "hljs-attribute": { - color: "#f0c674", + color: "#d2a8ff", }, "hljs-string": { - color: "#b5bd68", + color: "#a5d6ff", }, "hljs-symbol": { - color: "#b5bd68", + color: "#a5d6ff", }, "hljs-bullet": { - color: "#b5bd68", + color: "#a5d6ff", }, "hljs-addition": { - color: "#b5bd68", + color: "#7ee787", }, "hljs-title": { - color: "#81a2be", + color: "#d2a8ff", }, "hljs-section": { - color: "#81a2be", + color: "#d2a8ff", }, "hljs-keyword": { - color: "#b294bb", + color: "#ff7b72", }, "hljs-selector-tag": { - color: "#b294bb", + color: "#7ee787", }, hljs: { display: "block", overflowX: "auto", - background: "#1d1f21", - color: "#c5c8c6", + background: "#0d1117", + color: "#c9d1d9", padding: "0.5em", }, "hljs-emphasis": { diff --git a/src/assets/theme/light.ts b/src/assets/theme/light.ts index a7032e63..ce8413ea 100644 --- a/src/assets/theme/light.ts +++ b/src/assets/theme/light.ts @@ -1,90 +1,90 @@ export default { "hljs-comment": { - color: "#8e908c", + color: "#989fb1", }, "hljs-quote": { - color: "#8e908c", + color: "#989fb1", }, "hljs-variable": { - color: "#c82829", + color: "#d3423e", }, "hljs-template-variable": { - color: "#c82829", + color: "#d3423e", }, "hljs-tag": { - color: "#c82829", + color: "#ad5b68", }, "hljs-name": { - color: "#c82829", + color: "#ad5b68", }, "hljs-selector-id": { - color: "#c82829", + color: "#d3423e", }, "hljs-selector-class": { - color: "#c82829", + color: "#d3423e", }, "hljs-regexp": { - color: "#c82829", + color: "#0c969b", }, "hljs-deletion": { - color: "#c82829", + color: "#d3423e", }, "hljs-number": { - color: "#f5871f", + color: "#c96765", }, "hljs-built_in": { - color: "#f5871f", + color: "#c96765", }, "hljs-builtin-name": { - color: "#f5871f", + color: "#c96765", }, "hljs-literal": { - color: "#f5871f", + color: "#c96765", }, "hljs-type": { - color: "#f5871f", + color: "#c96765", }, "hljs-params": { - color: "#f5871f", + color: "#403f53", }, "hljs-meta": { - color: "#f5871f", + color: "#6c6f85", }, "hljs-link": { - color: "#f5871f", + color: "#ad5b68", }, "hljs-attribute": { - color: "#eab700", + color: "#994cc3", }, "hljs-string": { - color: "#718c00", + color: "#0c969b", }, "hljs-symbol": { - color: "#718c00", + color: "#0c969b", }, "hljs-bullet": { - color: "#718c00", + color: "#0c969b", }, "hljs-addition": { - color: "#718c00", + color: "#0c969b", }, "hljs-title": { - color: "#4271ae", + color: "#ad5b68", }, "hljs-section": { - color: "#4271ae", + color: "#ad5b68", }, "hljs-keyword": { - color: "#8959a8", + color: "#994cc3", }, "hljs-selector-tag": { - color: "#8959a8", + color: "#ad5b68", }, hljs: { display: "block", overflowX: "auto", - background: "white", - color: "#4d4d4c", + background: "#f8fbff", + color: "#403f53", padding: "0.5em", }, "hljs-emphasis": { diff --git a/src/components/app-sidebar/nav-plugins.tsx b/src/components/app-sidebar/nav-plugins.tsx index df2b4b58..45163460 100644 --- a/src/components/app-sidebar/nav-plugins.tsx +++ b/src/components/app-sidebar/nav-plugins.tsx @@ -87,7 +87,7 @@ export function NavPlugins() {
- + Plugins @@ -139,25 +139,35 @@ export function NavPlugins() { />
)} - {filtered.map((item) => ( - - - - {item.icon && } - {item.name} - - - - ))} + {filtered.map((item) => { + const isActive = item.url === location.pathname; + + return ( + + + + {item.icon && ( + + )} + {item.name} + + + + ); + })} {filtered.length === 0 && visible.length > 0 && search.trim() && (

No plugins match.

)} diff --git a/src/pages/about/index.tsx b/src/pages/about/index.tsx index 62c47829..7e306154 100644 --- a/src/pages/about/index.tsx +++ b/src/pages/about/index.tsx @@ -1,9 +1,4 @@ -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { useAboutStore } from '@/store/about'; import { openUrl } from '@/utils/linking'; @@ -18,13 +13,18 @@ import { ZapIcon, PlugZapIcon, MonitorIcon, + StarIcon, + ComputerIcon, } from 'lucide-react'; const FEATURES = [ { icon: ScrollTextIcon, text: 'Live logs, errors & stack traces' }, { icon: ZapIcon, text: 'Real-time performance & variable inspection' }, - { icon: PlugZapIcon, text: '18 built-in plugins — screenshots, REPL, profiler & more' }, + { icon: PlugZapIcon, text: '20+ built-in plugins — screenshots, REPL, profiler & more' }, { icon: MonitorIcon, text: 'Multi-session · mobile · disk mode' }, + { icon: ComputerIcon, text: 'CLI and build tools' }, + { icon: BookOpenIcon, text: 'Extensive docs, examples & API reference' }, + { icon: StarIcon, text: 'Open sourced with ❤️ by the community' }, ]; export function AboutModal() { @@ -41,8 +41,7 @@ export function AboutModal() { About Feather - {/* Hero */} -
+
🪶

Feather

@@ -53,7 +52,6 @@ export function AboutModal() {

- {/* Feature list */}
    {FEATURES.map(({ icon: Icon, text }) => (
  • @@ -63,34 +61,22 @@ export function AboutModal() { ))}
- {/* CTAs */} -
- -
- {/* Update banner */} {!isLatestVersion && (

New version available

-

- Update to get the latest features and fixes. -

+

Update to get the latest features and fixes.

- - diff --git a/src/pages/log/index.tsx b/src/pages/log/index.tsx index 8c95301c..234867a4 100644 --- a/src/pages/log/index.tsx +++ b/src/pages/log/index.tsx @@ -11,13 +11,14 @@ import { useConfig } from '@/hooks/use-config'; import { Log, LogType, useLogs } from '@/hooks/use-logs'; import { LuaBlock, TraceViewer } from '@/components/code'; import { isWeb } from '@/utils/platform'; -import { Command } from '@tauri-apps/plugin-shell'; +import { invoke } from '@tauri-apps/api/core'; import { useSettingsStore } from '@/store/settings'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { useConfigStore } from '@/store/config'; import { useSessionStore } from '@/store/session'; import { useQueryClient } from '@tanstack/react-query'; import { sessionQueryKey } from '@/hooks/use-ws-connection'; +import { toast } from 'sonner'; export const columns: ColumnDef[] = [ { @@ -51,12 +52,14 @@ export function TraceBlock({ code, basePath }: { code: string; basePath: string return; } - // TODO: add support for other OS (Windows) - // TODO: add support for other editors - // TODO: Test on Linux - await Command.create('code', ['-c', `${textEditorPath} --goto ${basePath}/${file}:${line}`]).execute(); + await invoke('open_source_location', { + editorPath: textEditorPath, + projectRoot: basePath, + relativeFile: file, + line, + }); } catch (e) { - console.log(e); + toast.error(e instanceof Error ? e.message : String(e), { position: 'bottom-center' }); } }} /> diff --git a/src/pages/observable/index.tsx b/src/pages/observable/index.tsx index 10821ddf..087dd428 100644 --- a/src/pages/observable/index.tsx +++ b/src/pages/observable/index.tsx @@ -14,7 +14,7 @@ import { lineDiff, hasDiff } from '@/utils/diff'; import { cn } from '@/utils/styles'; import { Badge } from '@/components/ui/badge'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { ActivityIcon, SearchIcon, SparklesIcon } from 'lucide-react'; +import { ActivityIcon, EyeIcon, SearchIcon } from 'lucide-react'; function DiffView({ oldValue, newValue }: { oldValue: string; newValue: string }) { const lines = lineDiff(oldValue, newValue); @@ -236,16 +236,16 @@ end`} {data.length > 0 && ( - - - {data.length.toLocaleString()} shown + + + {data.length.toLocaleString()} of {all.length.toLocaleString()} shown )}
diff --git a/src/pages/plugins/content.tsx b/src/pages/plugins/content.tsx index 0d47b993..749b1d81 100644 --- a/src/pages/plugins/content.tsx +++ b/src/pages/plugins/content.tsx @@ -2,7 +2,6 @@ import { useConfigStore } from '@/store/config'; import { useSettingsStore } from '@/store/settings'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; @@ -34,9 +33,10 @@ import { import { downloadFile } from '@/utils/file'; import { isWeb } from '@/utils/platform'; import { convertFileSrc } from '@tauri-apps/api/core'; -import { Bookmark, ChevronRight, DownloadIcon, ExternalLink } from 'lucide-react'; +import { Bookmark, CheckIcon, ChevronRight, DownloadIcon, ExternalLink } from 'lucide-react'; import { ReactNode, useCallback, useEffect, useState } from 'react'; import { Badge } from '@/components/ui/badge'; +import { cn } from '@/utils/styles'; const isDirectImageSrc = (src: string) => src.startsWith('data:') || src.startsWith('blob:'); const isResolvedBinarySrc = (src: string) => src.startsWith('blob:') || src.startsWith('data:'); @@ -576,6 +576,48 @@ const renderUiLabel = (node: PluginUiNode, control: ReactNode) => { ); }; +function PluginUiCheckbox({ + node, + onParamsChange, +}: { + node: PluginUiNode; + onParamsChange?: (params: Record) => void; +}) { + const name = getUiControlName(node); + const [checked, setChecked] = useState(node.checked === true); + + useEffect(() => { + setChecked(node.checked === true); + }, [node.checked]); + + return ( +
+ + {node.description ?

{node.description}

: null} +
+ ); +} + function PluginUiRenderer({ node, onAction, @@ -668,20 +710,7 @@ function PluginUiRenderer({ } if (node.type === 'checkbox') { - const name = getUiControlName(node); - return ( -
- name && onParamsChange?.({ [name]: checked === true ? 'true' : 'false' })} - /> -
- - {node.description ?

{node.description}

: null} -
-
- ); + return ; } if (node.type === 'switch') { diff --git a/src/pages/plugins/index.tsx b/src/pages/plugins/index.tsx index 71ea5bee..bfecbab5 100644 --- a/src/pages/plugins/index.tsx +++ b/src/pages/plugins/index.tsx @@ -7,13 +7,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { usePlugin, usePluginAction } from '@/hooks/use-plugin'; import { useConfigStore } from '@/store/config'; import { DynamicIcon, IconName } from 'lucide-react/dynamic'; -import { useCallback, useRef } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { Link, useHref } from 'react-router'; import { PluginContent } from './content'; -import { Checkbox } from '@/components/ui/checkbox'; import { openUrl } from '@/utils/linking'; import { PuzzleIcon, TriangleAlertIcon } from 'lucide-react'; import { FEATHER_PLUGIN_API } from '@/constants/feather-api'; +import { cn } from '@/utils/styles'; type PluginActionDefinition = { label: string; @@ -153,6 +153,8 @@ const pickInputProps = (props?: Record) => { }; const PluginAction = ({ label, action, icon, type, value, onClick, onFileClick, onChange, props, grouped }: PluginActionProps) => { + const [checked, setChecked] = useState(value === 'true'); + if (type === 'button') { return ( ); } diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index cb5cb1b5..3fae6daf 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -1,5 +1,8 @@ -import { useState } from 'react'; -import { Button } from '@/components/ui/button'; +import { useEffect, useMemo, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { open as openFolderDialog } from '@tauri-apps/plugin-dialog'; +import { Button, CopyButton } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { Dialog, DialogClose, @@ -11,17 +14,21 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Separator } from '@/components/ui/separator'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useSettingsStore } from '@/store/settings'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { useConfig } from '@/hooks/use-config'; import { useConfigStore } from '@/store/config'; import { useSessionStore } from '@/store/session'; import { MobileConnection } from '@/components/mobile-connection'; +import { openUrl } from '@/utils/linking'; +import { version as appVersion } from '../../../package.json'; import { ActivityIcon, CodeIcon, CopyIcon, + ExternalLinkIcon, EyeIcon, EyeOffIcon, FolderIcon, @@ -29,7 +36,63 @@ import { NetworkIcon, RefreshCwIcon, ShieldIcon, + TerminalIcon, } from 'lucide-react'; +import { toast } from 'sonner'; + +const INSTALL_DOCS_URL = 'https://kyonru.github.io/feather/installation/'; +const CLI_DOCS_URL = 'https://kyonru.github.io/feather/cli/'; + +function normalizeVersion(version?: string | null) { + return version?.trim().replace(/^v/i, '') ?? ''; +} + +type CliStatus = { + installed: boolean; + path?: string | null; + version?: string | null; + source?: string | null; + nodeVersion?: string | null; + npmVersion?: string | null; + error?: string | null; + installDocsUrl: string; + cliDocsUrl: string; +}; + +type DoctorCheck = { + group: string; + label: string; + severity: 'pass' | 'warn' | 'fail' | 'info'; + detail?: string; + fix?: string; +}; + +type DoctorResult = { + failures?: number; + warnings?: number; + checks?: DoctorCheck[]; +}; + +type VendorResult = { + vendors?: Array<{ + target: string; + configured: boolean; + exists: boolean; + valid: boolean; + detail: string; + configuredPath?: string; + relativePath?: string; + }>; +}; + +type CliProjectStatus = { + cli: CliStatus; + projectDir: string; + doctor?: DoctorResult | null; + buildDoctor?: DoctorResult | null; + vendors?: VendorResult | null; + errors: string[]; +}; function Section({ icon: Icon, @@ -41,13 +104,13 @@ function Section({ children: React.ReactNode; }) { return ( -
+
- {title} +

{title}

-
{children}
-
+
{children}
+ ); } @@ -55,6 +118,20 @@ function FieldDescription({ children }: { children: React.ReactNode }) { return

{children}

; } +function SettingsGrid({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function SettingsTabContent({ value, children }: { value: string; children: React.ReactNode }) { + return ( + + +
{children}
+
+
+ ); +} + function ThemeToggle() { const theme = useSettingsStore((state) => state.theme); const setTheme = useSettingsStore((state) => state.setTheme); @@ -282,7 +359,7 @@ function TextEditorInput() { const setTextEditorPath = useSettingsStore((state) => state.setTextEditorPath); return (
- + - Used to open log file locations from the desktop. Common values:{' '} - /usr/local/bin/code, /usr/bin/vim. + Used to open stack trace file locations through a direct, shell-free VS Code launch. Do not include command + arguments.
); } +function SeverityBadge({ severity }: { severity: DoctorCheck['severity'] }) { + const className = + severity === 'fail' + ? 'border-red-500 text-red-600' + : severity === 'warn' + ? 'border-amber-500 text-amber-600' + : severity === 'pass' + ? 'border-emerald-500 text-emerald-600' + : 'text-muted-foreground'; + + return ( + + {severity} + + ); +} + +function CliStatusPanel() { + const cliPath = useSettingsStore((state) => state.cliPath); + const setCliPath = useSettingsStore((state) => state.setCliPath); + const cliProjectDir = useSettingsStore((state) => state.cliProjectDir); + const setCliProjectDir = useSettingsStore((state) => state.setCliProjectDir); + const sourceDir = useConfigStore((state) => state.config?.sourceDir ?? ''); + const [status, setStatus] = useState(null); + const [projectStatus, setProjectStatus] = useState(null); + const [loading, setLoading] = useState(false); + + const projectDir = cliProjectDir || sourceDir; + const doctorChecks = projectStatus?.doctor?.checks ?? []; + const buildChecks = projectStatus?.buildDoctor?.checks ?? []; + const vendorEntries = projectStatus?.vendors?.vendors ?? []; + const importantChecks = useMemo( + () => + [...doctorChecks, ...buildChecks] + .filter((check) => check.severity === 'fail' || check.severity === 'warn') + .slice(0, 8), + [doctorChecks, buildChecks], + ); + + const refresh = async () => { + setLoading(true); + try { + const nextStatus = await invoke('get_cli_status', { cliPath: cliPath || null }); + setStatus(nextStatus); + if (projectDir) { + const nextProjectStatus = await invoke('get_cli_project_status', { + projectDir, + cliPath: cliPath || null, + }); + setProjectStatus(nextProjectStatus); + } else { + setProjectStatus(null); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error), { position: 'bottom-center' }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + refresh(); + }, []); + + const chooseProjectDir = async () => { + const selected = await openFolderDialog({ directory: true, multiple: false }); + if (typeof selected === 'string') { + setCliProjectDir(selected); + setProjectStatus(null); + } + }; + + const cliInstalled = projectStatus?.cli.installed ?? status?.installed ?? false; + const currentStatus = projectStatus?.cli ?? status; + const summary = projectStatus?.buildDoctor ?? projectStatus?.doctor; + const doctorProjectDir = projectStatus?.projectDir || projectDir; + const cliVersionMismatch = + cliInstalled && + Boolean(currentStatus?.version) && + normalizeVersion(currentStatus?.version) !== normalizeVersion(appVersion); + + return ( +
+
+
+ + {cliInstalled ? 'CLI installed' : 'CLI missing'} + + {currentStatus?.version && ( + + v{currentStatus.version} + + )} + {currentStatus?.source && {currentStatus.source}} +
+ {currentStatus?.path && ( +

{currentStatus.path}

+ )} + {currentStatus?.error &&

{currentStatus.error}

} + {cliVersionMismatch && ( +
+ CLI version mismatch. Desktop is v{appVersion}, but the detected CLI is v{currentStatus?.version}. Update + with npm install -g @kyonru/feather. +
+ )} +
+ + + +
+
+ +
+ + setCliPath(event.target.value)} + className="font-mono text-sm" + /> + + Optional. Leave empty to detect feather automatically. + +
+ +
+ +
+ setCliProjectDir(event.target.value)} + className="font-mono text-sm" + /> + +
+ + Used for read-only doctor and vendor checks. The active session source directory is used when this is empty. + +
+ + {currentStatus && ( +
+
+

Node

+

{currentStatus.nodeVersion ?? 'not found'}

+
+
+

npm

+

{currentStatus.npmVersion ?? 'not found'}

+
+
+ )} + + {summary && ( +
+
+

Doctor warnings

+

{summary.warnings ?? 0}

+
+
+

Doctor failures

+

{summary.failures ?? 0}

+
+
+ )} + + {vendorEntries.length > 0 && ( +
+ +
+ {vendorEntries.map((vendor) => ( +
+ {vendor.target} + + {vendor.valid ? 'ready' : vendor.exists ? 'incomplete' : 'missing'} + +
+ ))} +
+
+ )} + + {importantChecks.length > 0 && ( +
+
+ + {doctorProjectDir && ( +

Project: {doctorProjectDir}

+ )} +
+
+ {importantChecks.map((check, index) => ( +
+
+ + {check.label} + {check.group} +
+ {check.detail &&

{check.detail}

} + {check.fix && ( +
+ {check.fix} + +
+ )} +
+ ))} +
+
+ )} + + {projectStatus?.errors && projectStatus.errors.length > 0 && ( +
+ {projectStatus.errors.map((error) => ( +

{error}

+ ))} +
+ )} +
+ ); +} + export function SettingsModal() { const open = useSettingsStore((state) => state.open); const setOpen = useSettingsStore((state) => state.setOpen); @@ -307,48 +626,77 @@ export function SettingsModal() { return ( - - + + Settings Changes are applied immediately and persisted automatically. -
-
- -
- - + +
+ + + + General + + + + Connection + + + + Security + + + + CLI + + +
-
- - - - -
+
+ +
+ +
+
+ +
+
+ +
+
- + +
+ + + + + + +
+
-
- - - -
+ +
+ + + + + +
+
- - -
- -
- - - -
- -
-
+ +
+ +
+
+
+ - + + <> +
+ + + + +
+

+ These are setup shortcuts only. Feather will move out of the way once a session connects. +

+ )}
diff --git a/src/store/settings.ts b/src/store/settings.ts index 2ca62bd5..0485ce12 100644 --- a/src/store/settings.ts +++ b/src/store/settings.ts @@ -7,6 +7,8 @@ type SettingsStoreState = { // Port the Feather desktop WS server listens on (games connect to this) port: number; textEditorPath: string; + cliPath: string; + cliProjectDir: string; isLatestVersion: boolean; apiKey: string; appId: string; @@ -24,6 +26,8 @@ type SettingsStoreActions = { setTheme: (theme: 'system' | 'light' | 'dark') => void; setPort: (port: number) => void; setTextEditorPath: (textEditorPath: string) => void; + setCliPath: (cliPath: string) => void; + setCliProjectDir: (cliProjectDir: string) => void; setPausedLogs: (pausedLogs: boolean) => void; setApiKey: (apiKey: string) => void; setAppId: (appId: string) => void; @@ -53,6 +57,8 @@ const defaultSettings: SettingsStoreState = { sessionApiKeys: {}, port: 4004, textEditorPath: '/usr/local/bin/code', + cliPath: '', + cliProjectDir: '', pausedLogs: false, connectionTimeout: 15, hiddenPlugins: [], @@ -68,6 +74,8 @@ export const useSettingsStore = create()( setTheme: (theme: 'system' | 'light' | 'dark') => set({ theme }), setPort: (port: number) => set({ port }), setTextEditorPath: (textEditorPath: string) => set({ textEditorPath }), + setCliPath: (cliPath: string) => set({ cliPath }), + setCliProjectDir: (cliProjectDir: string) => set({ cliProjectDir }), reset: () => set((state) => ({ ...state, ...defaultSettings, open: state.open })), setPausedLogs: (pausedLogs: boolean) => set({ pausedLogs }), setApiKey: (apiKey: string) => set({ apiKey }),