diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml index d9384ab5..62ff6db1 100644 --- a/.github/workflows/cli-e2e.yml +++ b/.github/workflows/cli-e2e.yml @@ -26,9 +26,14 @@ permissions: jobs: cli-e2e: - name: CLI end-to-end - runs-on: ubuntu-latest - timeout-minutes: 10 + name: CLI end-to-end (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] steps: - name: Checkout @@ -40,10 +45,15 @@ jobs: node-version: 22.21.1 cache: "npm" - - name: Install LÖVE + - name: Install LÖVE (Linux) + if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y love + sudo apt-get install -y love squashfs-tools + + - name: Install squashfs-tools (macOS) + if: runner.os == 'macOS' + run: brew install squashfs - name: Install dependencies run: npm ci diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e72d5194..1a801d34 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -31,6 +31,10 @@ jobs: - name: Run linting run: npm run lint + + - name: Build and test VS Code extension + run: npm run extension:test + lint-lua: name: Run Lua (Love2D) Linting runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4626b7bc..a0a944ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: - name: Install LÖVE run: | sudo apt-get update - sudo apt-get install -y love + sudo apt-get install -y love xvfb - name: Get version from tag id: get_version @@ -58,6 +58,9 @@ jobs: - name: Run CLI e2e run: npm run test:cli:e2e + - name: Run Lua e2e + run: npm run test:lua:e2e + - name: Verify npm package contents run: npm pack --workspace=cli --dry-run diff --git a/.github/workflows/vscode-extension.yml b/.github/workflows/vscode-extension.yml new file mode 100644 index 00000000..270dd164 --- /dev/null +++ b/.github/workflows/vscode-extension.yml @@ -0,0 +1,108 @@ +name: VS Code extension release + +on: + push: + tags: + - "*" + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + vsixFile: ${{ steps.package.outputs.vsixFile }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22.21.1 + package-manager-cache: false + + - uses: oven-sh/setup-bun@v2 + + - run: npm ci + + - name: Get version from tag + id: get_version + run: | + VERSION="${GITHUB_REF_NAME#v}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Verify extension package version + run: | + EXT_VERSION="$(node -p "require('./vscode-extension/package.json').version")" + if [ "$EXT_VERSION" != "${{ steps.get_version.outputs.version }}" ]; then + echo "Tag version ${{ steps.get_version.outputs.version }} does not match vscode-extension/package.json version $EXT_VERSION." + exit 1 + fi + + - name: Test extension + run: npm run extension:test + + - name: Package extension + id: package + run: | + npm run extension:package + VERSION="${{ steps.get_version.outputs.version }}" + VSIX_PATH="$(ls vscode-extension/*.vsix | head -n 1)" + VSIX_FILE="feather-vscode-${VERSION}.vsix" + mkdir -p release-assets + cp "$VSIX_PATH" "release-assets/$VSIX_FILE" + cp cli/bin/feather "release-assets/feather-cli-darwin-arm64-${VERSION}.bin" + cp cli/bin/feather-darwin-x64 "release-assets/feather-cli-darwin-x64-${VERSION}.bin" + cp cli/bin/feather-linux-x64 "release-assets/feather-cli-linux-x64-${VERSION}.bin" + cp cli/bin/feather-win-x64.exe "release-assets/feather-cli-windows-x64-${VERSION}.exe" + echo "vsixPath=release-assets/$VSIX_FILE" >> "$GITHUB_OUTPUT" + echo "vsixFile=$VSIX_FILE" >> "$GITHUB_OUTPUT" + + - name: Upload VSIX + uses: actions/upload-artifact@v4 + with: + name: feather-vscode + path: ${{ steps.package.outputs.vsixPath }} + + - name: Attach VSIX and CLI binaries to GitHub Release + uses: ncipollo/release-action@v1 + with: + artifacts: release-assets/* + allowUpdates: true + + publish-vs-marketplace: + runs-on: ubuntu-latest + needs: build + permissions: + contents: read + steps: + - name: Download VSIX + uses: actions/download-artifact@v4 + with: + name: feather-vscode + path: . + + - name: Publish to Visual Studio Marketplace + uses: HaaLeo/publish-vscode-extension@v2 + with: + pat: ${{ secrets.VS_MARKETPLACE_TOKEN }} + registryUrl: https://marketplace.visualstudio.com + extensionFile: ${{ needs.build.outputs.vsixFile }} + + publish-open-vsx: + runs-on: ubuntu-latest + needs: build + permissions: + contents: read + steps: + - name: Download VSIX + uses: actions/download-artifact@v4 + with: + name: feather-vscode + path: . + + - name: Publish to Open VSX + uses: HaaLeo/publish-vscode-extension@v2 + with: + pat: ${{ secrets.OPEN_VSX_TOKEN }} + registryUrl: https://open-vsx.org + extensionFile: ${{ needs.build.outputs.vsixFile }} diff --git a/.gitignore b/.gitignore index f2ef53e3..c655af86 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ dist-ssr # Editor directories and files .vscode/* !.vscode/extensions.json +!.vscode/launch.json +!.vscode/tasks.json .idea .DS_Store *.suo @@ -31,4 +33,9 @@ test-results/ .env builds/ vendor/ -feather.build.json \ No newline at end of file +feather.build.json + +# VS Code extension build outputs +vscode-extension/bundled-*/ +vscode-extension/out/ +vscode-extension/*.vsix diff --git a/.husky/commit-msg b/.husky/commit-msg index 16c5abd4..9356f9cc 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:*|package:*|plugin:*|app:*|lua:*|tauri:*|feather:*|docs:*) + ci:*|cli:*|package:*|plugin:*|app:*|lua:*|tauri:*|feather:*|docs:*|vscode-extension:*) exit 0 ;; esac @@ -23,6 +23,7 @@ Commit message must start with one of: tauri: feather: docs: + vscode-extension: Examples: ci: update repo workflows @@ -32,6 +33,7 @@ Examples: tauri: update file permissions feather: update repo structure docs: add contribution guidelines + vscode-extension: update extension manifest EOF exit 1 diff --git a/.husky/pre-commit b/.husky/pre-commit index 90fde547..cdfeaa40 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -14,7 +14,7 @@ if ! git diff --exit-code cli/src/generated/plugin-catalog.ts; then fi bash scripts/set-version.sh -if ! git diff --exit-code package.json src-lua/feather/init.lua src-tauri/Cargo.toml src-tauri/tauri.conf.json; then +if ! git diff --exit-code package.json src-lua/feather/init.lua src-tauri/Cargo.toml src-tauri/tauri.conf.json cli/package.json vscode-extension/package.json; then echo "[feather] Version files are out of sync — they have been updated, please stage them and commit again." exit 1 fi diff --git a/.npmrc b/.npmrc deleted file mode 100644 index eab6f927..00000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -min-release-age=15 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..c870b95d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Feather VS Code Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}/vscode-extension"], + "outFiles": ["${workspaceFolder}/vscode-extension/out/**/*.js"], + "preLaunchTask": "npm: extension:build" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..93ed1316 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,12 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "npm: extension:build", + "type": "npm", + "script": "extension:build", + "group": "build", + "problemMatcher": "$tsc" + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index ae953a5e..da4fd9bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ 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.3.0] - 2026-05-22 - The one with the extension + +### Added + +- VS Code Extension +- Session Replay +- CLI Fastlane draft support +- Lua plugin unit tests +- Lua callback bus with priority overrides + +### Changed + +- CLI managed key to identify flow type +- Replay plugins now use the plugin handler bus instead of rewriting LÖVE hooks +- Improved session performance documentation +- Improved session replay examples and documentation +- Improved Feather upload process +- Improved Feather init process +- Improved Feather testing +- Improved CLI build flow +- Improved CLI configuration +- Improved CLI Linux vendoring +- Updated roadmap +- Shim hooks on load + +### Fixed + +- Fixed regex issues in CLI +- Fixed optimistic plugin updates +- Fixed input replay +- Fixed stack overflow on LÖVE hooks + ## [v1.2.0] - 2026-05-19 - The one with the shader and particles playground ### Added @@ -492,6 +524,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - LuaRocks package. - GitHub Actions CI. +[v1.3.0]: https://github.com/Kyonru/feather/compare/v1.2.0...v1.3.0 [v1.2.0]: https://github.com/Kyonru/feather/compare/v1.1.1...v1.2.0 [v1.1.1]: https://github.com/Kyonru/feather/compare/v1.1.0...v1.1.1 [v1.1.0]: https://github.com/Kyonru/feather/compare/v1.0.1...v1.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 017c539c..ccfa8b30 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,15 +1,29 @@ # Contributing to Feather -Thanks for helping make Feather better. This project spans a React/Tauri desktop app, a TypeScript CLI, and an embedded Lua runtime for LÖVE games, so the best contributions are small, well-tested, and clear about which part of the stack they touch. +Thanks for helping make Feather better. Feather spans a React/Tauri desktop app, a TypeScript CLI, a VS Code extension, and an embedded Lua runtime for LÖVE games. The best contributions are focused, testable, documented, and careful about the fact that Feather runs inside someone else's game during development. -Feather uses [Ink](https://github.com/vadimdemedes/ink) for interactive CLI workflows. Ink's own contributing guide is a good reference for the kind of contribution style we want too: focused changes, reproducible tests, and human-reviewed work. +Feather's current product direction is polish first: make setup, debugging, plugin workflows, release safety, and docs feel reliable before adding more surface area. New features should make LÖVE developers faster at debugging, validating, shipping, or reproducing a problem. -## Before You Start +## Working Principles -- Open an issue or draft PR for large features, protocol changes, or plugin API changes. -- Keep PRs focused. A bug fix, documentation update, CLI workflow, or Lua runtime change should usually stand on its own. -- Include docs when behavior changes. User-facing CLI, plugin, debugger, hot reload, or security changes should update `docs/` and relevant README files. -- Treat Feather as development tooling that can run inside someone’s game. Security-sensitive features such as Console, hot reload, and app ID validation should stay opt-in and clearly documented. +- Keep changes small and scoped. A CLI fix, Lua plugin change, desktop workflow, docs update, or VS Code extension change should usually stand on its own. +- Prefer CLI-managed development flows. Normal examples and integrations should work with `feather run` and `feather.config.lua`, not by requiring Feather directly from game code. +- Keep production builds Feather-free by default. Release builds must exclude Feather runtime files, `feather.config.lua`, plugins, local replay/debug artifacts, and generated development files unless the user explicitly opts into a debug build. +- Treat Console, hot reload, filesystem writes, insecure app pairing, debugger hooks, and replay/session capture as development-only features. They must be opt-in and clearly documented. +- Keep plugin APIs declarative and serializable. Lua plugins should expose data and actions; React should render generic plugin UI unless a dedicated desktop page is genuinely needed. +- Update docs when behavior changes. If a user-facing command, config field, plugin option, extension command, build behavior, or safety check changes, update the relevant docs in the same PR. + +## For Agents And Automation + +If you are an automated coding agent, follow these extra rules: + +- Read the relevant files before editing. Use `rg` and focused file reads to understand the existing pattern. +- Check `git status --short` before and after. Never revert unrelated changes; assume they belong to the user. +- Do not modify `package-lock.json` unless dependencies actually changed. +- Use `apply_patch` or normal editor-style edits. Avoid broad rewrites and formatting churn. +- Run the narrowest useful tests first, then broader checks when the change crosses boundaries. +- If a check cannot run because local tooling is missing, say so plainly and include the exact failure. +- Do not leave generated files stale. If a generated file changes because it should, include it. If it changes accidentally, restore it carefully. ## Development Setup @@ -26,66 +40,109 @@ npm run cli:build npm run feather -- --help ``` -Run the desktop app in development: +Run the desktop app: ```sh npm run tauri dev ``` -Serve the docs: +Serve docs: ```sh npm run docs ``` -## Common Development Workflows +## Project Layout -### Run the CLI +- `src/` contains the React desktop app. +- `src-tauri/` contains the Tauri shell and WebSocket server. +- `cli/` contains the Feather CLI, build/upload/release logic, package manager, and Ink workflows. +- `cli/test/commands/` contains CLI end-to-end tests using Node's built-in test runner. +- `src-lua/feather/` contains the embedded Lua runtime. +- `src-lua/plugins/` contains built-in Lua plugins. +- `src-lua/example/` contains runnable LÖVE examples. +- `vscode-extension/` contains the VS Code extension. +- `docs/` contains the MkDocs documentation site. +- `packages/` contains curated package registry entries. -When changing CLI code or the bundled Lua runtime, rebuild the Lua bundle and CLI before running local commands: +`docs/cli.md` is a symlink to `cli/README.md`, and `docs/vscode-extension.md` is a symlink to `vscode-extension/README.md`. Edit the source file directly when your editor or tool has trouble writing through symlinks. -```sh -bash ./scripts/bundle-lua.sh -npm run cli:build -npm run feather doctor +## CLI-Managed Examples + +Most examples should be plain LÖVE projects. Do not add direct `require("feather")`, `require("feather.auto")`, `FeatherDebugger(...)`, or `DEBUGGER:update(dt)` to normal examples unless the example is specifically demonstrating manual/auto embedding. + +Prefer this shape: + +```txt +src-lua/example/my_example/ + main.lua + conf.lua + feather.config.lua ``` -Use `npm run feather -- ` from the repo root. The `--` separates npm arguments from Feather arguments: +Run it with: ```sh -npm run feather -- --help -npm run feather -- doctor src-lua/example/test_cli -npm run feather -- run src-lua/example/test_cli +npm run feather -- run src-lua/example/my_example ``` -### Run Game Examples +Example game code may use guarded runtime APIs: -Run the `test_cli` example directly with verbose game arguments and an explicit config: +```lua +if DEBUGGER then + DEBUGGER:observe("player.x", player.x) +end +``` -```sh -npm run feather -- run src-lua/example/test_cli -- --verbose --config=src-lua/example/test_cli/feather.config.lua +Plugin options belong in `feather.config.lua`: + +```lua +return { + include = { "session-replay", "hot-reload" }, + pluginOptions = { + ["session-replay"] = { + captureJoystickAxis = true, + }, + ["hot-reload"] = { + enabled = true, + allow = { "gameplay" }, + }, + }, +} ``` -You can also run from `src-lua` and ask the example harness to load `test_cli`: +The CLI shim must preserve `pluginOptions` for any plugin, including IDs with dashes. If you change config parsing or shim generation, add or update CLI tests that prove nested plugin options survive injection. + +## Common Workflows + +Run the CLI: ```sh -npm run feather -- run src-lua --test-cli -- --verbose --config=src-lua/example/test_cli/feather.config.lua +npm run cli:build +npm run feather -- doctor src-lua/example/test_cli +npm run feather -- run src-lua/example/test_cli ``` -Keep the desktop app open while running examples if you are testing debugger, plugin, observer, log, or WebSocket behavior. +Run a focused CLI test file: -### Run Android Development Builds +```sh +npm run cli:build +node --test cli/test/commands/run.test.mjs +``` -Android workflows need Android SDK/JDK tooling, `adb`, and a local LÖVE Android vendor. Fetch the vendor first: +Run Lua examples: ```sh -npm run feather -- build vendor add android --dir src-lua/example/test_cli -npm run feather -- build vendor list --dir src-lua/example/test_cli +npm run feather -- run src-lua/example/test_cli +npm run feather -- run src-lua/example/session_replay/single_player +npm run feather -- run src-lua/example/session_replay/multiplayer +npm run feather -- run src-lua/example/session_replay/adapter ``` -Then build or run Android: +Run Android development builds: ```sh +npm run feather -- build vendor add android --dir src-lua/example/test_cli npm run feather -- build android --dir src-lua/example/test_cli --verbose npm run feather -- run src-lua/example/test_cli --target android --verbose ``` @@ -97,38 +154,34 @@ npm run feather -- build android --dir src-lua/example/test_cli --no-cache --ver npm run feather -- build android --dir src-lua/example/test_cli --clean --verbose ``` -## Project Layout - -- `src/` contains the React desktop app. -- `src-tauri/` contains the Tauri shell and WebSocket server. -- `cli/` contains the Feather CLI and Ink workflows. -- `src-lua/feather/` contains the embedded Lua runtime. -- `src-lua/plugins/` contains built-in Lua plugins. -- `src-lua/example/` contains runnable LÖVE examples. -- `docs/` contains the MkDocs documentation site. - -## Commit Messages +## Verification -Commit subjects must start with one of these prefixes: +Run the checks that match your change. For broad changes, run more than one lane. -```txt -cli: -app: -lua: -tauri: -feather: -docs: +```sh +npm run typecheck:web +npm run typecheck:lua +npm run lint +npm run cli:build +npm run test:cli:e2e +npm run test:lua:e2e +npm run test:app:e2e +npm run test:tauri:e2e +npm run extension:build +npm run extension:test ``` -Examples: +Focused guidance: -```txt -cli: add interactive remove workflow -app: improve session empty state -lua: harden hot reload allowlist checks -tauri: validate websocket payloads -docs: document app id pairing -``` +- React desktop changes: `npm run typecheck:web`, `npm run lint`, and Playwright if visible behavior changed. +- CLI changes: `npm run cli:build` and the relevant `node --test cli/test/commands/*.test.mjs` file. +- Lua runtime or plugin changes: `npm run typecheck:lua`, `npm run test:lua:e2e`, and any focused Lua e2e path if available. +- Build/upload/release safety changes: run targeted build, doctor, upload-safety, and release tests. +- Tauri/WebSocket changes: `npm run test:tauri:e2e`. +- VS Code extension changes: `npm run extension:build` and `npm run extension:test`. +- Docs-only changes: read the edited Markdown and run `npm run docs` when practical. + +If `love`, `luacheck`, Android SDK, Xcode, Fastlane, or other local tooling is missing, document that in the PR or final handoff. Do not pretend the check passed. ## Generated Files @@ -136,58 +189,111 @@ Some files are generated and must stay in sync: - `src-lua/manifest.txt` - `cli/src/generated/plugin-catalog.ts` +- `cli/src/generated/registry.json` - version fields in `package.json`, `src-lua/feather/init.lua`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json` -The pre-commit hook checks these automatically. You can refresh them manually: +Refresh them with: ```sh bash scripts/generate-manifest.sh npm run generate:plugin-catalog +npm run generate:registry bash scripts/set-version.sh ``` -## Checks +`npm run check:plugin-catalog` and `npm run check:registry` intentionally fail when generated output differs from git. If the generated diff is expected, commit it. + +Generated or local-only folders such as `vscode-extension/bundled-cli/`, `vscode-extension/bundled-bin/`, build outputs, caches, and replay/debug artifacts should not be committed. + +## Lua Runtime Guidelines + +- Keep Feather disabled in production unless the user explicitly chooses a debug build. +- Guard manual integration with `USE_DEBUGGER` when touching auto/manual init flows. +- Keep CLI mode clean: the CLI injects Feather and drives `DEBUGGER:update(dt)`. +- Do not make Console, hot reload, session replay, filesystem writes, or remote code execution load by default in production-oriented configs. +- Keep runtime APIs JSON-serializable when data crosses the WebSocket/plugin boundary. +- Avoid global mutation unless the plugin explicitly wraps LÖVE callbacks or exposes a guarded `DEBUGGER` API. +- Add Lua e2e coverage for runtime behavior, callback wrapping, replay, protocol, or plugin lifecycle changes. -Run the checks that match your change. For broad changes, run all of them. +## Plugin Guidelines + +Built-in plugins live under `src-lua/plugins//` and should usually include: + +```txt +src-lua/plugins// + init.lua + manifest.lua + README.md +``` + +After adding or changing a built-in plugin: ```sh -npm run typecheck:web -npm run typecheck:lua -npm run lint +bash scripts/generate-manifest.sh +npm run generate:plugin-catalog npm run cli:build -npm run test:cli:e2e +npm run typecheck:lua npm run test:lua:e2e -npm run test:app:e2e -npm run test:tauri:e2e ``` -Use the focused lanes when possible: +Plugin contribution tips: -- React app changes: `npm run typecheck:web`, `npm run lint`, and Playwright if UI behavior changed. -- CLI changes: `npm run cli:build` and `npm run test:cli:e2e`. -- Lua runtime/plugin changes: `npm run typecheck:lua` and `npm run test:lua:e2e`. -- Tauri/WebSocket changes: `npm run test:tauri:e2e`. -- Docs-only changes: `npm run docs` and skim the rendered page. +- Prefer improving existing plugins over adding more plugins. +- Mark opt-in, disabled, dangerous, or development-only behavior clearly in `manifest.lua` and docs. +- Keep plugin state session-local. +- Document setup, options, security implications, and at least one minimal snippet. +- Use `pluginOptions` in `feather.config.lua` for CLI-managed configuration. +- Avoid plugin-specific React code unless the workflow needs a dedicated desktop page. If you add one, keep the generic plugin tab useful for status and simple actions. -## Lua Runtime Guidelines +## Desktop App Guidelines -- Keep Feather disabled in production unless the user explicitly enables it. -- Guard generated imports with `USE_DEBUGGER`. -- Do not make Console, hot reload, or remote code execution features load by default. -- Do not allow hot reload to modify Feather internals or plugin allowlist/security configuration. -- Keep plugin APIs declarative and serializable. Lua plugins describe UI and behavior; React renders it. -- When adding built-in plugins, update the plugin manifest/catalog and documentation. +- Keep session data scoped to the active session. Never leak logs, plugin state, replay metadata, assets, or file roots between sessions. +- Show useful empty states when no session is selected or a session lacks a capability. +- Make requests optimistic only when rollback/error state is clear. +- Handle web mode and Tauri mode gracefully. +- Keep file access rooted in user-selected or configured directories. +- Use existing app patterns, query keys, routing, and shared UI components. ## CLI Guidelines -- Prefer interactive Ink workflows for commands that need several choices. - Keep non-interactive flags available for scripts and CI. -- Make generated Lua easy to remove later. Preserve `FEATHER-INIT` style comments and metadata when touching init/remove flows. -- When adding setup options, update `docs/cli.md`, `docs/configuration.md`, and generated config templates. +- Use Ink workflows for commands that need several choices, but never make automation depend on an interactive prompt. +- Preserve config and generated Lua in a form users can understand and remove. +- Follow skip-on-exists behavior for file installers. Use `--force` when overwriting is intentional. +- Keep `doctor` useful. New setup requirements should have a doctor check and a clear remediation message. +- When adding setup options, update `cli/README.md`, `docs/configuration.md`, and generated config templates. +- Build and upload commands must run production safety checks before shipping user-facing artifacts. + +## Build, Upload, And Release Safety + +Production paths should be boringly strict. + +- Release builds should exclude Feather runtime, plugins, `feather.config.lua`, `.feather-main.lua`, replay files, `.featherreplay`, logs, and generated debug artifacts. +- Upload safety should inspect existing artifacts where possible, including `.love`, `.zip`, `.apk`, `.aab`, `.ipa`, and app bundles. +- `feather doctor --production` should fail or warn on unsafe config, debug runtime footprints, missing signing/upload dependencies, weak app pairing, hot reload persistence, Console exposure, and replay artifacts. +- If a development config is unsafe, prefer offering a production staging/build path over asking users to manually edit several fields. + +## VS Code Extension Guidelines + +- The extension is a UI controller for the CLI. It should spawn the bundled CLI and avoid re-implementing CLI logic. +- Keep extension commands mapped closely to CLI commands. If behavior does not exist in the CLI, add it there first. +- Workspace-scoped settings should use `ConfigurationTarget.Workspace`, not `Global`, when they are project-specific. +- Register new commands in both `vscode-extension/src/extension.ts` and `vscode-extension/package.json`. +- Build/package flows should use the bundled CLI/binary prepared by `vscode-extension/scripts/prepare.mjs`. +- Do not commit generated extension bundles. + +## Documentation Guidelines + +- User-facing CLI behavior belongs in `cli/README.md` and, when broader, in `docs/usage.md` or a dedicated page. +- Runtime config belongs in `docs/configuration.md`. +- Plugin behavior belongs in `src-lua/plugins//README.md`; major workflows may also need a `docs/.md` page and a `mkdocs.yml` nav entry. +- VS Code extension behavior belongs in `vscode-extension/README.md`. +- Prefer copy-pasteable commands and small guarded Lua examples. +- Be explicit about what Feather does not do. For example, Session Replay records inputs and developer-selected state; it does not serialize an entire game. ## Package Catalog Contributions -Use the helper scripts instead of hand-writing package entries whenever possible. They fetch metadata, pin source commits or URLs, calculate SHA-256 checksums, write `packages/.json`, and regenerate `cli/src/generated/registry.json`. +Use helper scripts instead of hand-writing package entries whenever possible. They fetch metadata, pin source commits or URLs, calculate SHA-256 checksums, write `packages/.json`, and regenerate `cli/src/generated/registry.json`. For GitHub-hosted packages: @@ -195,7 +301,7 @@ For GitHub-hosted packages: npm run package:add ``` -For packages distributed as direct file URLs: +For direct file URLs: ```sh npm run package:add-url @@ -215,62 +321,44 @@ Package contribution tips: - Prefer tagged releases. If a package has no tags, pin a specific commit. - Keep install targets narrow and predictable, usually under `lib//`. - Include a realistic `require` path and a small usage example. -- Use `verified` only for packages that have been reviewed and pinned with checksums. Use `known` for checksum-pinned sources that still need extra review. +- Use `verified` only for packages reviewed and pinned with checksums. Use `known` for checksum-pinned sources that still need extra review. - Commit both `packages/.json` and `cli/src/generated/registry.json`. -## Plugin Contributions +## Commit Messages -Built-in plugins live under `src-lua/plugins//`. A plugin should usually include: +Commit subjects should start with one of these prefixes: ```txt -src-lua/plugins// - init.lua - manifest.lua - README.md -``` - -Start from a small existing plugin such as `runtime-snapshot`, `timer-inspector`, or `bookmark` when adding a new one. Keep plugin UI declarative through `getConfig()` so the desktop app can render it without plugin-specific React code. - -After adding or changing a built-in plugin: - -```sh -bash scripts/generate-manifest.sh -npm run generate:plugin-catalog -npm run typecheck:lua -npm run test:lua:e2e -npm run cli:build -npm run feather -- plugin list src-lua/example/test_cli +ci: +cli: +package: +plugin: +app: +lua: +tauri: +feather: +docs: +vscode-extension: ``` -To install and test a local plugin in an example project: +Examples: -```sh -npm run feather -- plugin install --dir src-lua/example/test_cli --local-src src-lua -npm run feather -- run src-lua/example/test_cli +```txt +cli: preserve plugin options in run shim +app: add session replay page +lua: harden hot reload allowlist checks +plugin: add session replay docs +docs: document cli-managed examples +vscode-extension: add release build command ``` -Plugin contribution tips: - -- Keep runtime state session-local and avoid global mutation unless the plugin explicitly wraps a LÖVE callback. -- Document setup, options, security implications, and at least one minimal usage snippet in the plugin `README.md`. -- Mark development-only or dangerous behavior clearly in `manifest.lua` and docs. -- Avoid desktop app changes unless the feature is generic for all server-driven plugins. -- Do not make plugins load Console, hot reload, filesystem writes, or remote code execution by default. - -## Desktop App Guidelines - -- Keep session-scoped behavior session-scoped. Avoid leaking data between connected games. -- Show empty states when no session is selected or a session does not support a feature. -- Handle web mode and Tauri mode gracefully. -- Keep file access rooted in user-selected or configured directories. -- Unless it's a generic code addition, plugins PR should not include plugin specific code for the Desktop app. - ## Pull Requests -Please include: +Include: - What changed and why. - Screenshots or short recordings for visible UI changes. -- The commands you ran. -- Any follow-up work or known limitations. -- AI Disclaimer +- The commands you ran and their results. +- Any checks you could not run and why. +- Known limitations or follow-up work. +- AI assistance disclosure when applicable. diff --git a/README.md b/README.md index 59fc1f3f..f5ec5c6f 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ The goal is to make the day-to-day loop of writing and testing a LÖVE game fast - **Variable inspection** — Watch values update as the game runs. - **Error capturing** — Errors are caught and shown with a full stack trace. - **Step debugger** — Breakpoints, step over/into/out, call stack, local variable inspection. +- **Session Replay** — Record inputs plus game-defined state checkpoints so playthroughs can be replayed, exported, and debugged later. - **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. @@ -42,13 +43,13 @@ 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 +```bash npm install -g @kyonru/feather ``` Initialize your project, open the Feather app, then run the game: -```sh +```bash feather init path/to/my-game feather run path/to/my-game ``` @@ -57,7 +58,7 @@ Feather is injected by the CLI for dev runs and debug builds, so your game code Optional vendor setup for web, mobile, and packaged desktop workflows: -```sh +```bash feather build vendor add web --dir path/to/my-game feather run path/to/my-game --target web @@ -70,13 +71,13 @@ feather run path/to/my-game --target ios For all build vendors, including desktop packaging runtimes: -```sh +```bash feather build vendor add all --dir path/to/my-game ``` Build release artifacts from the same CLI flow: -```sh +```bash 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 @@ -88,13 +89,14 @@ feather build steamos --dir path/to/my-game For more commands and options: -```sh +```bash feather --help feather run --help ``` See the [CLI docs](docs/cli.md) for `feather run`, `feather doctor`, `feather build`, and `feather upload`. + --- ## Package manager @@ -118,6 +120,7 @@ Available libraries include anim8, bump, hump, lume, flux, inspect, middleclass, - [CLI](docs/cli.md) - [Configuration](docs/configuration.md) - [Usage](docs/usage.md) — observers, logging, console, step debugger +- [Session Replay](docs/session-replay.md) - [Plugins](docs/plugins.md) - [Packages](packages/README.md) - [Recommendations](docs/recommendations.md) — security, performance, release builds diff --git a/ROADMAP.md b/ROADMAP.md index 7579fc02..12439e0b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,112 +1,103 @@ # Feather Roadmap -## Guiding Direction - -### Core Priorities - -1. Stability & trust first -2. Strong observability/debugging foundation -3. Asset/runtime inspection workflow -4. Fast iteration & hot-reload experience -5. Extensible plugin ecosystem -6. Public/community-ready release - ---- +I've been a fun month, but it's time to get back to my game, using all that i've been developing with feather to speedup development and serve as a real scenario for this tool. I'll be limiting new contributions to plugins, cool packages, and fixes, until i feel i have something worth focusing. Hopefully my experience with feather over the next months will serve as a good real world testing. -## 0.10.x — Iteration Workflow & Developer Experience +For now here's some future directions: -Focus: -Reduce iteration time and improve daily usability. - -### 0.10.0 – Developer Experience & Workflow - -#### Graphics Tooling +## Guiding Direction -- [ ] Shader inspector - - [ ] View/edit GLSL uniforms - - [ ] Hot-reload shader files +Feather already has enough surface area: CLI init/run/watch/build/upload/release, desktop inspector, package management, VS Code extension, and 24 built-in plugins. The next phase should deliberately pause feature growth and make the core workflow feel trustworthy, beautiful, and boringly reliable. -- [ ] Sprite atlas explorer -- [ ] Font inspector +External signal supports this direction: LÖVE tooling still clusters around launch/debug/profiling/hot reload, larger engines keep investing in profiling and content pipelines, and indie teams are constrained by testing and playtest capacity. Feather should lean into small-team confidence, not become a full engine. -#### Runtime Visualization +The core promise is faster debugging and safer shipping for indie and small-team LÖVE developers. -- [ ] Scene graph inspector - - [ ] Visualize draw order - - [ ] Entity hierarchy tree - - [ ] Collapsible runtime tree view +## Roadmap Policy -#### Workspace Experience +- Next 1-2 releases: no major new features unless they directly improve setup, reliability, docs, release safety, or existing plugin quality. +- Future features must pass one gate: they make a developer faster at debugging, validating, shipping, or reproducing a problem. +- Avoid: more random plugins, a full visual game editor, cloud accounts, broad AI features, or competing with full engines. +- Public roadmap items are candidate themes, not promised dates. +- But: If i have a cool idea or something I want to try out, i'll work on that. -- [ ] Persistent filtered views -- [ ] Persistent layout settings -- [ ] Saved debugger sessions -- [ ] Better keyboard shortcuts +## Future Feature Themes ---- +### 1. Repro & Crash Diagnostics -## 0.11.x — Plugin Ecosystem +- One-click "Create Debug Bundle" from desktop or CLI. +- Bundle logs, Feather config posture, build manifest, recent errors, plugin state, platform info, screenshots, and optional input replay. +- Optional Sentry/export integration later, but keep v1 local-first. -Focus: -Turn Feather into a platform instead of only a debugger. +Why: this matches Feather's strongest identity. When something breaks, Feather helps you understand it fast. -### 0.11.0 – Plugins & Extensions +### 2. Playtest Workflow -#### Official Plugins +- Stabilize input replay into "record session -> replay -> attach to bug report." +- Add bookmarks/notes during playtests. +- Add lightweight playtest report export: session length, errors, perf spikes, screenshots, input replay file. +- Later: batch replay in CI for regression smoke tests. -- [ ] Scene graph inspector -- [ ] Shader inspector -- [ ] Save data inspector +Why: indie teams have limited playtest capacity, so repeatable repros are more valuable than another inspector panel. ---- +### 3. Asset & Build Health -## Beyond v1 — Trust Release +- Asset audit: unused files, huge textures/audio, missing references, suspicious file sizes. +- Build size budget checks in `doctor`. +- Platform build diff: "what changed since last build." +- Release artifact inspector: show why an artifact is clean, not just that it passed. -Focus: -Production-ready release with security, trust, and community adoption. +Why: this reinforces release confidence without becoming a game editor. -### Security & Safety +### 4. Plugin SDK Hardening -- [ ] Security pass - - [ ] Remote debugging encryption +- Version the plugin UI/action protocol. +- Add plugin test harnesses and examples. +- Add plugin health diagnostics: slow plugin, failing request, missing capabilities, bad manifest. +- Improve generated plugin docs from manifests/config. -### Platform Readiness +Why: Feather has many plugins already; the ecosystem needs consistency more than quantity. -- [ ] Add privacy policy -- [ ] Improve crash recovery -- [ ] Final compatibility validation +### 5. Templates & Golden Paths -### Internationalization +Small but high-leverage. -- [ ] Add i18n support +- `feather init --template minimal|jam|mobile|release` +- Example projects that are tested in CI. +- VS Code "new Feather project" flow that mirrors CLI defaults. +- GitHub Actions templates for build/test/release. -### Ecosystem Expansion +Why: polish is often just fewer decisions at setup time. -- [ ] Multiple engine support (TBD) +## Parking Lot -### Launch +Keep these visible but explicitly later: -- [ ] Public release campaign -- [ ] Announce to broader gamedev communities -- [ ] Community showcase/demo projects -- [ ] Creator partnerships/tutorials +- Steam release automation. +- Remote/team sessions. +- Cloud crash dashboard. +- Scene graph / visual entity editor. +- Timeline-based replay debugger. +- Shader/particle asset marketplace. +- AI-assisted debugging summaries. -#### Quality +## Validation -- [ ] Add stress-test scenarios -- [ ] Improve error reporting/logging +Before promoting any parked feature, require: -#### Platform Reliability +- A clear user story from a real LÖVE workflow. +- A CLI or desktop happy path that works in under 5 minutes. +- `doctor` checks for setup failures. +- Docs with copy-pasteable commands. +- At least one e2e test or demo project coverage. +- No production release safety regression. -- [ ] Full OS & Love2D version testing (Windows/macOS/Linux) -- [ ] Smooth UI under heavy load spikes -- [ ] Graceful recovery from debugger disconnects -- [ ] Improve websocket stability and reconnection handling +## Assumptions -#### Performance +- Feather's core audience remains indie/small-team LÖVE developers. +- The main product promise is faster debugging, safer shipping and faster iterations, not becoming a general-purpose engine (for now). +- Public roadmap items should be framed as candidate themes, not promised dates. -- [ ] Low-overhead runtime communication improvements -- [ ] Reduce observer serialization overhead -- [ ] Optimize screenshot/memory handling +## Context Sources ---- +- LÖVE tooling market signal +- Integrated profiling/resource visibility reference: https://defold.com/manuals/profiling/ diff --git a/cli/.gitignore b/cli/.gitignore index 69c6ebc0..31784be8 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -1,3 +1,4 @@ dist/ lua/ node_modules/ +bin/ diff --git a/cli/README.md b/cli/README.md index 1fb0989d..8771beda 100644 --- a/cli/README.md +++ b/cli/README.md @@ -76,6 +76,8 @@ feather init # configure current directory feather init path/to/my-game # configure a specific directory feather init --no-plugins # feather core only, no plugins feather init --plugins screenshots,profiler +feather init --plugins hot-reload --hot-reload-allow game.player,game.systems.combat +feather init --session-name "My Game" --app-id feather-app-... feather init --remote --branch v0.7.0 # use a specific runtime release feather init --local-src ../feather/src-lua # use a local source tree feather init --install-dir lib/feather # configure like FEATHER_DIR=lib/feather @@ -88,12 +90,14 @@ feather init --install-dir lib/feather # configure like FEATHER_DIR=l By default, `feather init` opens an interactive terminal picker powered by Ink with CLI mode selected: -| Mode | Behavior | -| -------- | ------------------------------------------------------------------------------------------------------------------------ | -| `cli` | Recommended. Creates `feather.config.lua` for `feather run` without changing game code. | -| `auto` | Advanced embedded mode. Copies core/plugins and patches `main.lua` with a guarded `require("feather.auto")`. | +| Mode | Behavior | +| -------- | ----------------------------------------------------------------------------------------------------------------------- | +| `cli` | Recommended. Creates `feather.config.lua` for `feather run` without changing game code. | +| `auto` | Advanced embedded mode. Copies core/plugins and patches `main.lua` with a guarded `require("feather.auto")`. | | `manual` | Advanced embedded mode. Copies core/plugins, creates `feather.debugger.lua`, and loads it from `main.lua` when enabled. | +CLI mode writes a development config with `debug = true`, `autoRegisterErrorHandler = true`, and `managed = "cli"`. Unless you pass `--plugins` or `--no-plugins`, it includes `particle-system-playground` and `shader-graph` so the creative tooling is ready immediately. Opt-in or dangerous plugins, such as Console and Hot Reload, still require an explicit include. + Install source priority: 1. `--local-src ` copies from a local `src-lua` style tree. @@ -147,7 +151,7 @@ The interactive flow asks for: - Git branch or tag when using GitHub download, matching `FEATHER_BRANCH` - whether to install built-in plugins, matching `FEATHER_PLUGINS` - optional plugins to force-enable, such as Console, Physics Debug, and Timer Inspector -- plugins to skip/exclude, matching `FEATHER_SKIP_PLUGINS`; Console, HUMP Signal, and Lua State Machine start preselected like the shell installer defaults +- plugins to skip/exclude, matching `FEATHER_SKIP_PLUGINS`; Console, Hot Reload, HUMP Signal, and Lua State Machine start preselected like the shell installer defaults - advanced connection/runtime options from `feather.config.lua`, including host/port, socket vs disk mode, observers, logging, debugger, asset previews, capabilities, and binary threshold - a strong API key when Console is included @@ -190,16 +194,55 @@ return DEBUGGER **Options:** -| Option | Description | -| ---------------------- | ------------------------------------------------------------------------------ | -| `--remote` | Download from GitHub instead of copying the local/bundled Lua runtime. | -| `--branch ` | GitHub branch or tag to download from when using `--remote` (default: `main`). | -| `--local-src ` | Copy from a local `src-lua` style directory. | -| `--install-dir ` | Install directory for auto/manual modes (default: `feather`). | -| `--no-plugins` | Skip plugin installation. | -| `--plugins ` | Comma-separated list of plugin IDs to install (default: all). | -| `--mode ` | Setup mode: `cli`, `auto`, or `manual`. | -| `-y, --yes` | Skip confirmation prompts. | +| Option | Description | +| ---------------------------- | ---------------------------------------------------------------------------------------------------- | +| `--remote` | Download from GitHub instead of copying the local/bundled Lua runtime. | +| `--branch ` | GitHub branch or tag to download from when using `--remote` (default: `main`). | +| `--local-src ` | Copy from a local `src-lua` style directory. | +| `--install-dir ` | Install directory for auto/manual modes (default: `feather`). | +| `--no-plugins` | Skip plugin installation and omit the CLI-mode default include list. | +| `--plugins ` | Comma-separated plugin IDs. In CLI mode this overrides the default creative plugins. | +| `--hot-reload-allow ` | Comma-separated Lua module names allowed for Hot Reload; also includes the `hot-reload` plugin. | +| `--session-name ` | Session name shown in the Feather desktop app. | +| `--app-id ` | Desktop App ID allowed to send commands to this game. | +| `--mode ` | Setup mode: `cli`, `auto`, or `manual`. | +| `-y, --yes` | Skip confirmation prompts. | + +--- + +### `feather replay init` + +Create a centralized Session Replay adapter so replay code lives in one project file instead of being scattered through gameplay systems. + +```bash +feather replay init +feather replay init --dir path/to/my-game +feather replay init --path dev/replay.lua +feather replay init --force +feather replay init --no-config +``` + +By default this creates `dev/replay.lua`. If `feather.config.lua` exists, it also enables the `session-replay` plugin. + +The generated adapter exposes: + +- `register()` to install the `DEBUGGER:replayRegister()` capture/restore hook +- `start()` to start recording with an explicit initial baseline +- `stop()` to stop and load the recording +- `play()` to replay the selected session +- `capture()` and `restore()` placeholders for your game checkpoint logic + +Wire it once from your game: + +```lua +local replay = require("dev.replay") + +function love.load() + replay.register() +end +``` + +Then edit `dev/replay.lua` to delegate to your save, checkpoint, scene-loading, seed, or debug-state systems. The adapter no-ops when `DEBUGGER` is unavailable, so the game can require it safely while keeping Feather-specific logic contained. --- @@ -377,7 +420,7 @@ Doctor checks: - 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 +- build dependencies when `--build-target` is provided, plus upload readiness when an upload target is requested or `feather.build.json` is present - `USE_DEBUGGER` guards and `FEATHER-INIT` markers - risky settings such as hot reload, screenshot capture, and Console API keys - Feather desktop WebSocket reachability @@ -451,10 +494,44 @@ 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 add web --force # overwrite existing vendor directory 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. +## `build vendor add` + +Installs local build vendors into `vendor/` and updates `feather.build.json` by default. + +If a vendor directory already exists, it is skipped and installation continues for the remaining vendors. In interactive terminals, Feather prompts whether the existing vendor should be overwritten. Use `--force` to overwrite existing vendors without prompting. + +### Vendor Sources + +- **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 project tree +- **Desktop** — Downloads official LÖVE runtimes for: + - Windows + - macOS + - Linux +- **SteamOS** — Reuses the Linux runtime unless configured separately + +### Version Resolution + +> [!NOTE] +> Only 11.5 has been tested, future Love2D releases will be officially supported short after launch. + +Mobile and desktop vendors resolve versions using: + +1. `loveVersion` +2. `--ref` + +If neither is provided, Feather defaults to `11.5`. + +Web vendors behave slightly differently: + +- Defaults to the `main` branch of `love.js` +- Can be pinned using `--web-ref` +- Falls back to `--ref` if provided ```json { @@ -466,6 +543,12 @@ feather build vendor list "sourceDir": ".", "outDir": "builds", "exclude": ["screenshots/**", "tmp/**"], + "release": { + "fastlane": { + "path": "fastlane", + "bundleExec": "auto" + } + }, "targets": { "web": { "loveJsDir": "vendor/love.js" @@ -483,7 +566,13 @@ feather build vendor list "keystorePath": "signing/release.keystore", "keyAlias": "release", "storePasswordEnv": "ANDROID_STORE_PASSWORD", - "keyPasswordEnv": "ANDROID_KEY_PASSWORD" + "keyPasswordEnv": "ANDROID_KEY_PASSWORD", + "fastlane": { + "packageName": "com.example.mygame", + "track": "internal", + "releaseStatus": "completed", + "serviceAccountJsonEnv": "GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" + } } }, "ios": { @@ -497,7 +586,13 @@ feather build vendor list "exportMethod": "app-store-connect", "signingStyle": "manual", "provisioningProfileSpecifier": "My Game App Store", - "teamId": "ABCDE12345" + "teamId": "ABCDE12345", + "fastlane": { + "bundleIdentifier": "com.example.mygame", + "teamId": "ABCDE12345", + "exportMethod": "app-store", + "appStoreConnectApiKeyPathEnv": "APP_STORE_CONNECT_API_KEY_PATH" + } } }, "windows": { @@ -570,7 +665,38 @@ Mobile build notes: - 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 release` + +`feather release` is an optional Fastlane-backed layer for signed store-ready mobile workflows. It scaffolds editable Fastlane files, runs a clean Feather-free mobile release build for `beta` and `production`, checks generated artifacts for Feather runtime/debug files, and invokes the selected lane with explicit `FEATHER_*` environment variables. + +```bash +feather release init --dir path/to/my-game +feather release ios beta --dir path/to/my-game +feather release ios production --dir path/to/my-game +feather release android beta --dir path/to/my-game +feather release android production --dir path/to/my-game +feather release android metadata --dir path/to/my-game --skip-build +feather release ios screenshots --dir path/to/my-game --skip-build +``` + +Fastlane remains optional: `feather build android --release` and `feather build ios --release` still work without it. If a `Gemfile` exists, Feather runs `bundle exec fastlane`; otherwise it runs `fastlane` directly. Secrets stay in environment variables or files referenced by environment variables, never directly in `feather.build.json`. + +**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. | +| `--dry-run` | Show the release command without running Fastlane. | +| `--json` | Print machine-readable output only. | +| `--clean` | Remove build output before the release build. | +| `--no-cache` | Disable native build cache during the release build. | +| `--verbose` | Show native build and Fastlane output. | +| `--skip-build` | Run Fastlane using existing build manifest artifacts. | --- @@ -580,6 +706,7 @@ Upload a built artifact. V1 supports Itch through `butler`; Steam is registered ```bash feather upload itch web --dir path/to/my-game +feather upload itch android --dir path/to/my-game --build feather upload itch web --channel html5 --if-changed feather upload itch web --dry-run --json feather upload steam linux @@ -593,6 +720,8 @@ butler push : --userversion The Itch project and default channels come from `feather.build.json`. Use `--channel` or `--user-version` to override them in CI. +When `--build` is used, upload always performs a production-safe build first. Android and iOS upload builds force release mode, debugger embedding is disabled, `--allow-unsafe` and `--allow-feather-runtime` are rejected, and generated inspectable artifacts are checked for Feather runtime/debug files before upload. `--allow-feather-runtime` only applies when uploading an existing artifact you built yourself. + **Options:** | Option | Description | @@ -605,6 +734,8 @@ The Itch project and default channels come from `feather.build.json`. Use `--cha | `--dry-run` | Show the upload command without running it. | | `--if-changed` | Pass `--if-changed` to supported uploaders. | | `--hidden` | Pass `--hidden` to supported uploaders. | +| `--build` | Build a production-safe artifact before uploading. | +| `--allow-feather-runtime` | Override safety checks only for existing artifacts. | | `--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. @@ -627,6 +758,9 @@ In an interactive terminal, `feather update` opens an Ink workflow to choose loc This updates all `core:` files listed in `manifest.txt`. Plugin files are not touched — use `feather plugin update` for those. +> [!NOTE] +> CLI-managed projects (initialized with `feather init` in `cli` mode) do not embed the runtime in the project. For those projects `feather update` prints an informational message and exits — update the CLI package itself to get the latest runtime. + --- ### `feather plugin` @@ -674,8 +808,23 @@ feather plugin install console feather plugin install time-travel --remote --branch main feather plugin install console --local-src ../feather/src-lua feather plugin install console --install-dir lib/feather +feather plugin install console input-replay # install multiple at once +feather plugin install console --force # overwrite if already installed ``` +If a plugin is already installed, `feather plugin install` skips it and continues installing the others. In an interactive terminal it then offers to overwrite the skipped plugins. Pass `--force` to overwrite without prompting. + +**Options:** + +| Option | Description | +| ---------------------- | ------------------------------------------------------------- | +| `--force` | Overwrite already-installed plugins without prompting. | +| `--remote` | Download from GitHub instead of the local/bundled runtime. | +| `--branch ` | GitHub branch or tag when using `--remote` (default: `main`). | +| `--local-src ` | Copy from a local `src-lua` style directory. | +| `--install-dir ` | Install directory (default: `feather`). | +| `--dir ` | Project directory (default: current directory). | + #### `feather plugin remove ` Remove an installed plugin. @@ -699,6 +848,68 @@ When no plugin ID or source flag is provided in an interactive terminal, `feathe Use `--install-dir ` with plugin commands when the project was initialized outside the default `feather/` directory. +Use `--managed ` to override the managed-mode detection read from `feather.config.lua`. Accepted values are `cli`, `auto`, and `manual`. This is rarely needed; the config file is the source of truth. + +--- + +### `feather config` + +Update values in `feather.config.lua` without opening the file. + +#### `feather config plugins` + +Add or remove plugins from the `include`/`exclude` lists and keep the `capabilities` allowlist in sync. + +```bash +feather config plugins --include console,input-replay +feather config plugins --exclude hump.signal +feather config plugins --include profiler --exclude runtime-snapshot --dir path/to/my-game +``` + +**Options:** + +| Option | Description | +| ----------------- | ------------------------------------------------------- | +| `--include ` | Comma-separated plugin IDs to add to `include`. | +| `--exclude ` | Comma-separated plugin IDs to add to `exclude`. | +| `--dir ` | Project directory (default: current directory). | + +#### `feather config hot-reload` + +Enable the opt-in `hot-reload` plugin and write a narrow `debugger.hotReload.allow` list. + +```bash +feather config hot-reload --allow game.player,game.systems.combat +feather config hot-reload --allow game.player --dir path/to/my-game +``` + +This also writes `debug = true`, `autoRegisterErrorHandler = true`, `include = { "hot-reload", ... }`, the required `filesystem` capability, and safe defaults such as `deny = { "main", "conf", "feather.*" }` and `persistToDisk = false`. + +**Options:** + +| Option | Description | +| ----------------- | ------------------------------------------------------- | +| `--allow ` | Comma-separated Lua module names Hot Reload may update. | +| `--dir ` | Project directory (default: current directory). | + +#### `feather config managed ` + +Change the `managed` field that controls how Feather detects the integration mode. Useful when you want to override what `feather init` wrote without re-initialising the project. + +```bash +feather config managed cli +feather config managed auto +feather config managed manual --dir path/to/my-game +``` + +Valid modes are `cli`, `auto`, and `manual`. This updates the config field only — it does not patch `main.lua`, install the runtime, or generate `feather.debugger.lua`. Use `feather init --mode ` for a full mode transition. + +**Options:** + +| Option | Description | +| -------------- | ----------------------------------------------- | +| `--dir ` | Project directory (default: current directory). | + --- ## feather.config.lua diff --git a/cli/package.json b/cli/package.json index 49b0a51a..0a6fba8c 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@kyonru/feather", - "version": "1.2.0", + "version": "1.3.0", "description": "CLI for Feather — run and debug Love2D games without touching your game code", "license": "EPL-2.0", "keywords": [ @@ -31,6 +31,7 @@ ], "scripts": { "build": "tsc && cp src/generated/registry.json dist/generated/registry.json", + "build:binary": "bun build ./dist/index.js --compile --outfile bin/feather --target bun-darwin-arm64 && bun build ./dist/index.js --compile --outfile bin/feather-darwin-x64 --target bun-darwin-x64 && bun build ./dist/index.js --compile --outfile bin/feather-win-x64.exe --target bun-windows-x64 && bun build ./dist/index.js --compile --outfile bin/feather-linux-x64 --target bun-linux-x64", "dev": "cp src/generated/registry.json dist/generated/registry.json 2>/dev/null; tsc --watch", "bundle:lua": "bash ../scripts/bundle-lua.sh", "prepack": "npm run bundle:lua && npm run build", diff --git a/cli/src/commands/build-vendor.ts b/cli/src/commands/build-vendor.ts index 334992b5..38f53d26 100644 --- a/cli/src/commands/build-vendor.ts +++ b/cli/src/commands/build-vendor.ts @@ -7,6 +7,7 @@ import { printMuted, printStatus, printTable, + printWarning, style, } from '../lib/output.js'; import { @@ -15,7 +16,9 @@ import { isBuildVendorTarget, listBuildVendors, type BuildVendorTargetInput, + type ConcreteBuildVendorTarget, } from '../lib/build/vendor.js'; +import { confirmAction } from '../ui/confirm.js'; export type BuildVendorCommandOptions = { dir?: string; @@ -64,37 +67,48 @@ export async function buildVendorAddCommand(targetValues: string[], opts: BuildV return; } - if (opts.dryRun) { - printStatus('info', 'Build vendor plan'); + const installed = result.vendors.filter((v) => v.installed || v.skipped); + const skipped = result.skippedTargets; + + if (installed.length > 0 || opts.dryRun) { + 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: installed.map((vendor) => ({ + target: vendor.target, + ref: vendor.ref, + path: vendor.relativePath, + config: vendor.configUpdated ? 'updated' : opts.dryRun && opts.configUpdate !== false ? 'planned' : 'unchanged', + })), + }); + if (result.vendors.some((vendor) => vendor.target === 'steamos' && (vendor.installed || vendor.skipped))) { + printBlank(); + printMuted('SteamOS Devkit setup is manual before using `feather watch --target steamos`:'); + printMuted('Official Steam Deck loading docs: https://partner.steamgames.com/doc/steamdeck/loadgames'); + printMuted('Cross-platform Devkit client, especially useful on macOS: https://gitlab.steamos.cloud/devkit/steamos-devkit'); + } } else { - spinner?.succeed('Build vendors ready'); + spinner?.stop(); } - 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', - })), - }); - if (result.vendors.some((vendor) => vendor.target === 'steamos')) { - printBlank(); - printMuted('SteamOS Devkit setup is manual before using `feather watch --target steamos`:'); - printMuted('Official Steam Deck loading docs: https://partner.steamgames.com/doc/steamdeck/loadgames'); - printMuted('Cross-platform Devkit client, especially useful on macOS: https://gitlab.steamos.cloud/devkit/steamos-devkit'); + + if (skipped.length > 0 && !opts.dryRun) { + await handleSkippedVendors(skipped, opts, targets); } } catch (err) { spinner?.fail((err as Error).message); @@ -102,6 +116,49 @@ export async function buildVendorAddCommand(targetValues: string[], opts: BuildV } } +async function handleSkippedVendors( + skipped: ConcreteBuildVendorTarget[], + opts: BuildVendorCommandOptions, + originalTargets: BuildVendorTargetInput[], +): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + printWarning(`${skipped.length} vendor(s) already exist: ${skipped.join(', ')}. Use --force to overwrite.`); + return; + } + + const shouldOverride = await confirmAction({ + title: 'feather build vendor add', + label: `${skipped.length} vendor(s) already exist. Overwrite?`, + hint: 'Pass --force to skip this prompt.', + rows: skipped, + defaultYes: false, + }); + + if (!shouldOverride) { + printMuted(`Skipped: ${skipped.join(', ')}`); + return; + } + + const overwriteSpinner = createSpinner(`Overwriting ${skipped.join(', ')}…`).start(); + try { + await addBuildVendors(skipped as BuildVendorTargetInput[], { + projectDir: opts.dir, + configPath: opts.config, + vendorDir: opts.vendorDir, + ref: opts.ref, + webRef: opts.webRef, + androidRef: opts.androidRef, + iosRef: opts.iosRef, + force: true, + updateConfig: opts.configUpdate, + }); + overwriteSpinner.succeed(`Overwritten: ${skipped.join(', ')}`); + } catch (err) { + overwriteSpinner.fail((err as Error).message); + fail((err as Error).message, { silent: true }); + } +} + export function buildVendorListCommand(opts: BuildVendorListCommandOptions = {}): void { const result = listBuildVendors({ projectDir: opts.dir, diff --git a/cli/src/commands/config.ts b/cli/src/commands/config.ts new file mode 100644 index 00000000..933dafcb --- /dev/null +++ b/cli/src/commands/config.ts @@ -0,0 +1,314 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fail } from '../lib/command.js'; +import { loadConfig, luaValue, type FeatherConfig } from '../lib/config.js'; +import { assertSafeProjectTarget } from '../lib/path-safety.js'; +import { pluginCatalog } from '../generated/plugin-catalog.js'; +import { mergeCapabilities } from '../ui/init/config.js'; +import { icon, printLine } from '../lib/output.js'; +import { findConfigDir } from '../lib/paths.js'; + +export type ConfigPluginsOptions = { + dir?: string; + include?: string; + exclude?: string; +}; + +export type ConfigManagedOptions = { + dir?: string; +}; + +export type ConfigHotReloadOptions = { + dir?: string; + allow?: string; +}; + +const VALID_MANAGED_MODES = ['cli', 'auto', 'manual'] as const; + +const knownPluginIds = new Set(pluginCatalog.map((plugin) => plugin.id)); + +function parseIds(value: string | undefined): string[] { + if (!value) return []; + return [ + ...new Set( + value + .split(',') + .map((id) => id.trim()) + .filter(Boolean), + ), + ]; +} + +function assertKnownPlugins(ids: string[]): void { + const unknown = ids.filter((id) => !knownPluginIds.has(id)); + if (unknown.length > 0) { + fail(`Unknown plugin: ${unknown[0]}`, { + details: [`Available: ${[...knownPluginIds].sort().join(', ')}`], + }); + } +} + +function setArray(config: Record, key: 'include' | 'exclude', values: Set): void { + if (values.size > 0) { + config[key] = [...values].sort(); + } else { + delete config[key]; + } +} + +function hotReloadDebuggerConfig(allow: string[]): Record { + return { + enabled: true, + hotReload: { + enabled: true, + allow, + deny: ['main', 'conf', 'feather.*'], + persistToDisk: false, + clearOnBoot: false, + requireLocalNetwork: true, + }, + }; +} + +function valueEnd(source: string, start: number): number { + let i = start; + while (i < source.length && /\s/.test(source[i])) i++; + + if (source[i] !== '{') { + const lineEnd = source.indexOf('\n', i); + return lineEnd === -1 ? source.length : lineEnd; + } + + let depth = 0; + let quote: '"' | "'" | null = null; + while (i < source.length) { + const ch = source[i]; + const next = source[i + 1]; + + if (quote) { + if (ch === '\\' && next) { + i += 2; + continue; + } + if (ch === quote) quote = null; + i++; + continue; + } + + if (ch === '"' || ch === "'") { + quote = ch; + i++; + continue; + } + + if (ch === '-' && next === '-') { + while (i < source.length && source[i] !== '\n') i++; + continue; + } + + if (ch === '{') depth++; + if (ch === '}') { + depth--; + if (depth === 0) { + i++; + while (i < source.length && /[ \t]/.test(source[i])) i++; + if (source[i] === ',') i++; + return i; + } + } + i++; + } + + return source.length; +} + +function replaceTopLevelAssignment(source: string, key: string, rendered: string | undefined): string | undefined { + const assignment = new RegExp(`^\\s*${key}\\s*=`, 'm'); + const match = assignment.exec(source); + if (!match) return undefined; + + const equals = source.indexOf('=', match.index); + const end = valueEnd(source, equals + 1); + return `${source.slice(0, match.index)}${rendered ?? ''}${source.slice(end)}`; +} + +function upsertTopLevelValue(source: string, key: string, value: unknown): string { + const rendered = value === undefined ? undefined : ` ${key} = ${luaValue(value, 2)},`; + const replaced = replaceTopLevelAssignment(source, key, rendered); + if (replaced !== undefined) return replaced; + + const assignment = new RegExp( + `^\\s*${key}\\s*=\\s*(?:\\{[^\\n]*\\}|"[^"]*"|'[^']*'|true|false|-?\\d+(?:\\.\\d+)?),?\\s*$`, + 'm', + ); + const inlineAssignment = new RegExp( + `([,{]\\s*)${key}\\s*=\\s*(?:\\{[^}]*\\}|"[^"]*"|'[^']*'|true|false|-?\\d+(?:\\.\\d+)?)(,?)`, + ); + + if (assignment.test(source)) { + return source.replace(assignment, rendered ?? ''); + } + + if (rendered && inlineAssignment.test(source)) { + return source.replace(inlineAssignment, `$1${key} = ${luaValue(value, 2)}$2`); + } + + if (rendered) { + const multiLine = source.replace(/return\s*\{\s*\n/, (match) => `${match}${rendered}\n`); + if (multiLine !== source) return multiLine; + return source.replace(/return\s*\{\s*\}/, `return {\n${rendered}\n}`); + } + + return source; +} + +export async function configPluginsCommand(opts: ConfigPluginsOptions = {}): Promise { + const projectDir = findConfigDir(opts.dir ? resolve(opts.dir) : process.cwd()); + const includeIds = parseIds(opts.include); + const excludeIds = parseIds(opts.exclude); + + if (includeIds.length === 0 && excludeIds.length === 0) { + fail('No plugin changes requested.', { + details: ['Pass --include , --exclude , or both.'], + }); + } + + assertKnownPlugins([...includeIds, ...excludeIds]); + + let configPath: string; + try { + configPath = assertSafeProjectTarget(projectDir, 'feather.config.lua', 'Config update target'); + } catch (err) { + fail((err as Error).message); + } + + if (!existsSync(configPath)) { + fail(`No feather.config.lua found in ${projectDir}.`, { + details: ['Run `feather init` first.'], + }); + } + + const loaded = loadConfig(projectDir); + if (!loaded) { + fail(`Failed to load ${join(projectDir, 'feather.config.lua')}.`); + } + + const config = { ...(loaded as FeatherConfig) } as Record; + const originalSource = readFileSync(configPath, 'utf8'); + const include = new Set( + Array.isArray(config.include) ? config.include.filter((id): id is string => typeof id === 'string') : [], + ); + const exclude = new Set( + Array.isArray(config.exclude) ? config.exclude.filter((id): id is string => typeof id === 'string') : [], + ); + + for (const id of includeIds) { + include.add(id); + exclude.delete(id); + } + + for (const id of excludeIds) { + include.delete(id); + exclude.add(id); + } + + setArray(config, 'include', include); + setArray(config, 'exclude', exclude); + + const mergedCapabilities = mergeCapabilities(config.capabilities as string[] | 'all' | undefined, include); + if (mergedCapabilities && mergedCapabilities !== 'all') { + config.capabilities = mergedCapabilities; + } + + let nextSource = originalSource; + nextSource = upsertTopLevelValue(nextSource, 'include', config.include); + nextSource = upsertTopLevelValue(nextSource, 'exclude', config.exclude); + nextSource = upsertTopLevelValue(nextSource, 'capabilities', config.capabilities); + + writeFileSync(configPath, nextSource); + printLine(`${icon.success} Updated feather.config.lua plugin settings`); +} + +export async function configManagedCommand(mode: string, opts: ConfigManagedOptions = {}): Promise { + if (!(VALID_MANAGED_MODES as readonly string[]).includes(mode)) { + fail(`Invalid mode: ${mode}`, { + details: [`Valid modes: ${VALID_MANAGED_MODES.join(', ')}`], + }); + } + + const projectDir = findConfigDir(opts.dir ? resolve(opts.dir) : process.cwd()); + let configPath: string; + try { + configPath = assertSafeProjectTarget(projectDir, 'feather.config.lua', 'Config update target'); + } catch (err) { + fail((err as Error).message); + } + + if (!existsSync(configPath)) { + fail(`No feather.config.lua found in ${projectDir}.`, { + details: ['Run `feather init` first.'], + }); + } + + const source = readFileSync(configPath, 'utf8'); + const next = upsertTopLevelValue(source, 'managed', mode); + writeFileSync(configPath, next); + printLine(`${icon.success} Updated managed mode to "${mode}"`); +} + +export async function configHotReloadCommand(opts: ConfigHotReloadOptions = {}): Promise { + const projectDir = findConfigDir(opts.dir ? resolve(opts.dir) : process.cwd()); + const allow = parseIds(opts.allow); + + if (allow.length === 0) { + fail('No hot reload allowlist requested.', { + details: ['Pass --allow .'], + }); + } + + let configPath: string; + try { + configPath = assertSafeProjectTarget(projectDir, 'feather.config.lua', 'Config update target'); + } catch (err) { + fail((err as Error).message); + } + + if (!existsSync(configPath)) { + fail(`No feather.config.lua found in ${projectDir}.`, { + details: ['Run `feather init` first.'], + }); + } + + const loaded = loadConfig(projectDir); + if (!loaded) { + fail(`Failed to load ${join(projectDir, 'feather.config.lua')}.`); + } + + const config = { ...(loaded as FeatherConfig) } as Record; + const include = new Set( + Array.isArray(config.include) ? config.include.filter((id): id is string => typeof id === 'string') : [], + ); + const exclude = new Set( + Array.isArray(config.exclude) ? config.exclude.filter((id): id is string => typeof id === 'string') : [], + ); + include.add('hot-reload'); + exclude.delete('hot-reload'); + setArray(config, 'include', include); + setArray(config, 'exclude', exclude); + + const mergedCapabilities = mergeCapabilities(config.capabilities as string[] | 'all' | undefined, include); + if (mergedCapabilities && mergedCapabilities !== 'all') { + config.capabilities = mergedCapabilities; + } + + let nextSource = readFileSync(configPath, 'utf8'); + nextSource = upsertTopLevelValue(nextSource, 'debug', true); + nextSource = upsertTopLevelValue(nextSource, 'autoRegisterErrorHandler', true); + nextSource = upsertTopLevelValue(nextSource, 'include', config.include); + nextSource = upsertTopLevelValue(nextSource, 'exclude', config.exclude); + nextSource = upsertTopLevelValue(nextSource, 'capabilities', config.capabilities); + nextSource = upsertTopLevelValue(nextSource, 'debugger', hotReloadDebuggerConfig(allow)); + + writeFileSync(configPath, nextSource); + printLine(`${icon.success} Updated hot reload allowlist`); +} diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index 85c084fd..8b61f31a 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { findLoveBinary, getLoveVersion } from '../../lib/love.js'; import { loadConfig } from '../../lib/config.js'; @@ -18,6 +18,7 @@ import { uploadTargets, } from '../../lib/build/config.js'; import { androidProductId, iosBundleIdentifier, validateBuildConfigForTarget } from '../../lib/build/validation.js'; +import { fastlanePath } from '../../lib/build/release.js'; import { auditLockfile } from '../../lib/package/audit.js'; import { readLockfile } from '../../lib/package/lockfile.js'; import { lockfileEntrySourceSummary, lockfileUrlFindings } from '../../lib/package/provenance.js'; @@ -62,6 +63,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) let hotReloadEnabled = false; let broadHotReload = false; let hotReloadPersistence = false; + let sessionReplayIncluded = false; let debuggerEnabled = false; let writeToDisk = false; let weakNetworkAuth = true; @@ -168,27 +170,42 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) hasProjectDir ? undefined : 'Pass the game directory to `feather doctor `.', ); - if (hasProjectDir && (opts.buildTarget || opts.uploadTarget)) { + if (hasProjectDir) { 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.', - ); + const discoveredUploadTargets: SupportedUploadDoctorTarget[] = []; + if (!opts.uploadTarget && buildConfig.upload.itch) discoveredUploadTargets.push('itch'); + if (!opts.uploadTarget && !buildConfig.upload.itch && configExists) discoveredUploadTargets.push('itch'); + const uploadTargetsToCheck: SupportedUploadDoctorTarget[] = opts.uploadTarget === 'itch' ? ['itch'] : discoveredUploadTargets; + const shouldCheckBuildConfig = Boolean(opts.buildTarget || opts.uploadTarget || buildConfig.upload.itch); + if (shouldCheckBuildConfig) { + 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.', + ); + } else if (uploadTargetsToCheck.length > 0) { + add( + checks, + 'Build', + 'feather.build.json', + 'pass', + buildConfig.configPath, + ); + } if (opts.buildTarget && isDoctorBuildTarget(opts.buildTarget)) { const fromAll = opts.buildTarget === 'all'; @@ -200,33 +217,10 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) } } - 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.', - ); + for (const uploadTarget of uploadTargetsToCheck) { + addUploadTargetChecks(checks, buildConfig, uploadTarget, { + required: Boolean(opts.uploadTarget || buildConfig.upload[uploadTarget]), + }); } } catch (err) { add(checks, 'Build', 'feather.build.json', 'fail', (err as Error).message, 'Fix feather.build.json before building or uploading.'); @@ -342,6 +336,15 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) } const projectDirArg = shellArg(projectDir); const installDirArg = shellArg(effectiveInstallDir); + if (configOnlyMode && activeIncludedPluginIds.some((id) => knownPluginIds.has(id))) { + add( + checks, + 'Plugins', + 'CLI-managed plugins', + 'pass', + `${activeIncludedPluginIds.filter((id) => knownPluginIds.has(id)).length} included from bundled runtime`, + ); + } if (hasRuntime) { add( checks, @@ -414,6 +417,8 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) continue; } + if (configOnlyMode) continue; + if (!installedPlugins.has(id)) { missingIncludedPlugins.push(id); add( @@ -524,6 +529,17 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) ); } + sessionReplayIncluded = hasConfigArrayValue(activeConfigSource, 'include', 'session-replay') + || pluginDirs.some((dir) => readPluginManifest(dir)?.id === 'session-replay'); + add( + checks, + 'Safety', + 'Session replay', + productionSeverity(sessionReplayIncluded), + sessionReplayIncluded ? 'enabled' : 'disabled', + sessionReplayIncluded ? 'Remove session-replay from production configs and builds; replay files are development artifacts.' : undefined, + ); + debuggerEnabled = luaBoolEnabled(activeConfigSource, 'debugger') || /debugger\s*=\s*\{[\s\S]*?enabled\s*=\s*true/.test(activeConfigSource); add( checks, @@ -554,6 +570,25 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) ); } + const replayDirPath = join(projectDir, 'feather_replays'); + const replayArchives = existsSync(projectDir) + ? readdirSync(projectDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.featherreplay')) + .map((entry) => entry.name) + .sort() + : []; + const hasReplayArtifacts = existsSync(replayDirPath) || replayArchives.length > 0; + add( + checks, + 'Safety', + 'Session replay artifacts', + productionSeverity(hasReplayArtifacts), + hasReplayArtifacts + ? [existsSync(replayDirPath) ? 'feather_replays/' : '', ...replayArchives].filter(Boolean).join(', ') + : 'not present', + hasReplayArtifacts ? 'Remove local replay captures before committing, sharing, building, or uploading production artifacts.' : undefined, + ); + const lockfilePath = join(projectDir, 'feather.lock.json'); if (existsSync(lockfilePath)) { try { @@ -876,6 +911,28 @@ async function addBuildTargetChecks( ? androidReleaseSigningFix(buildConfig) : 'Use --release to check signed AAB/APK setup.', ); + if (release && hasFastlaneConfig(buildConfig, 'android')) { + addFastlaneChecks(checks, buildConfig, target, label); + const fastlane = androidConfig.release?.fastlane ?? {}; + add( + checks, + 'Build', + label('Google Play service account'), + fastlane.serviceAccountJsonEnv && process.env[fastlane.serviceAccountJsonEnv] ? 'pass' : 'fail', + fastlane.serviceAccountJsonEnv + ? process.env[fastlane.serviceAccountJsonEnv] ? fastlane.serviceAccountJsonEnv : `${fastlane.serviceAccountJsonEnv} missing` + : 'not configured', + 'Set targets.android.release.fastlane.serviceAccountJsonEnv and provide the JSON key path in that environment variable.', + ); + add( + checks, + 'Build', + label('Android Fastlane signing env'), + androidFastlaneSigningReady(buildConfig) ? 'pass' : 'warn', + androidFastlaneSigningDetail(buildConfig), + 'Set targets.android.release.fastlane keystorePath, keyAlias, storePasswordEnv, and keyPasswordEnv for Fastlane-managed signing.', + ); + } return; } @@ -948,6 +1005,29 @@ async function addBuildTargetChecks( iosConfig.release?.exportOptionsPlist ?? iosConfig.release?.exportMethod ?? 'generated development export options', 'Set targets.ios.release.exportOptionsPlist or exportMethod for App Store/TestFlight exports.', ); + if (hasFastlaneConfig(buildConfig, 'ios')) { + addFastlaneChecks(checks, buildConfig, target, label); + const fastlane = iosConfig.release?.fastlane ?? {}; + const apiKeyConfigured = Boolean( + (fastlane.appStoreConnectApiKeyPathEnv && process.env[fastlane.appStoreConnectApiKeyPathEnv]) + || ( + fastlane.appStoreConnectIssuerIdEnv + && process.env[fastlane.appStoreConnectIssuerIdEnv] + && fastlane.appStoreConnectKeyIdEnv + && process.env[fastlane.appStoreConnectKeyIdEnv] + && fastlane.appStoreConnectKeyContentEnv + && process.env[fastlane.appStoreConnectKeyContentEnv] + ), + ); + add( + checks, + 'Build', + label('App Store Connect API key'), + apiKeyConfigured ? 'pass' : 'fail', + apiKeyConfigured ? 'configured' : 'missing', + 'Set an App Store Connect API key env path, or issuer/key id/key content env names under targets.ios.release.fastlane.', + ); + } } return; } @@ -997,13 +1077,114 @@ function androidReleaseSigningSeverity(buildConfig: ReturnType, target: 'android' | 'ios'): boolean { + if (buildConfig.release.fastlane) return true; + if (existsSync(fastlanePath(buildConfig))) return true; + return target === 'android' + ? Boolean(buildConfig.targets.android?.release?.fastlane) + : Boolean(buildConfig.targets.ios?.release?.fastlane); +} + +function addFastlaneChecks( + checks: DoctorCheck[], + buildConfig: ReturnType, + target: SupportedBuildTarget, + label: (singleTargetLabel: string, allTargetBase?: string) => string, +): void { + const dir = fastlanePath(buildConfig); + add( + checks, + 'Build', + label('Fastlane directory'), + existsSync(dir) ? 'pass' : 'fail', + existsSync(dir) ? dir : 'not found', + `Run \`feather release init --dir ${buildConfig.projectDir}\`.`, + ); + const fastlane = commandVersion('fastlane', ['--version']); + const gemfile = existsSync(join(buildConfig.projectDir, 'Gemfile')); + const bundle = gemfile ? commandVersion('bundle', ['--version']) : null; + add( + checks, + 'Build', + label(target === 'ios' ? 'Fastlane' : 'Fastlane'), + fastlane || bundle ? 'pass' : 'fail', + fastlane ?? (bundle ? `${bundle}; fastlane via bundle exec` : 'not found'), + 'Install Fastlane directly or add it to your Gemfile and run bundle install.', + ); +} + +function androidFastlaneSigningReady(buildConfig: ReturnType): boolean { + const release = buildConfig.targets.android?.release?.fastlane; + if (!release) return false; + const keystorePath = release.keystorePath ? resolve(buildConfig.projectDir, release.keystorePath) : ''; + return Boolean( + release.keystorePath + && existsSync(keystorePath) + && release.keyAlias + && release.storePasswordEnv + && process.env[release.storePasswordEnv] + && release.keyPasswordEnv + && process.env[release.keyPasswordEnv], + ); +} + +function androidFastlaneSigningDetail(buildConfig: ReturnType): string { + const release = buildConfig.targets.android?.release?.fastlane; + if (!release) return 'not configured'; + const missing = [ + release.keystorePath ? '' : 'keystorePath', + release.keyAlias ? '' : 'keyAlias', + release.storePasswordEnv && process.env[release.storePasswordEnv] ? '' : release.storePasswordEnv ?? 'storePasswordEnv', + release.keyPasswordEnv && process.env[release.keyPasswordEnv] ? '' : release.keyPasswordEnv ?? 'keyPasswordEnv', + ].filter(Boolean); + return missing.length > 0 ? `missing ${missing.join(', ')}` : 'configured'; +} + const desktopBuildTargets = ['windows', 'macos', 'linux', 'steamos'] as const; type DoctorDesktopBuildTarget = typeof desktopBuildTargets[number]; +const supportedUploadDoctorTargets = ['itch'] as const; +type SupportedUploadDoctorTarget = typeof supportedUploadDoctorTargets[number]; function isDoctorDesktopBuildTarget(target: string): target is DoctorDesktopBuildTarget { return (desktopBuildTargets as readonly string[]).includes(target); } +function addUploadTargetChecks( + checks: DoctorCheck[], + buildConfig: ReturnType, + target: SupportedUploadDoctorTarget, + options: { required?: boolean } = {}, +): void { + if (target !== 'itch') return; + const required = Boolean(options.required); + const itchProject = buildConfig.upload.itch?.project; + add( + checks, + 'Upload', + 'Itch project', + itchProject ? 'pass' : required ? 'fail' : 'info', + 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' : required ? 'fail' : 'info', + 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' : required ? 'warn' : 'info', + process.env.BUTLER_API_KEY ? 'configured' : 'missing', + process.env.BUTLER_API_KEY ? undefined : 'Set BUTLER_API_KEY in CI or run `butler login` locally.', + ); +} + async function addDesktopBuildChecks( checks: DoctorCheck[], projectDir: string, diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 1a694a5e..cf0dc569 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -13,6 +13,7 @@ import { import { configTemplate, luaKey, luaValue } from '../lib/config.js'; import { chooseInitMode, type InitMode, type InitSetup } from '../ui/init/index.js'; import { mergeCapabilities } from '../ui/init/config.js'; +import { defaultIncludedPluginIds } from '../ui/init/model.js'; import { pluginCatalog } from '../generated/plugin-catalog.js'; import { resolveLocalLuaRoot } from '../lib/paths.js'; import { fail } from '../lib/command.js'; @@ -29,6 +30,9 @@ export interface InitOptions { yes?: boolean; mode?: InitMode; allowInsecureConnection?: boolean; + appId?: string; + sessionName?: string; + hotReloadAllow?: string[]; } const knownPlugins = pluginCatalog.map((plugin) => plugin.id); @@ -41,6 +45,40 @@ function addPluginCapabilities(config: Record, pluginIds: Itera } } +function addIncludedPlugins(config: Record, pluginIds: Iterable): void { + const include = new Set(Array.isArray(config.include) ? config.include.filter((id): id is string => typeof id === 'string') : []); + for (const id of pluginIds) { + include.add(id); + } + if (include.size > 0) { + config.include = [...include].sort(); + } +} + +function addCliDebugDefaults(config: Record): void { + if (config.debug === undefined) config.debug = true; + if (config.autoRegisterErrorHandler === undefined) config.autoRegisterErrorHandler = true; +} + +function hotReloadDebuggerConfig(allow: string[]): Record { + return { + enabled: true, + hotReload: { + enabled: true, + allow, + deny: ['main', 'conf', 'feather.*'], + persistToDisk: false, + clearOnBoot: false, + requireLocalNetwork: true, + }, + }; +} + +function addHotReloadConfig(config: Record, allow: string[]): void { + addCliDebugDefaults(config); + config.debugger = hotReloadDebuggerConfig(allow); +} + const toLocalName = (id: string) => id .split(/[-.]/) @@ -108,7 +146,14 @@ export async function initCommand(dir: string, opts: InitOptions): Promise branch: opts.branch ?? 'main', installDir: opts.installDir ?? 'feather', installPlugins: opts.noPlugins ? false : true, - config: opts.allowInsecureConnection ? { __DANGEROUS_INSECURE_CONNECTION__: true } : {}, + config: { + ...(opts.sessionName?.trim() ? { sessionName: opts.sessionName.trim() } : {}), + ...(opts.appId?.trim() + ? { appId: opts.appId.trim() } + : opts.allowInsecureConnection + ? { __DANGEROUS_INSECURE_CONNECTION__: true } + : {}), + }, exclude: [], }; const mode = setup.mode; @@ -123,7 +168,22 @@ export async function initCommand(dir: string, opts: InitOptions): Promise const alreadyInstalled = existsSync(installInitPath); const useRemote = opts.remote === true || setup.source === 'remote'; + // Record the init mode so plugin commands can detect CLI vs embedded without + // relying on the presence/absence of feather/init.lua in the project tree. + setup.config.managed = mode; + if (mode === 'cli') { + addCliDebugDefaults(setup.config); + const requestedPlugins = opts.plugins ?? defaultIncludedPluginIds; + const pluginIds = pluginsDisabled ? [] : requestedPlugins.filter((id) => !setup.exclude.includes(id)); + if (!pluginsDisabled && opts.hotReloadAllow && opts.hotReloadAllow.length > 0 && !pluginIds.includes('hot-reload')) { + pluginIds.push('hot-reload'); + } + addIncludedPlugins(setup.config, pluginIds); + addPluginCapabilities(setup.config, pluginIds); + if (!pluginsDisabled && (pluginIds.includes('hot-reload') || (opts.hotReloadAllow && opts.hotReloadAllow.length > 0))) { + addHotReloadConfig(setup.config, opts.hotReloadAllow ?? []); + } writeConfig(target, setup.config, { mode, installDir, @@ -204,7 +264,7 @@ export async function initCommand(dir: string, opts: InitOptions): Promise let failed = 0; for (const id of pluginIds) { try { - installPluginsFromLocal([id], sourceRoot, target, installDir); + installPluginsFromLocal([id], sourceRoot, target, installDir, undefined, true); pluginSpinner.text = `Copied plugin: ${id}`; } catch { failed++; diff --git a/cli/src/commands/package/shared.ts b/cli/src/commands/package/shared.ts index 1b29cd20..082c1ae2 100644 --- a/cli/src/commands/package/shared.ts +++ b/cli/src/commands/package/shared.ts @@ -1,11 +1,11 @@ import { resolve } from 'node:path'; -import { findProjectDir } from '../../lib/paths.js'; +import { findPackageDir } 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(); + return findPackageDir(dir ? resolve(dir) : process.cwd()); } export async function loadRegistryOrExit( diff --git a/cli/src/commands/plugin/install.ts b/cli/src/commands/plugin/install.ts index 00dcd1f3..5ff4032c 100644 --- a/cli/src/commands/plugin/install.ts +++ b/cli/src/commands/plugin/install.ts @@ -6,40 +6,105 @@ import { getPluginIds, installPlugin, installPluginsFromLocal, + normalizeInstallDir, } from '../../lib/install.js'; import { fail } from '../../lib/command.js'; -import { createSpinner } from '../../lib/output.js'; +import { createSpinner, printMuted, printWarning } 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'; +import { configPluginsCommand } from '../config.js'; +import { confirmAction } from '../../ui/confirm.js'; +import { type PluginSourceOptions, resolveManaged, resolvePluginProjectDir, warnDangerousPlugin } from './shared.js'; -export async function pluginInstallCommand(pluginId: string, opts: PluginSourceOptions): Promise { +async function offerOverride(skipped: string[], label: string): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + printWarning(`${skipped.length} plugin(s) already installed: ${skipped.join(', ')}. Use --force to overwrite.`); + return false; + } + return confirmAction({ + title: 'feather plugin install', + label: `${skipped.length} plugin(s) already installed. Overwrite?`, + hint: `Pass --force to skip this prompt. ${label}`, + rows: skipped, + defaultYes: false, + }); +} + +export async function pluginInstallCommand(pluginIds: string | 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); + const installDir = normalizeInstallDir(opts.installDir); + const force = opts.force === true; + const ids = (Array.isArray(pluginIds) ? pluginIds : [pluginIds]).flatMap((value) => + value.split(',').map((id) => id.trim()).filter(Boolean), + ); + const uniqueIds = [...new Set(ids)]; + if (uniqueIds.length === 0) { + fail('Plugin id is required.'); + } + for (const pluginId of uniqueIds) { + try { + assertValidPluginId(pluginId); + } catch (err) { + fail((err as Error).message); + } + } + + // CLI mode: plugin Lua files live in the bundled CLI runtime; only feather.config.lua + // `include` list needs updating. `--managed cli` overrides auto-detection. + if (resolveManaged(projectDir, installDir, opts.managed) === 'cli') { + await configPluginsCommand({ dir: projectDir, include: uniqueIds.join(',') }); + for (const pluginId of uniqueIds) { + warnDangerousPlugin(pluginId); + } + return; } 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(', ')] }); + for (const pluginId of uniqueIds) { + 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(); + const spinner = createSpinner(`Copying ${uniqueIds.join(', ')}…`).start(); + let result: { installed: string[]; skipped: string[] }; try { - installPluginsFromLocal([pluginId], sourceRoot, projectDir, installDir); - spinner.succeed(`Installed ${pluginId}`); - warnDangerousPlugin(pluginId); + result = installPluginsFromLocal(uniqueIds, sourceRoot, projectDir, installDir, undefined, force); } catch (err) { spinner.fail((err as Error).message); fail((err as Error).message, { cause: err, silent: true }); } + const { installed, skipped } = result!; + if (installed.length > 0) { + spinner.succeed(`Installed ${installed.join(', ')}`); + for (const pluginId of installed) { + warnDangerousPlugin(pluginId); + } + } else { + spinner.stop(); + } + if (skipped.length > 0) { + const shouldOverride = await offerOverride(skipped, 'Local source.'); + if (shouldOverride) { + const overwriteSpinner = createSpinner(`Overwriting ${skipped.join(', ')}…`).start(); + try { + const overwriteResult = installPluginsFromLocal(skipped, sourceRoot, projectDir, installDir, undefined, true); + overwriteSpinner.succeed(`Overwritten: ${overwriteResult.installed.join(', ')}`); + for (const pluginId of overwriteResult.installed) { + warnDangerousPlugin(pluginId); + } + } catch (err) { + overwriteSpinner.fail((err as Error).message); + fail((err as Error).message, { cause: err, silent: true }); + } + } else if (process.stdin.isTTY) { + printMuted(`Skipped: ${skipped.join(', ')}`); + } + } return; } @@ -54,17 +119,46 @@ export async function pluginInstallCommand(pluginId: string, opts: PluginSourceO } const available = getPluginIds(entries); - if (!available.includes(pluginId)) { - fail(`Unknown plugin: ${pluginId}`, { details: ['Available: ' + available.join(', ')] }); + for (const pluginId of uniqueIds) { + 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 }); + const skippedRemote: string[] = []; + for (const pluginId of uniqueIds) { + const installSpinner = createSpinner(`Installing ${pluginId}…`).start(); + try { + const result = await installPlugin(pluginId, entries, projectDir, branch, undefined, installDir, force); + if (result.skipped) { + installSpinner.stop(); + skippedRemote.push(pluginId); + } else { + installSpinner.succeed(`Installed ${pluginId}`); + warnDangerousPlugin(pluginId); + } + } catch (err) { + installSpinner.fail((err as Error).message); + fail((err as Error).message, { cause: err, silent: true }); + } + } + + if (skippedRemote.length > 0) { + const shouldOverride = await offerOverride(skippedRemote, 'Remote source.'); + if (shouldOverride) { + for (const pluginId of skippedRemote) { + const overwriteSpinner = createSpinner(`Overwriting ${pluginId}…`).start(); + try { + await installPlugin(pluginId, entries, projectDir, branch, undefined, installDir, true); + overwriteSpinner.succeed(`Overwritten: ${pluginId}`); + warnDangerousPlugin(pluginId); + } catch (err) { + overwriteSpinner.fail((err as Error).message); + fail((err as Error).message, { cause: err, silent: true }); + } + } + } else if (process.stdin.isTTY) { + printMuted(`Skipped: ${skippedRemote.join(', ')}`); + } } } diff --git a/cli/src/commands/plugin/list.ts b/cli/src/commands/plugin/list.ts index 73f4568c..a13c601a 100644 --- a/cli/src/commands/plugin/list.ts +++ b/cli/src/commands/plugin/list.ts @@ -1,10 +1,38 @@ import { existsSync } from 'node:fs'; +import { loadConfig } from '../../lib/config.js'; +import { pluginCatalog } from '../../generated/plugin-catalog.js'; import { findInstalledPluginDirs, readPluginManifest } from '../../lib/plugin-utils.js'; import { printBlank, printHeading, printMuted, printTable, style } from '../../lib/output.js'; -import { pluginsDir, resolvePluginProjectDir } from './shared.js'; +import { pluginsDir, resolveManaged, resolvePluginProjectDir } from './shared.js'; -export async function pluginListCommand(dir?: string, installDir = 'feather'): Promise { +export async function pluginListCommand(dir?: string, installDir = 'feather', managed?: string): Promise { const projectDir = resolvePluginProjectDir(dir); + + if (resolveManaged(projectDir, installDir, managed) === 'cli') { + const config = loadConfig(projectDir); + const included = Array.isArray(config?.include) ? (config.include as string[]) : []; + if (included.length === 0) { + printMuted('No plugins included. Run `feather plugin install ` to add one.'); + return; + } + const catalogMap = new Map(pluginCatalog.map((p) => [p.id, p])); + printHeading(`\nIncluded plugins (${included.length})\n`); + const rows = included.map((id) => { + const meta = catalogMap.get(id); + return { id, version: meta?.version ?? '', name: meta?.name ?? id }; + }); + 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(); + return; + } + const dirPath = pluginsDir(projectDir, installDir); if (!existsSync(dirPath)) { diff --git a/cli/src/commands/plugin/remove.ts b/cli/src/commands/plugin/remove.ts index 712ed4e1..f90fc0de 100644 --- a/cli/src/commands/plugin/remove.ts +++ b/cli/src/commands/plugin/remove.ts @@ -6,18 +6,42 @@ 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'; +import { configPluginsCommand } from '../config.js'; +import { resolveManaged, resolvePluginProjectDir } from './shared.js'; export async function pluginRemoveCommand( pluginId: string, - opts: { dir?: string; installDir?: string; yes?: boolean }, + opts: { dir?: string; installDir?: string; yes?: boolean; managed?: string }, ): Promise { const projectDir = resolvePluginProjectDir(opts.dir); + const installDir = normalizeInstallDir(opts.installDir); + + if (resolveManaged(projectDir, installDir, opts.managed) === 'cli') { + 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}" from include list?`, + hint: 'This removes the plugin from feather.config.lua (CLI-managed project).', + danger: false, + rows: [pluginId], + }); + if (!confirmed) { + printMuted('Plugin remove cancelled.'); + return; + } + } + await configPluginsCommand({ dir: projectDir, exclude: pluginId }); + return; + } + let pluginDir: string; try { pluginDir = assertSafeProjectTarget( projectDir, - join(normalizeInstallDir(opts.installDir), 'plugins', pluginIdToSourceDir(pluginId)), + join(installDir, 'plugins', pluginIdToSourceDir(pluginId)), 'Plugin remove target', ); } catch (err) { diff --git a/cli/src/commands/plugin/shared.ts b/cli/src/commands/plugin/shared.ts index cda30f9c..9804e54f 100644 --- a/cli/src/commands/plugin/shared.ts +++ b/cli/src/commands/plugin/shared.ts @@ -1,7 +1,8 @@ import { existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { normalizeInstallDir } from '../../lib/install.js'; -import { findProjectDir } from '../../lib/paths.js'; +import { loadConfig } from '../../lib/config.js'; +import { findPackageDir } from '../../lib/paths.js'; import { dangerousPluginIds, findInstalledPluginDirs, readPluginManifest } from '../../lib/plugin-utils.js'; import { printWarning } from '../../lib/output.js'; @@ -11,10 +12,29 @@ export type PluginSourceOptions = { installDir?: string; remote?: boolean; localSrc?: string; + managed?: string; + force?: boolean; }; +/** + * Returns the effective managed mode string for a project. + * Priority: explicit override > feather.config.lua `managed` field > filesystem fallback. + */ +export function resolveManaged(projectDir: string, installDir: string, override?: string): string | undefined { + if (override) return override; + const config = loadConfig(projectDir); + const fromConfig = config?.managed as string | undefined; + if (fromConfig) return fromConfig; + // Backward compat: no managed field but also no embedded runtime → infer CLI mode. + const hasConfig = existsSync(join(projectDir, 'feather.config.lua')) || existsSync(join(projectDir, '.featherrc.lua')); + if (hasConfig && !existsSync(join(projectDir, normalizeInstallDir(installDir), 'init.lua'))) { + return 'cli'; + } + return undefined; +} + export function resolvePluginProjectDir(dir?: string): string { - return dir ? resolve(dir) : findProjectDir(); + return findPackageDir(dir ? resolve(dir) : process.cwd()); } export function pluginsDir(projectDir: string, installDir = 'feather'): string { diff --git a/cli/src/commands/plugin/update.ts b/cli/src/commands/plugin/update.ts index 8de8cc37..785cdffb 100644 --- a/cli/src/commands/plugin/update.ts +++ b/cli/src/commands/plugin/update.ts @@ -12,15 +12,20 @@ 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'; +import { getInstalledPluginIds, pluginsDir, resolveManaged, 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 }, + opts: { dir?: string; branch?: string; installDir?: string; remote?: boolean; localSrc?: string; yes?: boolean; managed?: string }, ): Promise { const projectDir = resolvePluginProjectDir(opts.dir); const branch = opts.branch ?? 'main'; const installDir = opts.installDir ?? 'feather'; + + if (resolveManaged(projectDir, installDir, opts.managed) === 'cli') { + printMuted('CLI-managed project: plugins are bundled in the Feather CLI binary. Update the CLI to get the latest plugins.'); + return; + } const dirPath = pluginsDir(projectDir, installDir); if (pluginId) { try { @@ -73,7 +78,7 @@ export async function pluginUpdateCommand( for (const id of ids) { const s = createSpinner(`Updating ${id}…`).start(); try { - installPluginsFromLocal([id], sourceRoot, projectDir, installDir); + installPluginsFromLocal([id], sourceRoot, projectDir, installDir, undefined, true); s.succeed(`Updated ${id}`); warnDangerousPlugin(id); } catch (err) { @@ -101,7 +106,7 @@ export async function pluginUpdateCommand( for (const id of ids) { const s = createSpinner(`Updating ${id}…`).start(); try { - await installPlugin(id, entries, projectDir, branch, undefined, installDir); + await installPlugin(id, entries, projectDir, branch, undefined, installDir, true); s.succeed(`Updated ${id}`); warnDangerousPlugin(id); } catch (err) { diff --git a/cli/src/commands/release.ts b/cli/src/commands/release.ts new file mode 100644 index 00000000..37c869b7 --- /dev/null +++ b/cli/src/commands/release.ts @@ -0,0 +1,151 @@ +import { fail } from '../lib/command.js'; +import { + createSpinner, + printBlank, + printJson, + printKeyValues, + printMuted, + printStatus, + printTable, + style, +} from '../lib/output.js'; +import { + initFastlaneRelease, + isReleaseLane, + isReleaseTarget, + releaseLanes, + releaseSafetyWarnings, + releaseTargets, + runFastlaneRelease, + type ReleaseLane, + type ReleaseTarget, +} from '../lib/build/release.js'; + +export type ReleaseCommandOptions = { + dir?: string; + config?: string; + outDir?: string; + name?: string; + version?: string; + dryRun?: boolean; + json?: boolean; + clean?: boolean; + noCache?: boolean; + verbose?: boolean; + skipBuild?: boolean; +}; + +export async function releaseInitCommand(opts: ReleaseCommandOptions = {}): Promise { + const result = initFastlaneRelease({ + projectDir: opts.dir, + configPath: opts.config, + dryRun: opts.dryRun, + json: opts.json, + }); + if (!result.ok) { + if (opts.json) { + printJson(result); + process.exitCode = 1; + return; + } + fail(result.error); + } + if (opts.json) { + printJson(result); + return; + } + + printStatus(result.dryRun ? 'info' : 'success', result.dryRun ? 'Fastlane scaffold plan' : 'Fastlane scaffold ready'); + printBlank(); + printKeyValues([ + ['Project', result.projectDir], + ['Fastlane', result.fastlaneDir], + ]); + printBlank(); + printTable({ + columns: [ + { key: 'action', label: 'Action' }, + { key: 'path', label: 'Path' }, + ], + rows: result.files.map((file) => ({ action: file.action, path: file.path })), + }); +} + +export async function releaseRunCommand( + targetValue: string, + laneValue: string | undefined, + opts: ReleaseCommandOptions = {}, +): Promise { + if (!isReleaseTarget(targetValue)) { + fail(`Unknown release target: ${targetValue}`, { details: [`Available: ${releaseTargets.join(', ')}`] }); + } + const lane = laneValue ?? 'beta'; + if (!isReleaseLane(lane)) { + fail(`Unknown release lane: ${lane}`, { details: [`Available: ${releaseLanes.join(', ')}`] }); + } + + const target: ReleaseTarget = targetValue; + const releaseLane: ReleaseLane = lane; + const verbose = Boolean(opts.verbose && !opts.json && !opts.dryRun); + const spinner = opts.json || opts.dryRun || verbose ? null : createSpinner(`Running ${target} ${releaseLane} release…`).start(); + const result = runFastlaneRelease({ + target, + lane: releaseLane, + projectDir: opts.dir, + configPath: opts.config, + outDir: opts.outDir, + name: opts.name, + version: opts.version, + dryRun: opts.dryRun, + clean: opts.clean, + noCache: opts.noCache, + verbose, + skipBuild: opts.skipBuild, + }); + + if (!result.ok) { + spinner?.fail(result.error); + if (opts.json) { + printJson(result); + process.exitCode = 1; + return; + } + fail(result.error, { silent: Boolean(spinner) }); + } + + if (opts.json) { + printJson(result); + return; + } + + if (result.dryRun) { + printStatus('info', `Release plan for ${style.heading(`${result.target} ${result.lane}`)}`); + } else { + spinner?.succeed(`Released ${result.target} ${result.lane}`); + } + + printBlank(); + printKeyValues([ + ['Project', result.projectDir], + ['Fastlane', result.fastlaneDir], + ['Target', result.target], + ['Lane', result.lane], + ]); + if (result.artifacts.length > 0) { + printBlank(); + printTable({ + columns: [ + { key: 'type', label: 'Type' }, + { key: 'path', label: 'Path' }, + ], + rows: result.artifacts.map((artifact) => ({ type: artifact.type, path: artifact.path })), + }); + } + printBlank(); + printMuted(`Command: ${result.command.join(' ')}`); + const warnings = releaseSafetyWarnings(result.safety); + if (warnings.length > 0) { + printBlank(); + for (const warning of warnings) printMuted(`Safety: ${warning}`); + } +} diff --git a/cli/src/commands/replay.ts b/cli/src/commands/replay.ts new file mode 100644 index 00000000..0275e624 --- /dev/null +++ b/cli/src/commands/replay.ts @@ -0,0 +1,89 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fail } from '../lib/command.js'; +import { assertSafeProjectTarget } from '../lib/path-safety.js'; +import { icon, printLine, printMuted, printWarning, style } from '../lib/output.js'; +import { bundledLuaRoot, repoLuaRoot } from '../lib/paths.js'; +import { configPluginsCommand } from './config.js'; + +export type ReplayInitOptions = { + dir?: string; + path?: string; + force?: boolean; + config?: boolean; +}; + +const DEFAULT_ADAPTER_PATH = 'dev/replay.lua'; +const ADAPTER_TEMPLATE_PATH = join('plugins', 'session-replay', 'replay_adapter.lua'); + +function replayAdapterTemplate(): string { + const candidates = [join(bundledLuaRoot(), ADAPTER_TEMPLATE_PATH)]; + const repoRoot = repoLuaRoot(); + if (repoRoot) candidates.push(join(repoRoot, ADAPTER_TEMPLATE_PATH)); + + for (const path of candidates) { + if (existsSync(path)) return readFileSync(path, 'utf8'); + } + + fail('Session Replay adapter template was not found.', { + details: [`Expected ${ADAPTER_TEMPLATE_PATH} in bundled cli/lua or src-lua.`], + }); +} + +function usageSnippet(adapterPath: string): string { + const moduleName = adapterPath.replace(/\.lua$/i, '').replace(/[\\/]/g, '.'); + return `local replay = require("${moduleName}") + +function love.load() + replay.register() +end + +function love.keypressed(key) + if key == "f5" then + replay.start() + elseif key == "f6" then + replay.stop() + elseif key == "f7" then + replay.play() + end +end`; +} + +export async function replayInitCommand(opts: ReplayInitOptions = {}): Promise { + const projectDir = resolve(opts.dir ?? '.'); + const adapterPath = opts.path ?? DEFAULT_ADAPTER_PATH; + + const mainPath = assertSafeProjectTarget(projectDir, 'main.lua', 'main.lua check target'); + if (!existsSync(mainPath)) { + fail(`No main.lua found in ${projectDir}. Is this a LÖVE project?`); + } + + const targetPath = assertSafeProjectTarget(projectDir, adapterPath, 'Replay adapter target'); + if (existsSync(targetPath) && !opts.force) { + fail(`Replay adapter already exists: ${adapterPath}`, { + details: ['Pass --force to overwrite it, or use --path .'], + }); + } + + mkdirSync(dirname(targetPath), { recursive: true }); + writeFileSync(targetPath, replayAdapterTemplate()); + printLine(`${icon.success} Created ${adapterPath}`); + + const configPath = assertSafeProjectTarget(projectDir, 'feather.config.lua', 'Config check target'); + if (opts.config !== false) { + if (existsSync(configPath)) { + await configPluginsCommand({ dir: projectDir, include: 'session-replay' }); + } else { + printWarning('No feather.config.lua found; skipped enabling session-replay.'); + printMuted(' Run `feather init` first, or add `include = { "session-replay" }` manually.'); + } + } + + printLine(`\n${style.heading('Replay adapter usage')}`); + printMuted(usageSnippet(adapterPath)); + + const source = readFileSync(targetPath, 'utf8'); + if (!source.includes('replayRegister')) { + fail('Replay adapter scaffold was written but does not contain replayRegister.'); + } +} diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index 512c4474..b0bf35d1 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { existsSync, realpathSync } from "node:fs"; import { basename, dirname, join, relative, resolve } from "node:path"; import { findLoveBinary } from "../lib/love.js"; @@ -228,16 +228,7 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) ["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; + return await runLove(loveBin, [absGame, ...(opts.gameArgs ?? [])], process.env); } const shim = createShim({ @@ -258,18 +249,25 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) const env = shimEnv(absGame, sessionName); - const result = spawnSync(loveBin, [shim.dir, ...(opts.gameArgs ?? [])], { - stdio: "inherit", - env, - }); - - shim.cleanup(); - - if (result.error) { - fail(`Failed to launch love: ${result.error.message}`, { cause: result.error }); + try { + return await runLove(loveBin, [shim.dir, ...(opts.gameArgs ?? [])], env); + } finally { + shim.cleanup(); } +} - return result.status ?? 0; +function runLove(loveBin: string, args: string[], env: NodeJS.ProcessEnv): Promise { + return new Promise((resolveResult, reject) => { + const child = spawn(loveBin, args, { + stdio: ["ignore", "pipe", "pipe"], + env, + }); + + child.stdout?.on("data", (chunk: Buffer) => process.stdout.write(chunk)); + child.stderr?.on("data", (chunk: Buffer) => process.stderr.write(chunk)); + child.on("error", (err) => reject(new Error(`Failed to launch love: ${err.message}`, { cause: err }))); + child.on("close", (code) => resolveResult(code ?? 0)); + }); } export function resolveRunBuildContext(absGame: string, buildConfig: string | undefined): { diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index c408f246..46160c48 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -6,6 +6,7 @@ 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"; +import { resolveManaged } from "./plugin/shared.js"; export async function updateCommand( dir: string, @@ -13,6 +14,12 @@ export async function updateCommand( ): Promise { const target = resolve(dir); const installDir = normalizeInstallDir(opts.installDir); + + if (resolveManaged(target, installDir) === 'cli') { + printMuted('CLI-managed project: the Feather runtime is bundled in the CLI binary. Update the CLI to get the latest runtime.'); + return; + } + let installedInit: string; try { installedInit = assertSafeProjectTarget(target, join(installDir, "init.lua"), "Core update target"); diff --git a/cli/src/commands/upload.ts b/cli/src/commands/upload.ts index 4d8ab08c..69d5c58e 100644 --- a/cli/src/commands/upload.ts +++ b/cli/src/commands/upload.ts @@ -13,8 +13,9 @@ import { 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 { inspectUploadArtifact, type UploadSafetyResult } from '../lib/build/upload-safety.js'; import { chooseUploadWorkflow, type UploadWorkflowResult } from '../ui/upload-workflow.js'; -import type { UploadSafetyResult } from '../lib/build/upload-safety.js'; +import type { BuildResult } from '../lib/build/build.js'; export type UploadCommandOptions = { dir?: string; @@ -63,6 +64,28 @@ function printSafetyWarning(safety: UploadSafetyResult, warning: string): void { printDanger('Review this upload before sharing it with players.'); } +function uploadBuildReleaseMode(target: string): boolean { + return target === 'android' || target === 'ios'; +} + +function assertUploadBuildArtifactsSafe(result: BuildResult): void { + if (!result.ok) return; + + const inspected = result.artifacts + .map((artifact) => inspectUploadArtifact(artifact.path)) + .filter((safety) => safety.status !== 'unknown'); + const unsafe = inspected.find((safety) => safety.status === 'unsafe'); + if (unsafe) { + throw new Error( + `Upload build blocked because Feather runtime/debugging files were detected in ${unsafe.artifact}.\n${unsafe.detectedFiles.join('\n')}`, + ); + } + + if (inspected.length === 0) { + throw new Error('Upload build blocked because no generated artifact could be inspected for Feather runtime/debugging files.'); + } +} + async function resolveUploadInput( targetValue: string | undefined, buildTarget: string | undefined, @@ -125,6 +148,12 @@ export async function uploadCommand(targetValue: string | undefined, buildTarget if (!buildTarget || !isBuildTarget(buildTarget)) { fail('A build target is required with --build. Example: feather upload itch web --build'); } + if (opts.allowUnsafe) { + fail('Upload builds always run production safety checks; remove --allow-unsafe.'); + } + if (opts.allowFeatherRuntime) { + fail('Upload builds must be Feather-free; remove --allow-feather-runtime.'); + } const verbose = Boolean(opts.verbose && !opts.json && !opts.dryRun); const buildSpinner = opts.json || opts.dryRun || verbose ? null : createSpinner(`Building ${buildTarget}…`).start(); const buildResult = runBuild({ @@ -134,9 +163,11 @@ export async function uploadCommand(targetValue: string | undefined, buildTarget outDir: opts.outDir, clean: opts.clean, dryRun: false, - allowUnsafe: opts.allowUnsafe, - release: opts.release, + allowUnsafe: false, + release: uploadBuildReleaseMode(buildTarget), noCache: opts.noCache, + debugger: false, + skipProductionConfigPreflight: true, verbose, log: verbose ? printMuted : undefined, }); @@ -144,6 +175,12 @@ export async function uploadCommand(targetValue: string | undefined, buildTarget buildSpinner?.fail(buildResult.error); fail(buildResult.error, { silent: Boolean(buildSpinner) }); } + try { + assertUploadBuildArtifactsSafe(buildResult); + } catch (err) { + buildSpinner?.fail((err as Error).message); + fail((err as Error).message, { silent: Boolean(buildSpinner) }); + } buildSpinner?.succeed(`Built ${buildTarget}`); } const spinner = opts.json || opts.dryRun ? null : createSpinner(`Uploading to ${target}…`).start(); @@ -160,6 +197,7 @@ export async function uploadCommand(targetValue: string | undefined, buildTarget ifChanged: opts.ifChanged, hidden: opts.hidden, allowFeatherRuntime: opts.allowFeatherRuntime || resolved.confirmedUnsafe, + trustedFeatherFreeBuild: Boolean(opts.build), }); if (!result.ok) { diff --git a/cli/src/generated/plugin-catalog.ts b/cli/src/generated/plugin-catalog.ts index 7535e3cb..88905f74 100644 --- a/cli/src/generated/plugin-catalog.ts +++ b/cli/src/generated/plugin-catalog.ts @@ -261,6 +261,20 @@ export const pluginCatalog = [ "optIn": false, "disabled": true }, + { + "id": "session-replay", + "sourceDir": "session-replay", + "name": "Session Replay", + "description": "Record input and developer-provided state checkpoints for reproducible playthroughs", + "version": "0.1.0", + "capabilities": [ + "input", + "filesystem", + "binary" + ], + "optIn": true, + "disabled": true + }, { "id": "shader-graph", "sourceDir": "shader-graph", diff --git a/cli/src/index.ts b/cli/src/index.ts index 05b11d48..3b0c0023 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { Command } from 'commander'; -import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import packageJson from '../package.json' with { type: 'json' }; import { runCliAction } from './lib/command.js'; import { runCommand } from './commands/run.js'; import { initCommand } from './commands/init.js'; @@ -8,10 +9,13 @@ 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 { releaseInitCommand, releaseRunCommand } from './commands/release.js'; +import { replayInitCommand } from './commands/replay.js'; import { watchCommand } from './commands/watch.js'; import { buildVendorAddCommand, buildVendorListCommand } from './commands/build-vendor.js'; import { buildTargets } from './lib/build/config.js'; import { uploadCommand } from './commands/upload.js'; +import { configHotReloadCommand, configManagedCommand, configPluginsCommand } from './commands/config.js'; import { pluginListCommand, pluginInstallCommand, @@ -31,9 +35,8 @@ import { } from './commands/package.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; +const cliVersion = packageJson.version; function parseInitMode(value: string): InitMode { if (!initModes.has(value)) { @@ -42,528 +45,785 @@ function parseInitMode(value: string): InitMode { return value as InitMode; } -program - .name('feather') - .description('Run and debug Love2D games with Feather — zero game-side changes required') - .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, ios, or steamos', '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((gamePath: string | undefined, gameArgs: string[], opts) => runCliAction(() => runCommand(gamePath, { - love: opts.love as string | undefined, - target: opts.target as 'desktop' | 'web' | 'android' | 'ios' | 'steamos' | 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, - 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]') - .description('Initialize Feather in a Love2D project directory (default: current directory)') - .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') - .option('--branch ', 'GitHub branch to download from when using --remote', 'main') - .option('--local-src ', 'Copy Lua runtime from a local src-lua style directory') - .option('--install-dir ', 'Install directory for auto/manual modes', 'feather') - .option('--no-plugins', 'Skip plugin installation') - .option('--plugins ', 'Comma-separated list of plugins to install') - .option('--mode ', 'Setup mode: cli, auto, or manual', parseInitMode) - .option('-y, --yes', 'Skip confirmation prompts') - .option('--allow-insecure-connection', 'Set __DANGEROUS_INSECURE_CONNECTION__ in feather.config.lua (required with --yes if appId is not configured)') - .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, - installDir: opts.installDir as string, - noPlugins: opts.plugins === false, - plugins: - opts.plugins && opts.plugins !== true - ? (opts.plugins as string).split(',').map((s: string) => s.trim()) - : undefined, - mode: opts.mode as InitMode | undefined, - yes: opts.yes as boolean, - allowInsecureConnection: opts.allowInsecureConnection as boolean | undefined, - }))); - -program - .command('remove [dir]') - .description('Remove Feather files and init markers from a Love2D project') - .option('--install-dir ', 'Feather install directory override') - .option('--dry-run', 'Show what would be removed without changing files') - .option('--keep-config', 'Keep feather.config.lua') - .option('--keep-main', 'Keep main.lua FEATHER-INIT markers') - .option('--keep-manual', 'Keep feather.debugger.lua') - .option('--keep-runtime', 'Keep installed Feather runtime/plugins') - .option('-y, --yes', 'Skip interactive confirmation') - .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, - keepMain: opts.keepMain as boolean | undefined, - 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') - .option('--port ', 'Port to check for the Feather desktop WebSocket', (value) => Number(value)) - .option('--json', 'Print machine-readable diagnostics') - .option('--production', 'Fail when project settings are unsafe for production builds') - .option('--security', 'Print security-focused diagnostics; use with --json for automation') - .option('--target ', 'Check dependencies for a build target') - .option('--build-target ', 'Alias for --target') - .option('--upload-target ', 'Check dependencies for an upload target') - .option('--release', 'Include mobile release build checks with --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.target ?? 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}`) +function parseCommaList(value: unknown): string[] | undefined { + if (typeof value !== 'string' || !value) return undefined; + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +export function createProgram(): Command { + const program = new Command(); + + program + .name('feather') + .description('Run and debug Love2D games with Feather — zero game-side changes required') + .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, ios, or steamos', '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((gamePath: string | undefined, gameArgs: string[], opts) => + runCliAction(() => + runCommand(gamePath, { + love: opts.love as string | undefined, + target: opts.target as 'desktop' | 'web' | 'android' | 'ios' | 'steamos' | 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, + 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]') + .description('Initialize Feather in a Love2D project directory (default: current directory)') + .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') + .option('--branch ', 'GitHub branch to download from when using --remote', 'main') + .option('--local-src ', 'Copy Lua runtime from a local src-lua style directory') + .option('--install-dir ', 'Install directory for auto/manual modes', 'feather') + .option('--no-plugins', 'Skip plugin installation') + .option('--plugins ', 'Comma-separated list of plugins to install') + .option('--hot-reload-allow ', 'Comma-separated Lua module names to allow for hot reload') + .option('--session-name ', 'Session name shown in the Feather desktop app') + .option('--app-id ', 'Desktop App ID allowed to send commands to this game') + .option('--mode ', 'Setup mode: cli, auto, or manual', parseInitMode) + .option('-y, --yes', 'Skip confirmation prompts') + .option( + '--allow-insecure-connection', + 'Set __DANGEROUS_INSECURE_CONNECTION__ in feather.config.lua (required with --yes if appId is not configured)', + ) + .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, + installDir: opts.installDir as string, + noPlugins: opts.plugins === false, + plugins: parseCommaList(opts.plugins as string | undefined), + hotReloadAllow: parseCommaList(opts.hotReloadAllow as string | undefined), + mode: opts.mode as InitMode | undefined, + yes: opts.yes as boolean, + allowInsecureConnection: opts.allowInsecureConnection as boolean | undefined, + appId: opts.appId as string | undefined, + sessionName: opts.sessionName as string | undefined, + }), + ), + ); + + program + .command('remove [dir]') + .description('Remove Feather files and init markers from a Love2D project') + .option('--install-dir ', 'Feather install directory override') + .option('--dry-run', 'Show what would be removed without changing files') + .option('--keep-config', 'Keep feather.config.lua') + .option('--keep-main', 'Keep main.lua FEATHER-INIT markers') + .option('--keep-manual', 'Keep feather.debugger.lua') + .option('--keep-runtime', 'Keep installed Feather runtime/plugins') + .option('-y, --yes', 'Skip interactive confirmation') + .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, + keepMain: opts.keepMain as boolean | undefined, + 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') + .option('--port ', 'Port to check for the Feather desktop WebSocket', (value) => Number(value)) + .option('--json', 'Print machine-readable diagnostics') + .option('--production', 'Fail when project settings are unsafe for production builds') + .option('--security', 'Print security-focused diagnostics; use with --json for automation') + .option('--target ', 'Check dependencies for a build target') + .option('--build-target ', 'Alias for --target') + .option('--upload-target ', 'Check dependencies for an upload target') + .option('--release', 'Include mobile release build checks with --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.target ?? opts.buildTarget) as string | undefined, + uploadTarget: opts.uploadTarget as string | undefined, + release: opts.release as boolean | undefined, + }), + ), + ); + + const config = program.command('config').description('Update Feather configuration values'); + + const replay = program.command('replay').description('Scaffold and manage Session Replay adapters'); + + replay + .command('init') + .description('Create a centralized Session Replay adapter file') + .option('--dir ', 'Project directory (default: current directory)') + .option('--path ', 'Adapter path inside the project', 'dev/replay.lua') + .option('--force', 'Overwrite an existing adapter file') + .option('--no-config', 'Do not update feather.config.lua to include session-replay') + .action((opts) => + runCliAction(() => + replayInitCommand({ + dir: opts.dir as string | undefined, + path: opts.path as string | undefined, + force: opts.force as boolean | undefined, + config: opts.config as boolean | undefined, + }), + ), + ); + + config + .command('plugins') + .description('Update feather.config.lua plugin include/exclude settings and capability allowlist') + .option('--dir ', 'Project directory (default: current directory)') + .option('--include ', 'Comma-separated plugin IDs to include and enable') + .option('--exclude ', 'Comma-separated plugin IDs to exclude') + .action((opts) => + runCliAction(() => + configPluginsCommand({ + dir: opts.dir as string | undefined, + include: opts.include as string | undefined, + exclude: opts.exclude as string | undefined, + }), + ), + ); + + config + .command('managed ') + .description('Set the managed mode in feather.config.lua (cli, auto, manual)') + .option('--dir ', 'Project directory (default: current directory)') + .action((mode, opts) => + runCliAction(() => + configManagedCommand(mode as string, { + dir: opts.dir as string | undefined, + }), + ), + ); + + config + .command('hot-reload') + .description('Enable hot reload config and set its module allowlist') + .option('--dir ', 'Project directory (default: current directory)') + .option('--allow ', 'Comma-separated Lua module names to allow') + .action((opts) => + runCliAction(() => + configHotReloadCommand({ + dir: opts.dir as string | undefined, + allow: opts.allow as string | 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('--target ', 'Vendor target(s) to add') .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('--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') - .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') + .addHelpText('after', '\n --no-config Do not update feather.build.json') + .action((targets: string[], opts) => + runCliAction(() => + buildVendorAddCommand( + [...targets.filter((target) => target !== '--no-config'), ...((opts.target as string[] | undefined) ?? [])], + { + 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, + }), + ), + ); + + const release = program + .command('release') + .description('Run Fastlane-backed mobile release lanes'); + + release + .command('init') + .description('Create editable Fastlane release files') + .option('--dir ', 'Project directory (default: current directory)') + .option('--config ', 'Path to feather.build.json') + .option('--dry-run', 'Show planned files without writing them') + .option('--json', 'Output machine-readable JSON') + .action((opts) => + runCliAction(() => + releaseInitCommand({ + dir: opts.dir as string | undefined, + config: opts.config as string | undefined, + dryRun: opts.dryRun as boolean | undefined, + json: opts.json as boolean | undefined, + }), + ), + ); + + function addReleaseTargetCommand(target: 'ios' | 'android'): void { + release + .command(`${target} [lane]`) + .description(`Run a Fastlane ${target} release lane: beta, production, metadata, or screenshots`) + .option('--dir ', 'Project directory (default: current directory)') + .option('--config ', 'Path to feather.build.json') + .option('--out-dir ', 'Build output directory') + .option('--name ', 'Build product name') + .option('--version ', 'Build product version') + .option('--dry-run', 'Show the release plan without running Fastlane') + .option('--json', 'Output machine-readable JSON') + .option('--clean', 'Remove the output directory before release build') + .option('--no-cache', 'Disable Android/iOS native build cache during release build') + .option('--verbose', 'Show build/Fastlane command output') + .option('--skip-build', 'Run the Fastlane lane using existing build artifacts') + .action((lane: string | undefined, opts) => + runCliAction(() => + releaseRunCommand(target, lane, { + dir: opts.dir as string | undefined, + config: opts.config as string | undefined, + outDir: opts.outDir as string | undefined, + name: opts.name as string | undefined, + version: opts.version as string | undefined, + dryRun: opts.dryRun as boolean | undefined, + json: opts.json as boolean | undefined, + clean: opts.clean as boolean | undefined, + noCache: opts.cache === false, + verbose: opts.verbose as boolean | undefined, + skipBuild: opts.skipBuild as boolean | undefined, + }), + ), + ); + } + + addReleaseTargetCommand('ios'); + addReleaseTargetCommand('android'); + + 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', 'Deprecated for upload builds; mobile upload builds always use release mode') + .option('--allow-unsafe', 'Rejected with --build; upload builds always run production safety checks') + .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 existing 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]') + .description('Update the Feather core library in a project (default: current directory)') + .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') + .option('--branch ', 'GitHub branch to download from when using --remote', 'main') + .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) => + 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') + .description('Manage Feather plugins') + .option('--dir ', 'Project directory (default: current directory)') + .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') + .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') + .option('--managed ', 'Override managed mode detection (cli, auto, manual)') + .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() }); + + plugin + .command('list [dir]') + .description('List installed plugins') + .option('--install-dir ', 'Feather install directory', 'feather') + .option('--managed ', 'Override managed mode detection (cli, auto, manual)') + .action((dir: string | undefined, opts) => + runCliAction(() => { + const merged = pluginCommandOptions(opts); + return pluginListCommand(dir ?? (merged.dir as string | undefined), merged.installDir as string, merged.managed as string | undefined); + }), + ); + + plugin + .command('install ') + .description('Install one or more plugins from the Feather registry') + .option('--dir ', 'Project directory (default: current directory)') + .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') + .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') + .option('--managed ', 'Override managed mode detection (cli, auto, manual)') + .option('--force', 'Overwrite already-installed plugins without prompting') + .action((ids: string[], opts) => + runCliAction(() => { + const merged = pluginCommandOptions(opts); + return pluginInstallCommand(ids, { + 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, + managed: merged.managed as string | undefined, + force: merged.force as boolean | undefined, + }); + }), + ); + + plugin + .command('remove ') + .description('Remove an installed plugin') + .option('--dir ', 'Project directory (default: current directory)') + .option('--install-dir ', 'Feather install directory', 'feather') + .option('-y, --yes', 'Skip interactive confirmation') + .option('--managed ', 'Override managed mode detection (cli, auto, manual)') + .action((id: string, opts) => + runCliAction(() => { + const merged = pluginCommandOptions(opts); + return pluginRemoveCommand(id, { + dir: merged.dir as string | undefined, + installDir: merged.installDir as string, + yes: merged.yes as boolean | undefined, + managed: merged.managed as string | undefined, + }); + }), + ); + + plugin + .command('update [id]') + .description('Update a plugin (or all installed plugins if no id given)') + .option('--dir ', 'Project directory (default: current directory)') + .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') + .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') + .option('-y, --yes', 'Skip interactive selection and update all installed plugins when no id is given') + .option('--managed ', 'Override managed mode detection (cli, auto, manual)') + .action((id: string | undefined, opts) => + runCliAction(() => { + const merged = pluginCommandOptions(opts); + return pluginUpdateCommand(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, + yes: merged.yes as boolean | undefined, + managed: merged.managed as string | 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((query: string | undefined, opts) => + runCliAction(() => + packageSearchCommand(query, { + offline: opts.offline as boolean | undefined, + registryUrl: opts.registry as string | undefined, + }), + ), + ); + + pkg + .command('list') + .description('List all available packages (--installed for installed only)') + .option('--installed', 'Show only installed packages') + .option('--offline', 'Use bundled registry snapshot') + .option('--refresh', 'Force a fresh registry fetch ignoring cache') + .option('--dir ', 'Project directory') + .option('--registry ', 'Override registry URL') + .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 ') + .description('Show package details') + .option('--offline', 'Use bundled registry snapshot') + .option('--dir ', 'Project directory') + .option('--registry ', 'Override registry URL') + .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...]') + .description('Install one or more packages') + .option('--dry-run', 'Show what would be installed without writing files') + .option('--allow-untrusted', 'Allow installing experimental packages') + .option('--target ', 'Override install target directory') + .option('--from-url ', 'Install a single file from an arbitrary URL (requires --allow-untrusted)') + .option('--offline', 'Use bundled registry snapshot') + .option('--dir ', 'Project directory') + .option('-y, --yes', 'Skip confirmation prompts') + .option('--registry ', 'Override registry URL') + .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, + fromUrl: opts.fromUrl as string | undefined, + offline: opts.offline as boolean | undefined, + dir: opts.dir as string | undefined, + yes: opts.yes as boolean | undefined, + registryUrl: opts.registry as string | undefined, + }), + ), + ); + + pkg + .command('update [name]') + .description('Update an installed package (or all if no name given)') + .option('--dry-run', 'Show what would change without writing files') + .option('--offline', 'Use bundled registry snapshot') + .option('--dir ', 'Project directory') + .option('--registry ', 'Override registry URL') + .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((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((opts) => + runCliAction(() => + packageAuditCommand({ + dir: opts.dir as string | undefined, + json: opts.json as boolean | undefined, + }), + ), + ); + + program + .command('watch [game-path]') + .description( + 'Watch project files and restart desktop LÖVE or push game.love to a connected mobile device on change', + ) + .option('--target ', 'Watch target: desktop, android, ios, or steamos', 'desktop') + .option('--love ', 'Path to love executable for desktop watch') + .option('--device ', 'Android device serial or iOS simulator UDID') + .option('--debounce ', 'Debounce delay in milliseconds', (value) => Number(value), 500) + .option('--no-restart', 'Push game.love without restarting the app') + .option('--build-config ', 'Path to feather.build.json') + .option('--out-dir ', 'Build output directory') + .option('--no-plugins', 'Disable plugin loading (feather core only)') + .option('--no-debugger', 'Run desktop watch without Feather debugger injection') .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, + .option('--no-adb-reverse', 'Skip adb reverse setup for Android') + .option('--port ', 'Feather port for Android adb reverse', (value) => Number(value)) + .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') + .option('--runtime-config ', 'Path to feather.config.lua for debugger embedding') + .option('--verbose', 'Show build commands and native tool output') + .action((gamePath: string | undefined, opts) => + runCliAction(() => + watchCommand(gamePath, { + target: opts.target as 'desktop' | 'android' | 'ios' | 'steamos' | undefined, + love: opts.love as string | undefined, + debugger: opts.debugger !== false && !opts.disableDebugger, + device: opts.device as string | undefined, + debounce: opts.debounce as number | undefined, + restart: opts.restart as boolean | undefined, + buildConfig: opts.buildConfig as string | undefined, + outDir: opts.outDir as string | undefined, + noPlugins: opts.plugins === false, + adbReverse: opts.adbReverse as boolean | undefined, + port: opts.port as number | undefined, + featherPath: opts.featherPath as string | undefined, + pluginsDir: opts.pluginsDir as string | undefined, + runtimeConfig: opts.runtimeConfig as string | undefined, + verbose: opts.verbose as boolean | 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'))); + return program; } -function buildConfigOption(config: string | undefined, buildConfig: string | undefined): string | undefined { - if (buildConfig) return buildConfig; - return looksLikeRuntimeConfig(config) ? undefined : config; -} +export async function runCli(argv: string[] = process.argv.slice(2)): Promise { + const previousExitCode = process.exitCode; + process.exitCode = undefined; + const program = createProgram(); + program.exitOverride(); + + try { + await program.parseAsync(argv, { from: 'user' }); + } catch (err) { + if (err && typeof err === 'object' && 'exitCode' in err) { + process.exitCode = Number((err as { exitCode: number }).exitCode); + } else { + throw err; + } + } -function runtimeConfigOption( - config: string | undefined, - runtimeConfig: string | undefined, - configPath: string | undefined, -): string | undefined { - return runtimeConfig ?? configPath ?? (looksLikeRuntimeConfig(config) ? config : undefined); + const exitCode = typeof process.exitCode === 'number' ? process.exitCode : 0; + process.exitCode = previousExitCode; + return exitCode; } -for (const target of buildTargets) { - addBuildTargetCommand(target); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const exitCode = await runCli(); + process.exitCode = exitCode; } - -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('--target ', 'Vendor target(s) to add') - .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'), - ...((opts.target as string[] | undefined) ?? []), - ], { - 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]') - .description('Update the Feather core library in a project (default: current directory)') - .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') - .option('--branch ', 'GitHub branch to download from when using --remote', 'main') - .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) => 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') - .description('Manage Feather plugins') - .option('--dir ', 'Project directory (default: current directory)') - .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') - .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((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() }); - -plugin - .command('list [dir]') - .description('List installed plugins') - .option('--install-dir ', 'Feather install directory', 'feather') - .action((dir: string | undefined, opts) => runCliAction(() => { - const merged = pluginCommandOptions(opts); - return pluginListCommand(dir ?? (merged.dir as string | undefined), merged.installDir as string); - })); - -plugin - .command('install ') - .description('Install a plugin from the Feather registry') - .option('--dir ', 'Project directory (default: current directory)') - .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') - .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((id: string, opts) => runCliAction(() => { - const merged = pluginCommandOptions(opts); - 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') - .option('-y, --yes', 'Skip interactive confirmation') - .action((id: string, opts) => runCliAction(() => { - const merged = pluginCommandOptions(opts); - return pluginRemoveCommand(id, { - dir: merged.dir as string | undefined, - installDir: merged.installDir as string, - yes: merged.yes as boolean | undefined, - }); - })); - -plugin - .command('update [id]') - .description('Update a plugin (or all installed plugins if no id given)') - .option('--dir ', 'Project directory (default: current directory)') - .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') - .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') - .option('-y, --yes', 'Skip interactive selection and update all installed plugins when no id is given') - .action((id: string | undefined, opts) => runCliAction(() => { - const merged = pluginCommandOptions(opts); - return pluginUpdateCommand(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, - 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((query: string | undefined, opts) => runCliAction(() => packageSearchCommand(query, { - offline: opts.offline as boolean | undefined, - registryUrl: opts.registry as string | undefined, - }))); - -pkg - .command('list') - .description('List all available packages (--installed for installed only)') - .option('--installed', 'Show only installed packages') - .option('--offline', 'Use bundled registry snapshot') - .option('--refresh', 'Force a fresh registry fetch ignoring cache') - .option('--dir ', 'Project directory') - .option('--registry ', 'Override registry URL') - .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 ') - .description('Show package details') - .option('--offline', 'Use bundled registry snapshot') - .option('--dir ', 'Project directory') - .option('--registry ', 'Override registry URL') - .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...]') - .description('Install one or more packages') - .option('--dry-run', 'Show what would be installed without writing files') - .option('--allow-untrusted', 'Allow installing experimental packages') - .option('--target ', 'Override install target directory') - .option('--from-url ', 'Install a single file from an arbitrary URL (requires --allow-untrusted)') - .option('--offline', 'Use bundled registry snapshot') - .option('--dir ', 'Project directory') - .option('-y, --yes', 'Skip confirmation prompts') - .option('--registry ', 'Override registry URL') - .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, - fromUrl: opts.fromUrl as string | undefined, - offline: opts.offline as boolean | undefined, - dir: opts.dir as string | undefined, - yes: opts.yes as boolean | undefined, - registryUrl: opts.registry as string | undefined, - }))); - -pkg - .command('update [name]') - .description('Update an installed package (or all if no name given)') - .option('--dry-run', 'Show what would change without writing files') - .option('--offline', 'Use bundled registry snapshot') - .option('--dir ', 'Project directory') - .option('--registry ', 'Override registry URL') - .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((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((opts) => runCliAction(() => packageAuditCommand({ - dir: opts.dir as string | undefined, - json: opts.json as boolean | undefined, - }))); - -program - .command('watch [game-path]') - .description('Watch project files and restart desktop LÖVE or push game.love to a connected mobile device on change') - .option('--target ', 'Watch target: desktop, android, ios, or steamos', 'desktop') - .option('--love ', 'Path to love executable for desktop watch') - .option('--device ', 'Android device serial or iOS simulator UDID') - .option('--debounce ', 'Debounce delay in milliseconds', (value) => Number(value), 500) - .option('--no-restart', 'Push game.love without restarting the app') - .option('--build-config ', 'Path to feather.build.json') - .option('--out-dir ', 'Build output directory') - .option('--no-plugins', 'Disable plugin loading (feather core only)') - .option('--no-debugger', 'Run desktop watch without Feather debugger injection') - .option('--disable-debugger', 'Alias for --no-debugger') - .option('--no-adb-reverse', 'Skip adb reverse setup for Android') - .option('--port ', 'Feather port for Android adb reverse', (value) => Number(value)) - .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') - .option('--runtime-config ', 'Path to feather.config.lua for debugger embedding') - .option('--verbose', 'Show build commands and native tool output') - .action((gamePath: string | undefined, opts) => watchCommand(gamePath, { - target: opts.target as 'desktop' | 'android' | 'ios' | 'steamos' | undefined, - love: opts.love as string | undefined, - debugger: opts.debugger !== false && !opts.disableDebugger, - device: opts.device as string | undefined, - debounce: opts.debounce as number | undefined, - restart: opts.restart as boolean | undefined, - buildConfig: opts.buildConfig as string | undefined, - outDir: opts.outDir as string | undefined, - noPlugins: opts.plugins === false, - adbReverse: opts.adbReverse as boolean | undefined, - port: opts.port as number | undefined, - featherPath: opts.featherPath as string | undefined, - pluginsDir: opts.pluginsDir as string | undefined, - runtimeConfig: opts.runtimeConfig as string | undefined, - verbose: opts.verbose as boolean | undefined, - })); - -program.parse(); diff --git a/cli/src/lib/build/android.ts b/cli/src/lib/build/android.ts index 2260b546..a7dab91f 100644 --- a/cli/src/lib/build/android.ts +++ b/cli/src/lib/build/android.ts @@ -316,7 +316,7 @@ 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`; + return `${source.trimEnd()}\n${line}\n`; } function utf8ByteArray(value: string): string { diff --git a/cli/src/lib/build/build.ts b/cli/src/lib/build/build.ts index 2d6d5885..ec65b9d6 100644 --- a/cli/src/lib/build/build.ts +++ b/cli/src/lib/build/build.ts @@ -42,6 +42,7 @@ export type BuildOptions = LoadBuildConfigOptions & { noPlugins?: boolean; featherOverride?: string; pluginsOverride?: string; + skipProductionConfigPreflight?: boolean; verbose?: boolean; log?: NativeBuildLogger; }; @@ -73,14 +74,14 @@ export function assertBuildTargetSupported(target: BuildTarget): asserts target 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 source = existsSync(configPath) ? 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 = [ + config.includeRuntime ? 'Feather runtime is included in the build output' : '', /__DANGEROUS_INSECURE_CONNECTION__\s*=\s*true/.test(source) ? '__DANGEROUS_INSECURE_CONNECTION__ is enabled' : '', - socketMode && !hasAppId ? 'appId is missing for socket/network mode' : '', + source && 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' : '', @@ -118,7 +119,12 @@ export function runBuild(options: BuildOptions): BuildResult { assertReleaseTargetSupported(options.target, Boolean(options.release)); const config = loadBuildConfig(options); assertBuildConfigValidForTarget(config, options.target, Boolean(options.release)); - assertProductionBuildSafe(config, options.allowUnsafe); + const releaseBuildWithoutFeatherRuntime = + (options.target === 'android' || options.target === 'ios') && Boolean(options.release) && !config.includeRuntime; + assertProductionBuildSafe( + config, + options.allowUnsafe || options.skipProductionConfigPreflight || releaseBuildWithoutFeatherRuntime, + ); assertNoSymlinkEscape(config.projectDir, config.outDir, 'Build output directory'); if (options.dryRun) return planBuild(options); diff --git a/cli/src/lib/build/config.ts b/cli/src/lib/build/config.ts index 1677d31b..3a2a8aec 100644 --- a/cli/src/lib/build/config.ts +++ b/cli/src/lib/build/config.ts @@ -29,6 +29,16 @@ export type AndroidBuildTargetConfig = { keyAlias?: string; storePasswordEnv?: string; keyPasswordEnv?: string; + fastlane?: { + packageName?: string; + track?: string; + releaseStatus?: string; + serviceAccountJsonEnv?: string; + keystorePath?: string; + keyAlias?: string; + storePasswordEnv?: string; + keyPasswordEnv?: string; + }; }; }; @@ -54,6 +64,18 @@ export type IosBuildTargetConfig = { teamId?: string; configuration?: string; sdk?: string; + fastlane?: { + bundleIdentifier?: string; + teamId?: string; + exportMethod?: string; + appStoreConnectApiKeyPathEnv?: string; + appStoreConnectIssuerIdEnv?: string; + appStoreConnectKeyIdEnv?: string; + appStoreConnectKeyContentEnv?: string; + matchGitUrlEnv?: string; + matchPasswordEnv?: string; + matchType?: string; + }; }; }; @@ -76,6 +98,12 @@ export type FeatherBuildConfig = { exclude?: string[]; icon?: string; includeRuntime?: boolean; + release?: { + fastlane?: { + path?: string; + bundleExec?: 'auto' | 'always' | 'never'; + }; + }; targets?: { web?: { loveJsDir?: string; @@ -114,6 +142,7 @@ export type ResolvedBuildConfig = { exclude: string[]; icon?: string; includeRuntime: boolean; + release: NonNullable; targets: NonNullable; upload: NonNullable; }; @@ -135,6 +164,9 @@ const DEFAULT_EXCLUDES = [ 'feather.config.lua', 'feather.lock.json', 'feather.build.json', + 'feather_replays', + '*.featherreplay', + '**/*.featherreplay', ]; export function isBuildTarget(value: string): value is BuildTarget { @@ -219,6 +251,7 @@ export function loadBuildConfig(options: LoadBuildConfigOptions = {}): ResolvedB exclude, icon: raw.icon, includeRuntime, + release: raw.release ?? {}, targets: raw.targets ?? {}, upload: raw.upload ?? {}, }; diff --git a/cli/src/lib/build/files.ts b/cli/src/lib/build/files.ts index e32a1e27..8b3b9835 100644 --- a/cli/src/lib/build/files.ts +++ b/cli/src/lib/build/files.ts @@ -49,7 +49,7 @@ export function buildSlug(name: string): string { .trim() .toLowerCase() .replace(/[^a-z0-9._-]+/g, '-') - .replace(/^-+|-+$/g, '') || 'game'; + .replace(/^-+/, '').replace(/-+$/, '') || 'game'; } export function artifactBaseName(config: ResolvedBuildConfig): string { diff --git a/cli/src/lib/build/ios.ts b/cli/src/lib/build/ios.ts index ed1f3beb..e8b3d9f9 100644 --- a/cli/src/lib/build/ios.ts +++ b/cli/src/lib/build/ios.ts @@ -626,7 +626,7 @@ function findIosResourcesPhaseId(project: string): string | null { } function ensureResourceBuildFile(project: string, resourcesPhaseId: string, buildFileId: string): string { - const phasePattern = new RegExp(`(${resourcesPhaseId} /\\* Resources \\*/ = \\{[\\s\\S]*?files = \\(\\n)([\\s\\S]*?)(\\n\\s*\\);)`); + const phasePattern = new RegExp(`(${escapeRegExp(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/release.ts b/cli/src/lib/build/release.ts new file mode 100644 index 00000000..b82db9a8 --- /dev/null +++ b/cli/src/lib/build/release.ts @@ -0,0 +1,392 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { assertNoSymlinkEscape } from '../path-safety.js'; +import { runBuild, type BuildResult } from './build.js'; +import { latestManifestPath, readLatestManifest, type BuildArtifact } from './files.js'; +import { loadBuildConfig, type LoadBuildConfigOptions, type ResolvedBuildConfig } from './config.js'; +import { inspectUploadArtifact, uploadSafetyWarning, type UploadSafetyResult } from './upload-safety.js'; +import { androidProductId, assertBuildConfigValidForTarget, iosBundleIdentifier } from './validation.js'; + +export const releaseTargets = ['ios', 'android'] as const; +export const releaseLanes = ['beta', 'production', 'metadata', 'screenshots'] as const; + +export type ReleaseTarget = typeof releaseTargets[number]; +export type ReleaseLane = typeof releaseLanes[number]; + +export type ReleaseInitOptions = LoadBuildConfigOptions & { + dryRun?: boolean; + json?: boolean; +}; + +export type ReleaseRunOptions = LoadBuildConfigOptions & { + target: ReleaseTarget; + lane: ReleaseLane; + dryRun?: boolean; + clean?: boolean; + noCache?: boolean; + verbose?: boolean; + skipBuild?: boolean; +}; + +export type FastlaneCommandPlan = { + cwd: string; + command: string[]; + env: Record; +}; + +export type ReleaseInitResult = { + ok: true; + dryRun: boolean; + projectDir: string; + fastlaneDir: string; + files: Array<{ path: string; action: 'create' | 'skip' }>; +} | { + ok: false; + error: string; +}; + +export type ReleaseRunResult = { + ok: true; + dryRun: boolean; + target: ReleaseTarget; + lane: ReleaseLane; + projectDir: string; + fastlaneDir: string; + command: string[]; + artifacts: BuildArtifact[]; + safety: UploadSafetyResult[]; + build?: Extract; +} | { + ok: false; + error: string; +}; + +export function isReleaseTarget(value: string): value is ReleaseTarget { + return (releaseTargets as readonly string[]).includes(value); +} + +export function isReleaseLane(value: string): value is ReleaseLane { + return (releaseLanes as readonly string[]).includes(value); +} + +export function initFastlaneRelease(options: ReleaseInitOptions = {}): ReleaseInitResult { + try { + const config = loadBuildConfig(options); + const fastlaneDir = fastlanePath(config); + assertNoSymlinkEscape(config.projectDir, fastlaneDir, 'Fastlane directory'); + const files = fastlaneScaffold(config).map((file) => ({ + ...file, + action: existsSync(file.path) ? 'skip' as const : 'create' as const, + })); + if (!options.dryRun) { + for (const file of files) { + if (file.action === 'skip') continue; + mkdirSync(dirname(file.path), { recursive: true }); + writeFileSync(file.path, file.content); + } + } + return { + ok: true, + dryRun: Boolean(options.dryRun), + projectDir: config.projectDir, + fastlaneDir, + files: files.map(({ path, action }) => ({ path, action })), + }; + } catch (err) { + return { ok: false, error: (err as Error).message }; + } +} + +export function runFastlaneRelease(options: ReleaseRunOptions): ReleaseRunResult { + try { + const config = loadBuildConfig(options); + assertBuildConfigValidForTarget(config, options.target, true); + const fastlaneDir = fastlanePath(config); + assertNoSymlinkEscape(config.projectDir, fastlaneDir, 'Fastlane directory'); + if (!existsSync(fastlaneDir)) { + throw new Error(`Fastlane directory not found: ${fastlaneDir}. Run \`feather release init --dir ${config.projectDir}\`.`); + } + + const shouldBuild = !options.dryRun && !options.skipBuild && (options.lane === 'beta' || options.lane === 'production'); + const build = shouldBuild + ? runBuild({ + target: options.target, + projectDir: config.projectDir, + configPath: options.configPath, + outDir: options.outDir, + name: options.name, + version: options.version, + clean: options.clean, + dryRun: false, + release: true, + noCache: options.noCache, + debugger: false, + skipProductionConfigPreflight: true, + verbose: options.verbose, + log: options.verbose ? console.log : undefined, + }) + : undefined; + if (build && !build.ok) return { ok: false, error: build.error }; + + const artifacts = collectReleaseArtifacts(config, options.target, build); + const safety = artifacts.map((artifact) => inspectUploadArtifact(artifact.path)); + const unsafe = safety.find((result) => result.status === 'unsafe'); + if (unsafe) { + throw new Error(`Release blocked because Feather runtime/debugging files were detected in ${unsafe.artifact}.\n${unsafe.detectedFiles.join('\n')}`); + } + + const plan = fastlaneCommand(config, options.target, options.lane, artifacts); + if (!options.dryRun) { + const result = spawnSync(plan.command[0]!, plan.command.slice(1), { + cwd: plan.cwd, + env: { ...process.env, ...plan.env }, + encoding: options.verbose ? undefined : 'utf8', + stdio: options.verbose ? 'inherit' : 'pipe', + }); + if (result.error) { + const executable = plan.command.slice(0, plan.command[0] === 'bundle' ? 3 : 1).join(' '); + throw new Error(`${executable} not found. Install Fastlane or run \`feather doctor --target ${options.target} --release\`.`); + } + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || `fastlane failed with exit code ${result.status ?? 'unknown'}`).toString().trim()); + } + } + + return { + ok: true, + dryRun: Boolean(options.dryRun), + target: options.target, + lane: options.lane, + projectDir: config.projectDir, + fastlaneDir, + command: plan.command, + artifacts, + safety, + build: build?.ok ? build : undefined, + }; + } catch (err) { + return { ok: false, error: (err as Error).message }; + } +} + +export function fastlanePath(config: ResolvedBuildConfig): string { + const configured = config.release.fastlane?.path ?? 'fastlane'; + return resolve(config.projectDir, configured); +} + +export function fastlaneCommand( + config: ResolvedBuildConfig, + target: ReleaseTarget, + lane: ReleaseLane, + artifacts: BuildArtifact[] = [], +): FastlaneCommandPlan { + const mode = config.release.fastlane?.bundleExec ?? 'auto'; + const useBundle = mode === 'always' || (mode === 'auto' && existsSync(join(config.projectDir, 'Gemfile'))); + const command = useBundle + ? ['bundle', 'exec', 'fastlane', target, lane] + : ['fastlane', target, lane]; + const env = fastlaneEnv(config, target, lane, artifacts); + return { cwd: config.projectDir, command, env }; +} + +function fastlaneEnv( + config: ResolvedBuildConfig, + target: ReleaseTarget, + lane: ReleaseLane, + artifacts: BuildArtifact[], +): Record { + const artifact = (type: string) => artifacts.find((item) => item.type === type)?.path ?? ''; + const androidFastlane = config.targets.android?.release?.fastlane ?? {}; + const iosFastlane = config.targets.ios?.release?.fastlane ?? {}; + const loveAndroidDir = config.targets.android?.loveAndroidDir ? resolve(config.projectDir, config.targets.android.loveAndroidDir) : ''; + const loveIosDir = config.targets.ios?.loveIosDir ? resolve(config.projectDir, config.targets.ios.loveIosDir) : ''; + return { + FEATHER_PROJECT_DIR: config.projectDir, + FEATHER_OUT_DIR: config.outDir, + FEATHER_BUILD_MANIFEST: latestManifestPath(config.outDir), + FEATHER_RELEASE_TARGET: target, + FEATHER_RELEASE_LANE: lane, + FEATHER_ANDROID_PROJECT_DIR: target === 'android' ? loveAndroidDir : '', + FEATHER_ANDROID_AAB: target === 'android' ? artifact('aab') : '', + FEATHER_ANDROID_APK: target === 'android' ? artifact('apk') : '', + FEATHER_ANDROID_PACKAGE_NAME: androidFastlane.packageName ?? androidProductId(config), + FEATHER_ANDROID_TRACK: androidFastlane.track ?? (lane === 'production' ? 'production' : 'internal'), + FEATHER_ANDROID_RELEASE_STATUS: androidFastlane.releaseStatus ?? (lane === 'production' ? 'draft' : 'completed'), + FEATHER_ANDROID_SERVICE_ACCOUNT_JSON: envValue(androidFastlane.serviceAccountJsonEnv), + FEATHER_ANDROID_KEYSTORE_PATH: androidFastlane.keystorePath ? resolve(config.projectDir, androidFastlane.keystorePath) : '', + FEATHER_ANDROID_KEY_ALIAS: androidFastlane.keyAlias ?? '', + FEATHER_ANDROID_STORE_PASSWORD: envValue(androidFastlane.storePasswordEnv), + FEATHER_ANDROID_KEY_PASSWORD: envValue(androidFastlane.keyPasswordEnv), + FEATHER_IOS_XCODEPROJ: target === 'ios' && loveIosDir ? join(loveIosDir, 'platform', 'xcode', 'love.xcodeproj') : '', + FEATHER_IOS_IPA: target === 'ios' ? artifact('ipa') : '', + FEATHER_IOS_XCARCHIVE: target === 'ios' ? artifact('xcarchive') : '', + FEATHER_IOS_BUNDLE_IDENTIFIER: iosFastlane.bundleIdentifier ?? iosBundleIdentifier(config), + FEATHER_IOS_TEAM_ID: iosFastlane.teamId ?? config.targets.ios?.release?.teamId ?? config.targets.ios?.teamId ?? '', + FEATHER_IOS_EXPORT_METHOD: iosFastlane.exportMethod ?? config.targets.ios?.release?.exportMethod ?? 'app-store', + FEATHER_IOS_ASC_API_KEY_PATH: envValue(iosFastlane.appStoreConnectApiKeyPathEnv), + FEATHER_IOS_ASC_ISSUER_ID: envValue(iosFastlane.appStoreConnectIssuerIdEnv), + FEATHER_IOS_ASC_KEY_ID: envValue(iosFastlane.appStoreConnectKeyIdEnv), + FEATHER_IOS_ASC_KEY_CONTENT: envValue(iosFastlane.appStoreConnectKeyContentEnv), + FEATHER_IOS_MATCH_GIT_URL: envValue(iosFastlane.matchGitUrlEnv), + FEATHER_IOS_MATCH_PASSWORD: envValue(iosFastlane.matchPasswordEnv), + FEATHER_IOS_MATCH_TYPE: iosFastlane.matchType ?? (lane === 'production' ? 'appstore' : 'adhoc'), + }; +} + +function envValue(name: string | undefined): string { + return name ? process.env[name] ?? '' : ''; +} + +function collectReleaseArtifacts( + config: ResolvedBuildConfig, + target: ReleaseTarget, + build: BuildResult | undefined, +): BuildArtifact[] { + if (build?.ok) return build.artifacts.filter((artifact) => artifact.target === target); + const manifest = readLatestManifest(config.outDir); + if (!manifest) return []; + return manifest.artifacts.filter((artifact) => artifact.target === target); +} + +function fastlaneScaffold(config: ResolvedBuildConfig): Array<{ path: string; content: string }> { + const root = fastlanePath(config); + return [ + { path: join(root, 'Fastfile'), content: fastfile() }, + { path: join(root, 'Appfile'), content: appfile() }, + { path: join(root, '.env.example'), content: envExample() }, + { path: join(root, 'README.md'), content: readme() }, + { path: join(root, 'metadata', 'ios', 'en-US', 'description.txt'), content: `${config.description ?? config.name}\n` }, + { path: join(root, 'metadata', 'android', 'en-US', 'full_description.txt'), content: `${config.description ?? config.name}\n` }, + { path: join(root, 'screenshots', 'ios', '.gitkeep'), content: '' }, + { path: join(root, 'screenshots', 'android', '.gitkeep'), content: '' }, + ]; +} + +function fastfile(): string { + return `default_platform(:ios) + +def feather_required_env(name) + value = ENV[name].to_s + UI.user_error!("Missing #{name}") if value.empty? + value +end + +def feather_ios_api_key + path = ENV["FEATHER_IOS_ASC_API_KEY_PATH"].to_s + return app_store_connect_api_key(path: path) unless path.empty? + + key_id = ENV["FEATHER_IOS_ASC_KEY_ID"].to_s + issuer_id = ENV["FEATHER_IOS_ASC_ISSUER_ID"].to_s + key_content = ENV["FEATHER_IOS_ASC_KEY_CONTENT"].to_s + return nil if key_id.empty? || issuer_id.empty? || key_content.empty? + app_store_connect_api_key(key_id: key_id, issuer_id: issuer_id, key_content: key_content) +end + +platform :ios do + desc "Build and upload the Feather IPA to TestFlight" + lane :beta do + api_key = feather_ios_api_key + upload_to_testflight(ipa: feather_required_env("FEATHER_IOS_IPA"), api_key: api_key, skip_waiting_for_build_processing: true) + end + + desc "Upload the Feather IPA to App Store Connect" + lane :production do + api_key = feather_ios_api_key + upload_to_app_store(ipa: feather_required_env("FEATHER_IOS_IPA"), api_key: api_key, submit_for_review: false, automatic_release: false) + end + + desc "Validate App Store metadata" + lane :metadata do + deliver(metadata_path: "fastlane/metadata/ios", skip_binary_upload: true, skip_screenshots: true, force: true) + end + + desc "Sync iOS screenshots" + lane :screenshots do + deliver(screenshots_path: "fastlane/screenshots/ios", skip_binary_upload: true, skip_metadata: true, force: true) + end +end + +platform :android do + desc "Upload the Feather AAB to the configured Play beta/internal track" + lane :beta do + upload_to_play_store( + aab: feather_required_env("FEATHER_ANDROID_AAB"), + package_name: feather_required_env("FEATHER_ANDROID_PACKAGE_NAME"), + json_key: feather_required_env("FEATHER_ANDROID_SERVICE_ACCOUNT_JSON"), + track: ENV["FEATHER_ANDROID_TRACK"].to_s.empty? ? "internal" : ENV["FEATHER_ANDROID_TRACK"], + release_status: ENV["FEATHER_ANDROID_RELEASE_STATUS"].to_s.empty? ? "completed" : ENV["FEATHER_ANDROID_RELEASE_STATUS"] + ) + end + + desc "Upload the Feather AAB to the Play production track as draft by default" + lane :production do + upload_to_play_store( + aab: feather_required_env("FEATHER_ANDROID_AAB"), + package_name: feather_required_env("FEATHER_ANDROID_PACKAGE_NAME"), + json_key: feather_required_env("FEATHER_ANDROID_SERVICE_ACCOUNT_JSON"), + track: "production", + release_status: ENV["FEATHER_ANDROID_RELEASE_STATUS"].to_s.empty? ? "draft" : ENV["FEATHER_ANDROID_RELEASE_STATUS"] + ) + end + + desc "Validate Google Play metadata" + lane :metadata do + supply(metadata_path: "fastlane/metadata/android", package_name: feather_required_env("FEATHER_ANDROID_PACKAGE_NAME"), json_key: feather_required_env("FEATHER_ANDROID_SERVICE_ACCOUNT_JSON"), skip_upload_apk: true, skip_upload_aab: true) + end + + desc "Sync Android screenshots" + lane :screenshots do + supply(metadata_path: "fastlane/metadata/android", package_name: feather_required_env("FEATHER_ANDROID_PACKAGE_NAME"), json_key: feather_required_env("FEATHER_ANDROID_SERVICE_ACCOUNT_JSON"), skip_upload_apk: true, skip_upload_aab: true) + end +end +`; +} + +function appfile(): string { + return `app_identifier(ENV["FEATHER_IOS_BUNDLE_IDENTIFIER"]) +apple_team_id(ENV["FEATHER_IOS_TEAM_ID"]) unless ENV["FEATHER_IOS_TEAM_ID"].to_s.empty? +json_key_file(ENV["FEATHER_ANDROID_SERVICE_ACCOUNT_JSON"]) unless ENV["FEATHER_ANDROID_SERVICE_ACCOUNT_JSON"].to_s.empty? +package_name(ENV["FEATHER_ANDROID_PACKAGE_NAME"]) unless ENV["FEATHER_ANDROID_PACKAGE_NAME"].to_s.empty? +`; +} + +function envExample(): string { + return `# Feather reads these values from your shell/CI environment. +# Do not commit real secrets. + +APP_STORE_CONNECT_API_KEY_PATH= +APP_STORE_CONNECT_ISSUER_ID= +APP_STORE_CONNECT_KEY_ID= +APP_STORE_CONNECT_KEY_CONTENT= +MATCH_GIT_URL= +MATCH_PASSWORD= + +GOOGLE_PLAY_SERVICE_ACCOUNT_JSON= +ANDROID_STORE_PASSWORD= +ANDROID_KEY_PASSWORD= +`; +} + +function readme(): string { + return `# Feather Fastlane + +These files are generated by \`feather release init\` and are intended to be edited. + +Feather builds clean mobile artifacts first, then runs lanes with explicit \`FEATHER_*\` environment variables. +Secrets should come from your shell or CI environment, not \`feather.build.json\`. + +Common commands: + +\`\`\`bash +feather release ios beta --dir . +feather release ios production --dir . +feather release android beta --dir . +feather release android production --dir . +\`\`\` +`; +} + +export function releaseSafetyWarnings(safety: UploadSafetyResult[]): string[] { + return safety.map((item) => uploadSafetyWarning(item)).filter((item): item is string => Boolean(item)); +} diff --git a/cli/src/lib/build/upload-safety.ts b/cli/src/lib/build/upload-safety.ts index 93865b11..dc9c68b1 100644 --- a/cli/src/lib/build/upload-safety.ts +++ b/cli/src/lib/build/upload-safety.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { extname, join, relative } from 'node:path'; +import { inflateRawSync } from 'node:zlib'; export type UploadSafetyStatus = 'safe' | 'unsafe' | 'unknown'; @@ -11,17 +12,36 @@ export type UploadSafetyResult = { }; const FEATHER_PATTERNS = [ - /^feather(?:\/|$)/, - /^plugins(?:\/|$)/, - /^\.feather-main\.lua$/, - /^feather\.config\.lua$/, - /^feather\.debugger\.lua$/, - /^feather-build-manifest\.json$/, + /(?:^|\/)feather(?:\/|$)/, + /(?:^|\/)plugins(?:\/|$)/, + /(?:^|\/)\.feather-main\.lua$/, + /(?:^|\/)feather\.config\.lua$/, + /(?:^|\/)feather\.debugger\.lua$/, + /(?:^|\/)feather-build-manifest\.json$/, + /(?:^|\/)feather_replays(?:\/|$)/, + /(?:^|\/)[^/]+\.featherreplay$/, + /(?:^|\/)session-replay(?:\/|$)/, ]; +const ZIP_LIKE_EXTENSIONS = new Set(['.aab', '.apk', '.ipa', '.love', '.zip']); +const MAX_NESTED_ARCHIVE_DEPTH = 6; + +type ZipEntry = { + name: string; + compressedSize: number; + compressionMethod: number; + data?: Buffer; +}; function isFeatherPath(path: string): boolean { const normalized = path.replace(/\\/g, '/').replace(/^\/+/, ''); - return FEATHER_PATTERNS.some((pattern) => pattern.test(normalized)); + return normalized.split('!').some((segment) => { + const archivePath = segment.replace(/^\/+/, ''); + return FEATHER_PATTERNS.some((pattern) => pattern.test(archivePath)); + }); +} + +function isZipLikePath(path: string): boolean { + return ZIP_LIKE_EXTENSIONS.has(extname(path).toLowerCase()); } function inspectNames(artifact: string, names: string[]): UploadSafetyResult { @@ -33,7 +53,7 @@ function inspectNames(artifact: string, names: string[]): UploadSafetyResult { }; } -function listDirectoryNames(root: string): string[] { +function listInspectableDirectoryNames(root: string): string[] { const names: string[] = []; const visit = (dir: string) => { for (const entry of readdirSync(dir, { withFileTypes: true })) { @@ -41,18 +61,43 @@ function listDirectoryNames(root: string): string[] { const rel = relative(root, abs).replace(/\\/g, '/'); names.push(entry.isDirectory() ? `${rel}/` : rel); if (entry.isDirectory()) visit(abs); + else if (isZipLikePath(entry.name)) { + names.push(...zipEntryNames(readFileSync(abs), rel, 1)); + } } }; visit(root); return names; } -function zipEntryNames(zip: Buffer): string[] { +function zipEntryNames(zip: Buffer, prefix = '', depth = 0): string[] { + if (depth > MAX_NESTED_ARCHIVE_DEPTH) { + throw new Error(`nested archive depth exceeded ${MAX_NESTED_ARCHIVE_DEPTH}`); + } const names: string[] = []; + for (const entry of zipEntries(zip)) { + const archiveName = prefix ? `${prefix}!${entry.name}` : entry.name; + names.push(archiveName); + if (!entry.name.endsWith('/') && isZipLikePath(entry.name)) { + if (!entry.data) { + throw new Error( + `could not inspect nested archive ${archiveName}: unsupported compression method ${entry.compressionMethod}`, + ); + } + names.push(...zipEntryNames(entry.data, archiveName, depth + 1)); + } + } + return names; +} + +function zipEntries(zip: Buffer): ZipEntry[] { + const entries: ZipEntry[] = []; let offset = centralDirectoryOffset(zip); while (offset + 46 <= zip.length) { const signature = zip.readUInt32LE(offset); if (signature !== 0x02014b50) break; + const flags = zip.readUInt16LE(offset + 8); + const compressionMethod = zip.readUInt16LE(offset + 10); const compressedSize = zip.readUInt32LE(offset + 20); const uncompressedSize = zip.readUInt32LE(offset + 24); const nameLength = zip.readUInt16LE(offset + 28); @@ -63,10 +108,52 @@ function zipEntryNames(zip: Buffer): string[] { 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')); + const name = zip.subarray(nameStart, nameStart + nameLength).toString('utf8'); + const shouldReadData = !name.endsWith('/') && isZipLikePath(name); + entries.push({ + name, + compressedSize, + compressionMethod, + data: shouldReadData + ? readZipEntryData(zip, { + compressedSize, + compressionMethod, + encrypted: (flags & 0x0001) !== 0, + localHeaderOffset, + uncompressedSize, + }) + : undefined, + }); offset = nameStart + nameLength + extraLength + commentLength; } - return names; + return entries; +} + +function readZipEntryData( + zip: Buffer, + entry: { + compressedSize: number; + compressionMethod: number; + encrypted: boolean; + localHeaderOffset: number; + uncompressedSize: number; + }, +): Buffer | undefined { + if (entry.encrypted || (entry.compressionMethod !== 0 && entry.compressionMethod !== 8)) return undefined; + if (entry.localHeaderOffset + 30 > zip.length) throw new Error('invalid ZIP local header offset'); + const signature = zip.readUInt32LE(entry.localHeaderOffset); + if (signature !== 0x04034b50) throw new Error('invalid ZIP local header'); + const nameLength = zip.readUInt16LE(entry.localHeaderOffset + 26); + const extraLength = zip.readUInt16LE(entry.localHeaderOffset + 28); + const dataStart = entry.localHeaderOffset + 30 + nameLength + extraLength; + const dataEnd = dataStart + entry.compressedSize; + if (dataEnd > zip.length) throw new Error('ZIP entry data extends past archive end'); + const compressed = zip.subarray(dataStart, dataEnd); + const data = entry.compressionMethod === 8 ? inflateRawSync(compressed) : Buffer.from(compressed); + if (data.length !== entry.uncompressedSize) { + throw new Error('ZIP entry size mismatch'); + } + return data; } function centralDirectoryOffset(zip: Buffer): number { @@ -91,11 +178,20 @@ export function inspectUploadArtifact(artifact: string): UploadSafetyResult { const stat = statSync(artifact); if (stat.isDirectory()) { - return inspectNames(artifact, listDirectoryNames(artifact)); + try { + return inspectNames(artifact, listInspectableDirectoryNames(artifact)); + } catch (error) { + return { + status: 'unknown', + artifact, + detectedFiles: [], + reason: `could not inspect directory archive contents: ${(error as Error).message}`, + }; + } } const ext = extname(artifact).toLowerCase(); - if (ext === '.love' || ext === '.zip') { + if (ZIP_LIKE_EXTENSIONS.has(ext)) { try { return inspectNames(artifact, zipEntryNames(readFileSync(artifact))); } catch (error) { diff --git a/cli/src/lib/build/upload.ts b/cli/src/lib/build/upload.ts index 3de6ab57..d4de6bcd 100644 --- a/cli/src/lib/build/upload.ts +++ b/cli/src/lib/build/upload.ts @@ -16,6 +16,7 @@ export type UploadOptions = LoadBuildConfigOptions & { ifChanged?: boolean; hidden?: boolean; allowFeatherRuntime?: boolean; + trustedFeatherFreeBuild?: boolean; }; export type UploadResult = { @@ -57,8 +58,11 @@ export function runUpload(options: UploadOptions): UploadResult { 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) { + const warning = options.trustedFeatherFreeBuild && safety.status === 'unknown' + ? undefined + : uploadSafetyWarning(safety) ?? undefined; + const blockedBySafety = safety.status === 'unsafe' || (safety.status === 'unknown' && !options.trustedFeatherFreeBuild); + if (!options.dryRun && blockedBySafety && !options.allowFeatherRuntime) { return { ok: false, error: diff --git a/cli/src/lib/build/validation.ts b/cli/src/lib/build/validation.ts index 2ee9ccbe..80d03100 100644 --- a/cli/src/lib/build/validation.ts +++ b/cli/src/lib/build/validation.ts @@ -21,6 +21,8 @@ const IOS_EXPORT_METHODS = new Set([ 'debugging', ]); const IOS_SIGNING_STYLES = new Set(['automatic', 'manual']); +const FASTLANE_BUNDLE_EXEC_MODES = new Set(['auto', 'always', 'never']); +const FASTLANE_RELEASE_STATUSES = new Set(['completed', 'draft', 'halted', 'inProgress']); const ANDROID_ORIENTATIONS = new Set([ 'unspecified', 'landscape', @@ -64,7 +66,9 @@ export function assertBuildConfigValidForTarget(config: ResolvedBuildConfig, tar export function validateAndroidBuildConfig(config: ResolvedBuildConfig, release = false): BuildValidationIssue[] { const android = config.targets.android ?? {}; const releaseConfig = android.release ?? {}; + const fastlane = releaseConfig.fastlane ?? {}; const issues: BuildValidationIssue[] = []; + validateFastlaneRootConfig(config, 'android', issues); const productId = android.productId ?? config.productId ?? defaultProductId(config, 'android'); if (!ANDROID_PRODUCT_ID_RE.test(productId)) { issues.push({ @@ -121,9 +125,35 @@ export function validateAndroidBuildConfig(config: ResolvedBuildConfig, release ['targets.android.release.bundleArtifactPath', releaseConfig.bundleArtifactPath], ['targets.android.release.apkArtifactPath', releaseConfig.apkArtifactPath], ['targets.android.release.keystorePath', releaseConfig.keystorePath], + ['targets.android.release.fastlane.keystorePath', fastlane.keystorePath], ] as const) { if (value !== undefined) validateRelativePathish(value, field, 'android', issues); } + for (const [field, value] of [ + ['targets.android.release.fastlane.packageName', fastlane.packageName], + ['targets.android.release.fastlane.track', fastlane.track], + ['targets.android.release.fastlane.keyAlias', fastlane.keyAlias], + ] as const) { + if (value !== undefined && !isNonEmptySingleLine(value)) { + issues.push({ target: 'android', field, message: 'Use a non-empty single-line value.' }); + } + } + if (fastlane.releaseStatus !== undefined && !FASTLANE_RELEASE_STATUSES.has(fastlane.releaseStatus)) { + issues.push({ + target: 'android', + field: 'targets.android.release.fastlane.releaseStatus', + message: `Use one of: ${[...FASTLANE_RELEASE_STATUSES].join(', ')}.`, + }); + } + for (const [field, value] of [ + ['targets.android.release.fastlane.serviceAccountJsonEnv', fastlane.serviceAccountJsonEnv], + ['targets.android.release.fastlane.storePasswordEnv', fastlane.storePasswordEnv], + ['targets.android.release.fastlane.keyPasswordEnv', fastlane.keyPasswordEnv], + ] as const) { + if (value !== undefined && !ENV_NAME_RE.test(value)) { + issues.push({ target: 'android', field, message: 'Use a valid environment variable name.' }); + } + } const signingFields = [ releaseConfig.keystorePath, releaseConfig.keyAlias, @@ -157,7 +187,9 @@ export function validateAndroidBuildConfig(config: ResolvedBuildConfig, release export function validateIosBuildConfig(config: ResolvedBuildConfig, release = false): BuildValidationIssue[] { const ios = config.targets.ios ?? {}; const releaseConfig = ios.release ?? {}; + const fastlane = releaseConfig.fastlane ?? {}; const issues: BuildValidationIssue[] = []; + validateFastlaneRootConfig(config, 'ios', issues); const bundleId = ios.bundleIdentifier ?? ios.productId ?? config.productId ?? defaultProductId(config, 'ios'); if (!IOS_BUNDLE_ID_RE.test(bundleId)) { issues.push({ @@ -237,10 +269,72 @@ export function validateIosBuildConfig(config: ResolvedBuildConfig, release = fa message: 'Use automatic or manual.', }); } + for (const [field, value] of [ + ['targets.ios.release.fastlane.bundleIdentifier', fastlane.bundleIdentifier], + ] as const) { + if (value !== undefined && !IOS_BUNDLE_ID_RE.test(value)) { + issues.push({ target: 'ios', field, message: 'Use a reverse-DNS iOS bundle id, for example com.example.game.' }); + } + } + for (const [field, value] of [ + ['targets.ios.release.fastlane.teamId', fastlane.teamId], + ] as const) { + if (value !== undefined && (!isNonEmptySingleLine(value) || !TEAM_ID_RE.test(value))) { + issues.push({ target: 'ios', field, message: 'Use an Apple team id containing only letters and numbers.' }); + } + } + if (fastlane.exportMethod !== undefined && !IOS_EXPORT_METHODS.has(fastlane.exportMethod)) { + issues.push({ + target: 'ios', + field: 'targets.ios.release.fastlane.exportMethod', + message: `Use one of: ${[...IOS_EXPORT_METHODS].join(', ')}.`, + }); + } + for (const [field, value] of [ + ['targets.ios.release.fastlane.matchType', fastlane.matchType], + ] as const) { + if (value !== undefined && !isNonEmptySingleLine(value)) { + issues.push({ target: 'ios', field, message: 'Use a non-empty single-line value.' }); + } + } + for (const [field, value] of [ + ['targets.ios.release.fastlane.appStoreConnectApiKeyPathEnv', fastlane.appStoreConnectApiKeyPathEnv], + ['targets.ios.release.fastlane.appStoreConnectIssuerIdEnv', fastlane.appStoreConnectIssuerIdEnv], + ['targets.ios.release.fastlane.appStoreConnectKeyIdEnv', fastlane.appStoreConnectKeyIdEnv], + ['targets.ios.release.fastlane.appStoreConnectKeyContentEnv', fastlane.appStoreConnectKeyContentEnv], + ['targets.ios.release.fastlane.matchGitUrlEnv', fastlane.matchGitUrlEnv], + ['targets.ios.release.fastlane.matchPasswordEnv', fastlane.matchPasswordEnv], + ] as const) { + if (value !== undefined && !ENV_NAME_RE.test(value)) { + issues.push({ target: 'ios', field, message: 'Use a valid environment variable name.' }); + } + } } return issues; } +function validateFastlaneRootConfig( + config: ResolvedBuildConfig, + target: 'android' | 'ios', + issues: BuildValidationIssue[], +): void { + const fastlane = config.release?.fastlane ?? {}; + if (fastlane.path !== undefined) { + try { + assertSafeRelativePath(fastlane.path, 'release.fastlane.path'); + } catch (err) { + issues.push({ target, field: 'release.fastlane.path', message: (err as Error).message }); + } + } + if (fastlane.bundleExec !== undefined && !FASTLANE_BUNDLE_EXEC_MODES.has(fastlane.bundleExec)) { + issues.push({ + target, + field: 'release.fastlane.bundleExec', + message: `Use one of: ${[...FASTLANE_BUNDLE_EXEC_MODES].join(', ')}.`, + }); + } +} + export function defaultProductId(config: ResolvedBuildConfig, target: 'android' | 'ios'): string { const slug = buildSlug(config.name) .replace(/[^a-z0-9]+/g, '.') diff --git a/cli/src/lib/build/vendor.ts b/cli/src/lib/build/vendor.ts index e746f8ba..ab22c31a 100644 --- a/cli/src/lib/build/vendor.ts +++ b/cli/src/lib/build/vendor.ts @@ -2,9 +2,13 @@ import { spawnSync } from 'node:child_process'; import { chmodSync, cpSync, + closeSync, existsSync, mkdirSync, + openSync, readFileSync, + readSync, + rmSync, writeFileSync, } from 'node:fs'; import { inflateRawSync } from 'node:zlib'; @@ -49,6 +53,8 @@ export type BuildVendorResult = { repo: string; installed: boolean; skipped: boolean; + /** True when skipped because the vendor directory already exists (not --force, not SteamOS reuse). */ + alreadyExists: boolean; configUpdated: boolean; actions: string[]; }; @@ -59,6 +65,7 @@ export type BuildVendorAddResult = { configPath: string; loveVersion: string; vendors: BuildVendorResult[]; + skippedTargets: ConcreteBuildVendorTarget[]; }; export type BuildVendorListEntry = { @@ -126,6 +133,7 @@ export async function addBuildVendors(targets: BuildVendorTargetInput[], options configPath, loveVersion, vendors: results, + skippedTargets: results.filter((r) => r.alreadyExists).map((r) => r.target), }; } @@ -191,8 +199,20 @@ async function addSingleVendor(input: AddSingleVendorInput): Promise= 4.4). + // The macOS port doesn't auto-detect the offset, so we scan for the magic bytes ourselves. + if (existsSync(squashfsRoot)) rmSync(squashfsRoot, { recursive: true }); + const offset = findSquashFsOffset(loveAppImage); + const unsquashArgs = offset >= 0 + ? ['-offset', String(offset), '-d', squashfsRoot, loveAppImage] + : ['-d', squashfsRoot, loveAppImage]; + const us = spawnSync('unsquashfs', unsquashArgs, { cwd: targetPath, encoding: 'utf8' }); + if (!us.error && us.status === 0 && existsSync(join(squashfsRoot, 'bin', 'love'))) return; + + const detail = us.error ? us.error.message : (us.stderr || us.stdout || '').trim(); + throw new Error( + `Cannot extract the Linux AppImage on this platform (${process.platform}).\n` + + (detail ? `unsquashfs: ${detail}\n` : '') + + `Install squashfs-tools and retry: brew install squashfs\n` + + `Or run \`feather build vendor add linux\` on a Linux host.`, + ); } async function downloadRuntimeArchive(target: 'windows' | 'macos', loveVersion: string): Promise { diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index 2c36af5f..66ddc71f 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -1,8 +1,10 @@ import { readFileSync, existsSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; +import { findConfigDir } from "./paths.js"; export interface FeatherConfig { sessionName?: string; + managed?: string; include?: string[]; exclude?: string[]; pluginOptions?: Record>; @@ -92,6 +94,93 @@ function parseLuaTable(src: string): Record { if (items.length > 0) result[m[1]] = items; } + const pluginOptions = parsePluginOptions(body); + if (Object.keys(pluginOptions).length > 0) { + result.pluginOptions = pluginOptions; + } + + return result; +} + +function findTableBody(src: string, key: string): string | null { + const match = new RegExp(`(?:^|[^\\w])${key}\\s*=\\s*\\{`).exec(src); + if (!match) return null; + + const open = match.index + match[0].lastIndexOf('{'); + let depth = 0; + let quote: '"' | "'" | null = null; + for (let i = open; i < src.length; i += 1) { + const ch = src[i]; + if (quote) { + if (ch === '\\') { + i += 1; + } else if (ch === quote) { + quote = null; + } + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) return src.slice(open + 1, i); + } + } + return null; +} + +function parseSimpleValue(raw: string): unknown { + const value = raw.trim().replace(/,$/, '').trim(); + const stringMatch = value.match(/^["']([^"']*)["']$/); + if (stringMatch) return stringMatch[1]; + if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value); + if (value === 'true') return true; + if (value === 'false') return false; + if (value.startsWith('{') && value.endsWith('}')) { + const items = [...value.matchAll(/"([^"]*)"/g)].map((i) => i[1]); + if (items.length > 0) return items; + } + return undefined; +} + +function parseOptionsObject(src: string): Record { + const options: Record = {}; + for (const m of src.matchAll(/(\w+)\s*=\s*("[^"]*"|'[^']*'|-?\d+(?:\.\d+)?|true|false|\{[^{}]*\})/g)) { + const parsed = parseSimpleValue(m[2]); + if (parsed !== undefined) options[m[1]] = parsed; + } + return options; +} + +function parsePluginOptions(body: string): Record> { + const pluginBody = findTableBody(body, 'pluginOptions'); + if (!pluginBody) return {}; + + const result: Record> = {}; + const entryPattern = /(?:\["([^"]+)"\]|([A-Za-z_]\w*))\s*=\s*\{/g; + let match: RegExpExecArray | null; + while ((match = entryPattern.exec(pluginBody))) { + const id = match[1] ?? match[2]; + const open = match.index + match[0].lastIndexOf('{'); + let depth = 0; + let close = -1; + for (let i = open; i < pluginBody.length; i += 1) { + if (pluginBody[i] === '{') depth += 1; + if (pluginBody[i] === '}') { + depth -= 1; + if (depth === 0) { + close = i; + break; + } + } + } + if (close === -1) continue; + result[id] = parseOptionsObject(pluginBody.slice(open + 1, close)); + entryPattern.lastIndex = close + 1; + } return result; } @@ -101,9 +190,16 @@ export function loadConfig(gamePath: string, override?: string): FeatherConfig | : [ join(gamePath, "feather.config.lua"), join(gamePath, ".featherrc.lua"), + join(dirname(resolve(gamePath)), "feather.config.lua"), + join(dirname(resolve(gamePath)), ".featherrc.lua"), + join(findConfigDir(gamePath), "feather.config.lua"), + join(findConfigDir(gamePath), ".featherrc.lua"), ]; + const seen = new Set(); for (const path of candidates) { + if (seen.has(path)) continue; + seen.add(path); if (!existsSync(path)) continue; try { const src = readFileSync(path, "utf8"); diff --git a/cli/src/lib/install.ts b/cli/src/lib/install.ts index 3aa29351..c5ae61a3 100644 --- a/cli/src/lib/install.ts +++ b/cli/src/lib/install.ts @@ -116,8 +116,9 @@ export function installPluginsFromLocal( sourceRoot: string, targetDir: string, installDir = "feather", - onProgress?: (file: string) => void -): void { + onProgress?: (file: string) => void, + force = false, +): { installed: string[]; skipped: string[] } { const pluginsRoot = join(sourceRoot, "plugins"); if (!existsSync(pluginsRoot)) throw new Error(`No plugins directory found at ${pluginsRoot}`); @@ -130,12 +131,20 @@ export function installPluginsFromLocal( return { pluginId, sourceDir, source }; }); + const installed: string[] = []; + const skipped: string[] = []; for (const { pluginId, sourceDir, source } of plans) { const dest = assertSafeProjectTarget(targetDir, join(root, "plugins", sourceDir), "Plugin install target"); + if (!force && existsSync(dest)) { + skipped.push(pluginId); + continue; + } mkdirSync(dirname(dest), { recursive: true }); cpSync(source, dest, { recursive: true, force: true }); + installed.push(pluginId); onProgress?.(pluginId); } + return { installed, skipped }; } export async function installPlugin( @@ -144,15 +153,21 @@ export async function installPlugin( targetDir: string, branch = "main", onProgress?: (file: string) => void, - installDir = "feather" -): Promise { + installDir = "feather", + force = false, +): Promise<{ skipped: boolean }> { 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); + const pluginDir = assertSafeProjectTarget(targetDir, join(root, "plugins", pluginIdToSourceDir(pluginId)), "Plugin install target"); + if (!force && existsSync(pluginDir)) { + return { skipped: true }; + } const plans = pluginEntries.map((entry) => { const sourceDir = entry.sourceDir ?? pluginId.replace(/\./g, "/"); - const file = entry.file ?? entry.path.replace(new RegExp(`^plugins/${sourceDir}/`), ""); + const prefix = `plugins/${sourceDir}/`; + const file = entry.file ?? (entry.path.startsWith(prefix) ? entry.path.slice(prefix.length) : entry.path); if (sourceDir !== pluginIdToSourceDir(pluginId)) throw new Error(`Plugin manifest path mismatch: ${pluginId} should live in plugins/${pluginIdToSourceDir(pluginId)}`); try { assertSafeRelativePath(file, "Plugin file path"); @@ -176,6 +191,7 @@ export async function installPlugin( await downloadFile(entry.path, dest, branch); onProgress?.(entry.path); } + return { skipped: false }; } export function getPluginIds(entries: ManifestEntry[]): string[] { diff --git a/cli/src/lib/paths.ts b/cli/src/lib/paths.ts index fe0a8a19..9ec85253 100644 --- a/cli/src/lib/paths.ts +++ b/cli/src/lib/paths.ts @@ -2,14 +2,19 @@ import { existsSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -const __dirname = dirname(fileURLToPath(import.meta.url)); +const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url)); export function bundledLuaRoot(): string { - return resolve(__dirname, '../../lua'); + // When running as a compiled binary, lua/ is shipped next to the executable. + const execDir = dirname(process.execPath); + const sibling = join(execDir, 'lua'); + if (existsSync(join(sibling, 'feather', 'init.lua'))) return sibling; + // Fallback for npm/node: dist/lib/paths.js → ../../lua + return resolve(MODULE_DIR, '../../lua'); } export function repoLuaRoot(): string | null { - const candidate = resolve(__dirname, '../../../src-lua'); + const candidate = resolve(MODULE_DIR, '../../../src-lua'); return existsSync(join(candidate, 'feather', 'init.lua')) ? candidate : null; } @@ -18,8 +23,43 @@ export function resolveLocalLuaRoot(opts: { localSrc?: string }): string { return repoLuaRoot() ?? bundledLuaRoot(); } +function hasProjectConfig(dir: string): boolean { + return existsSync(join(dir, 'feather.config.lua')) || existsSync(join(dir, '.featherrc.lua')); +} + +function hasPackageLock(dir: string): boolean { + return existsSync(join(dir, 'feather.lock.json')); +} + +function hasRuntime(dir: string): boolean { + return existsSync(join(dir, 'feather', 'init.lua')); +} + +function hasMain(dir: string): boolean { + return existsSync(join(dir, 'main.lua')); +} + +function walkUp(start: string, predicate: (dir: string) => boolean): string | null { + let current = resolve(start); + while (true) { + if (predicate(current)) return current; + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } +} + +export function findConfigDir(cwd = process.cwd()): string { + const start = resolve(cwd); + return walkUp(start, hasProjectConfig) ?? findProjectDir(start); +} + +export function findPackageDir(cwd = process.cwd()): string { + const start = resolve(cwd); + return walkUp(start, (dir) => hasPackageLock(dir) || hasProjectConfig(dir)) ?? findProjectDir(start); +} + 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; + const start = resolve(cwd); + return walkUp(start, (dir) => hasProjectConfig(dir) || hasPackageLock(dir) || hasRuntime(dir) || hasMain(dir)) ?? start; } diff --git a/cli/src/lib/plugin-utils.ts b/cli/src/lib/plugin-utils.ts index dddb35fb..69b9906b 100644 --- a/cli/src/lib/plugin-utils.ts +++ b/cli/src/lib/plugin-utils.ts @@ -9,7 +9,7 @@ export type PluginManifest = { export type PluginTrust = 'bundled-core' | 'bundled-opt-in' | 'local' | 'remote' | 'unknown' | 'malformed'; -export const dangerousPluginIds = new Set(['console', 'hot-reload']); +export const dangerousPluginIds = new Set(['console', 'hot-reload', 'session-replay']); export function parseManagedValue(src: string, key: string): string | null { return src.match(new RegExp(`^--\\s*${key}:\\s*(.+)$`, 'm'))?.[1]?.trim() ?? null; diff --git a/cli/src/lib/shim.ts b/cli/src/lib/shim.ts index 1e7dfd8a..7232a7c1 100644 --- a/cli/src/lib/shim.ts +++ b/cli/src/lib/shim.ts @@ -3,15 +3,18 @@ import { tmpdir } from 'node:os'; import { join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -const __dirname = fileURLToPath(new URL('.', import.meta.url)); - // 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'); +const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url)); +const PACKAGED_LUA = resolve(MODULE_DIR, '../../lua'); +const SOURCE_LUA = resolve(MODULE_DIR, '../../../src-lua'); export function bundledLuaRoot(): string { + // When running as a compiled binary, lua/ ships next to the executable. + const siblingLua = join(dirname(process.execPath), 'lua'); + if (existsSync(join(siblingLua, 'feather', 'auto.lua'))) return siblingLua; + // Fallback for npm/node installs. 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; @@ -55,20 +58,46 @@ function scanPluginIds(pluginsDir: string): string[] { } } -function serializeLuaConfig(cfg: Record): string { - const lines: string[] = []; - for (const [k, v] of Object.entries(cfg)) { - if (typeof v === 'string') lines.push(` ${k} = ${JSON.stringify(v)},`); - else if (typeof v === 'number' || typeof v === 'boolean') lines.push(` ${k} = ${v},`); - else if (Array.isArray(v)) { - const items = v.map((i) => (typeof i === 'string' ? JSON.stringify(i) : String(i))); - lines.push(` ${k} = { ${items.join(', ')} },`); - } +function luaKey(key: string): string { + return /^[A-Za-z_]\w*$/.test(key) ? key : `[${JSON.stringify(key)}]`; +} + +function luaValue(value: unknown): string | null { + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + if (typeof value === 'boolean') return String(value); + if (Array.isArray(value)) { + const items = value.map(luaValue).filter((item): item is string => item !== null); + return `{ ${items.join(', ')} }`; } - return lines.join('\n'); + if (value && typeof value === 'object') { + const entries = Object.entries(value as Record) + .map(([key, nested]) => { + const serialized = luaValue(nested); + return serialized === null ? null : `${luaKey(key)} = ${serialized}`; + }) + .filter((entry): entry is string => entry !== null); + return `{ ${entries.join(', ')} }`; + } + return null; +} + +function serializeLuaConfig(cfg: Record): string { + return Object.entries(cfg) + .map(([key, value]) => { + const serialized = luaValue(value); + return serialized === null ? null : ` ${luaKey(key)} = ${serialized},`; + }) + .filter((line): line is string => line !== null) + .join('\n'); } -function buildMainLua(opts: ShimOptions, pluginsDir?: string): string { +function luaPackagePath(rootDir: string): string { + const normalized = rootDir.replace(/\\/g, '/'); + return `${normalized}/?.lua;${normalized}/?/init.lua`; +} + +function buildMainLua(opts: ShimOptions, featherDir: string, pluginsDir?: string): string { const sessionName = opts.sessionName ?? ''; const configLines = opts.userConfig ? serializeLuaConfig(opts.userConfig) : ''; @@ -76,11 +105,13 @@ function buildMainLua(opts: ShimOptions, pluginsDir?: string): string { const pluginListLine = pluginIds.length > 0 ? `FEATHER_PLUGIN_LIST = { ${pluginIds.map((id) => JSON.stringify(id)).join(', ')} }` : ''; - // Add the plugins parent dir to package.path so require("plugins.X") resolves - // via the OS filesystem (no PhysFS / symlink dependency). - const packagePathLine = pluginsDir && pluginIds.length > 0 - ? `package.path = package.path .. ";${dirname(pluginsDir).replace(/\\/g, '/')}/?.lua;${dirname(pluginsDir).replace(/\\/g, '/')}/?/init.lua"` - : ''; + // Add runtime/plugin parent dirs to package.path so require() resolves via the + // OS filesystem instead of depending on PhysFS symlink behavior. + const packagePaths = new Set([luaPackagePath(dirname(featherDir))]); + if (pluginsDir && pluginIds.length > 0) { + packagePaths.add(luaPackagePath(dirname(pluginsDir))); + } + const packagePathLine = `package.path = package.path .. ";${[...packagePaths].join(';')}"`; return `-- Feather CLI injector — generated, do not edit -- Game files are symlinked into this directory so love.filesystem works as normal. @@ -110,7 +141,8 @@ if gamePath then end -- CLI mode should not require game code to call DEBUGGER:update(dt). --- Wrap after loading the game so we preserve the user's love.update callback. +-- Defer wrapping until after love.load completes so games that define +-- love.update inside love.load() are handled correctly. -- If the game already calls DEBUGGER:update(dt), avoid a second update in the same frame. if DEBUGGER and type(DEBUGGER.update) == "function" and not DEBUGGER.__cliAutoUpdateInstalled then DEBUGGER.__cliAutoUpdateInstalled = true @@ -120,16 +152,26 @@ if DEBUGGER and type(DEBUGGER.update) == "function" and not DEBUGGER.__cliAutoUp return featherUpdate(self, dt) end - local gameUpdate = love.update - love.update = function(dt) - DEBUGGER.__cliAutoUpdatedThisFrame = false - if gameUpdate then - gameUpdate(dt) - end - if not DEBUGGER.__cliAutoUpdatedThisFrame then - featherUpdate(DEBUGGER, dt) + local function installUpdateHook() + if DEBUGGER.__cliUpdateHookInstalled then return end + DEBUGGER.__cliUpdateHookInstalled = true + local gameUpdate = love.update + love.update = function(dt) + DEBUGGER.__cliAutoUpdatedThisFrame = false + if gameUpdate then + gameUpdate(dt) + end + if not DEBUGGER.__cliAutoUpdatedThisFrame then + featherUpdate(DEBUGGER, dt) + end end end + + local gameLoad = love.load + love.load = function(arg) + if gameLoad then gameLoad(arg) end + installUpdateHook() + end end `; } @@ -150,6 +192,7 @@ const SHIM_OWNED = new Set(['main.lua', 'conf.lua', 'feather', 'plugins']); export function createShim(opts: ShimOptions): Shim { const absGame = resolve(opts.gamePath); const dir = mkdtempSync(join(tmpdir(), 'feather-')); + const resolvedFeatherDir = featherRoot(opts.featherOverride); // Resolve plugins directory early so buildMainLua can embed the ID list and // package.path addition (bypasses PhysFS symlink dependency in auto.lua). @@ -160,12 +203,12 @@ export function createShim(opts: ShimOptions): Shim { : (() => { const p = opts.pluginsOverride ? resolve(opts.pluginsOverride) - : pluginsRoot(featherRoot(opts.featherOverride), opts.featherOverride); + : pluginsRoot(resolvedFeatherDir, opts.featherOverride); return existsSync(p) ? p : undefined; })(); // 1. Write shim entry points - writeFileSync(join(dir, 'main.lua'), buildMainLua(opts, resolvedPluginsDir)); + writeFileSync(join(dir, 'main.lua'), buildMainLua(opts, resolvedFeatherDir, resolvedPluginsDir)); writeFileSync(join(dir, 'conf.lua'), buildConfLua()); // 2. Symlink every file/dir from the game into the shim root, except shim-owned names. @@ -177,7 +220,7 @@ export function createShim(opts: ShimOptions): Shim { // 3. Add feather library — prefer game-local install (already symlinked above if present) if (!existsSync(join(dir, 'feather'))) { - symlinkSync(featherRoot(opts.featherOverride), join(dir, 'feather'), 'dir'); + symlinkSync(resolvedFeatherDir, join(dir, 'feather'), 'dir'); } // 4. Add plugins directory symlink — still created for love.filesystem access to diff --git a/cli/src/ui/init/model.ts b/cli/src/ui/init/model.ts index 607932de..391127a0 100644 --- a/cli/src/ui/init/model.ts +++ b/cli/src/ui/init/model.ts @@ -77,6 +77,9 @@ export const toggleTone = (value: string): Tone | undefined => { return undefined; }; +export const defaultIncludedPluginIds = ["particle-system-playground", "shader-graph"]; +export const defaultIncludedPlugins = new Set(defaultIncludedPluginIds); + export const modes: Option[] = [ { value: "cli", diff --git a/cli/src/ui/init/workflow.tsx b/cli/src/ui/init/workflow.tsx index 798525e5..c5c75058 100644 --- a/cli/src/ui/init/workflow.tsx +++ b/cli/src/ui/init/workflow.tsx @@ -5,6 +5,7 @@ import { buildInitSetup } from "./config.js"; import { configToggles, dangerousInsecureConnection, + defaultIncludedPlugins, defaultSkippedPlugins, installSources, isStrongApiKey, @@ -56,7 +57,7 @@ function InitSetupPrompt({ const [includeCursor, setIncludeCursor] = useState(0); const [excludeCursor, setExcludeCursor] = useState(0); const [toggleCursor, setToggleCursor] = useState(0); - const [include, setInclude] = useState>(new Set()); + const [include, setInclude] = useState>(new Set(defaultIncludedPlugins)); const [exclude, setExclude] = useState>(new Set(defaultSkippedPlugins)); const [advanced, setAdvanced] = useState(false); const [host, setHost] = useState("127.0.0.1"); diff --git a/cli/stubs/react-devtools-core/index.js b/cli/stubs/react-devtools-core/index.js new file mode 100644 index 00000000..ff76698d --- /dev/null +++ b/cli/stubs/react-devtools-core/index.js @@ -0,0 +1,2 @@ +// Stub — devtools are not supported in standalone binary builds. +export default { connectToDevTools: () => {} }; diff --git a/cli/stubs/react-devtools-core/package.json b/cli/stubs/react-devtools-core/package.json new file mode 100644 index 00000000..3561cd36 --- /dev/null +++ b/cli/stubs/react-devtools-core/package.json @@ -0,0 +1 @@ +{ "name": "react-devtools-core", "version": "0.0.0", "type": "module", "main": "index.js" } diff --git a/cli/test/commands/build-android.test.mjs b/cli/test/commands/build-android.test.mjs index c80c8411..bad48eb9 100644 --- a/cli/test/commands/build-android.test.mjs +++ b/cli/test/commands/build-android.test.mjs @@ -167,6 +167,52 @@ test('build android --release: does not auto-embed Feather debugger runtime', () assert.equal(entries.has('feather.config.lua'), false); }); +test('build android --release: ignores dev Feather config when runtime is excluded', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + __DANGEROUS_INSECURE_CONNECTION__ = true, + include = { "console" }, + debugger = { hotReload = { enabled = true, allow = { "game.*" } } }, + writeToDisk = true, +} +`, + ); + writeBuildConfig(dir, { + name: 'Release Dev Config Android', + version: '1.0.0', + productId: 'com.example.releasedevconfigandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const result = run(['build', 'android', '--dir', dir, '--release', '--no-debugger', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const entries = readStoredZipEntries(join(dir, 'builds', 'release-dev-config-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.config.lua'), false); +}); + +test('build android --release: blocks explicit Feather runtime inclusion', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Release Runtime Android', + version: '1.0.0', + includeRuntime: true, + productId: 'com.example.releaseruntimeandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const result = run(['build', 'android', '--dir', dir, '--release', '--no-debugger', '--json']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Feather runtime is included in the build output')); +}); + test('build android: reuses dev native cache between builds', () => { const dir = makeTmp(); writeGame(dir); diff --git a/cli/test/commands/build-ios.test.mjs b/cli/test/commands/build-ios.test.mjs index de892c41..0356101b 100644 --- a/cli/test/commands/build-ios.test.mjs +++ b/cli/test/commands/build-ios.test.mjs @@ -13,6 +13,7 @@ import { writeBuildConfig, writeFakeCommand, writeFakeLoveIos, + writeFileSync, writeGame, readStoredZipEntries, } from './helpers.mjs'; @@ -445,6 +446,88 @@ process.exit(0); assert.ok(record.records[0].argv.includes('CODE_SIGNING_ALLOWED=NO')); }); +test('build ios --release: ignores dev Feather config when runtime is excluded', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + __DANGEROUS_INSECURE_CONNECTION__ = true, + include = { "console" }, + debugger = { hotReload = { enabled = true, allow = { "game.*" } } }, + writeToDisk = true, +} +`, + ); + writeBuildConfig(dir, { + name: 'Release Dev Config iOS', + version: '1.0.0', + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.releasedevconfigios', + release: { + archivePath: 'builds/dev-config.xcarchive', + exportPath: 'builds/dev-config-export', + }, + }, + }, + }); + const recordPath = join(dir, 'xcodebuild-dev-config-record.json'); + const { binDir } = writeFakeCommand( + dir, + 'xcodebuild', + ` +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +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'); +} +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ argv: args }, null, 2)); +process.exit(0); +`, + ); + + const result = run(['build', 'ios', '--dir', dir, '--release', '--no-debugger', '--json'], { + env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + const loveEntries = readStoredZipEntries(join(dir, 'builds', 'release-dev-config-ios-1.0.0.love')); + assert.equal(loveEntries.has('.feather-main.lua'), false); + assert.equal(loveEntries.has('feather/auto.lua'), false); + assert.equal(loveEntries.has('feather.config.lua'), false); +}); + +test('build ios --release: blocks explicit Feather runtime inclusion', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Release Runtime iOS', + version: '1.0.0', + includeRuntime: true, + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.releaseruntimeios', + }, + }, + }); + + const result = run(['build', 'ios', '--dir', dir, '--release', '--no-debugger', '--json'], { + env: { ...process.env, FEATHER_TEST_ALLOW_IOS_BUILD: '1' }, + }); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Feather runtime is included in the build output')); +}); + test('build ios: non-macOS hosts fail with setup guidance', { skip: process.platform === 'darwin' }, () => { const dir = makeTmp(); writeGame(dir); diff --git a/cli/test/commands/build-vendor.test.mjs b/cli/test/commands/build-vendor.test.mjs index 3d78555f..19fa3c1d 100644 --- a/cli/test/commands/build-vendor.test.mjs +++ b/cli/test/commands/build-vendor.test.mjs @@ -2,6 +2,7 @@ import { ANSI_RE, assert, + chmodSync, envWithPath, existsSync, join, @@ -11,6 +12,7 @@ import { readFileSync, run, test, + writeFileSync, writeBuildConfig, writeFakeAppleLibrariesZip, writeFakeAppImageTool, @@ -20,6 +22,8 @@ import { writeFakeLoveWindowsZip, writeFakeLoveAndroid, writeFakeLoveJs, + writeFakeNonNativeElfAppImage, + writeFakeUnsquashfs, writeFakeVendorGit, writeGame, } from './helpers.mjs'; @@ -283,15 +287,19 @@ test('build vendor add --no-config: fetches vendor without writing build config' assert.equal(parsed.vendors[0].configUpdated, false); }); -test('build vendor add: existing directories and conflicting config require --force', () => { +test('build vendor add: existing directory is skipped (not a fatal error); conflicting configured path still requires --force', () => { const dir = makeTmp(); writeGame(dir); mkdirSync(join(dir, 'vendor', 'love-android'), { recursive: true }); + // Existing directory: no longer fails — returns skippedTargets instead. const existing = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json']); - assert.equal(existing.exitCode, 1); - assert.ok(outputOf(existing).includes('--force')); + assert.equal(existing.exitCode, 0, outputOf(existing)); + const parsed = JSON.parse(existing.stdout); + assert.equal(parsed.vendors[0].alreadyExists, true); + assert.ok(parsed.skippedTargets.includes('android')); + // Conflicting configured path still fails without --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); @@ -348,3 +356,145 @@ test('build vendor add: missing git produces compact actionable error', () => { assert.equal(result.exitCode, 1); assert.ok(outputOf(result).includes('git is required')); }); + +// ── AppImage extraction fallback (non-native ELF → unsquashfs) ─────────────── + +test('build vendor add linux: falls back to unsquashfs with explicit offset when AppImage is non-native', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor Linux', version: '1.0.0', loveVersion: '11.5' }); + + // A foreign-architecture ELF with SquashFS magic at a known offset. + // Running it returns ENOEXEC on x86_64 Linux and all macOS variants. + const { appImage } = writeFakeNonNativeElfAppImage(dir); + const appImageTool = writeFakeAppImageTool(dir); + // Fake unsquashfs that creates the expected squashfs-root structure. + const unsquashfsBin = writeFakeUnsquashfs(dir); + + const result = run(['build', 'vendor', 'add', 'linux', '--dir', dir, '--json'], { + env: envWithPath(unsquashfsBin, { + FEATHER_TEST_LOVE_LINUX_APPIMAGE: appImage, + FEATHER_TEST_APPIMAGETOOL: appImageTool, + }), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].target, 'linux'); + assert.equal(existsSync(join(dir, 'vendor', 'love-linux', 'squashfs-root', 'bin', 'love')), true); + assert.equal(existsSync(join(dir, 'vendor', 'love-linux', 'appimagetool.AppImage')), true); +}); + +test('build vendor add linux: unsquashfs receives explicit -offset matching SquashFS magic position', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor Linux', version: '1.0.0', loveVersion: '11.5' }); + + const { appImage, sqfsOffset } = writeFakeNonNativeElfAppImage(dir); + const appImageTool = writeFakeAppImageTool(dir); + + // Recording unsquashfs: writes received args to a JSON file, then creates squashfs-root. + const recordPath = join(dir, 'unsquashfs-args.json'); + const binDir = join(dir, 'rec-bin'); + mkdirSync(binDir, { recursive: true }); + const script = join(binDir, 'unsquashfs'); + writeFileSync( + script, + `#!/usr/bin/env node +const fs = require('node:fs'), path = require('node:path'); +const args = process.argv.slice(2); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(args)); +let dest = null; +for (let i = 0; i < args.length - 1; i++) { + if (args[i] === '-d') { dest = args[i + 1]; break; } +} +if (!dest) process.exit(1); +fs.mkdirSync(path.join(dest, 'bin'), { recursive: true }); +fs.writeFileSync(path.join(dest, 'bin', 'love'), '#!/bin/sh\\n'); +fs.chmodSync(path.join(dest, 'bin', 'love'), 0o755); +process.exit(0); +`, + ); + chmodSync(script, 0o755); + + const result = run(['build', 'vendor', 'add', 'linux', '--dir', dir, '--json'], { + env: envWithPath(binDir, { + FEATHER_TEST_LOVE_LINUX_APPIMAGE: appImage, + FEATHER_TEST_APPIMAGETOOL: appImageTool, + }), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + + const args = JSON.parse(readFileSync(recordPath, 'utf8')); + const offsetIdx = args.indexOf('-offset'); + assert.ok(offsetIdx >= 0, 'unsquashfs was called with -offset'); + assert.equal(Number(args[offsetIdx + 1]), sqfsOffset, '-offset value matches SquashFS magic position'); +}); + +test('build vendor add linux: reports actionable error when AppImage cannot be extracted', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor Linux', version: '1.0.0', loveVersion: '11.5' }); + + const { appImage } = writeFakeNonNativeElfAppImage(dir); + const appImageTool = writeFakeAppImageTool(dir); + + // Run without any unsquashfs in PATH. + const result = run(['build', 'vendor', 'add', 'linux', '--dir', dir, '--json'], { + env: { + ...process.env, + NO_COLOR: '1', + FORCE_COLOR: '0', + PATH: '', + FEATHER_TEST_LOVE_LINUX_APPIMAGE: appImage, + FEATHER_TEST_APPIMAGETOOL: appImageTool, + }, + }); + assert.equal(result.exitCode, 1); + const out = outputOf(result); + assert.ok(out.includes('brew install squashfs') || out.includes('Linux host'), out); +}); + +test('build vendor add: skips existing vendor and warns without --force', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor Skip', version: '1.0.0', loveVersion: '11.5' }); + const { binDir } = writeFakeVendorGit(dir); + + // Install android vendor first. + run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json'], { env: envWithPath(binDir) }); + assert.equal(existsSync(join(dir, 'vendor', 'love-android', 'gradlew')), true); + + // Run again — should skip, not fail. + const result = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json'], { + env: envWithPath(binDir), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].target, 'android'); + assert.equal(parsed.vendors[0].alreadyExists, true); + assert.equal(parsed.skippedTargets[0], 'android'); +}); + +test('build vendor add --force: overwrites existing vendor directory', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor Force', version: '1.0.0', loveVersion: '11.5' }); + const { binDir } = writeFakeVendorGit(dir); + + // Install android vendor first. + run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json'], { env: envWithPath(binDir) }); + // Write a sentinel file to detect overwrite. + writeFileSync(join(dir, 'vendor', 'love-android', 'sentinel.txt'), 'original'); + + // Run again with --force — should overwrite. + const result = run(['build', 'vendor', 'add', 'android', '--force', '--dir', dir, '--json'], { + env: envWithPath(binDir), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].target, 'android'); + assert.equal(parsed.vendors[0].alreadyExists, false); + assert.equal(parsed.skippedTargets.length, 0); + // Sentinel file gone — directory was replaced. + assert.equal(existsSync(join(dir, 'vendor', 'love-android', 'sentinel.txt')), false); +}); diff --git a/cli/test/commands/build.test.mjs b/cli/test/commands/build.test.mjs index 8fd7d7af..31a702ba 100644 --- a/cli/test/commands/build.test.mjs +++ b/cli/test/commands/build.test.mjs @@ -6,6 +6,7 @@ import { existsSync, join, makeTmp, + mkdirSync, outputOf, readFileSync, run, @@ -73,6 +74,22 @@ test('build love: creates only a .love package and writes manifest', () => { assert.equal(manifest.target, 'love'); }); +test('build love: excludes local session replay artifacts by default', () => { + const dir = makeTmp(); + writeGame(dir); + mkdirSync(join(dir, 'feather_replays', 'session_1'), { recursive: true }); + writeFileSync(join(dir, 'feather_replays', 'session_1', 'manifest.json'), '{}'); + writeFileSync(join(dir, 'bug.featherreplay'), 'archive'); + writeBuildConfig(dir, { name: 'Replay Clean Package', version: '1.0.0' }); + + const result = run(['build', 'love', '--dir', dir, '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const entries = readStoredZipEntries(join(dir, 'builds', 'replay-clean-package-1.0.0.love')); + assert.equal(entries.has('main.lua'), true); + assert.equal(entries.has('bug.featherreplay'), false); + assert.equal(entries.has('feather_replays/session_1/manifest.json'), false); +}); + for (const target of ['windows', 'macos', 'linux', 'steamos']) { test(`build ${target}: packages with configured local LÖVE runtime vendor`, () => { const dir = makeTmp(); diff --git a/cli/test/commands/config.test.mjs b/cli/test/commands/config.test.mjs new file mode 100644 index 00000000..75729d82 --- /dev/null +++ b/cli/test/commands/config.test.mjs @@ -0,0 +1,162 @@ +/* eslint-disable no-undef */ +import { + assert, + join, + makeTmp, + outputOf, + readFileSync, + run, + test, + writeFileSync, + writeGame, +} from './helpers.mjs'; + +test('config plugins: adds included plugins and merges required capabilities', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + sessionName = "Config Game", + pluginOptions = { + console = { evalEnabled = true }, + }, + capabilities = { "logs" }, + include = { "runtime-snapshot" }, +} +`, + ); + + const result = run(['config', 'plugins', '--dir', dir, '--include', 'console,input-replay']); + assert.equal(result.exitCode, 0, outputOf(result)); + + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.match(config, /sessionName\s*=\s*"Config Game"/); + assert.match(config, /pluginOptions\s*=\s*\{/); + assert.match(config, /evalEnabled\s*=\s*true/); + assert.match(config, /include\s*=\s*\{\s*"console",\s*"input-replay",\s*"runtime-snapshot"\s*\}/); + assert.match(config, /capabilities\s*=\s*\{\s*"filesystem",\s*"input",\s*"logs"\s*\}/); +}); + +test('config plugins: resolves parent config when dir points at nested game main.lua', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { include = { "runtime-snapshot" } }\n'); + + const result = run(['config', 'plugins', '--dir', gameDir, '--include', 'console']); + assert.equal(result.exitCode, 0, outputOf(result)); + + assert.match(readFileSync(join(dir, 'feather.config.lua'), 'utf8'), /include\s*=\s*\{\s*"console",\s*"runtime-snapshot"\s*\}/); +}); + +test('config plugins: exclude removes included plugin and writes exclude list', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + 'return { include = { "console", "input-replay" }, exclude = { "bookmark" }, capabilities = { "filesystem", "input" } }\n', + ); + + const result = run(['config', 'plugins', '--dir', dir, '--exclude', 'console,hot-reload']); + assert.equal(result.exitCode, 0, outputOf(result)); + + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.match(config, /include\s*=\s*\{\s*"input-replay"\s*\}/); + assert.match(config, /exclude\s*=\s*\{\s*"bookmark",\s*"console",\s*"hot-reload"\s*\}/); + assert.match(config, /capabilities\s*=\s*\{\s*"filesystem",\s*"input"\s*\}/); +}); + +test('config plugins: missing config fails with init guidance', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['config', 'plugins', '--dir', dir, '--include', 'console']); + assert.equal(result.exitCode, 1, outputOf(result)); + assert.match(outputOf(result), /No feather\.config\.lua found/); + assert.match(outputOf(result), /feather init/); +}); + +test('config plugins: unknown plugin is rejected', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { sessionName = "Config Game" }\n'); + + const result = run(['config', 'plugins', '--dir', dir, '--include', 'not-a-plugin']); + assert.equal(result.exitCode, 1, outputOf(result)); + assert.match(outputOf(result), /Unknown plugin: not-a-plugin/); +}); + +test('config managed: sets managed field when not present', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return {\n sessionName = "My Game",\n}\n'); + + const result = run(['config', 'managed', 'auto', '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.match(config, /managed\s*=\s*"auto"/); + assert.match(config, /sessionName\s*=\s*"My Game"/); +}); + +test('config managed: updates an existing managed field', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return {\n managed = "auto",\n sessionName = "My Game",\n}\n'); + + const result = run(['config', 'managed', 'manual', '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.match(config, /managed\s*=\s*"manual"/); + assert.doesNotMatch(config, /managed\s*=\s*"auto"/); +}); + +test('config hot-reload: enables plugin and writes debugger allowlist', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + managed = "cli", + include = { "shader-graph" }, + capabilities = { "draw" }, +} +`, + ); + + const result = run(['config', 'hot-reload', '--dir', dir, '--allow', 'game.player,game.systems.combat']); + assert.equal(result.exitCode, 0, outputOf(result)); + + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.match(config, /debug\s*=\s*true/); + assert.match(config, /autoRegisterErrorHandler\s*=\s*true/); + assert.match(config, /include\s*=\s*\{\s*"hot-reload",\s*"shader-graph"\s*\}/); + assert.match(config, /capabilities\s*=\s*\{\s*"draw",\s*"filesystem"\s*\}/); + assert.match(config, /debugger\s*=\s*\{/); + assert.match(config, /hotReload\s*=\s*\{/); + assert.match(config, /allow\s*=\s*\{\s*"game\.player",\s*"game\.systems\.combat"\s*\}/); + assert.match(config, /deny\s*=\s*\{\s*"main",\s*"conf",\s*"feather\.\*"\s*\}/); +}); + +test('config managed: rejects invalid mode', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { managed = "cli" }\n'); + + const result = run(['config', 'managed', 'embedded', '--dir', dir]); + assert.equal(result.exitCode, 1, outputOf(result)); + assert.match(outputOf(result), /Invalid mode: embedded/); + assert.match(outputOf(result), /cli, auto, manual/); +}); + +test('config managed: missing config fails with init guidance', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['config', 'managed', 'cli', '--dir', dir]); + assert.equal(result.exitCode, 1, outputOf(result)); + assert.match(outputOf(result), /No feather\.config\.lua found/); + assert.match(outputOf(result), /feather init/); +}); diff --git a/cli/test/commands/doctor.test.mjs b/cli/test/commands/doctor.test.mjs index a981b326..43e250e2 100644 --- a/cli/test/commands/doctor.test.mjs +++ b/cli/test/commands/doctor.test.mjs @@ -81,6 +81,30 @@ test('doctor --json remains decoration-free and reports missing plugin directory assert.equal(labels.get('Plugin directory').detail, 'not installed'); }); +test('doctor --json treats included plugins as bundled in cli mode', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `-- mode: cli +-- installDir: feather +return { + appId = "feather-app-test-1234567890", + managed = "cli", + include = { "console", "shader-graph" }, +} +`, + ); + + const parsed = parseDoctorJson(dir); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Embedded Feather runtime').severity, 'info'); + assert.equal(labels.get('CLI-managed plugins').severity, 'pass'); + assert.equal(labels.get('CLI-managed plugins').detail, '2 included from bundled runtime'); + assert.equal(labels.has('Plugin console'), false); + assert.equal(labels.has('Plugin shader-graph'), false); +}); + test('doctor --json reports malformed plugin manifests with recovery text', () => { const dir = makeTmp(); writeGame(dir); @@ -115,7 +139,7 @@ test('doctor --production fails unsafe remote-control and production settings', `return { __DANGEROUS_INSECURE_CONNECTION__ = true, host = "0.0.0.0", - include = { "console", "hot-reload" }, + include = { "console", "hot-reload" }, apiKey = "dev", captureScreenshot = true, writeToDisk = true, @@ -151,6 +175,29 @@ test('doctor --production fails unsafe remote-control and production settings', } }); +test('doctor --production fails session replay config and local replay artifacts', () => { + const dir = makeTmp(); + writeGame(dir); + mkdirSync(join(dir, 'feather_replays', 'session_1'), { recursive: true }); + writeFileSync(join(dir, 'bug.featherreplay'), 'archive'); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + appId = "feather-app-test-1234567890", + include = { "session-replay" }, +} +`, + ); + + 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('Session replay')?.severity, 'fail'); + assert.equal(labels.get('Session replay artifacts')?.severity, 'fail'); + assert.ok(labels.get('Session replay artifacts')?.detail.includes('feather_replays/')); + assert.ok(labels.get('Session replay artifacts')?.detail.includes('bug.featherreplay')); +}); + test('doctor --json warns for missing Desktop App ID when insecure dev override is enabled', () => { const dir = makeTmp(); writeGame(dir); @@ -270,6 +317,48 @@ test('doctor: build and upload target checks report missing and configured depen assert.equal(labels.get('BUTLER_API_KEY')?.severity, 'pass'); }); +test('doctor: discovers upload dependency checks from feather.build.json', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + writeBuildConfig(dir, { + name: 'Doctor Upload Game', + version: '1.0.0', + upload: { itch: { project: 'tester/doctor-upload-game' } }, + }); + const { binDir } = writeFakeCommand(dir, 'butler', `console.log('butler test'); process.exit(0);`); + + const result = run(['doctor', dir, '--json'], { + env: envWithPath(binDir, { BUTLER_API_KEY: 'test-key' }), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('feather.build.json')?.severity, 'pass'); + assert.equal(labels.get('Itch project')?.severity, 'pass'); + assert.equal(labels.get('butler')?.severity, 'pass'); + assert.equal(labels.get('BUTLER_API_KEY')?.severity, 'pass'); +}); + +test('doctor: reports optional upload readiness when build config has no upload block', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + writeBuildConfig(dir, { + name: 'Doctor Optional Upload Game', + version: '1.0.0', + }); + + const result = run(['doctor', dir, '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('feather.build.json')?.severity, 'pass'); + assert.equal(labels.get('Itch project')?.severity, 'info'); + assert.equal(labels.get('butler')?.severity, labels.get('butler')?.detail === 'not found' ? 'info' : 'pass'); + assert.equal(labels.get('BUTLER_API_KEY')?.severity, process.env.BUTLER_API_KEY ? 'pass' : 'info'); +}); + test('doctor: desktop build targets report runtime vendors and packaging tools', () => { const dir = makeTmp(); writeGame(dir); diff --git a/cli/test/commands/helpers.mjs b/cli/test/commands/helpers.mjs index 4a55df28..840732f1 100644 --- a/cli/test/commands/helpers.mjs +++ b/cli/test/commands/helpers.mjs @@ -679,6 +679,66 @@ process.exit(0); return appImage; } +/** + * Creates a fake AppImage containing ELF magic (so it triggers ENOEXEC on any + * non-native platform) with real SquashFS v4 magic bytes at a known offset. + * Used to test the unsquashfs fallback path. + */ +function writeFakeNonNativeElfAppImage(dir) { + const appImage = join(dir, 'love-foreign.AppImage'); + const SQFS_OFFSET = 8192; // 8 KB, 4-byte aligned (AppImages use 1024-byte alignment) + + // Minimal AArch64 ELF header — triggers ENOEXEC on x86_64 Linux and all macOS. + // 64 bytes for the ELF ident + e_type/e_machine/e_version, rest zeroed. + const header = Buffer.alloc(64, 0); + header[0] = 0x7f; header[1] = 0x45; header[2] = 0x4c; header[3] = 0x46; // \x7fELF + header[4] = 0x02; // EI_CLASS: 64-bit + header[5] = 0x01; // EI_DATA: little-endian + header[6] = 0x01; // EI_VERSION: 1 + header.writeUInt16LE(0x00b7, 18); // e_machine: EM_AARCH64 = 183 + + const data = Buffer.alloc(SQFS_OFFSET + 8, 0); + header.copy(data, 0); + // SquashFS v4 magic (little-endian 0x73717368 = 'sqsh') + data.writeUInt32LE(0x73717368, SQFS_OFFSET); + + writeFileSync(appImage, data); + chmodSync(appImage, 0o755); + return { appImage, sqfsOffset: SQFS_OFFSET }; +} + +/** + * Creates a fake `unsquashfs` binary that parses -offset/-d args and writes + * a minimal squashfs-root structure expected by the CLI. + * Returns the bin directory to prepend to PATH. + */ +function writeFakeUnsquashfs(tmpDir) { + const binDir = join(tmpDir, 'fake-unsquashfs-bin'); + mkdirSync(binDir, { recursive: true }); + const script = join(binDir, 'unsquashfs'); + writeFileSync( + script, + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +// Parse -d from args (ignore -offset, -f, etc.) +let dest = null; +for (let i = 0; i < args.length - 1; i++) { + if (args[i] === '-d') { dest = args[i + 1]; break; } +} +if (!dest) { console.error('unsquashfs: missing -d'); process.exit(1); } +fs.mkdirSync(path.join(dest, 'bin'), { recursive: true }); +fs.writeFileSync(path.join(dest, 'bin', 'love'), '#!/bin/sh\\n'); +fs.chmodSync(path.join(dest, 'bin', 'love'), 0o755); +fs.writeFileSync(path.join(dest, 'AppRun'), '#!/bin/sh\\nexec "$APPDIR/bin/love" "$@"\\n'); +process.exit(0); +`, + ); + chmodSync(script, 0o755); + return binDir; +} + function writeFakeAppImageTool(dir) { const appImageTool = join(dir, 'appimagetool.AppImage'); writeFileSync( @@ -762,6 +822,8 @@ export { writeFakeLoveAndroid, writeFakeLoveIos, writeFakeLoveLinuxAppImage, + writeFakeNonNativeElfAppImage, + writeFakeUnsquashfs, writeFakeLoveMacosZip, writeFakeLoveWindowsZip, writeFakeLoveJs, diff --git a/cli/test/commands/init.test.mjs b/cli/test/commands/init.test.mjs index ddb5d255..8bb4e522 100644 --- a/cli/test/commands/init.test.mjs +++ b/cli/test/commands/init.test.mjs @@ -106,7 +106,9 @@ test('init e2e: defaults to cli mode and creates config without embedding runtim assert.equal(existsSync(join(project, 'feather.config.lua')), true); assert.equal(existsSync(join(project, 'feather')), false); - assert.match(readFileSync(join(project, 'feather.config.lua'), 'utf8'), /-- mode: cli/); + const config = readFileSync(join(project, 'feather.config.lua'), 'utf8'); + assert.match(config, /-- mode: cli/); + assert.match(config, /managed\s*=\s*"cli"/); assert.equal(readFileSync(join(project, 'main.lua'), 'utf8').includes('FEATHER-INIT'), false); const report = doctorJson(project); @@ -119,6 +121,333 @@ test('init e2e: defaults to cli mode and creates config without embedding runtim } }); +test('init e2e: auto mode writes managed = "auto" as parseable Lua field', () => { + const workspace = makeTmp(); + const project = join(workspace, 'auto-managed-game'); + + try { + writeE2eGame(project); + + runOk([ + 'init', + project, + '--mode', + 'auto', + '--local-src', + LOCAL_SRC, + '--install-dir', + 'feather', + '--no-plugins', + '--yes', + '--allow-insecure-connection', + ]); + + const config = readFileSync(join(project, 'feather.config.lua'), 'utf8'); + assert.match(config, /managed\s*=\s*"auto"/); + assert.match(config, /-- mode: auto/); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: cli mode removes only config, leaves main.lua untouched', () => { + const workspace = makeTmp(); + const project = join(workspace, 'cli-remove-game'); + + try { + writeE2eGame(project); + runOk(['init', project, '--no-plugins', '--yes', '--allow-insecure-connection']); + + assert.equal(existsSync(join(project, 'feather.config.lua')), true); + assert.equal(existsSync(join(project, 'feather')), false); + assert.equal(readFileSync(join(project, 'main.lua'), 'utf8').includes('FEATHER-INIT'), false); + + runOk(['remove', project, '--yes']); + + assert.equal(existsSync(join(project, 'feather.config.lua')), false); + // main.lua must still exist and be unmodified (CLI mode never patches it) + assert.ok(existsSync(join(project, 'main.lua'))); + 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: remove --dry-run prints targets without deleting files', () => { + const workspace = makeTmp(); + const project = join(workspace, 'dry-run-game'); + + try { + writeE2eGame(project); + runOk(['init', project, '--no-plugins', '--yes', '--allow-insecure-connection']); + + const result = run(['remove', project, '--dry-run']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('feather.config.lua')); + // file must still exist + assert.equal(existsSync(join(project, 'feather.config.lua')), true); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: remove without --yes in non-interactive mode fails', () => { + const workspace = makeTmp(); + const project = join(workspace, 'non-interactive-game'); + + try { + writeE2eGame(project); + runOk(['init', project, '--no-plugins', '--yes', '--allow-insecure-connection']); + + const result = run(['remove', project]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Refusing to remove Feather files without --yes')); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: auto mode remove keeps config with --keep-config', () => { + const workspace = makeTmp(); + const project = join(workspace, 'keep-config-game'); + + try { + writeE2eGame(project); + runOk([ + 'init', + project, + '--mode', + 'auto', + '--local-src', + LOCAL_SRC, + '--install-dir', + 'feather', + '--no-plugins', + '--yes', + '--allow-insecure-connection', + ]); + + runOk(['remove', project, '--yes', '--keep-config']); + + assert.equal(existsSync(join(project, 'feather')), false); + assert.equal(existsSync(join(project, 'feather.config.lua')), true); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: auto mode remove keeps runtime with --keep-runtime', () => { + const workspace = makeTmp(); + const project = join(workspace, 'keep-runtime-game'); + + try { + writeE2eGame(project); + runOk([ + 'init', + project, + '--mode', + 'auto', + '--local-src', + LOCAL_SRC, + '--install-dir', + 'feather', + '--no-plugins', + '--yes', + '--allow-insecure-connection', + ]); + + runOk(['remove', project, '--yes', '--keep-runtime']); + + assert.equal(existsSync(join(project, 'feather')), true); + assert.equal(existsSync(join(project, 'feather.config.lua')), false); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: cli mode round-trip — init, plugin install, plugin list, plugin remove, feather update', () => { + const workspace = makeTmp(); + const project = join(workspace, 'cli-roundtrip'); + + try { + writeE2eGame(project); + + // init in CLI mode + runOk(['init', project, '--no-plugins', '--yes', '--allow-insecure-connection']); + assert.match(readFileSync(join(project, 'feather.config.lua'), 'utf8'), /managed\s*=\s*"cli"/); + + // plugin install via CLI mode + runOk(['plugin', 'install', 'console', '--dir', project]); + const afterInstall = readFileSync(join(project, 'feather.config.lua'), 'utf8'); + assert.match(afterInstall, /include\s*=\s*\{[^}]*"console"/); + // no plugin files copied to filesystem + assert.equal(existsSync(join(project, 'feather', 'plugins', 'console')), false); + + // plugin list shows the installed plugin + const listResult = run(['plugin', 'list', '--dir', project]); + assert.equal(listResult.exitCode, 0, outputOf(listResult)); + assert.ok(outputOf(listResult).includes('console')); + + // plugin remove via CLI mode + runOk(['plugin', 'remove', 'console', '--dir', project, '--yes']); + const afterRemove = readFileSync(join(project, 'feather.config.lua'), 'utf8'); + // only check uncommented include lines (comment block also contains "include = ...") + const includeMatch = afterRemove.match(/^\s*include\s*=\s*\{([^}]*)\}/m); + assert.ok(!includeMatch || !includeMatch[1].includes('"console"')); + + // feather update is a no-op for CLI mode + const updateResult = run(['update', project]); + assert.equal(updateResult.exitCode, 0, outputOf(updateResult)); + assert.ok(outputOf(updateResult).includes('CLI')); + + // remove the project + runOk(['remove', project, '--yes']); + assert.equal(existsSync(join(project, 'feather.config.lua')), false); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: cli mode records selected plugins in generated config', () => { + const workspace = makeTmp(); + const project = join(workspace, 'cli-plugins-game'); + + try { + writeE2eGame(project); + + runOk([ + 'init', + project, + '--mode', + 'cli', + '--plugins', + 'console,input-replay', + '--yes', + '--allow-insecure-connection', + ]); + + const config = readFileSync(join(project, 'feather.config.lua'), 'utf8'); + assert.match(config, /include\s*=\s*\{\s*"console",\s*"input-replay"\s*\}/); + assert.match(config, /capabilities\s*=\s*\{\s*"filesystem",\s*"input"\s*\}/); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: cli mode includes default creative plugins', () => { + const workspace = makeTmp(); + const project = join(workspace, 'cli-default-plugins-game'); + + try { + writeE2eGame(project); + + runOk([ + 'init', + project, + '--mode', + 'cli', + '--yes', + '--allow-insecure-connection', + ]); + + const config = readFileSync(join(project, 'feather.config.lua'), 'utf8'); + assert.match(config, /include\s*=\s*\{\s*"particle-system-playground",\s*"shader-graph"\s*\}/); + assert.match(config, /capabilities\s*=\s*\{\s*"draw",\s*"filesystem"\s*\}/); + assert.match(config, /debug\s*=\s*true/); + assert.match(config, /autoRegisterErrorHandler\s*=\s*true/); + assert.doesNotMatch(config, /^\s*hotReload\s*=/m); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: cli mode hot reload writes debugger allowlist config', () => { + const workspace = makeTmp(); + const project = join(workspace, 'cli-hot-reload-game'); + + try { + writeE2eGame(project); + + runOk([ + 'init', + project, + '--mode', + 'cli', + '--plugins', + 'hot-reload', + '--hot-reload-allow', + 'game.player,game.systems.combat', + '--yes', + '--allow-insecure-connection', + ]); + + const config = readFileSync(join(project, 'feather.config.lua'), 'utf8'); + assert.match(config, /include\s*=\s*\{\s*"hot-reload"\s*\}/); + assert.match(config, /debug\s*=\s*true/); + assert.match(config, /autoRegisterErrorHandler\s*=\s*true/); + assert.match(config, /debugger\s*=\s*\{/); + assert.match(config, /enabled\s*=\s*true/); + assert.match(config, /hotReload\s*=\s*\{/); + assert.match(config, /allow\s*=\s*\{\s*"game\.player",\s*"game\.systems\.combat"\s*\}/); + assert.match(config, /deny\s*=\s*\{\s*"main",\s*"conf",\s*"feather\.\*"\s*\}/); + assert.match(config, /persistToDisk\s*=\s*false/); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: cli mode writes session name and app id from flags', () => { + const workspace = makeTmp(); + const project = join(workspace, 'cli-custom-config-game'); + + try { + writeE2eGame(project); + + runOk([ + 'init', + project, + '--mode', + 'cli', + '--session-name', + 'Custom Session', + '--app-id', + 'feather-app-test', + '--no-plugins', + '--yes', + ]); + + const config = readFileSync(join(project, 'feather.config.lua'), 'utf8'); + assert.match(config, /sessionName\s*=\s*"Custom Session"/); + assert.match(config, /appId\s*=\s*"feather-app-test"/); + assert.doesNotMatch(config, /^\s*__DANGEROUS_INSECURE_CONNECTION__\s*=\s*true/m); + assert.doesNotMatch(config, /^\s*include\s*=/m); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + test('init --yes without --allow-insecure-connection: config omits __DANGEROUS_INSECURE_CONNECTION__ and doctor fails on missing appId', () => { const workspace = makeTmp(); const project = join(workspace, 'secure-game'); diff --git a/cli/test/commands/package.test.mjs b/cli/test/commands/package.test.mjs index ce77ff5b..4f828b0a 100644 --- a/cli/test/commands/package.test.mjs +++ b/cli/test/commands/package.test.mjs @@ -62,6 +62,7 @@ function writeLock(dir, packages) { } function writeGame(dir) { + mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, 'main.lua'), `function love.update(dt) @@ -73,6 +74,17 @@ end ); } +test('package project resolver: nested game dir resolves parent project metadata', async () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + writeFileSync(join(dir, 'feather.config.lua'), 'return {}\n'); + writeLock(dir, {}); + + const { resolvePackageProjectDir } = await import('../../dist/commands/package/shared.js'); + assert.equal(resolvePackageProjectDir(gameDir), dir); +}); + function sourceFiles(dir) { return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const path = join(dir, entry.name); diff --git a/cli/test/commands/plugins-managed.test.mjs b/cli/test/commands/plugins-managed.test.mjs new file mode 100644 index 00000000..678a39e6 --- /dev/null +++ b/cli/test/commands/plugins-managed.test.mjs @@ -0,0 +1,532 @@ +/* eslint-disable no-undef */ +/** + * Tests for `--managed ` override and automatic managed-mode detection + * across all plugin subcommands (install, remove, update, list). + * + * Mode semantics: + * cli – plugins are bundled in the CLI binary; only feather.config.lua + * `include`/`exclude` lists are updated, no files copied. + * auto – embedded runtime; plugin Lua files are copied into the project. + * manual – same file-copying behaviour as auto. + */ +import { + LOCAL_SRC, + assert, + existsSync, + join, + makeTmp, + outputOf, + readFileSync, + run, + test, + writeFileSync, + writeMinimalRuntime, +} from './helpers.mjs'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Write a feather.config.lua with a `managed` field and an optional + * `include` list. Returns the path to the config file. + */ +function writeConfig(dir, managed, { include = [] } = {}) { + const lines = [` managed = "${managed}",`]; + if (include.length > 0) { + lines.push(` include = { ${include.map((id) => `"${id}"`).join(', ')} },`); + } + const path = join(dir, 'feather.config.lua'); + writeFileSync(path, `return {\n${lines.join('\n')}\n}\n`); + return path; +} + +/** + * Parse the string values from a named Lua array field, e.g. + * include = { "console", "hot-reload" } → ["console", "hot-reload"] + */ +function parseLuaArray(source, key) { + const re = new RegExp(`${key}\\s*=\\s*\\{([^}]*)\\}`); + const m = source.match(re); + if (!m) return []; + return [...m[1].matchAll(/"([^"]*)"/g)].map((match) => match[1]); +} + +/** + * Install a plugin in embedded (file-copy) mode, bypassing managed-mode + * detection in the config. Used to set up filesystem state for update/remove tests. + */ +function installEmbedded(dir, id = 'console') { + const result = run(['plugin', 'install', id, '--managed', 'auto', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); +} + +// --------------------------------------------------------------------------- +// plugin install +// --------------------------------------------------------------------------- + +test('plugin install --managed cli: updates include list, does not copy files (overrides embedded project)', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); // has feather/init.lua + writeConfig(dir, 'auto'); // config says auto … + // … but --managed cli wins + + const result = run(['plugin', 'install', 'console', '--managed', 'cli', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), false); + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.ok(config.includes('"console"'), `include list missing "console":\n${config}`); +}); + +test('plugin install --managed auto: copies files even when config says cli', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'cli'); // config says cli … + // … but --managed auto wins + + const result = run(['plugin', 'install', 'console', '--managed', 'auto', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), true); +}); + +test('plugin install --managed manual: copies files even when config says cli', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'cli'); + + const result = run(['plugin', 'install', 'console', '--managed', 'manual', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), true); +}); + +test('plugin install --managed cli: overrides filesystem fallback (feather/init.lua present, no managed field)', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); // would normally trigger embedded mode + writeFileSync(join(dir, 'feather.config.lua'), 'return {\n}\n'); + + const result = run(['plugin', 'install', 'console', '--managed', 'cli', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), false); + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.ok(config.includes('"console"'), `include list missing:\n${config}`); +}); + +test('plugin install --managed auto: overrides filesystem fallback (no feather/init.lua)', () => { + // Without override, no feather/init.lua + no managed field → CLI mode. + // --managed auto forces file-copy path. + const dir = makeTmp(); + writeMinimalRuntime(dir); // creates feather/init.lua so copy will work + writeFileSync(join(dir, 'feather.config.lua'), 'return {\n managed = "cli",\n}\n'); + + const result = run(['plugin', 'install', 'console', '--managed', 'auto', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), true); +}); + +test('plugin install: managed = cli detected from config', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli'); + + const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), false); + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.ok(config.includes('"console"'), config); +}); + +test('plugin install: managed = auto detected from config copies files', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + + const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), true); +}); + +test('plugin install: managed = manual detected from config copies files', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'manual'); + + const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), true); +}); + +test('plugin install --managed cli: multiple ids all added to include list', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli'); + + const result = run(['plugin', 'install', 'console', 'input-replay', '--managed', 'cli', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.ok(config.includes('"console"'), config); + assert.ok(config.includes('"input-replay"'), config); + assert.equal(existsSync(join(dir, 'feather', 'plugins')), false); +}); + +test('plugin install --managed cli: idempotent — re-install does not duplicate include list entry', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli'); + + run(['plugin', 'install', 'console', '--managed', 'cli', '--local-src', LOCAL_SRC, '--dir', dir]); + run(['plugin', 'install', 'console', '--managed', 'cli', '--local-src', LOCAL_SRC, '--dir', dir]); + + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + const matches = [...config.matchAll(/"console"/g)]; + assert.equal(matches.length, 1, `"console" should appear exactly once:\n${config}`); +}); + +test('plugin install --managed cli: unknown plugin id is rejected by catalog', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli'); + + const result = run(['plugin', 'install', 'zzz-missing', '--managed', 'cli', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Unknown plugin: zzz-missing'), outputOf(result)); +}); + +test('plugin install --managed auto: unknown plugin id rejected from local source', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + + const result = run(['plugin', 'install', 'zzz-missing', '--managed', 'auto', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Unknown plugin: zzz-missing'), outputOf(result)); +}); + +// --------------------------------------------------------------------------- +// plugin remove +// --------------------------------------------------------------------------- + +test('plugin remove --managed cli: removes plugin from include list', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli', { include: ['console'] }); + + const result = run(['plugin', 'remove', 'console', '--managed', 'cli', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.ok(!parseLuaArray(config, 'include').includes('console'), `"console" should not be in include:\n${config}`); +}); + +test('plugin remove --managed auto: deletes plugin files from filesystem', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + installEmbedded(dir); + + const pluginDir = join(dir, 'feather', 'plugins', 'console'); + assert.equal(existsSync(pluginDir), true); + + const result = run(['plugin', 'remove', 'console', '--managed', 'auto', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(pluginDir), false); +}); + +test('plugin remove --managed manual: deletes plugin files from filesystem', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'manual'); + installEmbedded(dir); + + const result = run(['plugin', 'remove', 'console', '--managed', 'manual', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console')), false); +}); + +test('plugin remove: managed = cli from config removes from include list', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli', { include: ['console', 'hot-reload'] }); + + const result = run(['plugin', 'remove', 'console', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + const included = parseLuaArray(config, 'include'); + assert.ok(!included.includes('console'), `"console" still in include:\n${config}`); + assert.ok(included.includes('hot-reload'), `"hot-reload" was unexpectedly removed:\n${config}`); +}); + +test('plugin remove: managed = auto from config removes filesystem files', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + installEmbedded(dir); + + const result = run(['plugin', 'remove', 'console', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console')), false); +}); + +test('plugin remove: managed = manual from config removes filesystem files', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'manual'); + installEmbedded(dir); + + const result = run(['plugin', 'remove', 'console', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console')), false); +}); + +test('plugin remove --managed cli overrides embedded project: removes from include list, files stay on disk', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto', { include: ['console'] }); + installEmbedded(dir); // files exist on disk + + const result = run(['plugin', 'remove', 'console', '--managed', 'cli', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.ok(!parseLuaArray(config, 'include').includes('console'), `"console" still in include:\n${config}`); + // Files stay on disk because we were in CLI mode (config update only) + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console')), true); +}); + +test('plugin remove --managed auto: fails when plugin file not found', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + + const result = run(['plugin', 'remove', 'console', '--managed', 'auto', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Plugin not found'), outputOf(result)); +}); + +// --------------------------------------------------------------------------- +// plugin update +// --------------------------------------------------------------------------- + +test('plugin update --managed cli: prints info message and exits 0', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli', { include: ['console'] }); + + const result = run(['plugin', 'update', '--managed', 'cli', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('CLI-managed'), outputOf(result)); +}); + +test('plugin update: managed = cli from config prints info and exits 0', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli'); + + const result = run(['plugin', 'update', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('CLI-managed'), outputOf(result)); +}); + +test('plugin update --managed cli: specific plugin id also prints info and exits 0', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli', { include: ['console'] }); + + const result = run(['plugin', 'update', 'console', '--managed', 'cli', '--local-src', LOCAL_SRC, '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('CLI-managed'), outputOf(result)); +}); + +test('plugin update --managed auto: updates plugin files', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + installEmbedded(dir); + + const installedInit = join(dir, 'feather', 'plugins', 'console', 'init.lua'); + writeFileSync(installedInit, 'damaged'); + + const result = run(['plugin', 'update', 'console', '--managed', 'auto', '--local-src', LOCAL_SRC, '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.notEqual(readFileSync(installedInit, 'utf8'), 'damaged'); + assert.ok(outputOf(result).includes('Updated console'), outputOf(result)); +}); + +test('plugin update --managed manual: updates plugin files', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'manual'); + installEmbedded(dir); + + const installedInit = join(dir, 'feather', 'plugins', 'console', 'init.lua'); + writeFileSync(installedInit, 'damaged'); + + const result = run(['plugin', 'update', 'console', '--managed', 'manual', '--local-src', LOCAL_SRC, '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.notEqual(readFileSync(installedInit, 'utf8'), 'damaged'); +}); + +test('plugin update: managed = auto from config updates plugin files', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + installEmbedded(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.notEqual(readFileSync(installedInit, 'utf8'), 'damaged'); +}); + +test('plugin update --managed auto overrides CLI config: updates files instead of printing info', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'cli'); // config says cli + installEmbedded(dir); // install via --managed auto so files exist + + const installedInit = join(dir, 'feather', 'plugins', 'console', 'init.lua'); + writeFileSync(installedInit, 'damaged'); + + // --managed auto overrides the cli config + const result = run(['plugin', 'update', 'console', '--managed', 'auto', '--local-src', LOCAL_SRC, '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.notEqual(readFileSync(installedInit, 'utf8'), 'damaged'); + assert.ok(!outputOf(result).includes('CLI-managed'), outputOf(result)); +}); + +// --------------------------------------------------------------------------- +// plugin list +// --------------------------------------------------------------------------- + +test('plugin list --managed cli: shows include list from config', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli', { include: ['console', 'hot-reload'] }); + + const result = run(['plugin', 'list', dir, '--managed', 'cli']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('console'), result.stdout); + assert.ok(result.stdout.includes('hot-reload'), result.stdout); +}); + +test('plugin list: managed = cli from config shows include list', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli', { include: ['console'] }); + + const result = run(['plugin', 'list', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('console'), result.stdout); +}); + +test('plugin list: managed = cli with empty include list shows no-plugins message', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli'); + + const result = run(['plugin', 'list', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('No plugins included'), outputOf(result)); +}); + +test('plugin list --managed cli: shows catalog name for known plugin ids', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli', { include: ['console'] }); + + const result = run(['plugin', 'list', dir, '--managed', 'cli']); + assert.equal(result.exitCode, 0, outputOf(result)); + // The catalog entry for 'console' has a name; it should appear in the table + assert.ok(result.stdout.includes('console'), result.stdout); +}); + +test('plugin list --managed auto: shows installed plugins from filesystem', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + installEmbedded(dir); + + const result = run(['plugin', 'list', dir, '--managed', 'auto']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('console'), result.stdout); +}); + +test('plugin list --managed manual: shows installed plugins from filesystem', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'manual'); + installEmbedded(dir); + + const result = run(['plugin', 'list', dir, '--managed', 'manual']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('console'), result.stdout); +}); + +test('plugin list: managed = auto from config shows filesystem plugins', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + installEmbedded(dir); + + const result = run(['plugin', 'list', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('console'), result.stdout); +}); + +test('plugin list: managed = manual from config shows filesystem plugins', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'manual'); + installEmbedded(dir); + + const result = run(['plugin', 'list', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('console'), result.stdout); +}); + +test('plugin list --managed auto overrides cli config: shows filesystem plugins', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'cli', { include: ['hot-reload'] }); + // Install console in embedded mode so it's on the filesystem + run(['plugin', 'install', 'console', '--managed', 'auto', '--local-src', LOCAL_SRC, '--dir', dir]); + + const result = run(['plugin', 'list', dir, '--managed', 'auto']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('console'), result.stdout); + // hot-reload is in include list but NOT on filesystem; auto mode should not show it + assert.ok(!result.stdout.includes('hot-reload'), result.stdout); +}); + +test('plugin list --managed cli overrides embedded config: shows include list not filesystem', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto', { include: ['hot-reload'] }); + installEmbedded(dir); // console on filesystem + + const result = run(['plugin', 'list', dir, '--managed', 'cli']); + assert.equal(result.exitCode, 0, outputOf(result)); + // Should show hot-reload (from include list), not console (filesystem only) + assert.ok(result.stdout.includes('hot-reload'), result.stdout); + assert.ok(!result.stdout.includes('console'), result.stdout); +}); + +// --------------------------------------------------------------------------- +// feather update (core runtime update) +// --------------------------------------------------------------------------- + +test('feather update: managed = cli from config prints info and exits 0', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli'); + + const result = run(['update', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('CLI-managed'), outputOf(result)); +}); + +test('feather update: managed = auto from config proceeds (attempts update)', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + + // With a minimal runtime, a local-src update should succeed + const result = run(['update', dir, '--yes', '--local-src', LOCAL_SRC]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(!outputOf(result).includes('CLI-managed'), outputOf(result)); +}); + +test('feather update: managed = manual from config proceeds (attempts update)', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'manual'); + + const result = run(['update', dir, '--yes', '--local-src', LOCAL_SRC]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(!outputOf(result).includes('CLI-managed'), outputOf(result)); +}); diff --git a/cli/test/commands/plugins.test.mjs b/cli/test/commands/plugins.test.mjs index 665fc586..5af3591d 100644 --- a/cli/test/commands/plugins.test.mjs +++ b/cli/test/commands/plugins.test.mjs @@ -13,6 +13,7 @@ import { test, writeFileSync, writeLocalPluginSource, + writeMinimalRuntime, } from './helpers.mjs'; test('plugin list: missing plugin directory is a clean empty state', () => { @@ -24,6 +25,7 @@ test('plugin list: missing plugin directory is a clean empty state', () => { test('plugin install: local source copies console manifest', () => { const dir = makeTmp(); + writeMinimalRuntime(dir); 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'); @@ -31,8 +33,24 @@ test('plugin install: local source copies console manifest', () => { assert.ok(outputOf(result).includes('Installed console')); }); +test('plugin install: accepts multiple ids and resolves parent project from nested game dir', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + mkdirSync(gameDir, { recursive: true }); + writeFileSync(join(gameDir, 'main.lua'), 'function love.draw() end\n'); + writeFileSync(join(dir, 'feather.config.lua'), 'return {}\n'); + writeMinimalRuntime(dir); + + const result = run(['plugin', 'install', 'console', 'input-replay', '--local-src', LOCAL_SRC, '--dir', gameDir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), true); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'input-replay', 'manifest.lua')), true); + assert.equal(existsSync(join(gameDir, 'feather')), false); +}); + test('plugin update: explicit local update refreshes damaged files', () => { const dir = makeTmp(); + writeMinimalRuntime(dir); run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); const installedInit = join(dir, 'feather', 'plugins', 'console', 'init.lua'); writeFileSync(installedInit, 'damaged'); @@ -48,6 +66,7 @@ test('plugin update: explicit local update refreshes damaged files', () => { test('plugin update: local --yes updates all installed plugins without selection', () => { const dir = makeTmp(); + writeMinimalRuntime(dir); 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'); @@ -76,6 +95,7 @@ test('plugin install: unknown local plugin exits 1', () => { test('plugin install: local manifest is validated before copying', () => { const dir = makeTmp(); + writeMinimalRuntime(dir); const source = join(makeTmp(), 'src-lua'); writeLocalPluginSource(source, 'bad-plugin', { version: null }); @@ -87,6 +107,7 @@ test('plugin install: local manifest is validated before copying', () => { test('plugin install: local manifest id must match plugin path', () => { const dir = makeTmp(); + writeMinimalRuntime(dir); const source = join(makeTmp(), 'src-lua'); writeLocalPluginSource(source, 'console', { manifestId: 'other-plugin' }); @@ -107,6 +128,7 @@ test('plugin install: refuses install directory symlink escaping project', () => const dir = makeTmp(); const outside = join(makeTmp(), 'outside-runtime'); mkdirSync(outside, { recursive: true }); + writeFileSync(join(outside, 'init.lua'), 'return {}\n'); symlinkSync(outside, join(dir, 'feather'), 'dir'); const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); @@ -138,6 +160,38 @@ test('plugin remove: refuses plugin directory symlink escaping project', () => { assert.equal(existsSync(join(outside, 'plugins', 'console', 'manifest.lua')), true); }); +test('plugin install: CLI mode project only updates feather.config.lua include list, does not copy files', () => { + // A CLI-mode project has feather.config.lua but NO feather/init.lua. + // Plugin code lives in the bundled runtime; only the include list needs updating. + const dir = makeTmp(); + writeFileSync(join(dir, 'main.lua'), 'function love.draw() end\n'); + writeFileSync(join(dir, 'feather.config.lua'), 'return {}\n'); + + const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + + // Plugin files must NOT be copied into the project + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), false); + + // feather.config.lua must have console in the include list + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.ok(config.includes('"console"'), `include list should contain "console"\n${config}`); +}); + +test('plugin install: CLI mode accepts multiple ids and adds all to include list', () => { + const dir = makeTmp(); + writeFileSync(join(dir, 'main.lua'), 'function love.draw() end\n'); + writeFileSync(join(dir, 'feather.config.lua'), 'return {}\n'); + + const result = run(['plugin', 'install', 'console', 'input-replay', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + + assert.equal(existsSync(join(dir, 'feather', 'plugins')), false); + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.ok(config.includes('"console"'), config); + assert.ok(config.includes('"input-replay"'), config); +}); + test('plugin list: malformed manifests do not crash and use directory fallback id', () => { const dir = makeTmp(); const pluginDir = join(dir, 'feather', 'plugins', 'bad-plugin'); @@ -149,3 +203,58 @@ test('plugin list: malformed manifests do not crash and use directory fallback i assert.ok(result.stdout.includes('bad-plugin')); assert.ok(result.stdout.includes('Bad Plugin')); }); + +test('plugin install: skips already-installed plugin and warns in non-TTY mode', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + + // First install succeeds. + const first = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(first.exitCode, 0, outputOf(first)); + const manifestPath = join(dir, 'feather', 'plugins', 'console', 'manifest.lua'); + assert.equal(existsSync(manifestPath), true); + + // Overwrite the manifest to detect whether it's been replaced. + writeFileSync(manifestPath, '-- sentinel\n'); + + // Second install without --force: should skip, file must remain unchanged. + const second = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(second.exitCode, 0, outputOf(second)); + assert.ok(outputOf(second).includes('already installed'), outputOf(second)); + assert.equal(readFileSync(manifestPath, 'utf8'), '-- sentinel\n'); +}); + +test('plugin install --force: overwrites already-installed plugin', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + + // First install. + run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + const manifestPath = join(dir, 'feather', 'plugins', 'console', 'manifest.lua'); + writeFileSync(manifestPath, '-- sentinel\n'); + + // Second install with --force: should overwrite. + const result = run(['plugin', 'install', 'console', '--force', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('Installed'), outputOf(result)); + assert.notEqual(readFileSync(manifestPath, 'utf8'), '-- sentinel\n'); +}); + +test('plugin install: installs new plugins and skips already-installed ones in the same batch', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + + // Pre-install console. + run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + const consoleSentinel = join(dir, 'feather', 'plugins', 'console', 'manifest.lua'); + writeFileSync(consoleSentinel, '-- sentinel\n'); + + // Install both console (already exists) and input-replay (new). + const result = run(['plugin', 'install', 'console', 'input-replay', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + // input-replay should be installed. + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'input-replay', 'manifest.lua')), true); + // console should be skipped — sentinel still intact. + assert.equal(readFileSync(consoleSentinel, 'utf8'), '-- sentinel\n'); + assert.ok(outputOf(result).includes('already installed'), outputOf(result)); +}); diff --git a/cli/test/commands/release.test.mjs b/cli/test/commands/release.test.mjs new file mode 100644 index 00000000..7532fa61 --- /dev/null +++ b/cli/test/commands/release.test.mjs @@ -0,0 +1,217 @@ +/* eslint-disable no-undef */ +import { + assert, + envWithPath, + existsSync, + join, + makeTmp, + outputOf, + readFileSync, + run, + test, + writeBuildConfig, + writeFakeCommand, + writeFakeLoveAndroid, + writeFileSync, + writeGame, + readStoredZipEntries, +} from './helpers.mjs'; + +function parseJson(result) { + assert.equal(result.stdout.trim().startsWith('{'), true, outputOf(result)); + return JSON.parse(result.stdout); +} + +function writeFakeFastlane(dir) { + const recordPath = join(dir, 'fastlane-record.json'); + const { binDir } = writeFakeCommand(dir, 'fastlane', ` +const fs = require('node:fs'); +const args = process.argv.slice(2); +if (args.includes('--version')) { + console.log('fastlane 2.220.0'); + process.exit(0); +} +const previous = fs.existsSync(${JSON.stringify(recordPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) + : []; +previous.push({ + argv: args, + env: { + FEATHER_RELEASE_TARGET: process.env.FEATHER_RELEASE_TARGET, + FEATHER_RELEASE_LANE: process.env.FEATHER_RELEASE_LANE, + FEATHER_ANDROID_PROJECT_DIR: process.env.FEATHER_ANDROID_PROJECT_DIR, + FEATHER_ANDROID_AAB: process.env.FEATHER_ANDROID_AAB, + FEATHER_ANDROID_APK: process.env.FEATHER_ANDROID_APK, + FEATHER_ANDROID_PACKAGE_NAME: process.env.FEATHER_ANDROID_PACKAGE_NAME, + FEATHER_ANDROID_SERVICE_ACCOUNT_JSON: process.env.FEATHER_ANDROID_SERVICE_ACCOUNT_JSON, + FEATHER_ANDROID_TRACK: process.env.FEATHER_ANDROID_TRACK, + FEATHER_ANDROID_RELEASE_STATUS: process.env.FEATHER_ANDROID_RELEASE_STATUS, + FEATHER_BUILD_MANIFEST: process.env.FEATHER_BUILD_MANIFEST, + }, + aabExists: process.env.FEATHER_ANDROID_AAB ? fs.existsSync(process.env.FEATHER_ANDROID_AAB) : false, +}); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); +process.exit(0); +`); + return { binDir, recordPath }; +} + +test('release init: dry-run reports standard Fastlane files without writing them', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Release Init', version: '1.0.0' }); + + const result = run(['release', 'init', '--dir', dir, '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = parseJson(result); + assert.equal(parsed.ok, true); + assert.equal(parsed.dryRun, true); + assert.equal(parsed.files.some((file) => file.path.endsWith('fastlane/Fastfile') && file.action === 'create'), true); + assert.equal(existsSync(join(dir, 'fastlane', 'Fastfile')), false); +}); + +test('release init: creates editable Fastlane scaffold', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Release Init', version: '1.0.0' }); + + const result = run(['release', 'init', '--dir', dir, '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = parseJson(result); + assert.equal(parsed.ok, true); + assert.equal(existsSync(join(dir, 'fastlane', 'Fastfile')), true); + assert.equal(existsSync(join(dir, 'fastlane', 'Appfile')), true); + assert.equal(existsSync(join(dir, 'fastlane', '.env.example')), true); + assert.ok(readFileSync(join(dir, 'fastlane', 'Fastfile'), 'utf8').includes('upload_to_testflight')); + assert.ok(readFileSync(join(dir, 'fastlane', 'Fastfile'), 'utf8').includes('upload_to_play_store')); +}); + +test('release android beta: builds Feather-free release artifacts and invokes Fastlane with env', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Release Android', + version: '1.0.0', + productId: 'com.example.releaseandroid', + targets: { + android: { + loveAndroidDir: 'love-android', + release: { + fastlane: { + packageName: 'com.example.releaseandroid', + serviceAccountJsonEnv: 'GOOGLE_PLAY_SERVICE_ACCOUNT_JSON', + track: 'internal', + releaseStatus: 'completed', + }, + }, + }, + }, + }); + const fastlane = writeFakeFastlane(dir); + const init = run(['release', 'init', '--dir', dir, '--json']); + assert.equal(init.exitCode, 0, outputOf(init)); + + const result = run(['release', 'android', 'beta', '--dir', dir, '--json'], { + env: envWithPath(fastlane.binDir, { GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: join(dir, 'play.json') }), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = parseJson(result); + assert.equal(parsed.ok, true); + assert.equal(parsed.target, 'android'); + assert.equal(parsed.lane, 'beta'); + assert.equal(parsed.command.join(' '), 'fastlane android beta'); + + const record = JSON.parse(readFileSync(fastlane.recordPath, 'utf8'))[0]; + assert.deepEqual(record.argv, ['android', 'beta']); + assert.equal(record.env.FEATHER_RELEASE_TARGET, 'android'); + assert.equal(record.env.FEATHER_RELEASE_LANE, 'beta'); + assert.equal(record.env.FEATHER_ANDROID_PACKAGE_NAME, 'com.example.releaseandroid'); + assert.equal(record.env.FEATHER_ANDROID_SERVICE_ACCOUNT_JSON, join(dir, 'play.json')); + assert.equal(record.env.FEATHER_ANDROID_TRACK, 'internal'); + assert.equal(record.env.FEATHER_ANDROID_RELEASE_STATUS, 'completed'); + assert.equal(record.aabExists, true); + + const entries = readStoredZipEntries(join(dir, 'builds', 'release-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.config.lua'), false); +}); + +test('release android: invalid Fastlane release config fails validation', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Bad Release Android', + version: '1.0.0', + productId: 'com.example.badreleaseandroid', + targets: { + android: { + loveAndroidDir: 'love-android', + release: { + fastlane: { + releaseStatus: 'ship-it', + serviceAccountJsonEnv: '1_BAD', + }, + }, + }, + }, + }); + const init = run(['release', 'init', '--dir', dir, '--json']); + assert.equal(init.exitCode, 0, outputOf(init)); + const fastlane = writeFakeFastlane(dir); + + const result = run(['release', 'android', 'beta', '--dir', dir, '--json'], { + env: envWithPath(fastlane.binDir), + }); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('targets.android.release.fastlane.releaseStatus')); + assert.ok(outputOf(result).includes('targets.android.release.fastlane.serviceAccountJsonEnv')); +}); + +test('doctor android --release: checks Fastlane and release env when configured', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeFileSync(join(dir, 'upload.keystore'), 'fake keystore'); + writeBuildConfig(dir, { + name: 'Doctor Fastlane Android', + version: '1.0.0', + productId: 'com.example.doctorfastlaneandroid', + release: { fastlane: { path: 'fastlane' } }, + targets: { + android: { + loveAndroidDir: 'love-android', + release: { + fastlane: { + packageName: 'com.example.doctorfastlaneandroid', + serviceAccountJsonEnv: 'GOOGLE_PLAY_SERVICE_ACCOUNT_JSON', + keystorePath: 'upload.keystore', + keyAlias: 'upload', + storePasswordEnv: 'ANDROID_STORE_PASSWORD', + keyPasswordEnv: 'ANDROID_KEY_PASSWORD', + }, + }, + }, + }, + }); + const init = run(['release', 'init', '--dir', dir, '--json']); + assert.equal(init.exitCode, 0, outputOf(init)); + const fastlane = writeFakeFastlane(dir); + + const result = run(['doctor', dir, '--target', 'android', '--release', '--json'], { + env: envWithPath(fastlane.binDir, { + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: join(dir, 'play.json'), + ANDROID_STORE_PASSWORD: 'store-secret', + ANDROID_KEY_PASSWORD: 'key-secret', + }), + }); + assert.equal(result.stdout.trim().startsWith('{'), true, outputOf(result)); + const parsed = JSON.parse(result.stdout); + const byLabel = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(byLabel.get('Fastlane directory')?.severity, 'pass'); + assert.equal(byLabel.get('Fastlane')?.severity, 'pass'); + assert.equal(byLabel.get('Google Play service account')?.severity, 'pass'); + assert.equal(byLabel.get('Android Fastlane signing env')?.severity, 'pass'); +}); diff --git a/cli/test/commands/replay.test.mjs b/cli/test/commands/replay.test.mjs new file mode 100644 index 00000000..e0d341a4 --- /dev/null +++ b/cli/test/commands/replay.test.mjs @@ -0,0 +1,55 @@ +/* eslint-disable no-undef */ +import { assert, existsSync, join, makeTmp, outputOf, readFileSync, run, test, writeFileSync, writeGame } from './helpers.mjs'; + +test('replay init: creates centralized adapter and enables session-replay plugin', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + managed = "cli", + include = { "shader-graph" }, + capabilities = { "draw" }, +} +`, + ); + + const result = run(['replay', 'init', '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + + const adapterPath = join(dir, 'dev', 'replay.lua'); + assert.equal(existsSync(adapterPath), true, 'adapter file should be created'); + const adapter = readFileSync(adapterPath, 'utf8'); + assert.match(adapter, /replayRegister/); + assert.match(adapter, /initialStates/); + assert.match(adapter, /function M\.start/); + assert.match(adapter, /local STREAM = "game"/); + + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.match(config, /include\s*=\s*\{\s*"session-replay",\s*"shader-graph"\s*\}/); + assert.match(config, /capabilities\s*=\s*\{\s*"binary",\s*"draw",\s*"filesystem",\s*"input"\s*\}/); +}); + +test('replay init: refuses to overwrite without force', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { managed = "cli" }\n'); + + const first = run(['replay', 'init', '--dir', dir]); + assert.equal(first.exitCode, 0, outputOf(first)); + + const second = run(['replay', 'init', '--dir', dir]); + assert.equal(second.exitCode, 1, outputOf(second)); + assert.match(outputOf(second), /Replay adapter already exists/); +}); + +test('replay init: supports custom path and no config update', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { include = { "shader-graph" } }\n'); + + const result = run(['replay', 'init', '--dir', dir, '--path', 'tools/replay_adapter.lua', '--no-config']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'tools', 'replay_adapter.lua')), true); + assert.doesNotMatch(readFileSync(join(dir, 'feather.config.lua'), 'utf8'), /session-replay/); +}); diff --git a/cli/test/commands/run.test.mjs b/cli/test/commands/run.test.mjs index 30e7e668..138fbc64 100644 --- a/cli/test/commands/run.test.mjs +++ b/cli/test/commands/run.test.mjs @@ -99,6 +99,11 @@ test('run: source checkout build exposes feather.auto without a bundled cli/lua assert.equal(result.exitCode, 0, outputOf(result)); const record = JSON.parse(readFileSync(recordPath, 'utf8')); assert.equal(record.featherAutoExists, true); + assert.ok( + [join(dirname(LOCAL_SRC), 'cli', 'lua'), LOCAL_SRC].some((root) => + record.shimMain.includes(`${root.replace(/\\/g, '/')}/?.lua`), + ), + ); }); test('run: accepts configPath aliases and recovers npm-stripped config path argument', () => { @@ -141,6 +146,50 @@ test('run: shim preloads config before requiring feather.auto', () => { assert.ok(record.shimMain.includes('require("feather.auto")')); }); +test('run: serializes nested plugin options for any plugin into the CLI shim', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const configPath = join(gameDir, 'feather.config.lua'); + writeFileSync( + configPath, + `return { + sessionName = "Plugin Config", + include = { "session-replay", "hot-reload", "console" }, + pluginOptions = { + ["session-replay"] = { + captureJoystickAxis = true, + keyframeInterval = 2, + }, + ["hot-reload"] = { + enabled = true, + allow = { "gameplay", "systems.player" }, + persistToDisk = false, + }, + console = { + evalEnabled = true, + }, + }, +} +`, + ); + 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.match(record.shimMain, /pluginOptions\s*=\s*\{/); + assert.match(record.shimMain, /\["session-replay"\]\s*=\s*\{/); + assert.match(record.shimMain, /captureJoystickAxis\s*=\s*true/); + assert.match(record.shimMain, /keyframeInterval\s*=\s*2/); + assert.match(record.shimMain, /\["hot-reload"\]\s*=\s*\{/); + assert.match(record.shimMain, /allow\s*=\s*\{\s*"gameplay",\s*"systems\.player"\s*\}/); + assert.match(record.shimMain, /persistToDisk\s*=\s*false/); + assert.match(record.shimMain, /console\s*=\s*\{/); + assert.match(record.shimMain, /evalEnabled\s*=\s*true/); +}); + test('run: shim auto-drives DEBUGGER update after loading the game', () => { const dir = makeTmp(); const gameDir = join(dir, 'game'); diff --git a/cli/test/commands/upload-safety.test.mjs b/cli/test/commands/upload-safety.test.mjs new file mode 100644 index 00000000..8565588c --- /dev/null +++ b/cli/test/commands/upload-safety.test.mjs @@ -0,0 +1,100 @@ +/* eslint-disable no-undef */ +import { assert, join, makeTmp, mkdirSync, test, writeFileSync } from './helpers.mjs'; +import { createZipBuffer } from '../../dist/lib/build/archive.js'; +import { inspectUploadArtifact } from '../../dist/lib/build/upload-safety.js'; + +function zip(entries) { + return createZipBuffer( + entries.map(([name, data]) => ({ + name, + data: Buffer.isBuffer(data) ? data : Buffer.from(data), + })), + ); +} + +function unsafeLove() { + return zip([ + ['main.lua', 'function love.draw() end\n'], + ['feather/init.lua', 'return {}\n'], + ]); +} + +test('upload safety detects Feather inside nested APK love archive', () => { + const dir = makeTmp(); + const artifact = join(dir, 'game.apk'); + writeFileSync( + artifact, + zip([ + ['AndroidManifest.xml', ''], + ['assets/game.love', unsafeLove()], + ]), + ); + + const safety = inspectUploadArtifact(artifact); + assert.equal(safety.status, 'unsafe'); + assert.ok(safety.detectedFiles.includes('assets/game.love!feather/init.lua')); +}); + +test('upload safety inspects AAB containers', () => { + const dir = makeTmp(); + const artifact = join(dir, 'game.aab'); + writeFileSync( + artifact, + zip([ + ['base/manifest/AndroidManifest.xml', ''], + ['base/assets/game.love', zip([['main.lua', 'function love.draw() end\n']])], + ]), + ); + + const safety = inspectUploadArtifact(artifact); + assert.equal(safety.status, 'safe'); + assert.deepEqual(safety.detectedFiles, []); +}); + +test('upload safety detects Feather inside IPA app love archive', () => { + const dir = makeTmp(); + const artifact = join(dir, 'game.ipa'); + writeFileSync( + artifact, + zip([ + ['Payload/Game.app/Info.plist', ''], + ['Payload/Game.app/game.love', unsafeLove()], + ]), + ); + + const safety = inspectUploadArtifact(artifact); + assert.equal(safety.status, 'unsafe'); + assert.ok(safety.detectedFiles.includes('Payload/Game.app/game.love!feather/init.lua')); +}); + +test('upload safety detects Feather inside .app bundle love archive', () => { + const dir = makeTmp(); + const artifact = join(dir, 'Game.app'); + mkdirSync(join(artifact, 'Contents', 'Resources', 'feather'), { recursive: true }); + writeFileSync(join(artifact, 'Info.plist'), ''); + writeFileSync(join(artifact, 'game.love'), unsafeLove()); + writeFileSync(join(artifact, 'Contents', 'Resources', 'feather', 'init.lua'), 'return {}\n'); + + const safety = inspectUploadArtifact(artifact); + assert.equal(safety.status, 'unsafe'); + assert.ok(safety.detectedFiles.includes('Contents/Resources/feather/init.lua')); + assert.ok(safety.detectedFiles.includes('game.love!feather/init.lua')); +}); + +test('upload safety detects session replay artifacts', () => { + const dir = makeTmp(); + const artifact = join(dir, 'game.love'); + writeFileSync( + artifact, + zip([ + ['main.lua', 'function love.draw() end\n'], + ['feather_replays/session_1/manifest.json', '{}'], + ['bug.featherreplay', 'archive'], + ]), + ); + + const safety = inspectUploadArtifact(artifact); + assert.equal(safety.status, 'unsafe'); + assert.ok(safety.detectedFiles.includes('bug.featherreplay')); + assert.ok(safety.detectedFiles.includes('feather_replays/session_1/manifest.json')); +}); diff --git a/cli/test/commands/upload.test.mjs b/cli/test/commands/upload.test.mjs index d697d153..d689068e 100644 --- a/cli/test/commands/upload.test.mjs +++ b/cli/test/commands/upload.test.mjs @@ -12,9 +12,11 @@ import { test, writeBuildConfig, writeFakeCommand, + writeFakeLoveAndroid, writeFakeLoveJs, writeFileSync, writeGame, + readStoredZipEntries, } from './helpers.mjs'; function writeUnsafeUploadManifest( @@ -213,6 +215,108 @@ test('upload itch: --build --dry-run builds an artifact and returns upload JSON assert.equal(parsed.safety.status, 'safe'); }); +test('upload itch: --build creates a clean artifact even when dev Feather config is unsafe', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + __DANGEROUS_INSECURE_CONNECTION__ = true, + writeToDisk = true, + include = { "console" }, + debugger = { + enabled = true, + hotReload = { + enabled = true, + allow = { "game.*" }, + persistToDisk = true, + }, + }, +} +`, + ); + writeBuildConfig(dir, { + name: 'Clean Upload', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/clean-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.safety.status, 'safe'); +}); + +test('upload itch: --build android forces release mode without Feather runtime', () => { + const dir = makeTmp(); + writeGame(dir); + const { recordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Android Upload', + version: '1.0.0', + productId: 'com.example.uploadandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + upload: { itch: { project: 'tester/android-upload', channels: { android: 'android' } } }, + }); + + const result = run(['upload', 'itch', 'android', '--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.artifact.endsWith('android-upload-1.0.0-android.aab'), true); + assert.equal(parsed.warning, undefined); + const loveEntries = readStoredZipEntries(join(dir, 'builds', 'android-upload-1.0.0.love')); + assert.equal(loveEntries.has('.feather-main.lua'), false); + assert.equal(loveEntries.has('feather/auto.lua'), false); + assert.equal(loveEntries.has('feather.config.lua'), false); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(record.records[0].argv.some((arg) => /bundle/i.test(arg))); + assert.ok(record.records[0].argv.some((arg) => /release/i.test(arg))); +}); + +test('upload itch: --build rejects unsafe overrides', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Guarded Upload', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/guarded-upload', channels: { web: 'html5' } } }, + }); + + const allowUnsafe = run(['upload', 'itch', 'web', '--dir', dir, '--build', '--allow-unsafe', '--dry-run', '--json']); + assert.equal(allowUnsafe.exitCode, 1); + assert.ok(outputOf(allowUnsafe).includes('remove --allow-unsafe')); + + const allowRuntime = run(['upload', 'itch', 'web', '--dir', dir, '--build', '--allow-feather-runtime', '--dry-run', '--json']); + assert.equal(allowRuntime.exitCode, 1); + assert.ok(outputOf(allowRuntime).includes('remove --allow-feather-runtime')); +}); + +test('upload itch: --build blocks generated artifacts containing Feather runtime', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + mkdirSync(join(dir, 'feather'), { recursive: true }); + writeFileSync(join(dir, 'feather', 'init.lua'), 'return {}\n'); + writeBuildConfig(dir, { + name: 'Embedded Runtime Upload', + version: '1.0.0', + includeRuntime: true, + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/embedded-runtime-upload', channels: { web: 'html5' } } }, + }); + + const result = run(['upload', 'itch', 'web', '--dir', dir, '--build', '--dry-run', '--json']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Upload build blocked')); + assert.ok(outputOf(result).includes('feather/')); +}); + test('upload itch: missing project reports a clear headless error', () => { const dir = makeTmp(); writeGame(dir); diff --git a/docs/configuration.md b/docs/configuration.md index 673f707a..f938936a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -40,6 +40,31 @@ > [!WARNING] > `captureScreenshot` can affect performance because it captures the current frame when errors are handled. Enable it only when you need visual error context. +## Generated CLI Config + +`feather init` defaults to CLI-managed mode. In that mode Feather creates `feather.config.lua` without patching game code, and `feather run` injects the runtime when you launch the game. + +The generated CLI config includes: + +```lua +return { + managed = "cli", + debug = true, + autoRegisterErrorHandler = true, + include = { "particle-system-playground", "shader-graph" }, + capabilities = { "draw", "filesystem" }, +} +``` + +Those default plugins are normal bundled plugins, not special cases. Other plugins are enabled by adding their IDs to `include`, either manually or with the CLI: + +```bash +feather config plugins --include profiler,input-replay +feather config plugins --exclude shader-graph +``` + +Plugins marked `optIn = true` or `disabled = true` in their `manifest.lua` only become active when included. This is how development-only tools such as Console and Hot Reload stay off unless you ask for them. + ## Hot Reload Hot reload is configured under `debugger.hotReload`, but the command handler only exists when the opt-in `hot-reload` plugin is installed and included: @@ -62,6 +87,13 @@ return { } ``` +The CLI can write the same shape for you: + +```bash +feather init path/to/my-game --plugins hot-reload --hot-reload-allow game.player,game.systems.combat --yes +feather config hot-reload --allow game.player,game.systems.combat --dir path/to/my-game +``` + See [Hot Reload](hot-reload.md) for the full workflow. > [!WARNING] diff --git a/docs/index.md b/docs/index.md index 172253fe..246d725a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,6 +46,13 @@ feather run path/to/my-game 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. +By default, CLI init enables error capture and includes the creative plugins `particle-system-playground` and `shader-graph`. Other plugins are controlled through `feather.config.lua`: + +```bash +feather config plugins --include profiler,input-replay --dir path/to/my-game +feather config hot-reload --allow game.player --dir path/to/my-game +``` + > [!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. diff --git a/docs/installation.md b/docs/installation.md index 7cca95c2..5201944b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -15,6 +15,18 @@ A new session tab appears in the Feather desktop app automatically. No `require` > [!NOTE] > 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. +`feather init` defaults to CLI-managed mode. It creates `feather.config.lua` with development defaults, enables error capture, and includes `particle-system-playground` plus `shader-graph`. Other plugins can be enabled later: + +```bash +feather config plugins --include profiler,input-replay --dir path/to/my-game +``` + +Hot Reload is development-only remote code execution, so it uses a separate allowlist command: + +```bash +feather config hot-reload --allow game.player,game.systems.combat --dir path/to/my-game +``` + ### Vendors and Platform Runs Add local LÖVE runtimes/templates once per project, then run or build those targets: @@ -65,6 +77,8 @@ feather remove path/to/my-game --yes See [CLI](cli.md) for all commands, flags, and `feather.config.lua` options. +Prefer working from VS Code? The [VS Code extension](vscode-extension.md) wraps the same CLI-managed setup, run, doctor, plugin, package, build, and upload flows inside the editor. + For CI or release scripts that need a security-only JSON report: ```bash @@ -85,6 +99,8 @@ curl -sSf https://raw.githubusercontent.com/Kyonru/feather/main/scripts/install- This creates a `feather/` directory (core library) and a `plugins/` directory (all built-in plugins) in your current folder. +The script installs normal built-in plugins by default, including `particle-system-playground` and `shader-graph`. It skips Console, Hot Reload, HUMP Signal, and Lua State Machine unless you opt in or override `FEATHER_SKIP_PLUGINS`. + **Customize with environment variables:** ```bash diff --git a/docs/recommendations.md b/docs/recommendations.md index 97428ccb..d2c4d529 100644 --- a/docs/recommendations.md +++ b/docs/recommendations.md @@ -108,6 +108,18 @@ feather upload itch web --dir path/to/my-game --dry-run --json 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. +For store-oriented mobile releases, Feather can also scaffold and run editable Fastlane lanes: + +```bash +feather release init --dir path/to/my-game +feather release ios beta --dir path/to/my-game +feather release ios production --dir path/to/my-game +feather release android beta --dir path/to/my-game +feather release android production --dir path/to/my-game +``` + +Fastlane is optional. Feather still creates the clean mobile artifact first, then passes explicit `FEATHER_*` environment variables to the selected lane. Keep secrets such as App Store Connect keys, Google Play service account JSON, Android keystore passwords, and match credentials in your shell or CI environment; `feather.build.json` should only contain non-secret ids and environment variable names. + --- ## Performance diff --git a/docs/session-replay.md b/docs/session-replay.md new file mode 120000 index 00000000..aab01a7c --- /dev/null +++ b/docs/session-replay.md @@ -0,0 +1 @@ +../src-lua/plugins/session-replay/README.md \ No newline at end of file diff --git a/docs/usage.md b/docs/usage.md index eb825220..6290eb4b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -14,9 +14,18 @@ feather run path/to/my-game --target ios The direct Lua API is still available for unusual projects that intentionally vendor Feather themselves. +If you work in VS Code, the [VS Code extension](vscode-extension.md) exposes the same CLI-managed workflow from the Feather activity bar, including init, run/watch, doctor, plugin/package management, release builds, uploads, and project settings. + > [!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. +CLI init creates a `feather.config.lua` with `debug = true`, automatic error capture enabled, and the default creative plugins `particle-system-playground` and `shader-graph` included. Add or remove plugins with: + +```bash +feather config plugins --include profiler,input-replay --dir path/to/my-game +feather config plugins --exclude shader-graph --dir path/to/my-game +``` + ```lua local FeatherDebugger = require "feather" local FeatherPluginManager = require "feather.plugin_manager" @@ -149,3 +158,33 @@ return { Open the **Time Travel** tab, click **Start Recording**, reproduce the bug, then click **Stop & Load** to fetch and scrub through the captured frames. → [Full Time Travel documentation](time-travel.md) + +--- + +## Session Replay + +Session Replay combines input replay with developer-selected state checkpoints so you can reproduce playthroughs. + +Enable it from `feather.config.lua`: + +```lua +return { + include = { "session-replay" }, +} +``` + +Add guarded state capture where it helps reproduction: + +```lua +if DEBUGGER then + DEBUGGER:replayState("player", { + x = player.x, + y = player.y, + health = player.health, + }) +end +``` + +For reliable playback, register a restore handler with `DEBUGGER:replayRegister()`. Feather captures an initial baseline at recording start, then records inputs and the state deltas you provide; it does not magically serialize the whole game. + +→ [Full Session Replay documentation](session-replay.md) diff --git a/docs/vscode-extension.md b/docs/vscode-extension.md new file mode 120000 index 00000000..b82efcce --- /dev/null +++ b/docs/vscode-extension.md @@ -0,0 +1 @@ +../vscode-extension/README.md \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 1ac52f5e..241a102d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,6 +32,7 @@ nav: - CLI: - Overview: cli.md - Package Management: packages.md + - VS Code Extension: vscode-extension.md - Usage: usage.md - Configuration: configuration.md - Debugger: debugger.md @@ -41,6 +42,7 @@ nav: - Particle System Playground: particle-system-playground.md - Shader Graph: shader-graph.md - Time Travel: time-travel.md + - Session Replay: session-replay.md - Recommendations: recommendations.md - Plugins: - Overview: plugins.md diff --git a/package-lock.json b/package-lock.json index 134770e0..8e65ea81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "feather", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "feather", - "version": "1.2.0", + "version": "1.3.0", "workspaces": [ - "cli" + "cli", + "vscode-extension" ], "dependencies": { "@base-ui/react": "1.4.1", @@ -412,6 +413,216 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@azu/format-text": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", + "integrity": "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@azu/style-format": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@azu/style-format/-/style-format-1.0.1.tgz", + "integrity": "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "@azu/format-text": "^1.0.1" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz", + "integrity": "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.10.1.tgz", + "integrity": "sha512-hTbvOi9Ko2Jvn+G/fSmjzHf9WbNcf/o3epMtbeGx/pMwMrVAbi6OgCJVeCfsAb8IybSRpaCSc4EDRlYAhgngUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.6.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.6.1.tgz", + "integrity": "sha512-VxKdEtUwDuLD0F1hOQP7kye0YadZxFJfv37Em440geEf/w9uggKnHpRrqwZJOdxmPUOdhZ9kyRtKuAJW8wUcRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.1.tgz", + "integrity": "sha512-tmQiQ2HvtzaeLqYGy3BemiPOSGPY4wCy1IW5zDWITKSs/s35WEd7Zij/hCxvUdAOzj6U3qnyaGbYXY91ortFEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.6.1", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", @@ -1204,6 +1415,16 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1271,6 +1492,44 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@oxc-project/types": { "version": "0.130.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", @@ -4602,102 +4861,342 @@ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" - }, - "node_modules/@tailwindcss/node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", - "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "node_modules/@secretlint/config-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", + "integrity": "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==", + "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.21.0", - "jiti": "^2.6.1", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.3.0" + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", - "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "node_modules/@secretlint/config-loader": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.2.tgz", + "integrity": "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==", + "dev": true, "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "ajv": "^8.17.1", + "debug": "^4.4.1", + "rc-config-loader": "^4.1.3" + }, "engines": { - "node": ">= 20" + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/config-loader/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-x64": "4.3.0", - "@tailwindcss/oxide-freebsd-x64": "4.3.0", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-x64-musl": "4.3.0", - "@tailwindcss/oxide-wasm32-wasi": "4.3.0", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", - "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", - "cpu": [ - "arm64" - ], + "node_modules/@secretlint/config-loader/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/core": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", + "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "structured-source": "^4.0.0" + }, "engines": { - "node": ">= 20" + "node": ">=20.0.0" } }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", - "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", - "cpu": [ - "arm64" - ], + "node_modules/@secretlint/formatter": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", + "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "@textlint/linter-formatter": "^15.2.0", + "@textlint/module-interop": "^15.2.0", + "@textlint/types": "^15.2.0", + "chalk": "^5.4.1", + "debug": "^4.4.1", + "pluralize": "^8.0.0", + "strip-ansi": "^7.1.0", + "table": "^6.9.0", + "terminal-link": "^4.0.0" + }, "engines": { - "node": ">= 20" + "node": ">=20.0.0" } }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", - "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", - "cpu": [ - "x64" - ], + "node_modules/@secretlint/formatter/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">= 20" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@secretlint/formatter/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@secretlint/formatter/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@secretlint/node": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.2.tgz", + "integrity": "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-loader": "^10.2.2", + "@secretlint/core": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "@secretlint/source-creator": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "p-map": "^7.0.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/profiler": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz", + "integrity": "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/resolver": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", + "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/secretlint-formatter-sarif": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz", + "integrity": "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-sarif-builder": "^3.2.0" + } + }, + "node_modules/@secretlint/secretlint-rule-no-dotenv": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", + "integrity": "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/secretlint-rule-preset-recommend": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.2.tgz", + "integrity": "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/source-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-10.2.2.tgz", + "integrity": "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2", + "istextorbinary": "^9.5.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/types": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.2.tgz", + "integrity": "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { @@ -5249,6 +5748,67 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@textlint/ast-node-types": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.7.1.tgz", + "integrity": "sha512-Wii5UgUKFEh9Uv6wbq1zr4/Kf+dtjiUuzPrrXzKp8H+ifkvKNzi23V4Nz+6wVyHQn5T28AFuc8VH8OtzvGYecA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.7.1.tgz", + "integrity": "sha512-TdwZ/debWYFD05K3CcoHtwvnCrza29wZxD+BjDTk/V5N7iRqkK1dTTHSD4A8AIgROLiDkHJmIKQbasbmsg8AvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azu/format-text": "^1.0.2", + "@azu/style-format": "^1.0.1", + "@textlint/module-interop": "15.7.1", + "@textlint/resolver": "15.7.1", + "@textlint/types": "15.7.1", + "chalk": "^4.1.2", + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "lodash": "^4.18.1", + "pluralize": "^2.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "table": "^6.9.0", + "text-table": "^0.2.0" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/pluralize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", + "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/module-interop": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.7.1.tgz", + "integrity": "sha512-Jg+sQW2L/cRJypk59wtcMUVVpt8vmit5ZMT3gUnFwevP3A6Qp1HfOtUy9ObT4hBX3lOSGT/ekcCDxR1pL7uH1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/resolver": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.7.1.tgz", + "integrity": "sha512-8XnO0pgF6mXnm41VvWmBbEIdGPhiCUt31uLZkOis1ECeg/1SoUcIT6Mx/F0e1rukq8l0UlOSeY9a31CsvRMK0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/types": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.7.1.tgz", + "integrity": "sha512-Vye/GmFNBTgVzZFtIFJTmLB+s2A7oIADxNG6r9UhfPuY+Czv0z5G3xeyFZZudPlfxURsKUyPIU5XsjOFqVp33A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "15.7.1" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -5402,6 +5962,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/prismjs": { "version": "1.26.5", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", @@ -5436,6 +6003,13 @@ "@types/react": "*" } }, + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -5691,6 +6265,21 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.5.tgz", + "integrity": "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", @@ -5717,43 +6306,283 @@ } } }, - "node_modules/@xyflow/react": { - "version": "12.10.2", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", - "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "node_modules/@vscode/test-electron": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", + "integrity": "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==", + "dev": true, "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.76", - "classcat": "^5.0.3", - "zustand": "^4.4.0" + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "jszip": "^3.10.1", + "ora": "^8.1.0", + "semver": "^7.6.2" }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" + "engines": { + "node": ">=16" } }, - "node_modules/@xyflow/react/node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "node_modules/@vscode/vsce": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.1.tgz", + "integrity": "sha512-MPn5p+DoudI+3GfJSpAZZraE1lgLv0LcwbH3+xy7RgEhty3UIkmUMUA+5jPTDaxXae00AnX5u77FxGM8FhfKKA==", + "dev": true, "license": "MIT", "dependencies": { - "use-sync-external-store": "^1.2.2" + "@azure/identity": "^4.1.0", + "@secretlint/node": "^10.1.2", + "@secretlint/secretlint-formatter-sarif": "^10.1.2", + "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", + "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", + "chalk": "^4.1.2", + "cheerio": "^1.0.0-rc.9", + "cockatiel": "^3.1.2", + "commander": "^12.1.0", + "form-data": "^4.0.0", + "glob": "^11.0.0", + "hosted-git-info": "^4.0.2", + "jsonc-parser": "^3.2.0", + "leven": "^3.1.0", + "markdown-it": "^14.1.0", + "mime": "^1.3.4", + "minimatch": "^3.0.3", + "parse-semver": "^1.1.1", + "read": "^1.0.7", + "secretlint": "^10.1.2", + "semver": "^7.5.2", + "tmp": "^0.2.3", + "typed-rest-client": "^1.8.4", + "url-join": "^4.0.1", + "xml2js": "^0.5.0", + "yauzl": "^3.2.1", + "yazl": "^2.2.2" }, - "engines": { - "node": ">=12.7.0" + "bin": { + "vsce": "vsce" }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" + "engines": { + "node": ">= 20" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true + "optionalDependencies": { + "keytar": "^7.7.0" + } + }, + "node_modules/@vscode/vsce-sign": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.9.tgz", + "integrity": "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g==", + "dev": true, + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optionalDependencies": { + "@vscode/vsce-sign-alpine-arm64": "2.0.6", + "@vscode/vsce-sign-alpine-x64": "2.0.6", + "@vscode/vsce-sign-darwin-arm64": "2.0.6", + "@vscode/vsce-sign-darwin-x64": "2.0.6", + "@vscode/vsce-sign-linux-arm": "2.0.6", + "@vscode/vsce-sign-linux-arm64": "2.0.6", + "@vscode/vsce-sign-linux-x64": "2.0.6", + "@vscode/vsce-sign-win32-arm64": "2.0.6", + "@vscode/vsce-sign-win32-x64": "2.0.6" + } + }, + "node_modules/@vscode/vsce-sign-alpine-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz", + "integrity": "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-alpine-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz", + "integrity": "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz", + "integrity": "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz", + "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz", + "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz", + "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz", + "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-win32-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz", + "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce-sign-win32-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz", + "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vscode/vsce/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@vscode/vsce/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true }, "react": { "optional": true @@ -5800,6 +6629,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -5856,6 +6695,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -5867,6 +6713,23 @@ "node": ">=10" } }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/auto-bind": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", @@ -5879,6 +6742,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/azure-devops-node-api": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", + "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "^1.8.4" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -5889,6 +6763,87 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/binaryextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", + "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/boundary": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", + "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -5902,42 +6857,145 @@ "node": "18 || 20 || >=22" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "fill-range": "^7.1.1" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=8" } }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" } }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "node_modules/character-entities-legacy": { @@ -5960,6 +7018,58 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -5989,6 +7099,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-spinners": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", @@ -6082,6 +7208,16 @@ "node": ">=6" } }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/code-excerpt": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", @@ -6112,6 +7248,19 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -6131,6 +7280,13 @@ "node": ">=18" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/concurrently": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", @@ -6173,6 +7329,13 @@ "node": ">=18" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6187,6 +7350,36 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -6418,49 +7611,62 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, - "node_modules/enhanced-resolve": { - "version": "5.21.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", - "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" }, "engines": { - "node": ">=10.13.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -6469,50 +7675,306 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/es-toolkit": { - "version": "1.46.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", - "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">=6" + "node": ">=0.4.0" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/eslint": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", - "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.5.5", - "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.1", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/editions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", + "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "version-range": "^4.15.0" + }, + "engines": { + "ecmascript": ">= es5", + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", + "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -6683,6 +8145,17 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6690,6 +8163,36 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -6703,6 +8206,33 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fault": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", @@ -6733,6 +8263,10 @@ } } }, + "node_modules/feather-vscode": { + "resolved": "vscode-extension", + "link": true + }, "node_modules/fflate": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", @@ -6751,6 +8285,19 @@ "node": ">=16.0.0" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -6787,6 +8334,40 @@ "dev": true, "license": "ISC" }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", @@ -6795,6 +8376,29 @@ "node": ">=0.4.x" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -6810,7 +8414,17 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-caller-file": { + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", @@ -6831,6 +8445,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -6839,6 +8478,20 @@ "node": ">=6" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.14.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", @@ -6862,6 +8515,39 @@ "resolved": "https://registry.npmjs.org/gif.js.optimized/-/gif.js.optimized-1.0.1.tgz", "integrity": "sha512-IS0F42Xken6lp/iR4irgG4r52tvxRkEKsXGZmlUHUOb00SWNMezJOJwkVaJk2MLW53rqzMbPnnBtEhs9hcMJ9w==" }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -6874,6 +8560,50 @@ "node": ">=10.13.0" } }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -6889,6 +8619,48 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hast-util-parse-selector": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", @@ -6934,6 +8706,80 @@ "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", "license": "CC0-1.0" }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -6949,6 +8795,41 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6959,6 +8840,13 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, "node_modules/immer": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", @@ -6990,6 +8878,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -7033,6 +8949,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -7088,6 +9020,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-interactive": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", @@ -7100,6 +9051,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-unicode-supported": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", @@ -7112,12 +9073,69 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/istextorbinary": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", + "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "binaryextensions": "^6.11.0", + "editions": "^6.21.0", + "textextensions": "^6.11.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -7127,8 +9145,28 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/json-buffer": { - "version": "3.0.1", + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true @@ -7146,6 +9184,111 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7155,6 +9298,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -7168,6 +9321,16 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -7417,6 +9580,16 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -7432,6 +9605,69 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", @@ -7486,6 +9722,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/lucide-react": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", @@ -7504,6 +9753,114 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -7513,6 +9870,33 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -7529,12 +9913,48 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -7553,6 +9973,14 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -7569,6 +9997,149 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-sarif-builder": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.4.0.tgz", + "integrity": "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sarif": "^2.1.7", + "fs-extra": "^11.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7586,6 +10157,97 @@ "node": ">= 0.8.0" } }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ora/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7610,12 +10272,39 @@ "p-limit": "^3.0.2" }, "engines": { - "node": ">=10" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -7641,6 +10330,110 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-semver": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", + "integrity": "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.1.0" + } + }, + "node_modules/parse-semver/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/patch-console": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", @@ -7668,6 +10461,53 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7718,6 +10558,16 @@ "node": ">=18" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -7746,6 +10596,35 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7776,6 +10655,13 @@ "node": ">=6" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -7786,6 +10672,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7796,6 +10694,53 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/radix-ui": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", @@ -7987,6 +10932,36 @@ } } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc-config-loader": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", + "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "json5": "^2.2.3", + "require-from-string": "^2.0.2" + } + }, "node_modules/react": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", @@ -8166,6 +11141,88 @@ "react-dom": ">=16 || >=17 || >= 18 || >=19" } }, + "node_modules/read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "mute-stream": "~0.0.4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/recharts": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", @@ -8236,6 +11293,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -8252,6 +11319,34 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", @@ -8285,6 +11380,43 @@ "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -8294,12 +11426,72 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/secretlint": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", + "integrity": "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-creator": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/node": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "debug": "^4.4.1", + "globby": "^14.1.0", + "read-pkg": "^9.0.1" + }, + "bin": { + "secretlint": "bin/secretlint.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/semver": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", @@ -8318,6 +11510,13 @@ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -8351,6 +11550,157 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/slice-ansi": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", @@ -8423,6 +11773,42 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -8449,52 +11835,179 @@ "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", "license": "MIT", - "engines": { - "node": ">=18" + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/structured-source": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", + "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boundary": "^2.0.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=8" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=8" + "node": ">=10.0.0" } }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/table/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, - "engines": { - "node": ">=8" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/table/node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", "dev": true, + "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" }, "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, "node_modules/tagged-tag": { @@ -8538,6 +12051,71 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/terminal-link": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", + "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "supports-hyperlinks": "^3.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/terminal-size": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", @@ -8550,6 +12128,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/textextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", + "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -8571,6 +12172,29 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -8674,6 +12298,30 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -8711,6 +12359,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-rest-client": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", + "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "qs": "^6.9.1", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -8749,6 +12409,30 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", @@ -8756,6 +12440,29 @@ "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -8766,6 +12473,13 @@ "punycode": "^2.1.0" } }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -8816,6 +12530,24 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, "node_modules/vaul": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", @@ -8828,6 +12560,19 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/version-range": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", + "integrity": "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==", + "dev": true, + "license": "Artistic-2.0", + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -8941,6 +12686,30 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9040,6 +12809,14 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", @@ -9061,6 +12838,46 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -9070,6 +12887,13 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -9097,6 +12921,30 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.3.0.tgz", + "integrity": "sha512-PtGEvEP30p7sbIBJKUBjUnqgTVOyMURc4dLo9iNyAJnNIEz9pm88cCXF21w94Kg3k6RXkeZh5DHOGS0qEONvNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yazl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", + "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -9152,6 +13000,45 @@ "optional": true } } + }, + "vscode-extension": { + "name": "feather-vscode", + "version": "1.2.0", + "license": "EPL-2.0", + "devDependencies": { + "@types/node": "22.15.0", + "@types/vscode": "1.101.0", + "@vscode/test-electron": "2.5.2", + "@vscode/vsce": "3.9.1", + "typescript": "6.0.3" + }, + "engines": { + "vscode": "^1.101.0" + } + }, + "vscode-extension/node_modules/@types/node": { + "version": "22.15.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.0.tgz", + "integrity": "sha512-99S8dWD2DkeE6PBaEDw+In3aar7hdoBvjyJMR6vaKBTzpvR0P00ClzJMOoVrj9D2+Sy/YCwACYHnBTpMhg1UCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "vscode-extension/node_modules/@types/vscode": { + "version": "1.101.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.101.0.tgz", + "integrity": "sha512-ZWf0IWa+NGegdW3iU42AcDTFHWW7fApLdkdnBqwYEtHVIBGbTu0ZNQKP/kX3Ds/uMJXIMQNAojHR4vexCEEz5Q==", + "dev": true, + "license": "MIT" + }, + "vscode-extension/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index bb5e0258..2bf38c40 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,11 @@ "name": "feather", "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": "1.2.0", + "version": "1.3.0", "type": "module", "workspaces": [ - "cli" + "cli", + "vscode-extension" ], "scripts": { "cli:build": "npm run build --workspace=cli", @@ -14,6 +15,12 @@ "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", + "extension:prepare": "npm run cli:build && npm run bundle:lua --workspace=cli && node vscode-extension/scripts/prepare.mjs", + "extension:build": "npm run extension:prepare && tsc -p vscode-extension/tsconfig.json", + "extension:binary": "npm run cli:build && npm run bundle:lua --workspace=cli && npm run build:binary --workspace=cli", + "extension:test": "npm run extension:build && npm run test --workspace=vscode-extension", + "extension:test:integration": "npm run extension:build && npm run test:integration --workspace=vscode-extension", + "extension:package": "npm run extension:binary && npm run extension:build && cd vscode-extension && npx @vscode/vsce package", "feather": "node cli/dist/index.js", "dev": "vite", "dev:lua": "concurrently \"vite\" \"sh ./scripts/watch-lua.sh\"", diff --git a/scripts/lua-e2e.mjs b/scripts/lua-e2e.mjs index 4f9c1668..6bebcec1 100644 --- a/scripts/lua-e2e.mjs +++ b/scripts/lua-e2e.mjs @@ -27,7 +27,12 @@ if (!love) { process.exit(1); } +const pluginSelector = process.argv[2]; const args = [gamePath, '--e2e']; + +if (pluginSelector) { + args.push(`--plugin-e2e=${pluginSelector}`); +} let command = love; let commandArgs = args; @@ -42,6 +47,10 @@ if (process.platform === 'linux' && !process.env.DISPLAY) { console.log(`lua-e2e: ${command} ${commandArgs.join(' ')}`); const result = spawnSync(command, commandArgs, { cwd: root, + env: { + ...process.env, + FEATHER_GAME_PATH: gamePath, + }, encoding: 'utf8', timeout: 15000, }); diff --git a/scripts/set-version.sh b/scripts/set-version.sh index 279ce5a9..738f9915 100755 --- a/scripts/set-version.sh +++ b/scripts/set-version.sh @@ -37,9 +37,14 @@ sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" \ sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" \ "${ROOT}/cli/package.json" +# vscode-extension/package.json +sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" \ + "${ROOT}/vscode-extension/package.json" + echo "Done. Updated:" echo " package.json" echo " src-lua/feather/init.lua" echo " src-tauri/Cargo.toml" echo " src-tauri/tauri.conf.json" echo " cli/package.json" +echo " vscode-extension/package.json" diff --git a/src-lua/e2e/plugins/animation_inspector.lua b/src-lua/e2e/plugins/animation_inspector.lua new file mode 100644 index 00000000..5dfcc673 --- /dev/null +++ b/src-lua/e2e/plugins/animation_inspector.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("animation-inspector") diff --git a/src-lua/e2e/plugins/audio_debug.lua b/src-lua/e2e/plugins/audio_debug.lua new file mode 100644 index 00000000..e9c0ee5d --- /dev/null +++ b/src-lua/e2e/plugins/audio_debug.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("audio-debug") diff --git a/src-lua/e2e/plugins/bookmark.lua b/src-lua/e2e/plugins/bookmark.lua new file mode 100644 index 00000000..c9ad02a7 --- /dev/null +++ b/src-lua/e2e/plugins/bookmark.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("bookmark") diff --git a/src-lua/e2e/plugins/collision_debug.lua b/src-lua/e2e/plugins/collision_debug.lua new file mode 100644 index 00000000..418eb0ad --- /dev/null +++ b/src-lua/e2e/plugins/collision_debug.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("collision-debug") diff --git a/src-lua/e2e/plugins/config_tweaker.lua b/src-lua/e2e/plugins/config_tweaker.lua new file mode 100644 index 00000000..d926b11b --- /dev/null +++ b/src-lua/e2e/plugins/config_tweaker.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("config-tweaker") diff --git a/src-lua/e2e/plugins/console.lua b/src-lua/e2e/plugins/console.lua new file mode 100644 index 00000000..b46ccb49 --- /dev/null +++ b/src-lua/e2e/plugins/console.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("console") diff --git a/src-lua/e2e/plugins/coroutine_monitor.lua b/src-lua/e2e/plugins/coroutine_monitor.lua new file mode 100644 index 00000000..b30384e8 --- /dev/null +++ b/src-lua/e2e/plugins/coroutine_monitor.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("coroutine-monitor") diff --git a/src-lua/e2e/plugins/entity_inspector.lua b/src-lua/e2e/plugins/entity_inspector.lua new file mode 100644 index 00000000..a124c04a --- /dev/null +++ b/src-lua/e2e/plugins/entity_inspector.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("entity-inspector") diff --git a/src-lua/e2e/plugins/filesystem.lua b/src-lua/e2e/plugins/filesystem.lua new file mode 100644 index 00000000..b11f1229 --- /dev/null +++ b/src-lua/e2e/plugins/filesystem.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("filesystem") diff --git a/src-lua/e2e/plugins/helper.lua b/src-lua/e2e/plugins/helper.lua new file mode 100644 index 00000000..e63355ac --- /dev/null +++ b/src-lua/e2e/plugins/helper.lua @@ -0,0 +1,285 @@ +local FeatherDebugger = require("feather") +local FeatherPluginManager = require("feather.plugin_manager") + +local PluginE2EHelper = {} + +local pluginSpecs = { + { + id = "animation-inspector", + modulePath = "plugins.animation-inspector", + suiteModulePath = "e2e.plugins.animation_inspector", + }, + { + id = "audio-debug", + modulePath = "plugins.audio-debug", + suiteModulePath = "e2e.plugins.audio_debug", + }, + { + id = "bookmark", + modulePath = "plugins.bookmark", + suiteModulePath = "e2e.plugins.bookmark", + }, + { + id = "collision-debug", + modulePath = "plugins.collision-debug", + suiteModulePath = "e2e.plugins.collision_debug", + }, + { + id = "config-tweaker", + modulePath = "plugins.config-tweaker", + suiteModulePath = "e2e.plugins.config_tweaker", + }, + { + id = "console", + modulePath = "plugins.console", + suiteModulePath = "e2e.plugins.console", + }, + { + id = "coroutine-monitor", + modulePath = "plugins.coroutine-monitor", + suiteModulePath = "e2e.plugins.coroutine_monitor", + }, + { + id = "entity-inspector", + modulePath = "plugins.entity-inspector", + suiteModulePath = "e2e.plugins.entity_inspector", + }, + { + id = "filesystem", + modulePath = "plugins.filesystem", + suiteModulePath = "e2e.plugins.filesystem", + }, + { + id = "hot-reload", + modulePath = "plugins.hot-reload", + suiteModulePath = "e2e.plugins.hot_reload", + }, + { + id = "hump.signal", + modulePath = "plugins.hump.signal", + suiteModulePath = "e2e.plugins.hump_signal", + }, + { + id = "ingame-overlay", + modulePath = "plugins.ingame-overlay", + suiteModulePath = "e2e.plugins.ingame_overlay", + }, + { + id = "input-replay", + modulePath = "plugins.input-replay", + suiteModulePath = "e2e.plugins.input_replay", + }, + { + id = "lua-state-machine", + modulePath = "plugins.lua-state-machine", + suiteModulePath = "e2e.plugins.lua_state_machine", + }, + { + id = "memory-snapshot", + modulePath = "plugins.memory-snapshot", + suiteModulePath = "e2e.plugins.memory_snapshot", + }, + { + id = "network-inspector", + modulePath = "plugins.network-inspector", + suiteModulePath = "e2e.plugins.network_inspector", + }, + { + id = "particle-editor", + modulePath = "plugins.particle-editor", + suiteModulePath = "e2e.plugins.particle_editor", + }, + { + id = "particle-system-playground", + modulePath = "plugins.particle-system-playground", + suiteModulePath = "e2e.plugins.particle_system_playground", + }, + { + id = "physics-debug", + modulePath = "plugins.physics-debug", + suiteModulePath = "e2e.plugins.physics_debug", + }, + { + id = "profiler", + modulePath = "plugins.profiler", + suiteModulePath = "e2e.plugins.profiler", + }, + { + id = "runtime-snapshot", + modulePath = "plugins.runtime-snapshot", + suiteModulePath = "e2e.plugins.runtime_snapshot", + }, + { + id = "screenshots", + modulePath = "plugins.screenshots", + suiteModulePath = "e2e.plugins.screenshots", + }, + { + id = "shader-graph", + modulePath = "plugins.shader-graph", + suiteModulePath = "e2e.plugins.shader_graph", + }, + { + id = "session-replay", + modulePath = "plugins.session-replay", + suiteModulePath = "e2e.plugins.session_replay", + }, + { + id = "time-travel", + modulePath = "plugins.time-travel", + suiteModulePath = "e2e.plugins.time_travel", + }, + { + id = "timer-inspector", + modulePath = "plugins.timer-inspector", + suiteModulePath = "e2e.plugins.timer_inspector", + }, +} + +local specsById = {} +for _, spec in ipairs(pluginSpecs) do + specsById[spec.id] = spec +end + +local function copyTable(source) + local target = {} + for key, value in pairs(source or {}) do + target[key] = value + end + return target +end + +function PluginE2EHelper.getPluginSpecs() + return pluginSpecs +end + +function PluginE2EHelper.getPluginSpec(pluginId) + local spec = specsById[pluginId] + if not spec then + error("Unknown plugin E2E suite: " .. tostring(pluginId), 2) + end + return spec +end + +function PluginE2EHelper.getSuiteModulePaths() + local modulePaths = {} + + for _, spec in ipairs(pluginSpecs) do + modulePaths[#modulePaths + 1] = spec.suiteModulePath + end + + return modulePaths +end + +function PluginE2EHelper.loadPluginDefinition(pluginId) + local spec = PluginE2EHelper.getPluginSpec(pluginId) + local manifest = require(spec.modulePath .. ".manifest") + local plugin = require(spec.modulePath) + + return { + spec = spec, + manifest = manifest, + plugin = plugin, + } +end + +function PluginE2EHelper.createPluginRecord(definition, optionOverrides, disabledOverride) + local manifest = definition.manifest + local pluginOptions = copyTable(manifest.opts or {}) + + for key, value in pairs(optionOverrides or {}) do + pluginOptions[key] = value + end + + return FeatherPluginManager.createPlugin( + definition.plugin, + manifest.id, + pluginOptions, + disabledOverride == nil and manifest.disabled or disabledOverride, + manifest.capabilities or {}, + { + api = manifest.api, + minApi = manifest.minApi, + maxApi = manifest.maxApi, + name = manifest.name, + version = manifest.version, + } + ) +end + +function PluginE2EHelper.createFeather(config) + return FeatherDebugger({ + debug = true, + mode = "disk", + sessionName = config.sessionName, + deviceId = config.deviceId, + assetPreview = config.assetPreview == true, + plugins = config.plugins, + debugger = { + enabled = false, + }, + }) +end + +function PluginE2EHelper.assertSmoke(context) + local feather = context.feather + local definition = context.definition + local assertEqual = context.assertEqual + local assertTruthy = context.assertTruthy + local pluginId = definition.manifest.id + local pluginRecord = feather.pluginManager:getPlugin(pluginId) + local config = feather:__getConfig() + + assertTruthy(pluginRecord, "plugin smoke suite registers " .. pluginId) + assertTruthy(pluginRecord.instance, "plugin smoke suite instantiates " .. pluginId) + assertTruthy(config.plugins[pluginId], "plugin smoke suite exposes " .. pluginId .. " in config") + assertEqual(pluginRecord.identifier, pluginId, "plugin smoke suite keeps identifier for " .. pluginId) + + return pluginRecord +end + +function PluginE2EHelper.createSmokeSuite(pluginId, options) + options = options or {} + + return { + id = pluginId, + run = function(assertEqual, assertTruthy) + local definition = PluginE2EHelper.loadPluginDefinition(pluginId) + local feather = PluginE2EHelper.createFeather({ + sessionName = options.sessionName or ("Plugin " .. pluginId .. " E2E"), + deviceId = options.deviceId or (("plugin-" .. pluginId .. "-e2e"):gsub("[^%w%-]", "-")), + assetPreview = options.assetPreview, + plugins = { + PluginE2EHelper.createPluginRecord(definition, options.pluginOptions, options.disabled ~= nil and options.disabled or false), + }, + }) + + local ok, result = xpcall(function() + local pluginRecord = PluginE2EHelper.assertSmoke({ + feather = feather, + definition = definition, + assertEqual = assertEqual, + assertTruthy = assertTruthy, + }) + + if options.run then + options.run({ + feather = feather, + definition = definition, + pluginRecord = pluginRecord, + assertEqual = assertEqual, + assertTruthy = assertTruthy, + }) + end + end, debug.traceback) + + feather:finish() + + if not ok then + error(result, 0) + end + end, + } +end + +return PluginE2EHelper diff --git a/src-lua/e2e/plugins/hot_reload.lua b/src-lua/e2e/plugins/hot_reload.lua new file mode 100644 index 00000000..a0176448 --- /dev/null +++ b/src-lua/e2e/plugins/hot_reload.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("hot-reload") diff --git a/src-lua/e2e/plugins/hump_signal.lua b/src-lua/e2e/plugins/hump_signal.lua new file mode 100644 index 00000000..2bec5ce1 --- /dev/null +++ b/src-lua/e2e/plugins/hump_signal.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("hump.signal") diff --git a/src-lua/e2e/plugins/ingame_overlay.lua b/src-lua/e2e/plugins/ingame_overlay.lua new file mode 100644 index 00000000..f5ee47d3 --- /dev/null +++ b/src-lua/e2e/plugins/ingame_overlay.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("ingame-overlay") diff --git a/src-lua/e2e/plugins/init.lua b/src-lua/e2e/plugins/init.lua new file mode 100644 index 00000000..bab6e9d7 --- /dev/null +++ b/src-lua/e2e/plugins/init.lua @@ -0,0 +1,47 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +local PluginE2ESuite = {} + +local function getSelectedPluginId() + local selected = os.getenv("FEATHER_E2E_PLUGIN") + if type(selected) == "string" and selected ~= "" then + return selected + end + + for _, value in ipairs(rawget(_G, "arg") or {}) do + local pluginId = value:match("^%-%-plugin%-e2e=(.+)$") + if pluginId and pluginId ~= "" then + return pluginId + end + end + + return nil +end + +function PluginE2ESuite.run(assertEqual, assertTruthy) + local selectedPluginId = getSelectedPluginId() + if selectedPluginId then + local ok, spec = pcall(PluginE2EHelper.getPluginSpec, selectedPluginId) + if not ok then + local orderedSuiteIds = {} + for _, pluginSpec in ipairs(PluginE2EHelper.getPluginSpecs()) do + orderedSuiteIds[#orderedSuiteIds + 1] = pluginSpec.id + end + error( + "Unknown plugin E2E selector '" .. selectedPluginId .. "'. Available: " .. table.concat(orderedSuiteIds, ", "), + 2 + ) + end + + local suite = require(spec.suiteModulePath) + suite.run(assertEqual, assertTruthy) + return + end + + for _, modulePath in ipairs(PluginE2EHelper.getSuiteModulePaths()) do + local suite = require(modulePath) + suite.run(assertEqual, assertTruthy) + end +end + +return PluginE2ESuite diff --git a/src-lua/e2e/plugins/input_replay.lua b/src-lua/e2e/plugins/input_replay.lua new file mode 100644 index 00000000..aaef7e5c --- /dev/null +++ b/src-lua/e2e/plugins/input_replay.lua @@ -0,0 +1,127 @@ +local FeatherDebugger = require("feather") +local FeatherPluginManager = require("feather.plugin_manager") +local InputReplayPlugin = require("plugins.input-replay") + +local InputReplaySuite = { + id = "input-replay", +} + +function InputReplaySuite.run(assertEqual, assertTruthy) + local originalReplayKeypressed = love.keypressed + local preReplayRecordCount = 0 + local lateRecordOverrideCount = 0 + local lateReplayOverrideCount = 0 + local originalKeyboardIsDown = love.keyboard and love.keyboard.isDown + local originalKeyboardIsScancodeDown = love.keyboard and love.keyboard.isScancodeDown + local originalMouseIsDown = love.mouse and love.mouse.isDown + local originalMouseGetPosition = love.mouse and love.mouse.getPosition + local originalMouseGetX = love.mouse and love.mouse.getX + local originalMouseGetY = love.mouse and love.mouse.getY + + love.keypressed = function() + preReplayRecordCount = preReplayRecordCount + 1 + end + + local inputReplayFeather = FeatherDebugger({ + debug = true, + mode = "disk", + sessionName = "Input Replay E2E", + deviceId = "input-replay-e2e", + assetPreview = false, + plugins = { + FeatherPluginManager.createPlugin(InputReplayPlugin, "input-replay", { + captureKeys = true, + captureMouse = false, + captureTouch = false, + captureJoystick = false, + }), + }, + debugger = { + enabled = false, + }, + }) + + local ok, result = xpcall(function() + local inputReplayPlugin = inputReplayFeather.pluginManager:getPlugin("input-replay") + local config = inputReplayFeather:__getConfig() + local replay = inputReplayPlugin and inputReplayPlugin.instance + + assertTruthy(inputReplayPlugin, "plugin smoke suite registers input-replay") + assertTruthy(replay, "plugin smoke suite instantiates input-replay") + assertTruthy(config.plugins["input-replay"], "plugin smoke suite exposes input-replay in config") + assertEqual(inputReplayPlugin.identifier, "input-replay", "plugin smoke suite keeps identifier for input-replay") + assertTruthy(replay, "input replay plugin instance is available") + + replay:startRecording() + love.keypressed("a", "a", false) + assertEqual(preReplayRecordCount, 1, "input replay recording preserves pre-hook keypressed override") + assertEqual(#replay.events, 1, "input replay records initial keypressed event") + + love.keypressed = function() + lateRecordOverrideCount = lateRecordOverrideCount + 1 + end + + inputReplayFeather:update(0) + love.keypressed("b", "b", false) + assertEqual(lateRecordOverrideCount, 1, "input replay recording survives late keypressed override") + assertEqual(#replay.events, 2, "input replay keeps recording after late keypressed override") + + replay:stopRecording() + replay.events = { + { time = 0, type = "keypressed", args = { "x", "x", false } }, + { time = 0, type = "keypressed", args = { "y", "y", false } }, + } + + love.keypressed = function() + lateReplayOverrideCount = lateReplayOverrideCount + 1 + end + + replay:startReplay() + inputReplayFeather:update(0) + assertEqual(lateReplayOverrideCount, 2, "input replay replays through late keypressed override") + assertEqual(replay.replaying, false, "input replay stops after queued replay events finish") + + replay.events = { + { time = 0, type = "keypressed", args = { "right", "d", false } }, + { time = 999, type = "keyreleased", args = { "right", "d" } }, + } + replay:startReplay() + inputReplayFeather:update(0) + assertEqual(love.keyboard.isDown("right"), true, "input replay exposes held keys to love.keyboard.isDown") + assertEqual(love.keyboard.isScancodeDown("d"), true, "input replay exposes held scancodes to love.keyboard.isScancodeDown") + replay:stopReplay() + + replay.events = { + { time = 0, type = "mousepressed", args = { 42, 64, 1, false, 1 } }, + { time = 999, type = "mousereleased", args = { 42, 64, 1, false, 1 } }, + } + replay:startReplay() + inputReplayFeather:update(0) + local mouseX, mouseY = love.mouse.getPosition() + assertEqual(love.mouse.isDown(1), true, "input replay exposes held mouse buttons to love.mouse.isDown") + assertEqual(mouseX, 42, "input replay exposes replay mouse x position") + assertEqual(mouseY, 64, "input replay exposes replay mouse y position") + assertEqual(love.mouse.getX(), 42, "input replay exposes replay mouse x getter") + assertEqual(love.mouse.getY(), 64, "input replay exposes replay mouse y getter") + replay:stopReplay() + end, debug.traceback) + + love.keypressed = originalReplayKeypressed + if love.keyboard then + love.keyboard.isDown = originalKeyboardIsDown + love.keyboard.isScancodeDown = originalKeyboardIsScancodeDown + end + if love.mouse then + love.mouse.isDown = originalMouseIsDown + love.mouse.getPosition = originalMouseGetPosition + love.mouse.getX = originalMouseGetX + love.mouse.getY = originalMouseGetY + end + inputReplayFeather:finish() + + if not ok then + error(result, 0) + end +end + +return InputReplaySuite diff --git a/src-lua/e2e/plugins/lua_state_machine.lua b/src-lua/e2e/plugins/lua_state_machine.lua new file mode 100644 index 00000000..e712ce3e --- /dev/null +++ b/src-lua/e2e/plugins/lua_state_machine.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("lua-state-machine") diff --git a/src-lua/e2e/plugins/memory_snapshot.lua b/src-lua/e2e/plugins/memory_snapshot.lua new file mode 100644 index 00000000..295932f0 --- /dev/null +++ b/src-lua/e2e/plugins/memory_snapshot.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("memory-snapshot") diff --git a/src-lua/e2e/plugins/network_inspector.lua b/src-lua/e2e/plugins/network_inspector.lua new file mode 100644 index 00000000..38c1f0d1 --- /dev/null +++ b/src-lua/e2e/plugins/network_inspector.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("network-inspector") diff --git a/src-lua/e2e/plugins/particle_editor.lua b/src-lua/e2e/plugins/particle_editor.lua new file mode 100644 index 00000000..a3aed15c --- /dev/null +++ b/src-lua/e2e/plugins/particle_editor.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("particle-editor") diff --git a/src-lua/e2e/plugins/particle_system_playground.lua b/src-lua/e2e/plugins/particle_system_playground.lua new file mode 100644 index 00000000..729b179b --- /dev/null +++ b/src-lua/e2e/plugins/particle_system_playground.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("particle-system-playground") diff --git a/src-lua/e2e/plugins/physics_debug.lua b/src-lua/e2e/plugins/physics_debug.lua new file mode 100644 index 00000000..17ba6a69 --- /dev/null +++ b/src-lua/e2e/plugins/physics_debug.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("physics-debug") diff --git a/src-lua/e2e/plugins/profiler.lua b/src-lua/e2e/plugins/profiler.lua new file mode 100644 index 00000000..4f0365c1 --- /dev/null +++ b/src-lua/e2e/plugins/profiler.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("profiler") diff --git a/src-lua/e2e/plugins/runtime_snapshot.lua b/src-lua/e2e/plugins/runtime_snapshot.lua new file mode 100644 index 00000000..c73a0fc7 --- /dev/null +++ b/src-lua/e2e/plugins/runtime_snapshot.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("runtime-snapshot") diff --git a/src-lua/e2e/plugins/screenshots.lua b/src-lua/e2e/plugins/screenshots.lua new file mode 100644 index 00000000..b84b40e6 --- /dev/null +++ b/src-lua/e2e/plugins/screenshots.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("screenshots") diff --git a/src-lua/e2e/plugins/session_replay.lua b/src-lua/e2e/plugins/session_replay.lua new file mode 100644 index 00000000..458b9ad1 --- /dev/null +++ b/src-lua/e2e/plugins/session_replay.lua @@ -0,0 +1,84 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("session-replay", { + run = function(context) + local replay = context.pluginRecord.instance + local assertEqual = context.assertEqual + local assertTruthy = context.assertTruthy + local restored = {} + local player = { x = 1, y = 2 } + + assertTruthy(replay, "session replay plugin instance is available") + + replay:registerState("player", function() + return { x = player.x, y = player.y } + end, function(state) + restored[#restored + 1] = state + player.x = state.x + player.y = state.y + end) + + replay:startRecording({ id = "session-replay-e2e" }) + assertTruthy( + love.filesystem.getInfo("feather_replays/session-replay-e2e/inputs.jsonl"), + "session replay pre-creates the input stream when recording starts" + ) + assertTruthy( + love.filesystem.getInfo("feather_replays/session-replay-e2e/state-0001.jsonl"), + "session replay pre-creates the state stream when recording starts" + ) + assertEqual(#replay.checkpoints, 1, "session replay only creates the initial checkpoint by default") + replay:recordState("player", { x = 1, y = 2 }, { keyframe = true }) + replay:recordState("player", { x = 1, y = 2 }) + player.x = 5 + player.y = 6 + local checkpointId = replay:recordCheckpoint("midpoint") + assertTruthy(checkpointId, "session replay creates a manual checkpoint") + assertTruthy( + love.filesystem.getInfo("feather_replays/session-replay-e2e/checkpoints.jsonl"), + "session replay pre-creates the checkpoint index" + ) + assertTruthy( + love.filesystem.getInfo("feather_replays/session-replay-e2e/checkpoints/" .. checkpointId .. ".json"), + "session replay writes checkpoint state files" + ) + player.x = 3 + player.y = 4 + replay:recordState("player", { x = 3, y = 4 }) + love.keypressed("space", "space", false) + replay:stopRecording() + + assertEqual(#replay.stateEvents, 2, "session replay stores sparse state deltas") + assertEqual(#replay.initialStates, 1, "session replay captures an initial baseline state") + assertEqual(#replay.checkpoints, 2, "session replay tracks initial and manual checkpoints") + assertEqual(#replay.inputEvents, 1, "session replay records input events") + + player.x = 99 + player.y = 99 + local ok = replay:startReplay("feather_replays/session-replay-e2e") + assertEqual(ok, true, "session replay starts from saved files") + assertEqual(player.x, 1, "session replay restores the initial baseline before playback") + local blockedId = replay:startRecording({ id = "should-not-start" }) + assertEqual(blockedId, nil, "session replay does not start a new recording while replaying") + assertEqual(replay.recording, false, "session replay stays out of recording mode during playback") + replay:stopReplay() + player.x = 99 + player.y = 99 + local seekOk = replay:seekReplay(checkpointId) + assertEqual(seekOk, true, "session replay can seek to a manual checkpoint") + assertEqual(player.x, 5, "session replay restores checkpoint state when seeking") + seekOk = replay:seekReplay(0) + assertEqual(seekOk, true, "session replay can seek to the initial checkpoint") + assertEqual(player.x, 1, "session replay restores initial checkpoint when seeking to zero") + seekOk = replay:seekReplay(999) + assertEqual(seekOk, true, "session replay can seek forward from the nearest checkpoint") + assertEqual(player.x, 3, "session replay fast-forwards state events after seeking") + replay:startReplay("feather_replays/session-replay-e2e") + replay:update(0.016, context.feather) + replay.replayStart = replay.replayStart - 10 + replay:update(0.016, context.feather) + + assertTruthy(#restored >= 3, "session replay restores initial baseline, checkpoints, and recorded state events") + assertEqual(player.x, 3, "session replay applies latest restored state") + end, +}) diff --git a/src-lua/e2e/plugins/shader_graph.lua b/src-lua/e2e/plugins/shader_graph.lua new file mode 100644 index 00000000..24130eee --- /dev/null +++ b/src-lua/e2e/plugins/shader_graph.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("shader-graph") diff --git a/src-lua/e2e/plugins/smoke.lua b/src-lua/e2e/plugins/smoke.lua new file mode 100644 index 00000000..28181143 --- /dev/null +++ b/src-lua/e2e/plugins/smoke.lua @@ -0,0 +1 @@ +return require("e2e.plugins") diff --git a/src-lua/e2e/plugins/time_travel.lua b/src-lua/e2e/plugins/time_travel.lua new file mode 100644 index 00000000..bc35b580 --- /dev/null +++ b/src-lua/e2e/plugins/time_travel.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("time-travel") diff --git a/src-lua/e2e/plugins/timer_inspector.lua b/src-lua/e2e/plugins/timer_inspector.lua new file mode 100644 index 00000000..16c69697 --- /dev/null +++ b/src-lua/e2e/plugins/timer_inspector.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("timer-inspector") diff --git a/src-lua/example/README.md b/src-lua/example/README.md index 692f43ac..b67c3482 100644 --- a/src-lua/example/README.md +++ b/src-lua/example/README.md @@ -32,6 +32,8 @@ love src-lua --hot-reload Open Feather's **Debugger** tab, select `example/hot_reload/gameplay.lua`, edit that file, then press **Reload** or enable **Watch**. +The Session Replay examples live under `src-lua/example/session_replay/` and demonstrate different integration styles. + The Lua E2E example is meant for automation. It runs assertions through LÖVE and exits on its own: ```bash diff --git a/src-lua/example/e2e/main.lua b/src-lua/example/e2e/main.lua index ddc771bd..3f132305 100644 --- a/src-lua/example/e2e/main.lua +++ b/src-lua/example/e2e/main.lua @@ -3,6 +3,7 @@ local Class = require("feather.lib.class") local FeatherPluginBase = require("feather.core.base") local FeatherPluginManager = require("feather.plugin_manager") local HotReloadPlugin = require("plugins.hot-reload") +local PluginE2ESuite = require("e2e.plugins") local E2EPlugin = Class({ __includes = FeatherPluginBase, @@ -40,6 +41,96 @@ local function assertTruthy(value, name) record(name) end +local function assertArrayEqual(actual, expected, name) + if #actual ~= #expected then + error(string.format("%s: expected %d values, got %d", name, #expected, #actual), 2) + end + + for index = 1, #expected do + if actual[index] ~= expected[index] then + error( + string.format("%s: expected [%s], got [%s]", name, table.concat(expected, ", "), table.concat(actual, ", ")), + 2 + ) + end + end + + record(name) +end + +local callbackOrder = {} +local stressHookCount = 0 +local stressHookSum = 0 +local stressKeyCount = 0 +local stressKeySum = 0 +local stressMouseMoveCount = 0 +local stressMouseMoveSum = 0 + +local OrderedPlugin = Class({ + __includes = FeatherPluginBase, +}) + +function OrderedPlugin:init(config) + FeatherPluginBase.init(self, config) + + if self.options.mode == "bus" then + config.callbacks.register("draw", function() + callbackOrder[#callbackOrder + 1] = self.options.label + end, self.options.priority ~= nil and { priority = self.options.priority } or nil) + end +end + +function OrderedPlugin:onDraw() + if self.options.mode == "legacy" then + callbackOrder[#callbackOrder + 1] = self.options.label + end +end + +local StressPlugin = Class({ + __includes = FeatherPluginBase, +}) + +function StressPlugin:init(config) + FeatherPluginBase.init(self, config) + + config.callbacks.register("draw", function() + stressHookCount = stressHookCount + 1 + stressHookSum = stressHookSum + self.options.index + end) + + config.callbacks.register("keypressed", function() + stressKeyCount = stressKeyCount + 1 + stressKeySum = stressKeySum + self.options.index + end) + + config.callbacks.register("mousemoved", function() + stressMouseMoveCount = stressMouseMoveCount + 1 + stressMouseMoveSum = stressMouseMoveSum + self.options.index + end) +end + +local function resetCallbackOrder() + for index = #callbackOrder, 1, -1 do + callbackOrder[index] = nil + end +end + +local function resetStressHooks() + stressHookCount = 0 + stressHookSum = 0 + stressKeyCount = 0 + stressKeySum = 0 + stressMouseMoveCount = 0 + stressMouseMoveSum = 0 +end + +local function assertDrawOrder(feather, expected, name) + resetCallbackOrder() + love.draw() + assertArrayEqual(callbackOrder, expected, name) + feather:finish() +end + local function run() local feather = FeatherDebugger({ debug = true, @@ -84,6 +175,11 @@ local function run() assertTruthy(feather, "feather creates debugger instance") assertTruthy(feather.hotReloader, "hot reloader is available") local helloConfig = feather:__getConfig() + local envGamePath = os.getenv("FEATHER_GAME_PATH") + if envGamePath and #envGamePath > 0 then + assertEqual(helloConfig.root_path, envGamePath, "CLI run config root_path uses real game path") + assertEqual(helloConfig.sourceDir, envGamePath, "CLI run config sourceDir uses real game path") + end assertEqual(helloConfig.debugger.hotReload.enabled, true, "hello config includes hot reload state") assertEqual(helloConfig.plugins["api-compatible"].incompatible, false, "compatible plugin remains available") assertEqual(helloConfig.plugins["api-compatible"].disabled, false, "compatible plugin remains enabled") @@ -148,6 +244,260 @@ return M feather:finish() + local fifoFeather = FeatherDebugger({ + debug = true, + mode = "disk", + sessionName = "Callback FIFO E2E", + deviceId = "callback-fifo-e2e", + assetPreview = false, + plugins = { + FeatherPluginManager.createPlugin(OrderedPlugin, "fifo-a", { mode = "bus", label = "A" }), + FeatherPluginManager.createPlugin(OrderedPlugin, "fifo-b", { mode = "bus", label = "B" }), + FeatherPluginManager.createPlugin(OrderedPlugin, "fifo-c", { mode = "bus", label = "C" }), + }, + debugger = { + enabled = false, + }, + }) + + assertDrawOrder(fifoFeather, { "A", "B", "C" }, "callback bus preserves FIFO order by default") + + local mixedPriorityFeather = FeatherDebugger({ + debug = true, + mode = "disk", + sessionName = "Callback Priority E2E", + deviceId = "callback-priority-e2e", + assetPreview = false, + plugins = { + FeatherPluginManager.createPlugin(OrderedPlugin, "mixed-a", { mode = "legacy", label = "A" }), + FeatherPluginManager.createPlugin(OrderedPlugin, "mixed-b", { mode = "bus", label = "B" }), + FeatherPluginManager.createPlugin(OrderedPlugin, "mixed-c", { + mode = "bus", + label = "C", + priority = 1000, + }), + FeatherPluginManager.createPlugin(OrderedPlugin, "mixed-d", { mode = "legacy", label = "D" }), + }, + debugger = { + enabled = false, + }, + }) + + resetCallbackOrder() + love.draw() + assertArrayEqual(callbackOrder, { "A", "B", "D", "C" }, "priority runs after default FIFO callbacks when larger") + + mixedPriorityFeather.pluginManager:disablePlugin("mixed-b") + resetCallbackOrder() + love.draw() + assertArrayEqual(callbackOrder, { "A", "D", "C" }, "disabled plugins stop scoped callback bus handlers") + mixedPriorityFeather:finish() + + local equalPriorityFeather = FeatherDebugger({ + debug = true, + mode = "disk", + sessionName = "Callback Stable E2E", + deviceId = "callback-stable-e2e", + assetPreview = false, + plugins = { + FeatherPluginManager.createPlugin(OrderedPlugin, "stable-a", { mode = "bus", label = "A", priority = 25 }), + FeatherPluginManager.createPlugin(OrderedPlugin, "stable-b", { mode = "bus", label = "B", priority = 25 }), + FeatherPluginManager.createPlugin(OrderedPlugin, "stable-c", { mode = "bus", label = "C", priority = 25 }), + }, + debugger = { + enabled = false, + }, + }) + + assertDrawOrder(equalPriorityFeather, { "A", "B", "C" }, "equal priorities preserve FIFO order") + + local pluginCount = 1000 + local expectedHookSum = (pluginCount * (pluginCount + 1)) / 2 + local stressPlugins = {} + local originalDraw = love.draw + local originalKeypressed = love.keypressed + local originalMousemoved = love.mousemoved + local beforeOverrideCount = 0 + local afterOverrideCount = 0 + local repeatedOverrideCount = 0 + local beforeKeyOverrideCount = 0 + local afterKeyOverrideCount = 0 + local repeatedKeyOverrideCount = 0 + local beforeMouseMoveOverrideCount = 0 + + love.draw = function() + beforeOverrideCount = beforeOverrideCount + 1 + end + + love.keypressed = function() + beforeKeyOverrideCount = beforeKeyOverrideCount + 1 + end + + love.mousemoved = function() + beforeMouseMoveOverrideCount = beforeMouseMoveOverrideCount + 1 + end + + for index = 1, pluginCount do + stressPlugins[index] = FeatherPluginManager.createPlugin(StressPlugin, "stress-" .. tostring(index), { + index = index, + }) + end + + local stressFeather = FeatherDebugger({ + debug = true, + mode = "disk", + sessionName = "Callback Stress E2E", + deviceId = "callback-stress-e2e", + assetPreview = false, + plugins = stressPlugins, + debugger = { + enabled = false, + }, + }) + + resetStressHooks() + love.draw() + assertEqual(beforeOverrideCount, 1, "external draw override registered before Feather still runs") + assertEqual( + stressHookCount, + pluginCount, + "all 1000 plugins receive draw when external override existed before Feather" + ) + assertEqual(stressHookSum, expectedHookSum, "all 1000 plugin draw hooks run exactly once with pre-Feather override") + love.keypressed("space", "space", false) + assertEqual(beforeKeyOverrideCount, 1, "external keypressed override registered before Feather still runs") + assertEqual( + stressKeyCount, + pluginCount, + "all 1000 plugins receive keypressed when external override existed before Feather" + ) + assertEqual( + stressKeySum, + expectedHookSum, + "all 1000 plugin keypressed hooks run exactly once with pre-Feather override" + ) + love.mousemoved(10, 20, 1, 2, false) + assertEqual(beforeMouseMoveOverrideCount, 1, "external mousemoved override registered before Feather still runs") + assertEqual( + stressMouseMoveCount, + pluginCount, + "all 1000 plugins receive mousemoved when external override existed before Feather" + ) + assertEqual( + stressMouseMoveSum, + expectedHookSum, + "all 1000 plugin mousemoved hooks run exactly once with pre-Feather override" + ) + + love.draw = function() + afterOverrideCount = afterOverrideCount + 1 + end + + love.keypressed = function() + afterKeyOverrideCount = afterKeyOverrideCount + 1 + end + + resetStressHooks() + stressFeather:update(0) + love.draw() + assertEqual(afterOverrideCount, 1, "external draw override registered after Feather still runs after rehook") + assertEqual( + stressHookCount, + pluginCount, + "all 1000 plugins receive draw after external override replaces Feather wrapper" + ) + assertEqual(stressHookSum, expectedHookSum, "all 1000 plugin draw hooks run exactly once after rehook") + love.keypressed("space", "space", false) + assertEqual(afterKeyOverrideCount, 1, "external keypressed override registered after Feather still runs after rehook") + assertEqual( + stressKeyCount, + pluginCount, + "all 1000 plugins receive keypressed after external override replaces Feather wrapper" + ) + assertEqual(stressKeySum, expectedHookSum, "all 1000 plugin keypressed hooks run exactly once after rehook") + + love.draw = function() + repeatedOverrideCount = repeatedOverrideCount + 1 + end + + love.keypressed = function() + repeatedKeyOverrideCount = repeatedKeyOverrideCount + 1 + end + + resetStressHooks() + stressFeather:update(0) + love.draw() + love.keypressed("space", "space", false) + assertEqual(repeatedOverrideCount, 1, "second external draw override still runs after another rehook") + assertEqual( + stressHookCount, + pluginCount, + "all 1000 plugins still receive draw after multiple external override replacements" + ) + assertEqual( + stressHookSum, + expectedHookSum, + "all 1000 plugin draw hooks still run exactly once after multiple rehooks" + ) + assertEqual(repeatedKeyOverrideCount, 1, "second external keypressed override still runs after another rehook") + assertEqual( + stressKeyCount, + pluginCount, + "all 1000 plugins still receive keypressed after multiple external override replacements" + ) + assertEqual( + stressKeySum, + expectedHookSum, + "all 1000 plugin keypressed hooks still run exactly once after multiple rehooks" + ) + + stressFeather:finish() + love.draw = originalDraw + love.keypressed = originalKeypressed + love.mousemoved = originalMousemoved + + local assetDrawCount = 0 + local assetHookCount = 0 + local AssetHookPlugin = Class({ + __includes = FeatherPluginBase, + init = function(self, config) + FeatherPluginBase.init(self, config) + config.callbacks.register("draw", function() + assetHookCount = assetHookCount + 1 + end) + end, + }) + + love.draw = function() + assetDrawCount = assetDrawCount + 1 + end + + local assetFeather = FeatherDebugger({ + debug = true, + mode = "disk", + sessionName = "Callback Asset Draw E2E", + deviceId = "callback-asset-draw-e2e", + assetPreview = true, + plugins = { + FeatherPluginManager.createPlugin(AssetHookPlugin, "asset-draw", {}), + }, + debugger = { + enabled = false, + }, + }) + + love.draw() + assetFeather:update(0) + love.draw() + assetFeather:update(0) + love.draw() + assertEqual(assetDrawCount, 3, "asset preview draw wrapper preserves game draw across rehooks") + assertEqual(assetHookCount, 3, "asset preview draw wrapper preserves callback bus draw across rehooks") + assetFeather:finish() + love.draw = originalDraw + + PluginE2ESuite.run(assertEqual, assertTruthy) + -- ── Auth handshake state machine ─────────────────────────────────────────── -- Test the challenge-response logic in isolation. We use a disk-mode instance -- so no real WS server is needed; __sendWs is a no-op when wsConnected=false. diff --git a/src-lua/example/session_replay/README.md b/src-lua/example/session_replay/README.md new file mode 100644 index 00000000..d36ef09d --- /dev/null +++ b/src-lua/example/session_replay/README.md @@ -0,0 +1,48 @@ +# Session Replay Examples + +These are CLI-managed LÖVE projects. Run them from the repository root: + +```bash +npm run feather -- run src-lua/example/session_replay/single_player +npm run feather -- run src-lua/example/session_replay/multiplayer +npm run feather -- run src-lua/example/session_replay/adapter +``` + +- `single_player/` shows direct `DEBUGGER:replayRegister()` usage with a reproducible scene and an intentionally divergent scene. +- `multiplayer/` shows a seeded arena, local multiplayer input, gamepad axis capture, and mutable pickup ownership. +- `adapter/` shows the recommended Replay Adapter shape: `main.lua` talks to `dev/replay.lua`, and the adapter delegates capture/restore to game systems. + +## Reproducible vs Divergent Scenarios + +The single-player example demonstrates a game-defined capture and restore handler: + +```bash +npm run feather -- run src-lua/example/session_replay/single_player +``` + +Open Feather's **Session Replay** page and use **Start Recording**, **Stop & Load**, and **Replay**. You can also use `F5` to record, `F6` to stop and load, and `F7` to replay from inside the example. + +Press `Tab` inside the single-player example to switch between two scenes: + +- `reproducible_scene.lua` captures enough checkpoint state to replay from a stable baseline. +- `divergent_scene.lua` intentionally omits a moving hazard from its checkpoint state, showing how replay can drift when recording starts after the game has already been running or when simulation state is not restored. + +The reproducible scene relies on Session Replay's initial baseline capture: when recording starts, the registered `game` state is stored once, then later playback restores that baseline before applying input and state deltas. + +## Multiplayer & Roguelike (RNG) based Scenarios + +There is also a local multiplayer variant with two players and optional gamepad-axis capture for player two: + +```bash +npm run feather -- run src-lua/example/session_replay/multiplayer +``` + +The multiplayer example also stores an initial `multiplayer` baseline when recording starts from `F5`, so both players, scores, gem ownership, and gamepad axis state are restored before replayed inputs run. Its arena is generated from a replayed seed, which mirrors the pattern many roguelikes use: restore the seed for fixed generated content, then replay only the mutable state that changed. + +## Minimizing Feather specific code + +The adapter example shows the recommended integration shape: gameplay code requires `dev.replay`, while all capture/restore and Feather-specific calls stay inside one adapter file. + +```bash +npm run feather -- run src-lua/example/session_replay/adapter +``` diff --git a/src-lua/example/session_replay/adapter/conf.lua b/src-lua/example/session_replay/adapter/conf.lua new file mode 100644 index 00000000..aef74f5d --- /dev/null +++ b/src-lua/example/session_replay/adapter/conf.lua @@ -0,0 +1,6 @@ +function love.conf(t) + t.window.title = "Feather Session Replay Adapter Example" + t.window.width = 900 + t.window.height = 620 + t.console = false +end diff --git a/src-lua/example/session_replay/adapter/dev/replay.lua b/src-lua/example/session_replay/adapter/dev/replay.lua new file mode 100644 index 00000000..b15fc817 --- /dev/null +++ b/src-lua/example/session_replay/adapter/dev/replay.lua @@ -0,0 +1,50 @@ +local game = require("game_state") + +local M = {} + +local STREAM = "adapter_demo" + +function M.register(debugger) + debugger = debugger or rawget(_G, "DEBUGGER") + if not (debugger and debugger.replayRegister) then + return false + end + + debugger:replayRegister(STREAM, game.captureReplayState, game.restoreReplayState, { + sampleInterval = 0.1, + }) + return true +end + +function M.start(debugger, opts) + debugger = debugger or rawget(_G, "DEBUGGER") + if not (debugger and debugger.startSessionReplay) then + return nil, "DEBUGGER is not available" + end + + opts = opts or {} + opts.initialStates = opts.initialStates or {} + opts.initialStates[STREAM] = opts.initialStates[STREAM] or game.captureReplayState() + return debugger:startSessionReplay(opts) +end + +function M.stop(debugger) + debugger = debugger or rawget(_G, "DEBUGGER") + if not (debugger and debugger.stopSessionReplay) then + return nil + end + return debugger:stopSessionReplay() +end + +function M.play(debugger, idOrPath) + debugger = debugger or rawget(_G, "DEBUGGER") + if not (debugger and debugger.playSessionReplay) then + return nil + end + return debugger:playSessionReplay(idOrPath) +end + +M.capture = game.captureReplayState +M.restore = game.restoreReplayState + +return M diff --git a/src-lua/example/session_replay/adapter/feather.config.lua b/src-lua/example/session_replay/adapter/feather.config.lua new file mode 100644 index 00000000..646a03bf --- /dev/null +++ b/src-lua/example/session_replay/adapter/feather.config.lua @@ -0,0 +1,19 @@ +-- CLI-managed Session Replay adapter example. +-- Run from the repo root: +-- npm run feather -- run src-lua/example/session_replay/adapter + +return { + sessionName = "Session Replay Adapter", + deviceId = "session-replay-adapter", + include = { "session-replay" }, + sampleRate = 0.25, + captureScreenshot = false, + defaultObservers = true, + autoRegisterErrorHandler = true, + pluginOptions = { + ["session-replay"] = { + keyframeInterval = 2, + }, + }, + __DANGEROUS_INSECURE_CONNECTION__ = true, +} diff --git a/src-lua/example/session_replay/adapter/game_state.lua b/src-lua/example/session_replay/adapter/game_state.lua new file mode 100644 index 00000000..3578de93 --- /dev/null +++ b/src-lua/example/session_replay/adapter/game_state.lua @@ -0,0 +1,260 @@ +local M = {} + +local ARENA = { + width = 900, + height = 620, + top = 86, +} + +M.player = { + x = 130, + y = 320, + score = 0, + speed = 230, +} + +M.world = { + seed = 91827, + elapsed = 0, + message = "Move with WASD or arrows. Record with F5.", + shards = {}, + blocks = {}, + events = {}, +} + +local function setColor(color) + love.graphics.setColor(color[1], color[2], color[3], color[4] or 1) +end + +local function nextSeed(seed) + return (seed * 1664525 + 1013904223) % 2147483647 +end + +local function distanceSq(x1, y1, x2, y2) + local dx = x1 - x2 + local dy = y1 - y2 + return dx * dx + dy * dy +end + +local function collidesWithBlock(x, y, radius) + for _, block in ipairs(M.world.blocks) do + local closestX = math.max(block.x, math.min(x, block.x + block.w)) + local closestY = math.max(block.y, math.min(y, block.y + block.h)) + if distanceSq(x, y, closestX, closestY) < radius * radius then + return true + end + end + return false +end + +local function generateWorld(seed) + local rng = love.math.newRandomGenerator(seed) + M.world.blocks = {} + M.world.shards = {} + + for i = 1, 4 do + local w = rng:random(70, 130) + local h = rng:random(30, 62) + M.world.blocks[i] = { + x = rng:random(190, ARENA.width - 190 - w), + y = rng:random(ARENA.top + 40, ARENA.height - 85 - h), + w = w, + h = h, + } + end + + for i = 1, 7 do + local shard + for _ = 1, 40 do + local x = rng:random(110, ARENA.width - 110) + local y = rng:random(ARENA.top + 35, ARENA.height - 55) + if distanceSq(x, y, 130, 320) > 120 * 120 and not collidesWithBlock(x, y, 26) then + shard = { x = x, y = y, taken = false } + break + end + end + M.world.shards[i] = shard or { x = 140 + i * 88, y = 160 + (i % 3) * 120, taken = false } + end +end + +function M.addEvent(message) + table.insert(M.world.events, 1, string.format("%05.2f %s", M.world.elapsed, message)) + if #M.world.events > 8 then + M.world.events[9] = nil + end + print("[Session Replay Adapter] " .. message) +end + +function M.reset(reason, seed) + M.player.x = 130 + M.player.y = 320 + M.player.score = 0 + M.world.elapsed = 0 + M.world.seed = seed or nextSeed(M.world.seed) + M.world.message = string.format("%s seed=%d", reason or "Reset", M.world.seed) + generateWorld(M.world.seed) +end + +function M.captureReplayState() + local shards = {} + for i, shard in ipairs(M.world.shards) do + shards[i] = { + x = shard.x, + y = shard.y, + taken = shard.taken, + } + end + + return { + world = { + seed = M.world.seed, + elapsed = M.world.elapsed, + message = M.world.message, + shards = shards, + }, + player = { + x = M.player.x, + y = M.player.y, + score = M.player.score, + }, + } +end + +function M.restoreReplayState(state) + if not state then + return + end + + if state.world then + if state.world.seed and state.world.seed ~= M.world.seed then + M.world.seed = state.world.seed + generateWorld(M.world.seed) + end + M.world.elapsed = state.world.elapsed or M.world.elapsed + M.world.message = state.world.message or M.world.message + for i, shardState in ipairs(state.world.shards or {}) do + if M.world.shards[i] then + M.world.shards[i].taken = shardState.taken == true + end + end + end + + if state.player then + M.player.x = state.player.x or M.player.x + M.player.y = state.player.y or M.player.y + M.player.score = state.player.score or M.player.score + end +end + +local function movePlayer(dt) + local dx, dy = 0, 0 + if love.keyboard.isDown("right", "d") then + dx = dx + 1 + end + if love.keyboard.isDown("left", "a") then + dx = dx - 1 + end + if love.keyboard.isDown("down", "s") then + dy = dy + 1 + end + if love.keyboard.isDown("up", "w") then + dy = dy - 1 + end + + if dx ~= 0 or dy ~= 0 then + local length = math.sqrt(dx * dx + dy * dy) + local nextX = M.player.x + (dx / length) * M.player.speed * dt + local nextY = M.player.y + (dy / length) * M.player.speed * dt + if not collidesWithBlock(nextX, nextY, 22) then + M.player.x = nextX + M.player.y = nextY + end + end + + M.player.x = math.max(28, math.min(love.graphics.getWidth() - 28, M.player.x)) + M.player.y = math.max(ARENA.top + 12, math.min(love.graphics.getHeight() - 28, M.player.y)) +end + +local function updateShards() + for _, shard in ipairs(M.world.shards) do + if not shard.taken and distanceSq(M.player.x, M.player.y, shard.x, shard.y) < 32 * 32 then + shard.taken = true + M.player.score = M.player.score + 1 + M.world.message = "Collected shard " .. tostring(M.player.score) + M.addEvent(M.world.message) + end + end +end + +function M.update(dt) + M.world.elapsed = M.world.elapsed + dt + movePlayer(dt) + updateShards() + + if DEBUGGER then + DEBUGGER:observe("session_replay_adapter.seed", M.world.seed) + DEBUGGER:observe("session_replay_adapter.score", M.player.score) + DEBUGGER:observe("session_replay_adapter.message", M.world.message) + end +end + +local function drawShard(shard) + love.graphics.push() + love.graphics.translate(shard.x, shard.y) + love.graphics.rotate(math.pi / 4) + setColor(shard.taken and { 0.18, 0.2, 0.25, 0.45 } or { 0.9, 0.82, 0.28 }) + love.graphics.rectangle("fill", -10, -10, 20, 20, 4) + love.graphics.setColor(1, 1, 1, shard.taken and 0.1 or 0.5) + love.graphics.rectangle("line", -10, -10, 20, 20, 4) + love.graphics.pop() +end + +local function drawBlock(block) + love.graphics.setColor(0.23, 0.28, 0.35) + love.graphics.rectangle("fill", block.x, block.y, block.w, block.h, 6) + love.graphics.setColor(0.55, 0.64, 0.72, 0.35) + love.graphics.rectangle("line", block.x, block.y, block.w, block.h, 6) +end + +function M.draw() + love.graphics.clear(0.08, 0.1, 0.13) + love.graphics.setColor(0.12, 0.15, 0.19) + love.graphics.rectangle("fill", 0, 76, love.graphics.getWidth(), love.graphics.getHeight() - 76) + + love.graphics.setColor(1, 1, 1) + love.graphics.print("Session Replay Adapter Example", 18, 16) + love.graphics.setColor(0.72, 0.78, 0.86) + love.graphics.print("F5 record F6 stop/load F7 replay R new seed Esc quit", 18, 40) + love.graphics.print("All replay-specific glue lives in dev/replay.lua", 480, 40) + + for _, block in ipairs(M.world.blocks) do + drawBlock(block) + end + for _, shard in ipairs(M.world.shards) do + drawShard(shard) + end + + love.graphics.setColor(0.35, 0.78, 1) + love.graphics.circle("fill", M.player.x, M.player.y, 22) + love.graphics.setColor(1, 1, 1, 0.85) + love.graphics.circle("line", M.player.x, M.player.y, 22) + + love.graphics.setColor(1, 1, 1) + love.graphics.print("score: " .. tostring(M.player.score), 18, 104) + love.graphics.print("seed: " .. tostring(M.world.seed), 18, 126) + love.graphics.print("stream: adapter_demo", 18, 148) + love.graphics.print(M.world.message, 18, 170) + + local status = DEBUGGER and "connected to Feather runtime" or "DEBUGGER not available" + setColor(DEBUGGER and { 0.35, 1, 0.58 } or { 1, 0.36, 0.36 }) + love.graphics.print(status, 18, love.graphics.getHeight() - 32) + + love.graphics.setColor(0.72, 0.78, 0.86) + local y = 220 + love.graphics.print("Recent events", love.graphics.getWidth() - 320, y) + for i, line in ipairs(M.world.events) do + love.graphics.print(line, love.graphics.getWidth() - 320, y + 22 * i) + end +end + +return M diff --git a/src-lua/example/session_replay/adapter/main.lua b/src-lua/example/session_replay/adapter/main.lua new file mode 100644 index 00000000..1a5a2013 --- /dev/null +++ b/src-lua/example/session_replay/adapter/main.lua @@ -0,0 +1,39 @@ +local game = require("game_state") +local replay = require("dev.replay") + +function love.load() + love.graphics.setFont(love.graphics.newFont(14)) + game.reset("Adapter demo ready", game.world.seed) + replay.register() + game.addEvent("Replay adapter registered from dev/replay.lua") +end + +function love.update(dt) + game.update(dt) +end + +function love.keypressed(key) + if key == "f5" then + local id, err = replay.start() + game.addEvent(id and "Recording started with adapter baseline" or ("Could not start recording: " .. tostring(err))) + elseif key == "f6" then + replay.stop() + game.addEvent("Recording stopped and loaded") + elseif key == "f7" then + game.reset("Replay baseline changed before playback") + replay.play() + game.addEvent("Replay started through adapter") + elseif key == "r" then + game.reset("New seeded run") + game.addEvent("Manual reset") + elseif key == "escape" then + if DEBUGGER then + DEBUGGER:finish() + end + love.event.quit() + end +end + +function love.draw() + game.draw() +end diff --git a/src-lua/example/session_replay/multiplayer/conf.lua b/src-lua/example/session_replay/multiplayer/conf.lua new file mode 100644 index 00000000..3e5d7ffc --- /dev/null +++ b/src-lua/example/session_replay/multiplayer/conf.lua @@ -0,0 +1,6 @@ +function love.conf(t) + t.window.title = "Feather Session Replay Multiplayer Example" + t.window.width = 960 + t.window.height = 640 + t.console = false +end diff --git a/src-lua/example/session_replay/multiplayer/feather.config.lua b/src-lua/example/session_replay/multiplayer/feather.config.lua new file mode 100644 index 00000000..26046571 --- /dev/null +++ b/src-lua/example/session_replay/multiplayer/feather.config.lua @@ -0,0 +1,20 @@ +-- CLI-managed Session Replay multiplayer example. +-- Run from the repo root: +-- npm run feather -- run src-lua/example/session_replay/multiplayer + +return { + sessionName = "Session Replay Multiplayer", + deviceId = "session-replay-multiplayer", + include = { "session-replay" }, + sampleRate = 0.25, + captureScreenshot = false, + defaultObservers = true, + autoRegisterErrorHandler = true, + pluginOptions = { + ["session-replay"] = { + captureJoystickAxis = true, + keyframeInterval = 2, + }, + }, + __DANGEROUS_INSECURE_CONNECTION__ = true, +} diff --git a/src-lua/example/session_replay/multiplayer/main.lua b/src-lua/example/session_replay/multiplayer/main.lua new file mode 100644 index 00000000..8afbf6ac --- /dev/null +++ b/src-lua/example/session_replay/multiplayer/main.lua @@ -0,0 +1,399 @@ +local players = { + { + id = "p1", + name = "Player 1", + x = 150, + y = 320, + score = 0, + color = { 0.2, 0.72, 1 }, + keys = { up = "w", down = "s", left = "a", right = "d" }, + }, + { + id = "p2", + name = "Player 2", + x = 810, + y = 320, + score = 0, + color = { 1, 0.48, 0.22 }, + keys = { up = "up", down = "down", left = "left", right = "right" }, + padX = 0, + padY = 0, + }, +} + +local ARENA = { + width = 900, + height = 620, + top = 92, +} + +local world = { + elapsed = 0, + round = 1, + seed = 73419, + message = "P1: WASD. P2: arrows or first gamepad left stick.", + gems = {}, + obstacles = {}, + events = {}, +} + +local function setColor(color) + love.graphics.setColor(color[1], color[2], color[3], color[4] or 1) +end + +local function addEvent(message) + table.insert(world.events, 1, string.format("%05.2f %s", world.elapsed, message)) + if #world.events > 9 then + world.events[10] = nil + end + print("[Session Replay Multiplayer] " .. message) +end + +local function nextSeed(seed) + return (seed * 1103515245 + 12345) % 2147483647 +end + +local function distanceSq(x1, y1, x2, y2) + local dx = x1 - x2 + local dy = y1 - y2 + return dx * dx + dy * dy +end + +local function overlapsSpawn(x, y, radius) + return distanceSq(x, y, 150, 320) < radius * radius or distanceSq(x, y, 810, 320) < radius * radius +end + +local function collidesWithObstacle(x, y, radius) + for _, obstacle in ipairs(world.obstacles) do + local closestX = math.max(obstacle.x, math.min(x, obstacle.x + obstacle.w)) + local closestY = math.max(obstacle.y, math.min(y, obstacle.y + obstacle.h)) + if distanceSq(x, y, closestX, closestY) < radius * radius then + return true + end + end + return false +end + +local function generateArena(seed) + local rng = love.math.newRandomGenerator(seed) + world.obstacles = {} + world.gems = {} + + for i = 1, 5 do + local obstacle + for _ = 1, 30 do + local w = rng:random(64, 116) + local h = rng:random(34, 72) + local x = rng:random(170, ARENA.width - 170 - w) + local y = rng:random(ARENA.top + 24, ARENA.height - 80 - h) + if not overlapsSpawn(x + w / 2, y + h / 2, 150) then + obstacle = { x = x, y = y, w = w, h = h } + break + end + end + world.obstacles[i] = obstacle or { x = 245 + i * 80, y = 190 + (i % 2) * 120, w = 76, h = 42 } + end + + for i = 1, 8 do + local gem + for _ = 1, 40 do + local x = rng:random(120, ARENA.width - 120) + local y = rng:random(ARENA.top + 35, ARENA.height - 55) + if not overlapsSpawn(x, y, 110) and not collidesWithObstacle(x, y, 28) then + gem = { x = x, y = y, owner = nil } + break + end + end + world.gems[i] = gem or { x = 160 + i * 78, y = ARENA.top + 70 + (i % 3) * 125, owner = nil } + end +end + +local function resetRound(reason, seed) + players[1].x = 150 + players[1].y = 320 + players[1].score = 0 + players[2].x = 810 + players[2].y = 320 + players[2].score = 0 + players[2].padX = 0 + players[2].padY = 0 + world.elapsed = 0 + world.round = world.round + 1 + world.seed = seed or nextSeed(world.seed) + world.message = string.format("%s seed=%d", reason or "Round reset", world.seed) + generateArena(world.seed) +end + +local function captureReplayState() + local playerState = {} + for i, player in ipairs(players) do + playerState[i] = { + id = player.id, + x = player.x, + y = player.y, + score = player.score, + padX = player.padX, + padY = player.padY, + } + end + + local gems = {} + for i, gem in ipairs(world.gems) do + gems[i] = { + x = gem.x, + y = gem.y, + owner = gem.owner, + } + end + + return { + players = playerState, + world = { + elapsed = world.elapsed, + round = world.round, + seed = world.seed, + message = world.message, + gems = gems, + }, + } +end + +local function restoreReplayState(state) + if not state then + return + end + + for i, playerState in ipairs(state.players or {}) do + local player = players[i] + if player then + player.x = playerState.x or player.x + player.y = playerState.y or player.y + player.score = playerState.score or player.score + player.padX = playerState.padX or 0 + player.padY = playerState.padY or 0 + end + end + + if state.world then + if state.world.seed and state.world.seed ~= world.seed then + world.seed = state.world.seed + generateArena(world.seed) + end + world.elapsed = state.world.elapsed or world.elapsed + world.round = state.world.round or world.round + world.message = state.world.message or world.message + world.gems = {} + for i, gem in ipairs(state.world.gems or {}) do + world.gems[i] = { + x = gem.x, + y = gem.y, + owner = gem.owner, + } + end + end +end + +local function registerReplay() + if DEBUGGER then + DEBUGGER:replayRegister("multiplayer", captureReplayState, restoreReplayState, { + sampleInterval = 0.08, + }) + addEvent("Session Replay registered through CLI injection.") + else + addEvent("Run with: npm run feather -- run src-lua/example/session_replay/multiplayer") + end +end + +local function axis(value) + if math.abs(value) < 0.18 then + return 0 + end + return value +end + +function love.load() + love.graphics.setFont(love.graphics.newFont(14)) + resetRound("Seeded arena ready", world.seed) + registerReplay() + addEvent("Open Session Replay, record a short local multiplayer run, then replay it.") +end + +function love.gamepadaxis(_joystick, axisName, value) + if axisName == "leftx" then + players[2].padX = axis(value) + elseif axisName == "lefty" then + players[2].padY = axis(value) + end +end + +local function keyboardVector(player) + local dx, dy = 0, 0 + if love.keyboard.isDown(player.keys.right) then + dx = dx + 1 + end + if love.keyboard.isDown(player.keys.left) then + dx = dx - 1 + end + if love.keyboard.isDown(player.keys.down) then + dy = dy + 1 + end + if love.keyboard.isDown(player.keys.up) then + dy = dy - 1 + end + return dx, dy +end + +local function updatePlayer(player, dt) + local dx, dy = keyboardVector(player) + if player.id == "p2" then + dx = dx + (player.padX or 0) + dy = dy + (player.padY or 0) + end + + if dx ~= 0 or dy ~= 0 then + local length = math.sqrt(dx * dx + dy * dy) + local nextX = player.x + (dx / length) * 235 * dt + local nextY = player.y + (dy / length) * 235 * dt + if not collidesWithObstacle(nextX, nextY, 24) then + player.x = nextX + player.y = nextY + end + end + + player.x = math.max(28, math.min(love.graphics.getWidth() - 28, player.x)) + player.y = math.max(92, math.min(love.graphics.getHeight() - 28, player.y)) +end + +local function updateGems() + for _, gem in ipairs(world.gems) do + if not gem.owner then + for _, player in ipairs(players) do + local dx = player.x - gem.x + local dy = player.y - gem.y + if dx * dx + dy * dy < 32 * 32 then + gem.owner = player.id + player.score = player.score + 1 + world.message = player.name .. " scored" + addEvent(world.message) + break + end + end + end + end +end + +function love.update(dt) + world.elapsed = world.elapsed + dt + for _, player in ipairs(players) do + updatePlayer(player, dt) + end + updateGems() + + if DEBUGGER then + DEBUGGER:observe("session_replay_multiplayer.p1", players[1].score) + DEBUGGER:observe("session_replay_multiplayer.p2", players[2].score) + DEBUGGER:observe("session_replay_multiplayer.seed", world.seed) + DEBUGGER:observe("session_replay_multiplayer.message", world.message) + end +end + +function love.keypressed(key) + if key == "f5" and DEBUGGER then + local id, err = DEBUGGER:startSessionReplay({ + initialStates = { + multiplayer = captureReplayState(), + }, + }) + addEvent(id and "Recording started" or ("Could not start recording: " .. tostring(err))) + elseif key == "f6" and DEBUGGER then + DEBUGGER:stopSessionReplay() + addEvent("Recording stopped and loaded") + elseif key == "f7" and DEBUGGER then + resetRound("Replay baseline") + DEBUGGER:playSessionReplay() + addEvent("Replay started") + elseif key == "r" then + resetRound("Manual seeded reset") + addEvent("Manual reset") + elseif key == "escape" then + if DEBUGGER then + DEBUGGER:finish() + end + love.event.quit() + end +end + +local function drawGem(gem) + love.graphics.push() + love.graphics.translate(gem.x, gem.y) + love.graphics.rotate(math.pi / 4) + if gem.owner == "p1" then + setColor(players[1].color) + elseif gem.owner == "p2" then + setColor(players[2].color) + else + love.graphics.setColor(0.95, 0.85, 0.28) + end + love.graphics.rectangle("fill", -11, -11, 22, 22, 4) + love.graphics.setColor(1, 1, 1, gem.owner and 0.12 or 0.5) + love.graphics.rectangle("line", -11, -11, 22, 22, 4) + love.graphics.pop() +end + +local function drawObstacle(obstacle) + love.graphics.setColor(0.23, 0.28, 0.34) + love.graphics.rectangle("fill", obstacle.x, obstacle.y, obstacle.w, obstacle.h, 6) + love.graphics.setColor(0.55, 0.64, 0.72, 0.35) + love.graphics.rectangle("line", obstacle.x, obstacle.y, obstacle.w, obstacle.h, 6) +end + +local function drawPlayer(player) + setColor(player.color) + love.graphics.circle("fill", player.x, player.y, 24) + love.graphics.setColor(1, 1, 1, 0.85) + love.graphics.circle("line", player.x, player.y, 24) + love.graphics.print(player.name, player.x - 28, player.y + 32) +end + +function love.draw() + love.graphics.clear(0.08, 0.1, 0.13) + love.graphics.setColor(0.12, 0.15, 0.19) + love.graphics.rectangle("fill", 0, 76, love.graphics.getWidth(), love.graphics.getHeight() - 76) + + love.graphics.setColor(1, 1, 1) + love.graphics.print("Session Replay Multiplayer", 18, 16) + love.graphics.setColor(0.72, 0.78, 0.86) + love.graphics.print("F5 record F6 stop/load F7 replay R reset Esc quit", 18, 40) + love.graphics.print("P1 WASD. P2 arrows or first gamepad left stick.", 440, 40) + + for _, obstacle in ipairs(world.obstacles) do + drawObstacle(obstacle) + end + + for _, gem in ipairs(world.gems) do + drawGem(gem) + end + for _, player in ipairs(players) do + drawPlayer(player) + end + + setColor(players[1].color) + love.graphics.print("P1 score: " .. tostring(players[1].score), 18, 100) + setColor(players[2].color) + love.graphics.print("P2 score: " .. tostring(players[2].score), 18, 122) + love.graphics.setColor(1, 1, 1) + love.graphics.print("state stream: multiplayer", 18, 146) + love.graphics.print("seeded arena: " .. tostring(world.seed), 18, 168) + love.graphics.print(world.message, 18, 190) + + local status = DEBUGGER and "connected to Feather runtime" or "DEBUGGER not available" + setColor(DEBUGGER and { 0.35, 1, 0.58 } or { 1, 0.36, 0.36 }) + love.graphics.print(status, 18, love.graphics.getHeight() - 32) + + love.graphics.setColor(0.72, 0.78, 0.86) + local y = 220 + love.graphics.print("Recent events", love.graphics.getWidth() - 320, y) + for i, line in ipairs(world.events) do + love.graphics.print(line, love.graphics.getWidth() - 320, y + 22 * i) + end +end diff --git a/src-lua/example/session_replay/single_player/conf.lua b/src-lua/example/session_replay/single_player/conf.lua new file mode 100644 index 00000000..6e6cf519 --- /dev/null +++ b/src-lua/example/session_replay/single_player/conf.lua @@ -0,0 +1,6 @@ +function love.conf(t) + t.window.title = "Feather Session Replay Example" + t.window.width = 900 + t.window.height = 620 + t.console = false +end diff --git a/src-lua/example/session_replay/single_player/divergent_scene.lua b/src-lua/example/session_replay/single_player/divergent_scene.lua new file mode 100644 index 00000000..c0fbd959 --- /dev/null +++ b/src-lua/example/session_replay/single_player/divergent_scene.lua @@ -0,0 +1,252 @@ +local M = {} + +local player = { + x = 120, + y = 320, + speed = 220, + score = 0, + color = { 1, 0.48, 0.22 }, +} + +local world = { + elapsed = 0, + message = "Divergent: the hazard phase is intentionally not restored.", + targets = {}, + trail = {}, +} + +local hazard = { + phase = 0, + x = 480, + y = 320, + radius = 26, +} + +local logs = {} + +local function setColor(color) + love.graphics.setColor(color[1], color[2], color[3], color[4] or 1) +end + +local function log(message) + table.insert(logs, 1, string.format("%05.2f %s", world.elapsed, message)) + if #logs > 8 then + logs[9] = nil + end + print("[Session Replay Divergent] " .. message) +end + +local function resetTargets() + world.targets = { + { x = 250, y = 160, taken = false }, + { x = 705, y = 160, taken = false }, + { x = 480, y = 455, taken = false }, + } +end + +function M.reset(reason) + player.x = 120 + player.y = 320 + player.score = 0 + world.elapsed = 0 + world.trail = {} + world.message = reason or "Reset" + hazard.phase = 0 + hazard.x = 480 + hazard.y = 320 + resetTargets() +end + +function M.capture() + local targets = {} + for i, target in ipairs(world.targets) do + targets[i] = { + x = target.x, + y = target.y, + taken = target.taken, + } + end + + return { + player = { + x = player.x, + y = player.y, + score = player.score, + }, + world = { + elapsed = world.elapsed, + message = world.message, + targets = targets, + }, + -- Intentionally omitted: hazard.phase, hazard.x, hazard.y. + -- This scene demonstrates why a replay can diverge if the game does not + -- restore everything that affects simulation. + } +end + +function M.restore(state) + if not state then + return + end + + if state.player then + player.x = state.player.x or player.x + player.y = state.player.y or player.y + player.score = state.player.score or player.score + end + + if state.world then + world.elapsed = state.world.elapsed or world.elapsed + world.message = state.world.message or world.message + world.targets = {} + for i, target in ipairs(state.world.targets or {}) do + world.targets[i] = { + x = target.x, + y = target.y, + taken = target.taken == true, + } + end + end +end + +local function movePlayer(dt) + local dx, dy = 0, 0 + if love.keyboard.isDown("right", "d") then + dx = dx + 1 + end + if love.keyboard.isDown("left", "a") then + dx = dx - 1 + end + if love.keyboard.isDown("down", "s") then + dy = dy + 1 + end + if love.keyboard.isDown("up", "w") then + dy = dy - 1 + end + + if dx ~= 0 or dy ~= 0 then + local length = math.sqrt(dx * dx + dy * dy) + player.x = player.x + (dx / length) * player.speed * dt + player.y = player.y + (dy / length) * player.speed * dt + end + + player.x = math.max(28, math.min(love.graphics.getWidth() - 28, player.x)) + player.y = math.max(88, math.min(love.graphics.getHeight() - 28, player.y)) +end + +local function updateTargets() + for _, target in ipairs(world.targets) do + if not target.taken then + local dx = player.x - target.x + local dy = player.y - target.y + if dx * dx + dy * dy < 32 * 32 then + target.taken = true + player.score = player.score + 1 + world.message = "Collected target " .. tostring(player.score) + log(world.message) + end + end + end +end + +local function updateHazard(dt) + hazard.phase = hazard.phase + dt + hazard.x = 480 + math.sin(hazard.phase * 1.7) * 210 + hazard.y = 320 + math.cos(hazard.phase * 1.1) * 120 + + local dx = player.x - hazard.x + local dy = player.y - hazard.y + if dx * dx + dy * dy < (hazard.radius + 18) * (hazard.radius + 18) then + player.score = math.max(0, player.score - 1) + world.message = "Hazard touched player; divergence depends on hazard phase." + player.x = 120 + player.y = 320 + log("Hazard collision") + end +end + +local function updateTrail() + if #world.trail == 0 then + world.trail[1] = { x = player.x, y = player.y } + return + end + + local last = world.trail[#world.trail] + local dx = player.x - last.x + local dy = player.y - last.y + if dx * dx + dy * dy > 12 * 12 then + world.trail[#world.trail + 1] = { x = player.x, y = player.y } + if #world.trail > 80 then + table.remove(world.trail, 1) + end + end +end + +function M.update(dt) + world.elapsed = world.elapsed + dt + movePlayer(dt) + updateHazard(dt) + updateTargets() + updateTrail() + + if DEBUGGER then + DEBUGGER:observe("session_replay.scene", "divergent") + DEBUGGER:observe("session_replay.score", player.score) + DEBUGGER:observe("session_replay.hazard_phase", string.format("%.2f", hazard.phase)) + DEBUGGER:observe("session_replay.message", world.message) + end +end + +local function drawDiamond(x, y, taken) + love.graphics.push() + love.graphics.translate(x, y) + love.graphics.rotate(math.pi / 4) + setColor(taken and { 0.18, 0.2, 0.24, 0.7 } or { 1, 0.78, 0.22 }) + love.graphics.rectangle("fill", -12, -12, 24, 24, 4) + love.graphics.setColor(1, 1, 1, taken and 0.08 or 0.45) + love.graphics.rectangle("line", -12, -12, 24, 24, 4) + love.graphics.pop() +end + +function M.draw() + love.graphics.setColor(0.36, 0.2, 0.17, 0.5) + for _, point in ipairs(world.trail) do + love.graphics.circle("fill", point.x, point.y, 4) + end + + for _, target in ipairs(world.targets) do + drawDiamond(target.x, target.y, target.taken) + end + + love.graphics.setColor(1, 0.18, 0.16, 0.8) + love.graphics.circle("fill", hazard.x, hazard.y, hazard.radius) + love.graphics.setColor(1, 1, 1, 0.55) + love.graphics.circle("line", hazard.x, hazard.y, hazard.radius) + + setColor(player.color) + love.graphics.circle("fill", player.x, player.y, 22) + love.graphics.setColor(1, 1, 1, 0.85) + love.graphics.circle("line", player.x, player.y, 22) + + love.graphics.setColor(1, 1, 1) + love.graphics.print(string.format("score: %d / %d", player.score, #world.targets), 18, 112) + love.graphics.print(string.format("time: %.2f", world.elapsed), 18, 134) + love.graphics.print("mode: divergent checkpoint", 18, 156) + love.graphics.print("hazard phase is not restored", 18, 178) + love.graphics.print(world.message, 18, 200) + + love.graphics.setColor(0.72, 0.78, 0.86) + local y = 240 + love.graphics.print("Recent events", love.graphics.getWidth() - 290, y) + for i, line in ipairs(logs) do + love.graphics.print(line, love.graphics.getWidth() - 290, y + 22 * i) + end +end + +function M.log(message) + log(message) +end + +M.reset("Fresh run") + +return M diff --git a/src-lua/example/session_replay/single_player/feather.config.lua b/src-lua/example/session_replay/single_player/feather.config.lua new file mode 100644 index 00000000..d5465474 --- /dev/null +++ b/src-lua/example/session_replay/single_player/feather.config.lua @@ -0,0 +1,20 @@ +-- CLI-managed Session Replay example. +-- Run from the repo root: +-- npm run feather -- run src-lua/example/session_replay/single_player + +return { + sessionName = "Session Replay Example", + deviceId = "session-replay-example", + include = { "session-replay" }, + sampleRate = 0.25, + captureScreenshot = false, + defaultObservers = true, + autoRegisterErrorHandler = true, + pluginOptions = { + ["session-replay"] = { + keyframeInterval = 2, + captureMouseMove = true, + }, + }, + __DANGEROUS_INSECURE_CONNECTION__ = true, +} diff --git a/src-lua/example/session_replay/single_player/main.lua b/src-lua/example/session_replay/single_player/main.lua new file mode 100644 index 00000000..1336582c --- /dev/null +++ b/src-lua/example/session_replay/single_player/main.lua @@ -0,0 +1,115 @@ +local scenes = { + reproducible = require("reproducible_scene"), + divergent = require("divergent_scene"), +} + +local sceneOrder = { "reproducible", "divergent" } +local sceneIndex = 1 +local activeName = sceneOrder[sceneIndex] + +local function activeScene() + return scenes[activeName] +end + +local function log(message) + activeScene().log(message) +end + +local function setColor(color) + love.graphics.setColor(color[1], color[2], color[3], color[4] or 1) +end + +local function captureReplaySnapshot() + return { + scene = activeName, + state = activeScene().capture(), + } +end + +local function registerReplay() + if DEBUGGER then + DEBUGGER:replayRegister("game", captureReplaySnapshot, function(snapshot) + if snapshot and snapshot.scene and scenes[snapshot.scene] then + activeName = snapshot.scene + for index, name in ipairs(sceneOrder) do + if name == activeName then + sceneIndex = index + break + end + end + end + if snapshot and snapshot.state then + activeScene().restore(snapshot.state) + end + end, { + sampleInterval = 0.1, + }) + log("Session Replay registered through CLI injection.") + else + log("Run with: npm run feather -- run src-lua/example/session_replay/single_player") + end +end + +local function switchScene() + sceneIndex = sceneIndex % #sceneOrder + 1 + activeName = sceneOrder[sceneIndex] + activeScene().reset("Scene switched") + log("Switched to " .. activeName .. " scene") +end + +function love.load() + love.graphics.setFont(love.graphics.newFont(14)) + registerReplay() + log("Open the Session Replay page in Feather.") +end + +function love.update(dt) + activeScene().update(dt) +end + +function love.keypressed(key) + if key == "tab" then + switchScene() + elseif key == "f5" and DEBUGGER then + local id, err = DEBUGGER:startSessionReplay({ + initialStates = { + game = captureReplaySnapshot(), + }, + }) + log(id and "Started session replay recording" or ("Could not start recording: " .. tostring(err))) + elseif key == "f6" and DEBUGGER then + DEBUGGER:stopSessionReplay() + log("Stopped recording and loaded replay") + elseif key == "f7" and DEBUGGER then + activeScene().reset("Replay baseline reset before playback") + DEBUGGER:playSessionReplay() + log("Started replay") + elseif key == "r" then + activeScene().reset("Manual reset") + log("Manual reset") + elseif key == "escape" then + if DEBUGGER then + DEBUGGER:finish() + end + love.event.quit() + end +end + +function love.draw() + love.graphics.clear(0.08, 0.1, 0.13) + + love.graphics.setColor(0.12, 0.15, 0.19) + love.graphics.rectangle("fill", 0, 72, love.graphics.getWidth(), love.graphics.getHeight() - 72) + + love.graphics.setColor(1, 1, 1) + love.graphics.print("Session Replay Example", 18, 16) + love.graphics.setColor(0.72, 0.78, 0.86) + love.graphics.print("Tab scene F5 record F6 stop/load F7 replay R reset Esc quit", 18, 40) + love.graphics.print("Active scene: " .. activeName, 650, 40) + + activeScene().draw() + + local status = DEBUGGER and "connected to Feather runtime" or "DEBUGGER not available" + setColor(DEBUGGER and { 0.35, 1, 0.58 } or { 1, 0.36, 0.36 }) + love.graphics.print(status, 18, love.graphics.getHeight() - 32) +end diff --git a/src-lua/example/session_replay/single_player/reproducible_scene.lua b/src-lua/example/session_replay/single_player/reproducible_scene.lua new file mode 100644 index 00000000..d6ed3e62 --- /dev/null +++ b/src-lua/example/session_replay/single_player/reproducible_scene.lua @@ -0,0 +1,292 @@ +local M = {} + +local player = { + x = 120, + y = 320, + speed = 220, + score = 0, + color = { 0.2, 0.72, 1 }, +} + +local world = { + run = 1, + elapsed = 0, + message = "Reproducible: checkpoint restores player, targets, and sparkle.", + targets = {}, + trail = {}, +} + +local sparkle = { + x = 450, + y = 300, + pulse = 0, + trail = {}, +} + +local logs = {} + +local function setColor(color) + love.graphics.setColor(color[1], color[2], color[3], color[4] or 1) +end + +local function log(message) + table.insert(logs, 1, string.format("%05.2f %s", world.elapsed, message)) + if #logs > 8 then + logs[9] = nil + end + print("[Session Replay Reproducible] " .. message) +end + +local function resetTargets() + world.targets = { + { x = 260, y = 150, taken = false }, + { x = 640, y = 180, taken = false }, + { x = 430, y = 380, taken = false }, + { x = 720, y = 470, taken = false }, + } +end + +function M.reset(reason) + player.x = 120 + player.y = 320 + player.score = 0 + world.elapsed = 0 + world.trail = {} + world.run = world.run + 1 + world.message = reason or "Reset" + sparkle.x = 450 + sparkle.y = 300 + sparkle.pulse = 0 + sparkle.trail = {} + resetTargets() +end + +function M.capture() + local targets = {} + for i, target in ipairs(world.targets) do + targets[i] = { + x = target.x, + y = target.y, + taken = target.taken, + } + end + + return { + player = { + x = player.x, + y = player.y, + score = player.score, + }, + sparkle = { + x = sparkle.x, + y = sparkle.y, + pulse = sparkle.pulse, + }, + world = { + run = world.run, + elapsed = world.elapsed, + message = world.message, + targets = targets, + }, + } +end + +function M.restore(state) + if not state then + return + end + + if state.player then + player.x = state.player.x or player.x + player.y = state.player.y or player.y + player.score = state.player.score or player.score + end + + if state.sparkle then + sparkle.x = state.sparkle.x or sparkle.x + sparkle.y = state.sparkle.y or sparkle.y + sparkle.pulse = state.sparkle.pulse or sparkle.pulse + end + + if state.world then + world.run = state.world.run or world.run + world.elapsed = state.world.elapsed or world.elapsed + world.message = state.world.message or world.message + world.targets = {} + for i, target in ipairs(state.world.targets or {}) do + world.targets[i] = { + x = target.x, + y = target.y, + taken = target.taken == true, + } + end + end +end + +local function movePlayer(dt) + local dx, dy = 0, 0 + if love.keyboard.isDown("right", "d") then + dx = dx + 1 + end + if love.keyboard.isDown("left", "a") then + dx = dx - 1 + end + if love.keyboard.isDown("down", "s") then + dy = dy + 1 + end + if love.keyboard.isDown("up", "w") then + dy = dy - 1 + end + + if dx ~= 0 or dy ~= 0 then + local length = math.sqrt(dx * dx + dy * dy) + player.x = player.x + (dx / length) * player.speed * dt + player.y = player.y + (dy / length) * player.speed * dt + end + + player.x = math.max(28, math.min(love.graphics.getWidth() - 28, player.x)) + player.y = math.max(88, math.min(love.graphics.getHeight() - 28, player.y)) +end + +local function updateTargets() + for _, target in ipairs(world.targets) do + if not target.taken then + local dx = player.x - target.x + local dy = player.y - target.y + if dx * dx + dy * dy < 34 * 34 then + target.taken = true + player.score = player.score + 1 + world.message = "Collected target " .. tostring(player.score) + log(world.message) + end + end + end +end + +local function updateTrail() + if #world.trail == 0 then + world.trail[1] = { x = player.x, y = player.y } + return + end + + local last = world.trail[#world.trail] + local dx = player.x - last.x + local dy = player.y - last.y + if dx * dx + dy * dy > 12 * 12 then + world.trail[#world.trail + 1] = { x = player.x, y = player.y } + if #world.trail > 80 then + table.remove(world.trail, 1) + end + end +end + +local function updateSparkle(dt) + local x, y = love.mouse.getPosition() + sparkle.x = x + sparkle.y = y + sparkle.pulse = sparkle.pulse + dt * 6 + + if #sparkle.trail == 0 then + sparkle.trail[1] = { x = x, y = y, life = 1 } + else + local last = sparkle.trail[#sparkle.trail] + local dx = x - last.x + local dy = y - last.y + if dx * dx + dy * dy > 10 * 10 then + sparkle.trail[#sparkle.trail + 1] = { x = x, y = y, life = 1 } + end + end + + for i = #sparkle.trail, 1, -1 do + local point = sparkle.trail[i] + point.life = point.life - dt * 0.9 + if point.life <= 0 or #sparkle.trail > 42 then + table.remove(sparkle.trail, i) + end + end +end + +function M.update(dt) + world.elapsed = world.elapsed + dt + movePlayer(dt) + updateTargets() + updateTrail() + updateSparkle(dt) + + if DEBUGGER then + DEBUGGER:observe("session_replay.scene", "reproducible") + DEBUGGER:observe("session_replay.score", player.score) + DEBUGGER:observe("session_replay.position", string.format("%d, %d", player.x, player.y)) + DEBUGGER:observe("session_replay.sparkle", string.format("%d, %d", sparkle.x, sparkle.y)) + DEBUGGER:observe("session_replay.message", world.message) + end +end + +local function drawDiamond(x, y, taken) + love.graphics.push() + love.graphics.translate(x, y) + love.graphics.rotate(math.pi / 4) + setColor(taken and { 0.18, 0.2, 0.24, 0.7 } or { 1, 0.78, 0.22 }) + love.graphics.rectangle("fill", -12, -12, 24, 24, 4) + love.graphics.setColor(1, 1, 1, taken and 0.08 or 0.45) + love.graphics.rectangle("line", -12, -12, 24, 24, 4) + love.graphics.pop() +end + +local function drawSparkle() + for _, point in ipairs(sparkle.trail) do + local alpha = math.max(0, math.min(1, point.life)) + love.graphics.setColor(0.95, 0.78, 1, alpha * 0.42) + love.graphics.circle("fill", point.x, point.y, 8 * alpha) + love.graphics.setColor(0.4, 0.9, 1, alpha * 0.5) + love.graphics.circle("fill", point.x, point.y, 3 * alpha) + end + + local radius = 10 + math.sin(sparkle.pulse) * 3 + love.graphics.setColor(1, 0.95, 0.45, 0.9) + love.graphics.circle("fill", sparkle.x, sparkle.y, 4) + love.graphics.setColor(0.95, 0.78, 1, 0.85) + love.graphics.line(sparkle.x - radius, sparkle.y, sparkle.x + radius, sparkle.y) + love.graphics.line(sparkle.x, sparkle.y - radius, sparkle.x, sparkle.y + radius) + love.graphics.setColor(0.4, 0.9, 1, 0.65) + love.graphics.circle("line", sparkle.x, sparkle.y, radius) +end + +function M.draw() + love.graphics.setColor(0.22, 0.34, 0.42, 0.55) + for _, point in ipairs(world.trail) do + love.graphics.circle("fill", point.x, point.y, 4) + end + + for _, target in ipairs(world.targets) do + drawDiamond(target.x, target.y, target.taken) + end + + drawSparkle() + + setColor(player.color) + love.graphics.circle("fill", player.x, player.y, 22) + love.graphics.setColor(1, 1, 1, 0.85) + love.graphics.circle("line", player.x, player.y, 22) + + love.graphics.setColor(1, 1, 1) + love.graphics.print(string.format("score: %d / %d", player.score, #world.targets), 18, 112) + love.graphics.print(string.format("time: %.2f", world.elapsed), 18, 134) + love.graphics.print("mode: reproducible checkpoint", 18, 156) + love.graphics.print(world.message, 18, 178) + + love.graphics.setColor(0.72, 0.78, 0.86) + local y = 240 + love.graphics.print("Recent events", love.graphics.getWidth() - 290, y) + for i, line in ipairs(logs) do + love.graphics.print(line, love.graphics.getWidth() - 290, y + 22 * i) + end +end + +function M.log(message) + log(message) +end + +M.reset("Fresh run") + +return M diff --git a/src-lua/feather/callback_bus.lua b/src-lua/feather/callback_bus.lua new file mode 100644 index 00000000..1ceb60b5 --- /dev/null +++ b/src-lua/feather/callback_bus.lua @@ -0,0 +1,124 @@ +local Class = require(FEATHER_PATH .. ".lib.class") + +---@class FeatherCallbackBusEntry +---@field fn function +---@field order number +---@field priority number|nil +---@field active boolean + +---@class FeatherCallbackBus +---@field registrations table +---@field allowedCallbacks table +---@field nextOrder number +local FeatherCallbackBus = Class({}) + +local function normalizedPriority(priority) + if priority == nil then + return 0 + end + return priority +end + +local function sortRegistrations(registrations) + table.sort(registrations, function(a, b) + local aPriority = normalizedPriority(a.priority) + local bPriority = normalizedPriority(b.priority) + + if aPriority == bPriority then + return a.order < b.order + end + + return aPriority < bPriority + end) +end + +function FeatherCallbackBus:init(callbackNames) + self.registrations = {} + self.allowedCallbacks = {} + self.nextOrder = 0 + + for _, name in ipairs(callbackNames or {}) do + self.allowedCallbacks[name] = true + self.registrations[name] = {} + end +end + +function FeatherCallbackBus:isSupported(name) + return self.allowedCallbacks[name] == true +end + +function FeatherCallbackBus:assertSupported(name) + if not self:isSupported(name) then + error("[FeatherCallbackBus] Unsupported callback: " .. tostring(name), 3) + end +end + +---@param name string +---@param fn function +---@param opts? { priority?: number } +---@return function unregister +function FeatherCallbackBus:register(name, fn, opts) + self:assertSupported(name) + + if type(fn) ~= "function" then + error("[FeatherCallbackBus] Callback handler must be a function", 2) + end + + if opts ~= nil and type(opts) ~= "table" then + error("[FeatherCallbackBus] Callback options must be a table", 2) + end + + local priority = opts and opts.priority or nil + if priority ~= nil and type(priority) ~= "number" then + error("[FeatherCallbackBus] Callback priority must be a number", 2) + end + + self.nextOrder = self.nextOrder + 1 + + local entry = { + fn = fn, + order = self.nextOrder, + priority = priority, + active = true, + } + + local registrations = self.registrations[name] + registrations[#registrations + 1] = entry + sortRegistrations(registrations) + + return function() + if not entry.active then + return false + end + + entry.active = false + + for index, candidate in ipairs(registrations) do + if candidate == entry then + table.remove(registrations, index) + return true + end + end + + return false + end +end + +function FeatherCallbackBus:dispatch(name, ...) + self:assertSupported(name) + + local registrations = self.registrations[name] + local snapshot = {} + + for index = 1, #registrations do + snapshot[index] = registrations[index] + end + + for _, entry in ipairs(snapshot) do + if entry.active then + entry.fn(...) + end + end +end + +return FeatherCallbackBus diff --git a/src-lua/feather/core/assets.lua b/src-lua/feather/core/assets.lua index 4b7b12f7..f451ba46 100644 --- a/src-lua/feather/core/assets.lua +++ b/src-lua/feather/core/assets.lua @@ -152,6 +152,10 @@ function FeatherAssets:update() self:_hookDraw() end +function FeatherAssets:isDrawWrapper(fn) + return fn ~= nil and fn == self._drawWrapper +end + function FeatherAssets:hasPreview() return self._previewReady ~= nil end diff --git a/src-lua/feather/core/base.lua b/src-lua/feather/core/base.lua index 9e2554dd..4d02aa5a 100644 --- a/src-lua/feather/core/base.lua +++ b/src-lua/feather/core/base.lua @@ -29,6 +29,7 @@ function FeatherPlugin:init(config) self.options = config.options or {} self.logger = config.logger or {} self.observer = config.observer or {} + self.callbacks = config.callbacks or {} self.api = config.api self.minApi = config.minApi self.maxApi = config.maxApi diff --git a/src-lua/feather/core/logger.lua b/src-lua/feather/core/logger.lua index 5da76213..1203a4be 100644 --- a/src-lua/feather/core/logger.lua +++ b/src-lua/feather/core/logger.lua @@ -140,7 +140,7 @@ function FeatherLogger:__countOnRepeat(type, ...) end end ----@alias LogType "output" | "trace" | "error" | "feather:finish" | "feather:start" | "fatal" +---@alias LogType "output" | "trace" | "warn" | "error" | "feather:finish" | "feather:start" | "fatal" ---@class FeatherLine ---@field type LogType ---@field str? string diff --git a/src-lua/feather/init.lua b/src-lua/feather/init.lua index d057e26d..5aae9aa6 100644 --- a/src-lua/feather/init.lua +++ b/src-lua/feather/init.lua @@ -9,6 +9,7 @@ FEATHER_PATH = FEATHER_PATH or PATH local Class = require(FEATHER_PATH .. ".lib.class") local json = require(FEATHER_PATH .. ".lib.json") local errorhandler = require(FEATHER_PATH .. ".error_handler") +local FeatherCallbackBus = require(FEATHER_PATH .. ".callback_bus") local FeatherPluginManager = require(FEATHER_PATH .. ".plugin_manager") local FeatherLogger = require(FEATHER_PATH .. ".core.logger") local FeatherObserver = require(FEATHER_PATH .. ".core.observer") @@ -20,7 +21,7 @@ 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 = "1.2.0" +local FEATHER_VERSION_NAME = "1.3.0" local FEATHER_API = 5 local FEATHER_VERSION = { @@ -28,6 +29,43 @@ local FEATHER_VERSION = { api = FEATHER_API, } +local function is_absolute_path(path) + return path:sub(1, 1) == "/" or path:match("^%a:[/\\]") ~= nil +end + +local function join_path(base, child) + if not child or #child == 0 then + return base + end + if is_absolute_path(child) then + return child + end + local suffix = base:sub(-1) + if suffix == "/" or suffix == "\\" then + return base .. child + end + return base .. "/" .. child +end + +local CALLBACK_NAMES = { + "draw", + "keypressed", + "keyreleased", + "mousepressed", + "mousereleased", + "mousemoved", + "touchpressed", + "touchreleased", + "touchmoved", + "joystickpressed", + "joystickreleased", + "joystickhat", + "joystickaxis", + "gamepadpressed", + "gamepadreleased", + "gamepadaxis", +} + ---@class Feather: FeatherConfig ---@field lastError number ---@field debug boolean @@ -51,6 +89,15 @@ local FEATHER_VERSION = { ---@field protected __pushObservers fun(self: Feather) ---@field protected __pushAssets fun(self: Feather) ---@field attachBinary fun(self: Feather, mime: string, bytes: string): table +---@field startSessionReplay fun(self: Feather, opts?: table): string|nil +---@field stopSessionReplay fun(self: Feather): string|nil +---@field playSessionReplay fun(self: Feather, idOrPath?: string, opts?: table): boolean|nil +---@field seekSessionReplay fun(self: Feather, timeOrCheckpointId: number|string): boolean|nil +---@field replayInitialState fun(self: Feather, name: string, state: table, opts?: table): boolean|nil +---@field replayCheckpoint fun(self: Feather, nameOrOpts?: string|table, stateOverrides?: table): string|boolean|nil +---@field replayState fun(self: Feather, name: string, state: table, opts?: table): boolean|nil +---@field replay fun(self: Feather, name: string, state: table, opts?: table): boolean|nil +---@field replayRegister fun(self: Feather, name: string, captureFn?: function, restoreFn?: function, opts?: table): boolean|nil ---@field ui table Declarative UI node builders for plugins local Feather = Class({}) @@ -199,6 +246,7 @@ function Feather:init(config) self.assets = FeatherAssets(self.featherLogger) end + self.callbackBus = FeatherCallbackBus(CALLBACK_NAMES) self.pluginManager = FeatherPluginManager(self, self.featherLogger, self.featherObserver) self.pluginManager:hookLoveCallbacks() self.debugOverlay = FeatherDebugOverlay(self, conf.debugOverlay) @@ -324,13 +372,15 @@ function Feather:__sendHello() end function Feather:__getConfig() - local root_path = get_current_dir() + local cliGamePath = os.getenv("FEATHER_GAME_PATH") + local hasCliGamePath = type(cliGamePath) == "string" and #cliGamePath > 0 + local root_path = hasCliGamePath and cliGamePath or get_current_dir() if #self.baseDir > 0 then - root_path = root_path .. "/" .. self.baseDir + root_path = join_path(root_path, self.baseDir) end local sourceDir = root_path ---@diagnostic disable-next-line: undefined-field - if love.filesystem.getSourceDirectory then + if not hasCliGamePath and love.filesystem.getSourceDirectory then ---@diagnostic disable-next-line: undefined-field sourceDir = love.filesystem.getSourceDirectory() end @@ -489,6 +539,7 @@ function Feather:__handleCommand(msg) local path = msg.plugin:find("^/plugins/") and msg.plugin or ("/plugins/" .. msg.plugin) local request = { method = "PUT", path = path, params = msg.params or {}, headers = {} } self.pluginManager:handleParamsUpdate(request, self) + self:__sendHello() elseif msg.type == "cmd:plugin:set_enabled" and msg.plugin then local enabled = msg.enabled == true local ok = false @@ -611,6 +662,24 @@ function Feather:__handleCommand(msg) if plugin then plugin.instance:sendFrames(msg.data, self) end + elseif msg.type and msg.type:match("^cmd:session_replay:") then + local plugin = self.pluginManager:getPlugin("session-replay") + if plugin and plugin.instance and not plugin.disabled then + local ok, err = plugin.instance:handleSessionReplayCommand(msg, self) + if not ok and err then + self:__sendWs(json.encode({ + type = "session_replay:error", + session = self.sessionId, + data = { message = tostring(err) }, + })) + end + else + self:__sendWs(json.encode({ + type = "session_replay:error", + session = self.sessionId, + data = { message = "Session Replay plugin is not enabled. Include session-replay in feather.config.lua." }, + })) + end elseif msg.type == "cmd:eval" and msg.code then local consolePlugin = self.pluginManager:getPlugin("console") if consolePlugin and consolePlugin.instance and not consolePlugin.disabled then @@ -837,6 +906,119 @@ function Feather:action(plugin, action, params) self.pluginManager:action(plugin, action, params, self) end +function Feather:__sessionReplayPlugin() + if not self.debug or not self.pluginManager then + return nil + end + local plugin = self.pluginManager:getPlugin("session-replay") + if not plugin or not plugin.instance or plugin.disabled then + return nil + end + return plugin.instance +end + +function Feather:startSessionReplay(opts) + local plugin = self:__sessionReplayPlugin() + if plugin and plugin.startRecording then + local id, err = plugin:startRecording(opts or {}) + if plugin.sendStatus then + plugin:sendStatus(self) + end + if plugin.sendReplayList then + plugin:sendReplayList(self) + end + return id, err + end + return nil +end + +function Feather:stopSessionReplay() + local plugin = self:__sessionReplayPlugin() + if plugin and plugin.stopRecording then + local id = plugin:stopRecording(self) + if plugin.sendStatus then + plugin:sendStatus(self) + end + if plugin.sendReplayList then + plugin:sendReplayList(self) + end + return id + end + return nil +end + +function Feather:playSessionReplay(idOrPath, opts) + local plugin = self:__sessionReplayPlugin() + if plugin and plugin.startReplay then + local ok, err = plugin:startReplay(idOrPath, opts or {}) + if plugin.sendStatus then + plugin:sendStatus(self) + end + return ok, err + end + return nil +end + +function Feather:seekSessionReplay(timeOrCheckpointId) + local plugin = self:__sessionReplayPlugin() + if plugin and plugin.seekReplay then + local ok, err = plugin:seekReplay(timeOrCheckpointId) + if plugin.sendStatus then + plugin:sendStatus(self) + end + return ok, err + end + return nil +end + +function Feather:replayInitialState(name, state, opts) + local plugin = self:__sessionReplayPlugin() + if plugin and plugin.recordInitialState then + return plugin:recordInitialState(name, state, opts or {}) + end + return nil +end + +function Feather:replayCheckpoint(nameOrOpts, stateOverrides) + local plugin = self:__sessionReplayPlugin() + if plugin and plugin.recordCheckpoint then + return plugin:recordCheckpoint(nameOrOpts, stateOverrides) + end + return nil +end + +function Feather:stopSessionReplayPlayback() + local plugin = self:__sessionReplayPlugin() + if plugin and plugin.stopReplay then + plugin:stopReplay() + if plugin.sendStatus then + plugin:sendStatus(self) + end + return true + end + return nil +end + +function Feather:replayState(name, state, opts) + local plugin = self:__sessionReplayPlugin() + if plugin and plugin.recordState then + return plugin:recordState(name, state, opts or {}) + end + return nil +end + +function Feather:replay(name, state, opts) + return self:replayState(name, state, opts) +end + +function Feather:replayRegister(name, captureFn, restoreFn, opts) + local plugin = self:__sessionReplayPlugin() + if plugin and plugin.registerState then + return plugin:registerState(name, captureFn, restoreFn, opts or {}) + end + return nil +end + ---@param enabled boolean function Feather:toggleScreenshots(enabled) self.captureScreenshot = enabled diff --git a/src-lua/feather/plugin_manager.lua b/src-lua/feather/plugin_manager.lua index 9d0c0e19..79f41134 100644 --- a/src-lua/feather/plugin_manager.lua +++ b/src-lua/feather/plugin_manager.lua @@ -6,11 +6,31 @@ local Class = require(FEATHER_PATH .. ".lib.class") --- @field disabled boolean --- @field errorCount number --- @field capabilities string[] +--- @field callbackDisposers function[]|nil ---@class FeatherPluginManager ---@field plugins FeatherPluginInstance[] local FeatherPluginManager = Class({}) +local LOVE_CALLBACKS = { + { name = "draw", method = "onDraw" }, + { name = "keypressed", method = "onKeypressed" }, + { name = "keyreleased", method = "onKeyreleased" }, + { name = "mousepressed", method = "onMousepressed" }, + { name = "mousereleased", method = "onMousereleased" }, + { name = "mousemoved", method = "onMousemoved" }, + { name = "touchpressed", method = "onTouchpressed" }, + { name = "touchreleased", method = "onTouchreleased" }, + { name = "touchmoved", method = "onTouchmoved" }, + { name = "joystickpressed", method = "onJoystickpressed" }, + { name = "joystickreleased", method = "onJoystickreleased" }, + { name = "joystickhat", method = "onJoystickhat" }, + { name = "joystickaxis", method = "onJoystickaxis" }, + { name = "gamepadpressed", method = "onGamepadpressed" }, + { name = "gamepadreleased", method = "onGamepadreleased" }, + { name = "gamepadaxis", method = "onGamepadaxis" }, +} + local function normalizeApiCompatibility(api) if api == nil then return {} @@ -85,38 +105,17 @@ local function describeApiCompatibility(compatibility, currentApi) if compatibility.minApi ~= nil or compatibility.maxApi ~= nil then local min = compatibility.minApi ~= nil and tostring(compatibility.minApi) or "any" local max = compatibility.maxApi ~= nil and tostring(compatibility.maxApi) or "any" - return "Requires Feather plugin API " - .. min - .. "-" - .. max - .. "; desktop API is " - .. tostring(currentApi) - .. "." + return "Requires Feather plugin API " .. min .. "-" .. max .. "; desktop API is " .. tostring(currentApi) .. "." end return "Requires a different Feather plugin API. Desktop API is " .. tostring(currentApi) .. "." end -local function callbackReferences(callback, target) - if type(callback) ~= "function" or type(target) ~= "function" then - return false - end - if not debug or not debug.getupvalue then - return false - end - - local index = 1 - while true do - local name, value = debug.getupvalue(callback, index) - if not name then - break - end - if value == target then - return true - end - index = index + 1 +local function isCallable(value) + if type(value) == "function" then + return true end - - return false + local meta = type(value) == "table" and getmetatable(value) or nil + return type(meta) == "table" and type(meta.__call) == "function" end ---@param feather Feather @@ -127,6 +126,7 @@ function FeatherPluginManager:init(feather, logger, observer) self.logger = logger self.observer = observer self.feather = feather + self.callbackBus = feather.callbackBus self._hookedCallbacks = nil if not feather.plugins then @@ -149,20 +149,36 @@ function FeatherPluginManager:init(feather, logger, observer) compatibility.minApi = compatibility.minApi or plugin.minApi compatibility.maxApi = compatibility.maxApi or plugin.maxApi compatibility.currentApi = feather.version - - if not isApiCompatible(compatibility, feather.version) then - local message = describeApiCompatibility(compatibility, feather.version) - table.insert(self.plugins, { - instance = nil, - identifier = plugin.identifier, - disabled = true, - incompatible = true, - incompatibilityReason = message, - capabilities = plugin.capabilities or {}, - compatibility = compatibility, - name = plugin.name, - version = plugin.version, + local pluginRecord = { + instance = nil, + identifier = plugin.identifier, + disabled = plugin.disabled or false, + incompatible = false, + incompatibilityReason = nil, + capabilities = plugin.capabilities or {}, + compatibility = compatibility, + name = plugin.name, + version = plugin.version, + callbackDisposers = {}, + } + + if not isCallable(plugin.plugin) then + pluginRecord.disabled = true + table.insert(self.plugins, pluginRecord) + self.logger:log({ + type = "warn", + str = "Plugin <" + .. tostring(plugin.identifier) + .. "> is disabled: expected a callable plugin module, got " + .. type(plugin.plugin) + .. ".", }) + elseif not isApiCompatible(compatibility, feather.version) then + local message = describeApiCompatibility(compatibility, feather.version) + pluginRecord.disabled = true + pluginRecord.incompatible = true + pluginRecord.incompatibilityReason = message + table.insert(self.plugins, pluginRecord) self.logger:log({ type = "error", str = "Plugin <" .. plugin.identifier .. "> is not compatible: " .. message, @@ -175,6 +191,7 @@ function FeatherPluginManager:init(feather, logger, observer) feather = feather, logger = logger, observer = observer, + callbacks = self:createCallbackRegistrar(pluginRecord), api = compatibility.api, minApi = compatibility.minApi, maxApi = compatibility.maxApi, @@ -185,22 +202,26 @@ function FeatherPluginManager:init(feather, logger, observer) 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, - }) + pluginRecord.instance = pluginInstance + pluginRecord.disabled = plugin.disabled or not supported or false + pluginRecord.incompatible = not supported + pluginRecord.incompatibilityReason = not supported and describeApiCompatibility(compatibility, feather.version) + or nil + table.insert(self.plugins, pluginRecord) + + if supported then + self:registerPluginCallbacks(pluginRecord) + else + self:disposePluginCallbacks(pluginRecord) + end if not supported then self.logger:log({ type = "error", - str = "Plugin <" .. plugin.identifier .. "> is not compatible: " .. describeApiCompatibility(compatibility, feather.version), + str = "Plugin <" .. plugin.identifier .. "> is not compatible: " .. describeApiCompatibility( + compatibility, + feather.version + ), }) end @@ -220,6 +241,7 @@ function FeatherPluginManager:init(feather, logger, observer) end end else + self:disposePluginCallbacks(pluginRecord) -- pluginInstance is the formatted error+traceback string from the xpcall handler self.logger:log({ type = "error", str = tostring(pluginInstance) }) end @@ -227,6 +249,58 @@ function FeatherPluginManager:init(feather, logger, observer) end end +function FeatherPluginManager:createCallbackRegistrar(plugin) + return { + register = function(name, fn, opts) + local disposer = self.callbackBus:register(name, function(...) + if plugin.disabled then + return + end + + return fn(...) + end, opts) + + plugin.callbackDisposers[#plugin.callbackDisposers + 1] = disposer + return disposer + end, + } +end + +function FeatherPluginManager:registerPluginCallbacks(plugin) + if not plugin.instance then + return + end + + for _, callback in ipairs(LOVE_CALLBACKS) do + local disposer = self.callbackBus:register(callback.name, function(...) + if plugin.disabled or not plugin.instance then + return + end + + local method = plugin.instance[callback.method] + if type(method) ~= "function" then + return + end + + pcall(method, plugin.instance, ...) + end) + + plugin.callbackDisposers[#plugin.callbackDisposers + 1] = disposer + end +end + +function FeatherPluginManager:disposePluginCallbacks(plugin) + if not plugin.callbackDisposers then + return + end + + for _, dispose in ipairs(plugin.callbackDisposers) do + dispose() + end + + plugin.callbackDisposers = {} +end + function FeatherPluginManager:update(dt, feather) for _, plugin in ipairs(self.plugins) do if plugin.instance and not plugin.disabled then @@ -360,41 +434,42 @@ function FeatherPluginManager:hookLoveCallbacks() local mgr = self - local function dispatch(method, ...) - for _, p in ipairs(mgr.plugins) do - if p.instance and not p.disabled then - pcall(p.instance[method], p.instance, ...) - end - end + local function dispatch(name, ...) + mgr.callbackBus:dispatch(name, ...) end self._loveCallbackOriginals = self._loveCallbackOriginals or {} self._loveCallbackWrappers = self._loveCallbackWrappers or {} - local callbacks = { - { name = "draw", method = "onDraw" }, - { name = "keypressed", method = "onKeypressed" }, - { name = "keyreleased", method = "onKeyreleased" }, - { name = "mousepressed", method = "onMousepressed" }, - { name = "mousereleased", method = "onMousereleased" }, - { name = "touchpressed", method = "onTouchpressed" }, - { name = "touchreleased", method = "onTouchreleased" }, - { name = "joystickpressed", method = "onJoystickpressed" }, - { name = "joystickreleased", method = "onJoystickreleased" }, - } - - for _, callback in ipairs(callbacks) do + for _, callback in ipairs(LOVE_CALLBACKS) do local name = callback.name local method = callback.method local wrapper = self._loveCallbackWrappers[name] if not wrapper then + self._dispatchingLoveCallbacks = self._dispatchingLoveCallbacks or {} + wrapper = function(...) + if mgr._dispatchingLoveCallbacks[name] then + return + end + + mgr._dispatchingLoveCallbacks[name] = true + local original = mgr._loveCallbackOriginals and mgr._loveCallbackOriginals[name] + if original and original ~= wrapper then - original(...) + local ok, err = pcall(original, ...) + if not ok and mgr.logger then + mgr.logger:log({ + type = "error", + str = "[FeatherPluginManager] love." .. name .. " original callback error: " .. tostring(err), + }) + end end - dispatch(method, ...) + + dispatch(name, ...) + local overlay = mgr.feather and mgr.feather.debugOverlay if overlay then if method == "onDraw" and overlay.onDraw then @@ -405,12 +480,19 @@ function FeatherPluginManager:hookLoveCallbacks() overlay:onTouchpressed(...) end end + + mgr._dispatchingLoveCallbacks[name] = false end self._loveCallbackWrappers[name] = wrapper end local current = love[name] - if current ~= wrapper and not callbackReferences(current, wrapper) then + local isFeatherOwnedWrapper = false + if name == "draw" and self.feather and self.feather.assets and self.feather.assets.isDrawWrapper then + isFeatherOwnedWrapper = self.feather.assets:isDrawWrapper(current) + end + + if current ~= wrapper and (not isFeatherOwnedWrapper or self._loveCallbackOriginals[name] == nil) then self._loveCallbackOriginals[name] = current love[name] = wrapper end @@ -443,6 +525,7 @@ function FeatherPluginManager:finish(feather) if plugin.instance then pcall(plugin.instance.finish, plugin.instance, feather) end + self:disposePluginCallbacks(plugin) end end diff --git a/src-lua/manifest.txt b/src-lua/manifest.txt index 771c303f..f46c491a 100644 --- a/src-lua/manifest.txt +++ b/src-lua/manifest.txt @@ -1,4 +1,5 @@ core:feather/auto.lua +core:feather/callback_bus.lua core:feather/core/assets.lua core:feather/core/base.lua core:feather/core/debug_overlay.lua @@ -64,6 +65,9 @@ plugin:runtime-snapshot:init.lua plugin:runtime-snapshot:manifest.lua plugin:screenshots:init.lua plugin:screenshots:manifest.lua +plugin:session-replay:init.lua +plugin:session-replay:manifest.lua +plugin:session-replay:replay_adapter.lua plugin:shader-graph:init.lua plugin:shader-graph:manifest.lua plugin:time-travel:init.lua diff --git a/src-lua/plugins/README.md b/src-lua/plugins/README.md index 4fa8701b..cb4b95d7 100644 --- a/src-lua/plugins/README.md +++ b/src-lua/plugins/README.md @@ -116,9 +116,74 @@ return { | `optIn` | `boolean` | If `true`, the plugin is not registered at all unless its ID appears in `config.include`. | | `disabled` | `boolean` | If `true`, the plugin registers and appears in the UI but starts inactive. Users can enable it from the desktop, or via `config.include`. | +### Loading and enabling plugins + +`feather.auto` scans the bundled or installed plugin directory and reads each `manifest.lua`. + +- Plugins with `optIn = false` and `disabled = false` load automatically unless their ID is in `config.exclude`. +- Plugins with `optIn = true` are skipped unless their ID is in `config.include`. +- Plugins with `disabled = true` are registered but inactive unless their ID is in `config.include`. + +CLI-managed projects use `feather.config.lua` as the source of truth for plugin selection. Use the CLI helpers to edit that list and keep capabilities in sync: + +```bash +feather config plugins --include profiler,input-replay +feather config plugins --exclude shader-graph +``` + +`feather init` includes `particle-system-playground` and `shader-graph` by default in CLI mode. Development-only plugins such as `console` and `hot-reload` remain explicit opt-ins. + ### Love-event hooks -Instead of patching `love.*` callbacks inside `init()`, override the corresponding `on*` method. `FeatherPluginManager` patches each love callback once and dispatches to all enabled plugins — this prevents conflicts when multiple plugins hook the same callback. +Instead of patching `love.*` callbacks inside `init()`, use Feather's callback bus or override the corresponding `on*` method. `FeatherPluginManager` patches each love callback once and dispatches through a shared bus — this prevents conflicts when multiple plugins hook the same callback. + +### Callback bus + +`config.callbacks.register(name, fn, opts)` registers a handler on the shared runtime callback bus. + +```lua +function MyPlugin:init(config) + self.disposeOverlay = config.callbacks.register("draw", function() + -- Runs after love.draw(); use love.graphics here + end) + + config.callbacks.register("draw", function() + -- Rare override: run later than default callbacks + end, { + priority = 1000, + }) +end +``` + +Supported callback names are: + +- `draw` +- `keypressed` +- `keyreleased` +- `mousepressed` +- `mousereleased` +- `mousemoved` +- `touchpressed` +- `touchreleased` +- `touchmoved` +- `joystickpressed` +- `joystickreleased` +- `joystickhat` +- `joystickaxis` +- `gamepadpressed` +- `gamepadreleased` +- `gamepadaxis` + +Ordering rules: + +- No priority: FIFO, based on registration order. +- Lower numeric priorities run earlier; higher numeric priorities run later. +- Undefined priorities preserve FIFO order. +- Equal priorities preserve FIFO order. + +Use priority as a rare escape hatch. Normal plugins should usually omit it. + +Legacy `on*` methods still work and are routed through the same callback bus. If your plugin only needs Love callback hooks, overriding `onDraw`, `onKeypressed`, `onMousemoved`, and friends remains valid. ```lua function MyPlugin:onDraw() @@ -132,16 +197,22 @@ end function MyPlugin:onKeyreleased(key, scancode) end function MyPlugin:onMousepressed(x, y, button, istouch, presses) end function MyPlugin:onMousereleased(x, y, button, istouch, presses) end +function MyPlugin:onMousemoved(x, y, dx, dy, istouch) end function MyPlugin:onTouchpressed(id, x, y, dx, dy, pressure) end function MyPlugin:onTouchreleased(id, x, y, dx, dy, pressure) end +function MyPlugin:onTouchmoved(id, x, y, dx, dy, pressure) end function MyPlugin:onJoystickpressed(joystick, button) end function MyPlugin:onJoystickreleased(joystick, button) end +function MyPlugin:onJoystickhat(joystick, hat, direction) end +function MyPlugin:onJoystickaxis(joystick, axis, value) end +function MyPlugin:onGamepadpressed(joystick, button) end +function MyPlugin:onGamepadreleased(joystick, button) end +function MyPlugin:onGamepadaxis(joystick, axis, value) end ``` Only override the methods you need — unused ones default to no-ops in the base class. -> [!NOTE] -> `input-replay` keeps its own hook system because it simulates love events during replay. Routing those through the central dispatcher would cause recursion. +Replay plugins should also use the callback bus for recording. They may still temporarily wrap polling APIs such as `love.keyboard.isDown` or `love.mouse.getPosition` during playback, because replaying polled input is simulation rather than callback observation. ### Capabilities @@ -211,7 +282,7 @@ The FeatherPluginManager handles the lifecycle of each plugin. Each plugin's `up #### Initialization -- `init(config)`: Called when the plugin is initialized. `config.options` contains the options passed to `createPlugin`, and `config.logger` / `config.observer` are always available. +- `init(config)`: Called when the plugin is initialized. `config.options` contains the options passed to `createPlugin`, `config.logger` / `config.observer` are always available, and `config.callbacks.register(...)` lets plugins join the shared love-event callback bus. - `getConfig()`: Returns the plugin configuration (type, icon, tab name, actions). Sent to the desktop app on connect. #### Data Push (every cycle) diff --git a/src-lua/plugins/hot-reload/README.md b/src-lua/plugins/hot-reload/README.md index c3102714..9ad6f65b 100644 --- a/src-lua/plugins/hot-reload/README.md +++ b/src-lua/plugins/hot-reload/README.md @@ -21,7 +21,21 @@ Only enable hot reload when all of this is true: ## Enable It -Configure it from `feather.config.lua`: +For CLI-managed projects, let Feather write the allowlist config: + +```bash +feather init path/to/my-game --plugins hot-reload --hot-reload-allow game.player,game.enemy --yes +``` + +For an already initialized project: + +```bash +feather config hot-reload --allow game.player,game.enemy --dir path/to/my-game +``` + +The VS Code extension uses the same CLI path. When you select the `hot-reload` plugin during init or plugin install, it prompts for Lua files and converts project paths like `game/player.lua` into module names like `game.player`. + +Or configure it manually from `feather.config.lua`: ```lua return { diff --git a/src-lua/plugins/input-replay/README.md b/src-lua/plugins/input-replay/README.md index 66c08b21..fe911136 100644 --- a/src-lua/plugins/input-replay/README.md +++ b/src-lua/plugins/input-replay/README.md @@ -36,7 +36,7 @@ FeatherPluginManager.createPlugin(InputReplayPlugin, "input-replay", { ### Recording -When you press **Record**, the plugin wraps LÖVE's input callbacks (`love.keypressed`, `love.keyreleased`, `love.mousepressed`, `love.mousereleased`, and optionally `love.mousemoved`) to intercept events. Each event is stored with: +When you press **Record**, the plugin records input through Feather's shared callback bus. Each event is stored with: - **Timestamp** — seconds elapsed since recording started (high-resolution via `socket.gettime` or `love.timer.getTime`) - **Type** — which callback fired @@ -46,7 +46,7 @@ The original callbacks still execute normally — recording is transparent to th ### Replaying -When you press **Replay**, the plugin fires the recorded events at their original timestamps by calling the original (pre-hook) LÖVE callbacks. This means: +When you press **Replay**, the plugin fires the recorded events at their original timestamps through the active LÖVE callbacks. This means: - Events replay in the exact order they were recorded - Timing is preserved relative to replay start @@ -63,9 +63,9 @@ Replay stops automatically when all events have been fired, or manually via the ### Hook Safety -- Hooks are installed lazily (only when recording or replaying starts) -- Original callbacks are preserved and restored on `finish()` -- If a callback didn't exist before hooking, the hook still works (and fires with no original to call) +- Recording uses Feather's shared callback bus instead of installing plugin-owned LÖVE callback wrappers. +- During playback, temporary polling shims mirror `love.keyboard.isDown`, `love.mouse.isDown`, and mouse position APIs so polling-based controls can observe replayed input. +- Replay does not re-record events, so callback dispatch avoids feedback loops. ## 🎮 Actions diff --git a/src-lua/plugins/input-replay/init.lua b/src-lua/plugins/input-replay/init.lua index 1c76a5d0..f4332096 100644 --- a/src-lua/plugins/input-replay/init.lua +++ b/src-lua/plugins/input-replay/init.lua @@ -34,12 +34,15 @@ end ---@field captureTouchMove boolean ---@field captureJoystick boolean ---@field captureJoystickAxis boolean ----@field _originals table Original love callbacks saved for restoration ----@field _hooked boolean +---@field _pollOriginals table Original polling functions saved for restoration +---@field _virtualInput table Replay-time input state for polling APIs +---@field _callbackDisposers function[] +---@field _pollHooked boolean local InputReplayPlugin = Class({ __includes = Base, init = function(self, config) self.options = config.options or {} + self.feather = config.feather self.logger = config.logger self.observer = config.observer self.events = {} @@ -56,43 +59,87 @@ local InputReplayPlugin = Class({ self.captureTouchMove = self.options.captureTouchMove == true -- off by default (noisy) self.captureJoystick = self.options.captureJoystick ~= false self.captureJoystickAxis = self.options.captureJoystickAxis == true -- off by default (noisy) - self._originals = {} - self._hooked = false + self._pollOriginals = {} + self._virtualInput = { + keys = {}, + scancodes = {}, + mouseButtons = {}, + mouseX = nil, + mouseY = nil, + } + self._callbackDisposers = {} + self._pollHooked = false + + if config.callbacks then + self:_installCallbackBusHooks(config.callbacks) + end end, }) ---- Install hooks on love callbacks to intercept input events. ---- Safe to call multiple times — only hooks once. -function InputReplayPlugin:_installHooks() - if self._hooked then +local CALLBACK_BUS_EVENTS = { + keypressed = true, + keyreleased = true, + mousepressed = true, + mousereleased = true, + mousemoved = true, + touchpressed = true, + touchreleased = true, + touchmoved = true, + joystickpressed = true, + joystickreleased = true, + joystickhat = true, + joystickaxis = true, + gamepadpressed = true, + gamepadreleased = true, + gamepadaxis = true, +} + +function InputReplayPlugin:_shouldCapture(name) + if name == "keypressed" or name == "keyreleased" then + return self.captureKeys + elseif name == "mousepressed" or name == "mousereleased" then + return self.captureMouse + elseif name == "mousemoved" then + return self.captureMouseMove + elseif name == "touchpressed" or name == "touchreleased" then + return self.captureTouch + elseif name == "touchmoved" then + return self.captureTouchMove + elseif + name == "joystickpressed" + or name == "joystickreleased" + or name == "joystickhat" + or name == "gamepadpressed" + or name == "gamepadreleased" + then + return self.captureJoystick + elseif name == "joystickaxis" or name == "gamepadaxis" then + return self.captureJoystickAxis + end + return false +end + +function InputReplayPlugin:_recordEvent(name, args) + if not self.recording or not self:_shouldCapture(name) then return end - self._hooked = true - - local selfRef = self - - -- Helper: wrap a love callback, recording events when active - local function hookCallback(name, argsTransform) - local original = love[name] - selfRef._originals[name] = original - - love[name] = function(...) - -- Record if active - if selfRef.recording then - local elapsed = gettime() - selfRef.recordStart - if #selfRef.events < selfRef.maxEvents then - local args = argsTransform and argsTransform(...) or { ... } - selfRef.events[#selfRef.events + 1] = { - time = elapsed, - type = name, - args = args, - } - end - end - -- Always call the original - if original then - return original(...) - end + if #self.events >= self.maxEvents then + return + end + self.events[#self.events + 1] = { + time = gettime() - self.recordStart, + type = name, + args = args, + } +end + +function InputReplayPlugin:_installCallbackBusHooks(callbacks) + local function register(name, argsTransform) + local ok, disposer = pcall(callbacks.register, name, function(...) + self:_recordEvent(name, argsTransform and argsTransform(...) or { ... }) + end) + if ok and disposer then + self._callbackDisposers[#self._callbackDisposers + 1] = disposer end end @@ -105,78 +152,204 @@ function InputReplayPlugin:_installHooks() -- Joystick userdata is stored as its numeric ID so events are JSON-serializable. -- The ID is resolved back to the live object at replay time via REPLAY_RESOLVERS. local function joystickArgs(joystick, ...) - local id = joystick:getID() -- returns id, instanceid; we keep only id + local id = joystick and joystick.getID and joystick:getID() or tostring(joystick) return { id, ... } end - if self.captureKeys then - hookCallback("keypressed") - hookCallback("keyreleased") + register("keypressed") + register("keyreleased") + register("mousepressed") + register("mousereleased") + register("mousemoved") + register("touchpressed", touchArgs) + register("touchreleased", touchArgs) + register("touchmoved", touchArgs) + register("joystickpressed", joystickArgs) + register("joystickreleased", joystickArgs) + register("joystickhat", joystickArgs) + register("joystickaxis", joystickArgs) + register("gamepadpressed", joystickArgs) + register("gamepadreleased", joystickArgs) + register("gamepadaxis", joystickArgs) +end + +--- Start recording input events. +function InputReplayPlugin:startRecording() + if self.replaying then + self:stopReplay() end + self.events = {} + self.recording = true + self.recordStart = gettime() + if self.logger and self.logger.log then + self.logger:log({ type = "trace", str = "[InputReplay] Recording started" }) + end +end - if self.captureMouse then - hookCallback("mousepressed") - hookCallback("mousereleased") +--- Stop recording. +function InputReplayPlugin:stopRecording() + self.recording = false + if self.logger and self.logger.log then + self.logger:log({ type = "trace", str = "[InputReplay] Recording stopped — " .. #self.events .. " events" }) end +end - if self.captureMouseMove then - hookCallback("mousemoved") +function InputReplayPlugin:_resetVirtualInput() + self._virtualInput = { + keys = {}, + scancodes = {}, + mouseButtons = {}, + mouseX = nil, + mouseY = nil, + } +end + +function InputReplayPlugin:_setVirtualKey(key, scancode, down) + if key ~= nil then + self._virtualInput.keys[key] = down and true or nil + end + if scancode ~= nil then + self._virtualInput.scancodes[scancode] = down and true or nil end +end - if self.captureTouch then - hookCallback("touchpressed", touchArgs) - hookCallback("touchreleased", touchArgs) +function InputReplayPlugin:_setVirtualMouse(button, down, x, y) + if button ~= nil then + self._virtualInput.mouseButtons[button] = down and true or nil end + if x ~= nil then + self._virtualInput.mouseX = x + end + if y ~= nil then + self._virtualInput.mouseY = y + end +end - if self.captureTouchMove then - hookCallback("touchmoved", touchArgs) +function InputReplayPlugin:_installPollingHooks() + if self._pollHooked then + return end + self._pollHooked = true + + if love.keyboard then + if love.keyboard.isDown then + self._pollOriginals.keyboardIsDown = love.keyboard.isDown + love.keyboard.isDown = function(...) + local keys = { ... } + for _, key in ipairs(keys) do + if self._virtualInput.keys[key] then + return true + end + end + return self._pollOriginals.keyboardIsDown(...) + end + end - if self.captureJoystick then - hookCallback("joystickpressed", joystickArgs) - hookCallback("joystickreleased", joystickArgs) - hookCallback("joystickhat", joystickArgs) - hookCallback("gamepadpressed", joystickArgs) - hookCallback("gamepadreleased", joystickArgs) + if love.keyboard.isScancodeDown then + self._pollOriginals.keyboardIsScancodeDown = love.keyboard.isScancodeDown + love.keyboard.isScancodeDown = function(...) + local scancodes = { ... } + for _, scancode in ipairs(scancodes) do + if self._virtualInput.scancodes[scancode] then + return true + end + end + return self._pollOriginals.keyboardIsScancodeDown(...) + end + end end - if self.captureJoystickAxis then - hookCallback("joystickaxis", joystickArgs) - hookCallback("gamepadaxis", joystickArgs) + if love.mouse then + if love.mouse.isDown then + self._pollOriginals.mouseIsDown = love.mouse.isDown + love.mouse.isDown = function(...) + local buttons = { ... } + for _, button in ipairs(buttons) do + if self._virtualInput.mouseButtons[button] then + return true + end + end + return self._pollOriginals.mouseIsDown(...) + end + end + + if love.mouse.getPosition then + self._pollOriginals.mouseGetPosition = love.mouse.getPosition + love.mouse.getPosition = function() + if self._virtualInput.mouseX ~= nil and self._virtualInput.mouseY ~= nil then + return self._virtualInput.mouseX, self._virtualInput.mouseY + end + return self._pollOriginals.mouseGetPosition() + end + end + + if love.mouse.getX then + self._pollOriginals.mouseGetX = love.mouse.getX + love.mouse.getX = function() + if self._virtualInput.mouseX ~= nil then + return self._virtualInput.mouseX + end + return self._pollOriginals.mouseGetX() + end + end + + if love.mouse.getY then + self._pollOriginals.mouseGetY = love.mouse.getY + love.mouse.getY = function() + if self._virtualInput.mouseY ~= nil then + return self._virtualInput.mouseY + end + return self._pollOriginals.mouseGetY() + end + end end end ---- Remove hooks and restore original callbacks. -function InputReplayPlugin:_removeHooks() - if not self._hooked then +function InputReplayPlugin:_removePollingHooks() + if not self._pollHooked then return end - for name, original in pairs(self._originals) do - love[name] = original - end - self._originals = {} - self._hooked = false -end ---- Start recording input events. -function InputReplayPlugin:startRecording() - if self.replaying then - self:stopReplay() + if love.keyboard then + if self._pollOriginals.keyboardIsDown then + love.keyboard.isDown = self._pollOriginals.keyboardIsDown + end + if self._pollOriginals.keyboardIsScancodeDown then + love.keyboard.isScancodeDown = self._pollOriginals.keyboardIsScancodeDown + end end - self:_installHooks() - self.events = {} - self.recording = true - self.recordStart = gettime() - if self.logger and self.logger.log then - self.logger:log({ type = "trace", str = "[InputReplay] Recording started" }) + + if love.mouse then + if self._pollOriginals.mouseIsDown then + love.mouse.isDown = self._pollOriginals.mouseIsDown + end + if self._pollOriginals.mouseGetPosition then + love.mouse.getPosition = self._pollOriginals.mouseGetPosition + end + if self._pollOriginals.mouseGetX then + love.mouse.getX = self._pollOriginals.mouseGetX + end + if self._pollOriginals.mouseGetY then + love.mouse.getY = self._pollOriginals.mouseGetY + end end + + self._pollOriginals = {} + self._pollHooked = false end ---- Stop recording. -function InputReplayPlugin:stopRecording() - self.recording = false - if self.logger and self.logger.log then - self.logger:log({ type = "trace", str = "[InputReplay] Recording stopped — " .. #self.events .. " events" }) +function InputReplayPlugin:_applyVirtualInput(event) + local args = event.args or {} + if event.type == "keypressed" then + self:_setVirtualKey(args[1], args[2], true) + elseif event.type == "keyreleased" then + self:_setVirtualKey(args[1], args[2], false) + elseif event.type == "mousepressed" then + self:_setVirtualMouse(args[3], true, args[1], args[2]) + elseif event.type == "mousereleased" then + self:_setVirtualMouse(args[3], false, args[1], args[2]) + elseif event.type == "mousemoved" then + self:_setVirtualMouse(nil, nil, args[1], args[2]) end end @@ -188,7 +361,8 @@ function InputReplayPlugin:startReplay() if #self.events == 0 then return end - self:_installHooks() + self:_resetVirtualInput() + self:_installPollingHooks() self.replaying = true self.replayStart = gettime() self.replayIndex = 1 @@ -200,6 +374,8 @@ end --- Stop replay. function InputReplayPlugin:stopReplay() self.replaying = false + self:_resetVirtualInput() + self:_removePollingHooks() if self.logger and self.logger.log then self.logger:log({ type = "trace", str = "[InputReplay] Replay stopped" }) end @@ -253,6 +429,20 @@ local REPLAY_RESOLVERS = { end, } +function InputReplayPlugin:_dispatchReplayEvent(event) + self:_applyVirtualInput(event) + + local resolver = REPLAY_RESOLVERS[event.type] + local replayArgs = resolver and resolver(event.args) or event.args + if not replayArgs then + return + end + + if CALLBACK_BUS_EVENTS[event.type] and love[event.type] then + pcall(love[event.type], unpack(replayArgs)) + end +end + --- Called every frame by the plugin manager. function InputReplayPlugin:update() if not self.replaying then @@ -268,17 +458,7 @@ function InputReplayPlugin:update() break -- not yet end - -- Fire the original callback (not our hook, to avoid re-recording). - -- For joystick events the stored args use a numeric ID; resolve back to the - -- live Joystick object before firing. - local original = self._originals[event.type] - if original then - local resolver = REPLAY_RESOLVERS[event.type] - local replayArgs = resolver and resolver(event.args) or event.args - if replayArgs then - pcall(original, unpack(replayArgs)) - end - end + self:_dispatchReplayEvent(event) self.replayIndex = self.replayIndex + 1 end @@ -535,7 +715,11 @@ function InputReplayPlugin:handleParamsUpdate(request, _feather) end function InputReplayPlugin:finish(_feather) - self:_removeHooks() + self:_removePollingHooks() + for _, dispose in ipairs(self._callbackDisposers) do + pcall(dispose) + end + self._callbackDisposers = {} self.recording = false self.replaying = false end diff --git a/src-lua/plugins/session-replay/README.md b/src-lua/plugins/session-replay/README.md new file mode 100644 index 00000000..c4054bb7 --- /dev/null +++ b/src-lua/plugins/session-replay/README.md @@ -0,0 +1,419 @@ +# Session Replay + +Session Replay combines input replay with developer-selected state checkpoints so a playthrough can be recorded, exported, imported, and reproduced later. + +Feather orchestrates the recording and playback. Your game decides which state matters and, when you want deterministic reproduction, how that state is restored. + +It does not serialize your whole game. Reliable reproduction comes from deterministic inputs plus optional restore callbacks for the state streams you care about. + +## Enable The Plugin + +Session Replay is development-only and opt-in: + +```lua +-- feather.config.lua +return { + include = { "session-replay" }, +} +``` + +Run through the CLI or VS Code extension so production builds can stay Feather-free: + +```bash +feather run path/to/my-game +``` + +## Minimal Capture + +Use guarded calls from game code: + +```lua +if DEBUGGER then + DEBUGGER:replayState("player", { + x = player.x, + y = player.y, + health = player.health, + }) +end +``` + +`DEBUGGER:replay("player", state)` is available as a shorthand. State must be JSON-serializable: strings, numbers, booleans, arrays, and tables with string keys. + +Session Replay stores sparse deltas. If a named state stream serializes to the same value as the previous sample, Feather skips it. Periodic keyframes are still written so longer recordings have stable checkpoints. + +## Recording Files And Flushes + +When a session starts, Feather creates the replay folder and baseline files up front: + +- `manifest.json` +- `initial.json` +- `inputs.jsonl` +- `state-0001.jsonl` +- `checkpoints.jsonl` +- `checkpoints/.json` + +Input and state events are then captured in memory first and flushed to disk in small batches from the plugin update loop, on stop, and before export/load. This keeps input callbacks lightweight so the first captured key, mouse, touch, or joystick event is not also paying the cost of opening replay files. + +You can tune the tradeoff: + +```lua +return { + include = { "session-replay" }, + pluginOptions = { + ["session-replay"] = { + flushInterval = 0.2, + flushMaxLines = 128, + }, + }, +} +``` + +Lower values reduce possible data loss if the game crashes mid-recording. Higher values reduce disk I/O during heavy input or state capture. + +## Reliable Restore + +For reliable reproduction, register a capture and restore pair: + +```lua +if DEBUGGER then + DEBUGGER:replayRegister("player", function() + return { + x = player.x, + y = player.y, + health = player.health, + } + end, function(state) + player.x = state.x + player.y = state.y + player.health = state.health + end) +end +``` + +During recording, Feather calls the capture function and stores deltas. During playback, it calls the restore function at the recorded timestamps while also replaying inputs. + +Feather does not deep-copy or restore arbitrary Lua objects. Prefer compact state streams such as player position, current room, RNG seed, quest flags, or a game-defined checkpoint. + +## Initial Baseline + +When a recording starts, Session Replay captures an initial baseline for registered state streams and writes it to `initial.json`. Playback restores that baseline once before replaying inputs and timed state deltas. + +For most games, the registered capture function is enough: + +```lua +if DEBUGGER then + DEBUGGER:replayRegister("game", captureSceneCheckpoint, restoreSceneCheckpoint) +end +``` + +For a specific scene, save point, or cutscene moment, pass an explicit baseline when starting: + +```lua +if DEBUGGER then + DEBUGGER:startSessionReplay({ + initialStates = { + game = captureSceneCheckpoint(), + combat = captureCombatCheckpoint(), + }, + }) +end +``` + +If you want full manual control, disable automatic initial capture and set baseline streams yourself: + +```lua +if DEBUGGER then + DEBUGGER:startSessionReplay({ captureInitial = false }) + DEBUGGER:replayInitialState("game", captureSceneCheckpoint()) +end +``` + +## Seekable Checkpoints + +Session Replay can seek by restoring a checkpoint and replaying forward from that point. This supports jumping forward or backward without reverse-simulating the game. + +The initial baseline is checkpoint `0`. You can create named manual checkpoints during recording: + +```lua +if DEBUGGER then + DEBUGGER:replayCheckpoint("before_boss") +end +``` + +You can also pass explicit state overrides for a checkpoint when a scene or save system has a better snapshot than the registered capture functions: + +```lua +if DEBUGGER then + DEBUGGER:replayCheckpoint({ + id = "shop_entry", + label = "Shop Entry", + }, { + game = captureShopCheckpoint(), + }) +end +``` + +Seek from code by time, checkpoint id, or label: + +```lua +if DEBUGGER then + DEBUGGER:seekSessionReplay("before_boss") + DEBUGGER:playSessionReplay(nil, { seekTo = 12.5 }) +end +``` + +Automatic checkpoints are available but opt-in: + +```lua +return { + include = { "session-replay" }, + pluginOptions = { + ["session-replay"] = { + checkpointInterval = 10, + maxCheckpoints = 100, + }, + }, +} +``` + +Prefer manual checkpoints for large sessions or heavy state snapshots. Automatic checkpoints repeatedly call registered capture functions and write full checkpoint files, so they can add disk and serialization cost. + +## Seeded And Procedural Content + +For roguelikes, procgen arenas, randomized loot, and other seed-driven systems, avoid recording every fixed generated object when a seed can recreate it. Store the seed in the initial baseline, regenerate the fixed world during restore, then apply only the mutable state that changed during play. + +That usually looks like: + +```lua +local function captureReplay() + return { + world = { + seed = run.seed, + levelId = currentLevel.id, + pickups = capturePickupOwnership(), + }, + players = capturePlayers(), + } +end + +local function restoreReplay(state) + if state.world then + run.seed = state.world.seed + loadLevel(state.world.levelId) + generateArenaFromSeed(run.seed) + restorePickupOwnership(state.world.pickups) + end + + if state.players then + restorePlayers(state.players) + end +end + +if DEBUGGER then + DEBUGGER:replayRegister("run", captureReplay, restoreReplay) +end +``` + +The seed is baseline state: it restores the deterministic layout once. Ownership, destroyed walls, opened doors, collected pickups, enemy health, or any other state that changes after generation should still be captured as mutable replay state. + +The multiplayer example uses this pattern. Gems and obstacles are generated from a seed, then replay captures player positions, scores, gamepad axis state, and gem ownership. + +## Reproduction Boundaries + +Session Replay is a repro aid, not a full save-state emulator. It records inputs, timing, and the state streams your game provides. It does not automatically capture Lua globals, random number generator state, physics solver internals, timers, coroutines, loaded assets, entities created before recording, or anything else your game does not expose. + +For reliable playback, start from a known checkpoint. If recording begins after the game has already been running, playback can diverge unless your capture/restore handler includes enough state to rebuild that moment. + +Good checkpoint candidates include: + +- current scene or level +- player position, inventory, health, and score +- RNG seed or deterministic random state +- important entities and their simulation state +- timers, cooldowns, quest flags, and trigger state +- camera or viewport state when input depends on it + +The intended promise is: Feather orchestrates inputs and developer-selected checkpoints; your game defines what "same starting point" means. + +## Integration Cost + +Session Replay has a real integration cost. It asks your game to describe what matters for reproduction, and that can become architectural debt if Feather calls are scattered through gameplay code. + +Treat Session Replay as an advanced repro tool, not a required setup step. It is usually worth the cost when you need to reproduce bugs from playtests, roguelike runs, input-heavy interactions, long setup sequences, or hard-to-hit timing issues. For simple projects, input-only replay or normal logs may be enough. + +Keep the cost small: + +- Prefer one or a few centralized replay adapters instead of many `DEBUGGER:replayState()` calls across entities. +- Put capture and restore code near existing save, checkpoint, level-loading, or debug-state systems. +- Capture semantic state such as `levelId`, seeds, player state, inventory, flags, and entity snapshots instead of raw object graphs. +- Use seeds and IDs for fixed generated content, then capture only mutable differences. +- Guard all usage with `if DEBUGGER then ... end`, or hide it behind a tiny local wrapper such as `devReplay.register(...)`. +- Keep production builds Feather-free; replay files and runtime code are development artifacts. + +A healthy integration looks like this: + +```lua +local function captureReplay() + return saveSystem.captureDebugCheckpoint() +end + +local function restoreReplay(state) + saveSystem.restoreDebugCheckpoint(state) +end + +if DEBUGGER then + DEBUGGER:replayRegister("game", captureReplay, restoreReplay) +end +``` + +Avoid wiring Feather into every gameplay branch. This shape is fragile: + +```lua +-- Avoid spreading this pattern everywhere. +if DEBUGGER then + DEBUGGER:replayState("enemy_17", enemy) +end +``` + +Session Replay mitigates some of the setup cost: + +- Input capture works without state capture, so you can start with replaying controls only. +- `DEBUGGER:replayRegister()` gives one central capture/restore hook for a whole game, scene, or run. +- Initial baselines are captured automatically from registered streams at recording start. +- `initialStates` lets you define scene-specific or moment-specific baselines without sampling them forever. +- Sparse deltas skip repeated identical state. +- Release and upload safety checks treat replay files and Session Replay runtime footprints as development-only. + +Feather should not try to magically serialize the whole game to avoid this cost. That would be more surprising and more dangerous than an explicit adapter. The safest model is: Feather records inputs and transports replay data; your game owns the checkpoint contract. + +## Replay Adapter + +Use a Replay Adapter when you want Session Replay without spreading Feather-specific calls throughout the game. The adapter is one project-local file that owns capture, restore, baseline, and optional programmatic controls. + +Create the scaffold: + +```bash +feather replay init --dir path/to/my-game +``` + +This creates: + +```text +dev/replay.lua +``` + +If `feather.config.lua` exists, the command also enables the `session-replay` plugin. Use `--no-config` to skip that update, `--path ` to choose another adapter path, or `--force` to overwrite an existing adapter. + +Then wire the adapter once: + +```lua +local replay = require("dev.replay") + +function love.load() + replay.register() +end + +function love.keypressed(key) + if key == "f5" then + replay.start() + elseif key == "f6" then + replay.stop() + elseif key == "f7" then + replay.play() + end +end +``` + +Edit `dev/replay.lua` so its `capture()` and `restore()` functions call your game systems. A good adapter usually delegates to existing save, checkpoint, scene-loading, or debug-state modules: + +```lua +local function capture() + return saveSystem.captureDebugCheckpoint() +end + +local function restore(state) + saveSystem.restoreDebugCheckpoint(state) +end +``` + +The adapter can be required in production safely because it no-ops when `DEBUGGER` is unavailable. Production release builds should still exclude replay files and Feather runtime artifacts. + +## Input Coverage + +Session Replay records keyboard, mouse button, touch, joystick, and gamepad callbacks. + +Mouse movement, touch movement, and joystick/gamepad axes are intentionally disabled by default because they can produce a lot of data. Enable them when the game needs that input stream: + +```lua +-- feather.config.lua +return { + include = { "session-replay" }, + pluginOptions = { + ["session-replay"] = { + captureMouseMove = true, + captureTouchMove = true, + captureJoystickAxis = true, + }, + }, +} +``` + +Keyboard and mouse polling are virtually mirrored during playback for `love.keyboard.isDown`, `love.keyboard.isScancodeDown`, `love.mouse.isDown`, and `love.mouse.getPosition`. Joystick and gamepad replay is callback-driven, so prefer handling axes through `love.gamepadaxis` or `love.joystickaxis` when you want deterministic replay. + +## Desktop Workflow + +1. Open **Session Replay** in the Feather app. +2. Click **Start Recording**. +3. Play through the bug or scenario. +4. Click **Stop & Load**. +5. Click **Replay** to reproduce it in the connected game. +6. Click **Export** to save a `.featherreplay` file. +7. Later, click **Import** and **Replay** to reproduce the same session. + +The page shows available replay sessions, recording status, duration, input count, initial state count, state event count, state streams, and missing restore handlers. + +## Examples + +The repository includes runnable examples: + +```bash +npm run feather -- run src-lua/example/session_replay/single_player +npm run feather -- run src-lua/example/session_replay/multiplayer +npm run feather -- run src-lua/example/session_replay/adapter +``` + +The single-player example includes two scenes. Press `Tab` to switch between a reproducible checkpoint scene and a divergent scene that intentionally omits a moving hazard from its replay state. The multiplayer example uses a seeded arena, showing how roguelike-style games can restore fixed generated content from a seed while replaying mutable state such as players, scores, and pickups. The adapter example keeps all Feather-specific replay calls inside `dev/replay.lua`. + +## Programmatic Control + +```lua +if DEBUGGER then + DEBUGGER:startSessionReplay() + + -- play through a repro + + DEBUGGER:stopSessionReplay() + DEBUGGER:playSessionReplay() +end +``` + +## Local Files + +Recordings are written under Love2D save data: + +```text +feather_replays// + manifest.json + initial.json + inputs.jsonl + state-0001.jsonl +``` + +These are development artifacts. `feather doctor --production` and upload safety checks flag `feather_replays/`, `.featherreplay` files, and Session Replay runtime footprints before production builds or uploads. + +## Limits + +- V1 is local-first. Replay files stay in Love2D save data or exported `.featherreplay` archives. +- Exported `.featherreplay` files are local archives, not cloud sync. +- Determinism still depends on your game. Capture and restore RNG seeds, level state, and important simulation state when inputs alone are not enough. +- Structural sub-field diffs may come later; V1 compares each named state stream by stable JSON output. diff --git a/src-lua/plugins/session-replay/init.lua b/src-lua/plugins/session-replay/init.lua new file mode 100644 index 00000000..e648c249 --- /dev/null +++ b/src-lua/plugins/session-replay/init.lua @@ -0,0 +1,1672 @@ +local Class = require(FEATHER_PATH .. ".lib.class") +local Base = require(FEATHER_PATH .. ".core.base") +local json = require(FEATHER_PATH .. ".lib.json") + +local unpack = unpack or table.unpack + +local gettime +do + local ok, socket = pcall(require, "socket") + if ok and socket and socket.gettime then + gettime = socket.gettime + elseif love and love.timer then + gettime = love.timer.getTime + else + gettime = os.clock + end +end + +local function isArray(value) + if type(value) ~= "table" then + return false + end + local count = 0 + for key in pairs(value) do + if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then + return false + end + count = count + 1 + end + return count == #value +end + +local stableEncode +local function encodeString(value) + return json.encode(value) +end + +local function sortedKeys(value) + local keys = {} + for key in pairs(value) do + if type(key) ~= "string" then + error("session replay state tables must use string keys or array indexes", 3) + end + keys[#keys + 1] = key + end + table.sort(keys) + return keys +end + +stableEncode = function(value, stack) + local kind = type(value) + if kind == "nil" or kind == "string" or kind == "number" or kind == "boolean" then + return json.encode(value) + end + if kind ~= "table" then + error("session replay state cannot encode " .. kind, 3) + end + + stack = stack or {} + if stack[value] then + error("session replay state cannot encode circular tables", 3) + end + stack[value] = true + + local out = {} + if isArray(value) then + for index = 1, #value do + out[#out + 1] = stableEncode(value[index], stack) + end + stack[value] = nil + return "[" .. table.concat(out, ",") .. "]" + end + + for _, key in ipairs(sortedKeys(value)) do + out[#out + 1] = encodeString(key) .. ":" .. stableEncode(value[key], stack) + end + stack[value] = nil + return "{" .. table.concat(out, ",") .. "}" +end + +local function nowId() + local randomPart = math.random and math.random(100000, 999999) or os.time() + return "session_" .. os.date("%Y%m%d_%H%M%S") .. "_" .. tostring(randomPart) +end + +local function sanitizeId(value, fallback) + local id = tostring(value or fallback or "checkpoint") + id = id:gsub("[^%w%._%-]", "_") + id = id:gsub("_+", "_") + if id == "" or id == "." or id == ".." then + id = fallback or "checkpoint" + end + return id +end + +local function joinPath(base, child) + if not base or base == "" then + return child + end + return base .. "/" .. child +end + +local function ensureDirectory(path) + if love and love.filesystem and love.filesystem.createDirectory then + return love.filesystem.createDirectory(path) + end + return false +end + +local function writeFile(path, content) + if not (love and love.filesystem and love.filesystem.write) then + return false, "love.filesystem.write is unavailable" + end + return love.filesystem.write(path, content) +end + +local function appendFile(path, content) + if not (love and love.filesystem) then + return false, "love.filesystem is unavailable" + end + if love.filesystem.append then + return love.filesystem.append(path, content) + end + local existing = "" + if love.filesystem.getInfo and love.filesystem.getInfo(path) then + existing = love.filesystem.read(path) or "" + end + return love.filesystem.write(path, existing .. content) +end + +local function readFile(path) + if love and love.filesystem and love.filesystem.read then + return love.filesystem.read(path) + end + return nil +end + +local function listFiles(path) + if love and love.filesystem and love.filesystem.getDirectoryItems then + local ok, items = pcall(love.filesystem.getDirectoryItems, path) + if ok and items then + table.sort(items) + return items + end + end + return {} +end + +local function readJsonLines(path) + local content = readFile(path) + local events = {} + if not content then + return events + end + for line in content:gmatch("[^\r\n]+") do + if #line > 0 then + local ok, decoded = pcall(json.decode, line) + if ok and decoded then + events[#events + 1] = decoded + end + end + end + return events +end + +local function countStreams(streams) + local count = 0 + if type(streams) == "table" then + for _ in pairs(streams) do + count = count + 1 + end + end + return count +end + +local function clearArray(value) + for index = #value, 1, -1 do + value[index] = nil + end +end + +local function copyEvent(event) + local copy = {} + for key, value in pairs(event or {}) do + copy[key] = value + end + return copy +end + +local CALLBACK_BUS_EVENTS = { + keypressed = true, + keyreleased = true, + mousepressed = true, + mousereleased = true, + mousemoved = true, + touchpressed = true, + touchreleased = true, + touchmoved = true, + joystickpressed = true, + joystickreleased = true, + joystickhat = true, + joystickaxis = true, + gamepadpressed = true, + gamepadreleased = true, + gamepadaxis = true, +} + +local SessionReplayPlugin = Class({ + __includes = Base, + init = function(self, config) + Base.init(self, config) + self.options = config.options or {} + self.feather = config.feather + self.logger = config.logger + self.recording = false + self.replaying = false + self.recordStart = 0 + self.replayStart = 0 + self.replayInputIndex = 1 + self.replayStateIndex = 1 + self.maxInputEvents = self.options.maxInputEvents or 20000 + self.keyframeInterval = self.options.keyframeInterval or 5 + self.chunkMaxEvents = self.options.chunkMaxEvents or 1000 + self.flushInterval = tonumber(self.options.flushInterval) or 0.2 + self.flushMaxLines = tonumber(self.options.flushMaxLines) or 128 + self.checkpointInterval = tonumber(self.options.checkpointInterval) + self.maxCheckpoints = tonumber(self.options.maxCheckpoints) or 100 + if self.flushInterval < 0 then + self.flushInterval = 0 + end + if self.flushMaxLines < 1 then + self.flushMaxLines = 1 + end + if self.checkpointInterval ~= nil and self.checkpointInterval <= 0 then + self.checkpointInterval = nil + end + if self.maxCheckpoints < 1 then + self.maxCheckpoints = 1 + end + self.captureKeys = self.options.captureKeys ~= false + self.captureMouse = self.options.captureMouse ~= false + self.captureMouseMove = self.options.captureMouseMove == true + self.captureTouch = self.options.captureTouch ~= false + self.captureTouchMove = self.options.captureTouchMove == true + self.captureJoystick = self.options.captureJoystick ~= false + self.captureJoystickAxis = self.options.captureJoystickAxis == true + self.rootDir = self.options.rootDir or "feather_replays" + self.currentReplayId = nil + self.currentReplayDir = nil + self.manifest = nil + self.inputEvents = {} + self.stateEvents = {} + self.initialStates = {} + self.checkpoints = {} + self._lastState = {} + self._lastKeyframeAt = {} + self._pendingInputLines = {} + self._pendingStateLines = {} + self._lastFlushAt = 0 + self._lastCheckpointAt = nil + self._stateRegistrations = {} + self._missingRestorers = {} + self._pollOriginals = {} + self._virtualInput = { keys = {}, scancodes = {}, mouseButtons = {}, mouseX = nil, mouseY = nil } + self._pollHooked = false + self._callbackDisposers = {} + + ensureDirectory(self.rootDir) + if config.callbacks then + self:_installCallbackBusHooks(config.callbacks) + end + end, +}) + +function SessionReplayPlugin:_log(message) + if self.logger and self.logger.log then + self.logger:log({ type = "trace", str = "[SessionReplay] " .. message }) + end +end + +function SessionReplayPlugin:_shouldCapture(name) + if name == "keypressed" or name == "keyreleased" then + return self.captureKeys + elseif name == "mousepressed" or name == "mousereleased" then + return self.captureMouse + elseif name == "mousemoved" then + return self.captureMouseMove + elseif name == "touchpressed" or name == "touchreleased" then + return self.captureTouch + elseif name == "touchmoved" then + return self.captureTouchMove + elseif + name == "joystickpressed" + or name == "joystickreleased" + or name == "joystickhat" + or name == "gamepadpressed" + or name == "gamepadreleased" + then + return self.captureJoystick + elseif name == "joystickaxis" or name == "gamepadaxis" then + return self.captureJoystickAxis + end + return false +end + +function SessionReplayPlugin:_inputPath() + return joinPath(self.currentReplayDir, "inputs.jsonl") +end + +function SessionReplayPlugin:_statePath() + return joinPath(self.currentReplayDir, "state-0001.jsonl") +end + +function SessionReplayPlugin:_initialPath() + return joinPath(self.currentReplayDir, "initial.json") +end + +function SessionReplayPlugin:_checkpointIndexPath() + return joinPath(self.currentReplayDir, "checkpoints.jsonl") +end + +function SessionReplayPlugin:_checkpointDir() + return joinPath(self.currentReplayDir, "checkpoints") +end + +function SessionReplayPlugin:_checkpointPath(id) + return joinPath(self:_checkpointDir(), sanitizeId(id, "checkpoint") .. ".json") +end + +function SessionReplayPlugin:_manifestPath() + return joinPath(self.currentReplayDir, "manifest.json") +end + +function SessionReplayPlugin:_writeManifest(status) + if not self.manifest or not self.currentReplayDir then + return + end + self.manifest.status = status or self.manifest.status + self.manifest.updatedAt = os.date("!%Y-%m-%dT%H:%M:%SZ") + writeFile(self:_manifestPath(), json.encode(self.manifest)) +end + +function SessionReplayPlugin:_queueInputLine(line) + self._pendingInputLines[#self._pendingInputLines + 1] = line + if #self._pendingInputLines >= self.flushMaxLines then + self:_flushReplayWrites() + end +end + +function SessionReplayPlugin:_queueStateLine(line) + self._pendingStateLines[#self._pendingStateLines + 1] = line + if #self._pendingStateLines >= self.flushMaxLines then + self:_flushReplayWrites() + end +end + +function SessionReplayPlugin:_flushReplayWrites() + if not self.currentReplayDir then + return + end + if #self._pendingInputLines > 0 then + appendFile(self:_inputPath(), table.concat(self._pendingInputLines)) + clearArray(self._pendingInputLines) + end + if #self._pendingStateLines > 0 then + appendFile(self:_statePath(), table.concat(self._pendingStateLines)) + clearArray(self._pendingStateLines) + end + self._lastFlushAt = gettime() +end + +function SessionReplayPlugin:_recordInputEvent(name, args) + if not self.recording or self.replaying or not self:_shouldCapture(name) then + return + end + if #self.inputEvents >= self.maxInputEvents then + return + end + local event = { + time = gettime() - self.recordStart, + type = name, + args = args, + } + self.inputEvents[#self.inputEvents + 1] = event + if self.manifest then + self.manifest.inputCount = #self.inputEvents + self.manifest.duration = event.time + end + self:_queueInputLine(json.encode(event) .. "\n") +end + +function SessionReplayPlugin:_installCallbackBusHooks(callbacks) + local function register(name, argsTransform) + local ok, disposer = pcall(callbacks.register, name, function(...) + self:_recordInputEvent(name, argsTransform and argsTransform(...) or { ... }) + end) + if ok and disposer then + self._callbackDisposers[#self._callbackDisposers + 1] = disposer + end + end + + local function touchArgs(id, x, y, dx, dy, pressure) + return { tostring(id), x, y, dx, dy, pressure } + end + + local function joystickArgs(joystick, ...) + local id = joystick and joystick.getID and joystick:getID() or tostring(joystick) + return { id, ... } + end + + register("keypressed") + register("keyreleased") + register("mousepressed") + register("mousereleased") + register("mousemoved") + register("touchpressed", touchArgs) + register("touchreleased", touchArgs) + register("touchmoved", touchArgs) + register("joystickpressed", joystickArgs) + register("joystickreleased", joystickArgs) + register("joystickhat", joystickArgs) + register("joystickaxis", joystickArgs) + register("gamepadpressed", joystickArgs) + register("gamepadreleased", joystickArgs) + register("gamepadaxis", joystickArgs) +end + +function SessionReplayPlugin:_resetVirtualInput() + self._virtualInput = { keys = {}, scancodes = {}, mouseButtons = {}, mouseX = nil, mouseY = nil } +end + +function SessionReplayPlugin:_setVirtualKey(key, scancode, down) + if key ~= nil then + self._virtualInput.keys[key] = down and true or nil + end + if scancode ~= nil then + self._virtualInput.scancodes[scancode] = down and true or nil + end +end + +function SessionReplayPlugin:_setVirtualMouse(button, down, x, y) + if button ~= nil then + self._virtualInput.mouseButtons[button] = down and true or nil + end + if x ~= nil then + self._virtualInput.mouseX = x + end + if y ~= nil then + self._virtualInput.mouseY = y + end +end + +function SessionReplayPlugin:_installPollingHooks() + if self._pollHooked then + return + end + self._pollHooked = true + + if love.keyboard then + if love.keyboard.isDown then + self._pollOriginals.keyboardIsDown = love.keyboard.isDown + love.keyboard.isDown = function(...) + for _, key in ipairs({ ... }) do + if self._virtualInput.keys[key] then + return true + end + end + return self._pollOriginals.keyboardIsDown(...) + end + end + if love.keyboard.isScancodeDown then + self._pollOriginals.keyboardIsScancodeDown = love.keyboard.isScancodeDown + love.keyboard.isScancodeDown = function(...) + for _, scancode in ipairs({ ... }) do + if self._virtualInput.scancodes[scancode] then + return true + end + end + return self._pollOriginals.keyboardIsScancodeDown(...) + end + end + end + + if love.mouse then + if love.mouse.isDown then + self._pollOriginals.mouseIsDown = love.mouse.isDown + love.mouse.isDown = function(...) + for _, button in ipairs({ ... }) do + if self._virtualInput.mouseButtons[button] then + return true + end + end + return self._pollOriginals.mouseIsDown(...) + end + end + if love.mouse.getPosition then + self._pollOriginals.mouseGetPosition = love.mouse.getPosition + love.mouse.getPosition = function() + if self._virtualInput.mouseX ~= nil and self._virtualInput.mouseY ~= nil then + return self._virtualInput.mouseX, self._virtualInput.mouseY + end + return self._pollOriginals.mouseGetPosition() + end + end + end +end + +function SessionReplayPlugin:_removePollingHooks() + if not self._pollHooked then + return + end + if love.keyboard then + if self._pollOriginals.keyboardIsDown then + love.keyboard.isDown = self._pollOriginals.keyboardIsDown + end + if self._pollOriginals.keyboardIsScancodeDown then + love.keyboard.isScancodeDown = self._pollOriginals.keyboardIsScancodeDown + end + end + if love.mouse then + if self._pollOriginals.mouseIsDown then + love.mouse.isDown = self._pollOriginals.mouseIsDown + end + if self._pollOriginals.mouseGetPosition then + love.mouse.getPosition = self._pollOriginals.mouseGetPosition + end + end + self._pollOriginals = {} + self._pollHooked = false +end + +function SessionReplayPlugin:_applyVirtualInput(event) + local args = event.args or {} + if event.type == "keypressed" then + self:_setVirtualKey(args[1], args[2], true) + elseif event.type == "keyreleased" then + self:_setVirtualKey(args[1], args[2], false) + elseif event.type == "mousepressed" then + self:_setVirtualMouse(args[3], true, args[1], args[2]) + elseif event.type == "mousereleased" then + self:_setVirtualMouse(args[3], false, args[1], args[2]) + elseif event.type == "mousemoved" then + self:_setVirtualMouse(nil, nil, args[1], args[2]) + end +end + +local function resolveJoystick(id) + if not (love.joystick and love.joystick.getJoysticks) then + return nil + end + for _, joystick in ipairs(love.joystick.getJoysticks()) do + if joystick:getID() == id then + return joystick + end + end + return nil +end + +local REPLAY_RESOLVERS = { + joystickpressed = function(a) + local joystick = resolveJoystick(a[1]) + return joystick and { joystick, a[2] } + end, + joystickreleased = function(a) + local joystick = resolveJoystick(a[1]) + return joystick and { joystick, a[2] } + end, + joystickhat = function(a) + local joystick = resolveJoystick(a[1]) + return joystick and { joystick, a[2], a[3] } + end, + joystickaxis = function(a) + local joystick = resolveJoystick(a[1]) + return joystick and { joystick, a[2], a[3] } + end, + gamepadpressed = function(a) + local joystick = resolveJoystick(a[1]) + return joystick and { joystick, a[2] } + end, + gamepadreleased = function(a) + local joystick = resolveJoystick(a[1]) + return joystick and { joystick, a[2] } + end, + gamepadaxis = function(a) + local joystick = resolveJoystick(a[1]) + return joystick and { joystick, a[2], a[3] } + end, +} + +function SessionReplayPlugin:_dispatchReplayInput(event) + self:_applyVirtualInput(event) + local resolver = REPLAY_RESOLVERS[event.type] + local replayArgs = resolver and resolver(event.args or {}) or event.args or {} + if not replayArgs then + return + end + if CALLBACK_BUS_EVENTS[event.type] and love[event.type] then + pcall(love[event.type], unpack(replayArgs)) + end +end + +function SessionReplayPlugin:startRecording(opts) + opts = opts or {} + if self.recording then + return self.currentReplayId + end + if self.replaying then + return nil, "Cannot start session replay recording while replaying" + end + ensureDirectory(self.rootDir) + self.currentReplayId = opts.id or nowId() + self.currentReplayDir = joinPath(self.rootDir, self.currentReplayId) + ensureDirectory(self.currentReplayDir) + self.recordStart = gettime() + self.inputEvents = {} + self.stateEvents = {} + self.initialStates = {} + self.checkpoints = {} + self._lastState = {} + self._lastKeyframeAt = {} + self._pendingInputLines = {} + self._pendingStateLines = {} + self._lastFlushAt = self.recordStart + self._lastCheckpointAt = nil + self.manifest = { + version = 1, + id = self.currentReplayId, + status = "recording", + startedAt = os.date("!%Y-%m-%dT%H:%M:%SZ"), + duration = 0, + inputCount = 0, + stateCount = 0, + initialStateCount = 0, + keyframeCount = 0, + checkpointCount = 0, + streams = {}, + chunks = { "initial.json", "inputs.jsonl", "state-0001.jsonl", "checkpoints.jsonl" }, + } + writeFile(self:_inputPath(), "") + writeFile(self:_statePath(), "") + writeFile(self:_initialPath(), "[]") + ensureDirectory(self:_checkpointDir()) + writeFile(self:_checkpointIndexPath(), "") + self:_captureInitialStates(opts) + writeFile(self:_initialPath(), json.encode(self.initialStates)) + self.checkpoints = { self:_initialCheckpoint() } + self.manifest.checkpointCount = #self.checkpoints + self.recordStart = gettime() + self._lastFlushAt = self.recordStart + self._lastCheckpointAt = 0 + self:_writeManifest("recording") + self.recording = true + self:_log("Recording started: " .. self.currentReplayId) + return self.currentReplayId +end + +function SessionReplayPlugin:stopRecording(feather) + if not self.recording then + return self.currentReplayId + end + self:_flushReplayWrites() + self.recording = false + if self.manifest then + self.manifest.duration = gettime() - self.recordStart + end + self:_writeManifest("stopped") + self:_log("Recording stopped: " .. tostring(self.currentReplayId)) + if feather then + self:sendRecording(feather) + end + return self.currentReplayId +end + +function SessionReplayPlugin:recordState(name, state, opts) + opts = opts or {} + if not self.recording or not name or name == "" then + return false + end + + local ok, encoded = pcall(stableEncode, state) + if not ok then + self:_log("Could not encode state '" .. tostring(name) .. "': " .. tostring(encoded)) + return false, encoded + end + + local elapsed = gettime() - self.recordStart + local previous = self._lastState[name] + local keyframeDue = self._lastKeyframeAt[name] == nil + or (elapsed - self._lastKeyframeAt[name]) >= self.keyframeInterval + local isKeyframe = opts.keyframe == true or keyframeDue + if previous == encoded and not isKeyframe then + return false + end + + local event = { + time = elapsed, + name = name, + value = state, + keyframe = isKeyframe or nil, + } + self.stateEvents[#self.stateEvents + 1] = event + self._lastState[name] = encoded + if isKeyframe then + self._lastKeyframeAt[name] = elapsed + end + if self.manifest then + self.manifest.stateCount = #self.stateEvents + self.manifest.duration = elapsed + local previousStream = self.manifest.streams[name] + self.manifest.streams[name] = { + count = (previousStream and previousStream.count or 0) + 1, + initial = previousStream and previousStream.initial or nil, + hasRestore = self._stateRegistrations[name] and type(self._stateRegistrations[name].restore) == "function" + or false, + } + if isKeyframe then + self.manifest.keyframeCount = (self.manifest.keyframeCount or 0) + 1 + end + end + self:_queueStateLine(json.encode(event) .. "\n") + return true +end + +function SessionReplayPlugin:recordInitialState(name, state, opts) + opts = opts or {} + if type(name) ~= "string" or name == "" then + return false, "state name is required" + end + if not self.currentReplayDir or not self.manifest then + return false, "No active session replay baseline" + end + + local ok, encoded = pcall(stableEncode, state) + if not ok then + self:_log("Could not encode initial state '" .. tostring(name) .. "': " .. tostring(encoded)) + return false, encoded + end + + local event = { + name = name, + value = state, + time = 0, + keyframe = true, + source = opts.source, + } + for index = #self.initialStates, 1, -1 do + if self.initialStates[index].name == name then + table.remove(self.initialStates, index) + end + end + self.initialStates[#self.initialStates + 1] = event + self._lastState[name] = encoded + self._lastKeyframeAt[name] = 0 + + if self.manifest then + self.manifest.initialStateCount = #self.initialStates + self.manifest.streams[name] = self.manifest.streams[name] or {} + self.manifest.streams[name].initial = true + self.manifest.streams[name].hasRestore = self._stateRegistrations[name] + and type(self._stateRegistrations[name].restore) == "function" + or false + end + if self.currentReplayDir then + writeFile(self:_initialPath(), json.encode(self.initialStates)) + self:_writeManifest(self.manifest.status) + end + return true +end + +function SessionReplayPlugin:_recordInitialMap(states, source) + if type(states) ~= "table" then + return + end + if type(states.name) == "string" and states.value ~= nil then + self:recordInitialState(states.name, states.value, { source = source }) + return + end + for name, state in pairs(states) do + if type(name) == "string" then + self:recordInitialState(name, state, { source = source }) + elseif type(state) == "table" and type(state.name) == "string" then + self:recordInitialState(state.name, state.value, { source = source }) + end + end +end + +function SessionReplayPlugin:_captureInitialStates(opts) + opts = opts or {} + self:_recordInitialMap(opts.initialState, "start") + self:_recordInitialMap(opts.initialStates, "start") + + if opts.captureInitial == false then + return + end + + for name, registration in pairs(self._stateRegistrations) do + if type(registration.capture) == "function" and not self._lastState[name] then + local ok, state = pcall(registration.capture) + if ok then + self:recordInitialState(name, state, { source = "capture" }) + else + self:_log("Initial capture failed for state '" .. tostring(name) .. "': " .. tostring(state)) + end + end + end +end + +function SessionReplayPlugin:_initialCheckpoint() + return { + id = "0", + time = 0, + label = "Start", + source = "initial", + inputIndex = 1, + stateIndex = 1, + stateCount = #self.initialStates, + states = self.initialStates, + } +end + +function SessionReplayPlugin:_normalizeCheckpointOptions(nameOrOpts, stateOverrides) + local opts = {} + local overrides = stateOverrides + if type(nameOrOpts) == "table" then + for key, value in pairs(nameOrOpts) do + opts[key] = value + end + if opts.label == nil and type(opts.name) == "string" then + opts.label = opts.name + end + overrides = overrides or opts.states or opts.stateOverrides + elseif type(nameOrOpts) == "string" and nameOrOpts ~= "" then + opts.label = nameOrOpts + opts.id = nameOrOpts + end + return opts, overrides +end + +function SessionReplayPlugin:_stateEventsFromMap(states, elapsed, source) + local events = {} + if type(states) ~= "table" then + return events + end + local function add(name, value) + if type(name) ~= "string" or name == "" then + return + end + local ok, encoded = pcall(stableEncode, value) + if not ok then + self:_log("Could not encode checkpoint state '" .. tostring(name) .. "': " .. tostring(encoded)) + return + end + events[#events + 1] = { + name = name, + value = value, + time = elapsed or 0, + keyframe = true, + source = source, + } + end + if type(states.name) == "string" and states.value ~= nil then + add(states.name, states.value) + return events + end + for name, state in pairs(states) do + if type(name) == "string" then + add(name, state) + elseif type(state) == "table" and type(state.name) == "string" then + add(state.name, state.value) + end + end + return events +end + +function SessionReplayPlugin:_captureCheckpointStates(elapsed, overrides, source) + local byName = {} + for name, registration in pairs(self._stateRegistrations) do + if type(registration.capture) == "function" then + local ok, state = pcall(registration.capture) + if ok then + byName[name] = { + name = name, + value = state, + time = elapsed or 0, + keyframe = true, + source = source, + } + else + self:_log("Checkpoint capture failed for state '" .. tostring(name) .. "': " .. tostring(state)) + end + end + end + for _, event in ipairs(self:_stateEventsFromMap(overrides, elapsed, source)) do + byName[event.name] = event + end + local events = {} + for _, event in pairs(byName) do + events[#events + 1] = event + end + table.sort(events, function(a, b) + return tostring(a.name) < tostring(b.name) + end) + return events +end + +function SessionReplayPlugin:_checkpointId(base) + local id = sanitizeId(base, "checkpoint_" .. string.format("%04d", math.max(#self.checkpoints, 1))) + local used = {} + for _, checkpoint in ipairs(self.checkpoints or {}) do + used[checkpoint.id] = true + end + if not used[id] then + return id + end + local index = 1 + while used[id .. "_" .. tostring(index)] do + index = index + 1 + end + return id .. "_" .. tostring(index) +end + +function SessionReplayPlugin:recordCheckpoint(nameOrOpts, stateOverrides) + if not self.recording or not self.currentReplayDir or not self.manifest then + return false, "No active session replay recording" + end + if (#self.checkpoints - 1) >= self.maxCheckpoints then + return false, "Session replay checkpoint limit reached" + end + local opts, overrides = self:_normalizeCheckpointOptions(nameOrOpts, stateOverrides) + local elapsed = gettime() - self.recordStart + local source = opts.source or "manual" + local id = self:_checkpointId(opts.id or opts.label) + local label = opts.label or id + local states = self:_captureCheckpointStates(elapsed, overrides, source) + local checkpoint = { + id = id, + time = elapsed, + label = label, + source = source, + inputIndex = #self.inputEvents + 1, + stateIndex = #self.stateEvents + 1, + stateCount = #states, + } + + ensureDirectory(self:_checkpointDir()) + writeFile(self:_checkpointPath(id), json.encode({ + id = id, + label = label, + source = source, + time = elapsed, + inputIndex = checkpoint.inputIndex, + stateIndex = checkpoint.stateIndex, + states = states, + })) + appendFile(self:_checkpointIndexPath(), json.encode(checkpoint) .. "\n") + + local loaded = copyEvent(checkpoint) + loaded.states = states + self.checkpoints[#self.checkpoints + 1] = loaded + self._lastCheckpointAt = elapsed + self.manifest.checkpointCount = #self.checkpoints + self.manifest.duration = math.max(self.manifest.duration or 0, elapsed) + self:_writeManifest(self.manifest.status) + return id +end + +function SessionReplayPlugin:registerState(name, captureFn, restoreFn, opts) + if type(name) ~= "string" or name == "" then + return false, "state name is required" + end + self._stateRegistrations[name] = { + capture = captureFn, + restore = restoreFn, + opts = opts or {}, + sampleInterval = opts and opts.sampleInterval or 0, + nextSampleAt = 0, + } + return true +end + +function SessionReplayPlugin:_captureRegisteredStates(elapsed) + for name, registration in pairs(self._stateRegistrations) do + if type(registration.capture) == "function" and elapsed >= (registration.nextSampleAt or 0) then + registration.nextSampleAt = elapsed + (registration.sampleInterval or 0) + local ok, state = pcall(registration.capture) + if ok then + self:recordState(name, state, registration.opts) + else + self:_log("Capture failed for state '" .. tostring(name) .. "': " .. tostring(state)) + end + end + end +end + +function SessionReplayPlugin:_loadReplay(idOrPath) + local replayId = idOrPath or self.currentReplayId + if not replayId or replayId == "" then + return nil, "No session replay selected" + end + local dir = replayId:find("/") and replayId or joinPath(self.rootDir, replayId) + local manifestContent = readFile(joinPath(dir, "manifest.json")) + if not manifestContent then + return nil, "No manifest found for " .. tostring(replayId) + end + local ok, manifest = pcall(json.decode, manifestContent) + if not ok or not manifest then + return nil, "Invalid manifest for " .. tostring(replayId) + end + + local inputEvents = readJsonLines(joinPath(dir, "inputs.jsonl")) + local initialContent = readFile(joinPath(dir, "initial.json")) + local initialStates = {} + if initialContent and #initialContent > 0 then + local initialOk, decoded = pcall(json.decode, initialContent) + if initialOk and type(decoded) == "table" then + initialStates = decoded + end + end + local stateEvents = {} + for _, file in ipairs(listFiles(dir)) do + if file:match("^state%-%d+%.jsonl$") then + local events = readJsonLines(joinPath(dir, file)) + for _, event in ipairs(events) do + stateEvents[#stateEvents + 1] = event + end + end + end + table.sort(stateEvents, function(a, b) + return (a.time or 0) < (b.time or 0) + end) + + local checkpoints = { + { + id = "0", + time = 0, + label = "Start", + source = "initial", + inputIndex = 1, + stateIndex = 1, + stateCount = #initialStates, + states = initialStates, + }, + } + for _, checkpoint in ipairs(readJsonLines(joinPath(dir, "checkpoints.jsonl"))) do + if checkpoint.id then + checkpoint.id = tostring(checkpoint.id) + local checkpointContent = readFile(joinPath(joinPath(dir, "checkpoints"), sanitizeId(checkpoint.id) .. ".json")) + local states = {} + if checkpointContent and #checkpointContent > 0 then + local checkpointOk, decoded = pcall(json.decode, checkpointContent) + if checkpointOk and type(decoded) == "table" then + if checkpoint.label == nil and type(decoded.label) == "string" then + checkpoint.label = decoded.label + end + if checkpoint.source == nil and type(decoded.source) == "string" then + checkpoint.source = decoded.source + end + if checkpoint.inputIndex == nil and type(decoded.inputIndex) == "number" then + checkpoint.inputIndex = decoded.inputIndex + end + if checkpoint.stateIndex == nil and type(decoded.stateIndex) == "number" then + checkpoint.stateIndex = decoded.stateIndex + end + if type(decoded.states) == "table" then + states = decoded.states + end + end + end + local loaded = copyEvent(checkpoint) + loaded.label = loaded.label or loaded.id + loaded.states = states + loaded.stateCount = loaded.stateCount or #states + checkpoints[#checkpoints + 1] = loaded + end + end + table.sort(checkpoints, function(a, b) + return (a.time or 0) < (b.time or 0) + end) + + return { + id = manifest.id or replayId, + dir = dir, + manifest = manifest, + initialStates = initialStates, + inputEvents = inputEvents, + stateEvents = stateEvents, + checkpoints = checkpoints, + } +end + +function SessionReplayPlugin:startReplay(idOrPath, opts) + opts = opts or {} + if self.recording then + self:stopRecording() + end + local replay, err = self:_loadReplayIntoMemory(idOrPath) + if not replay then + return false, err + end + + self:_resetVirtualInput() + self:_installPollingHooks() + if opts.seekTo ~= nil then + local checkpoint, targetTime = self:_checkpointFor(opts.seekTo) + local ok, applyErr = self:_applyCheckpoint(checkpoint) + if not ok then + return false, applyErr + end + self:_fastForwardTo(targetTime) + self.replayStart = gettime() - targetTime + else + self:_applyInitialStates() + self.replayStart = gettime() + end + self.replaying = true + self:_log("Replay started: " .. tostring(replay.id)) + return true +end + +function SessionReplayPlugin:stopReplay() + self.replaying = false + self:_resetVirtualInput() + self:_removePollingHooks() + self:_log("Replay stopped") +end + +function SessionReplayPlugin:_applyReplayState(event) + local registration = self._stateRegistrations[event.name] + if registration and type(registration.restore) == "function" then + local ok, err = pcall(registration.restore, event.value, event) + if not ok then + self:_log("Restore failed for state '" .. tostring(event.name) .. "': " .. tostring(err)) + end + else + self._missingRestorers[event.name] = true + end +end + +function SessionReplayPlugin:_applyInitialStates() + for _, event in ipairs(self.initialStates or {}) do + self:_applyReplayState(event) + end +end + +function SessionReplayPlugin:_checkpointFor(target) + local checkpoints = self.checkpoints or {} + if type(target) == "string" then + for _, checkpoint in ipairs(checkpoints) do + if checkpoint.id == target or checkpoint.label == target then + return checkpoint, checkpoint.time or 0 + end + end + end + + local targetTime = tonumber(target) or 0 + local selected = checkpoints[1] + for _, checkpoint in ipairs(checkpoints) do + if (checkpoint.time or 0) <= targetTime then + selected = checkpoint + else + break + end + end + return selected, targetTime +end + +function SessionReplayPlugin:_applyCheckpoint(checkpoint) + if not checkpoint then + return false, "No checkpoint available" + end + self:_resetVirtualInput() + for _, event in ipairs(checkpoint.states or {}) do + self:_applyReplayState(event) + end + self.replayInputIndex = checkpoint.inputIndex or 1 + self.replayStateIndex = checkpoint.stateIndex or 1 + return true +end + +function SessionReplayPlugin:_fastForwardTo(targetTime) + targetTime = tonumber(targetTime) or 0 + while self.replayStateIndex <= #self.stateEvents do + local event = self.stateEvents[self.replayStateIndex] + if (event.time or 0) > targetTime then + break + end + self:_applyReplayState(event) + self.replayStateIndex = self.replayStateIndex + 1 + end + + while self.replayInputIndex <= #self.inputEvents do + local event = self.inputEvents[self.replayInputIndex] + if (event.time or 0) > targetTime then + break + end + self:_dispatchReplayInput(event) + self.replayInputIndex = self.replayInputIndex + 1 + end +end + +function SessionReplayPlugin:_loadReplayIntoMemory(idOrPath) + local replay, err = self:_loadReplay(idOrPath) + if not replay then + self:_log(tostring(err)) + return nil, err + end + self.currentReplayId = replay.id + self.currentReplayDir = replay.dir + self.manifest = replay.manifest + self.initialStates = replay.initialStates or {} + self.inputEvents = replay.inputEvents or {} + self.stateEvents = replay.stateEvents or {} + self.checkpoints = replay.checkpoints or { self:_initialCheckpoint() } + self.replayInputIndex = 1 + self.replayStateIndex = 1 + self._missingRestorers = {} + return replay +end + +function SessionReplayPlugin:seekReplay(target, opts) + opts = opts or {} + if self.recording then + return false, "Cannot seek while recording" + end + if not self.currentReplayDir then + return false, "No session replay selected" + end + local replay, err = self:_loadReplayIntoMemory(self.currentReplayDir) + if not replay then + return false, err + end + local checkpoint, targetTime = self:_checkpointFor(target) + if not checkpoint then + return false, "No checkpoint available" + end + self:_installPollingHooks() + local ok, applyErr = self:_applyCheckpoint(checkpoint) + if not ok then + return false, applyErr + end + self:_fastForwardTo(targetTime) + self.replaying = opts.play == true + self.replayStart = gettime() - targetTime + if not self.replaying then + self:_removePollingHooks() + end + self:_log("Replay seeked to " .. tostring(target) .. " from checkpoint " .. tostring(checkpoint.id)) + return true +end + +function SessionReplayPlugin:update(_dt, feather) + if self.recording then + local elapsed = gettime() - self.recordStart + self:_captureRegisteredStates(elapsed) + if self.checkpointInterval and (elapsed - (self._lastCheckpointAt or 0)) >= self.checkpointInterval then + self:recordCheckpoint({ source = "auto", label = "Auto " .. string.format("%.1fs", elapsed) }) + end + if (gettime() - self._lastFlushAt) >= self.flushInterval then + self:_flushReplayWrites() + end + end + + if not self.replaying then + return + end + + local elapsed = gettime() - self.replayStart + while self.replayStateIndex <= #self.stateEvents do + local event = self.stateEvents[self.replayStateIndex] + if (event.time or 0) > elapsed then + break + end + self:_applyReplayState(event) + self.replayStateIndex = self.replayStateIndex + 1 + end + + while self.replayInputIndex <= #self.inputEvents do + local event = self.inputEvents[self.replayInputIndex] + if (event.time or 0) > elapsed then + break + end + self:_dispatchReplayInput(event) + self.replayInputIndex = self.replayInputIndex + 1 + end + + if self.replayInputIndex > #self.inputEvents and self.replayStateIndex > #self.stateEvents then + self:stopReplay() + if feather then + self:sendStatus(feather) + end + end +end + +function SessionReplayPlugin:_filePayload(path, feather, dir) + local content = readFile(joinPath(dir or self.currentReplayDir, path)) or "" + if feather and feather.attachBinary then + local ref = feather:attachBinary("application/json", content) + return { path = path, content = ref.src, binary = ref.binary, bytes = #content } + end + return { path = path, content = content, bytes = #content } +end + +function SessionReplayPlugin:sendRecording(feather, idOrPath) + if not feather then + return + end + if idOrPath and self.recording then + return false, "Cannot load another replay while recording" + end + if idOrPath then + local replay, err = self:_loadReplay(idOrPath) + if not replay then + return false, err + end + self.currentReplayId = replay.id + self.currentReplayDir = replay.dir + self.manifest = replay.manifest + self.initialStates = replay.initialStates or {} + self.inputEvents = replay.inputEvents + self.stateEvents = replay.stateEvents + self.checkpoints = replay.checkpoints or {} + end + if not self.currentReplayDir then + return false, "No replay selected" + end + if self.recording and self.manifest then + self:_flushReplayWrites() + self:_writeManifest("recording") + end + local files = { + self:_filePayload("manifest.json", feather, self.currentReplayDir), + self:_filePayload("initial.json", feather, self.currentReplayDir), + self:_filePayload("inputs.jsonl", feather, self.currentReplayDir), + self:_filePayload("checkpoints.jsonl", feather, self.currentReplayDir), + } + for _, file in ipairs(listFiles(self.currentReplayDir)) do + if file:match("^state%-%d+%.jsonl$") then + files[#files + 1] = self:_filePayload(file, feather, self.currentReplayDir) + end + end + for _, file in ipairs(listFiles(joinPath(self.currentReplayDir, "checkpoints"))) do + if file:match("%.json$") then + files[#files + 1] = self:_filePayload(joinPath("checkpoints", file), feather, self.currentReplayDir) + end + end + feather:__sendWs(json.encode({ + type = "session_replay:recording", + session = feather.sessionId, + data = { + manifest = self.manifest, + files = files, + }, + })) + feather:__sendPendingBinaries() + return true +end + +function SessionReplayPlugin:_replaySummary(id, manifest) + manifest = manifest or {} + return { + id = tostring(manifest.id or id), + status = manifest.status or "unknown", + startedAt = manifest.startedAt, + updatedAt = manifest.updatedAt, + duration = manifest.duration or 0, + inputCount = manifest.inputCount or 0, + stateCount = manifest.stateCount or 0, + initialStateCount = manifest.initialStateCount or 0, + keyframeCount = manifest.keyframeCount or 0, + checkpointCount = manifest.checkpointCount or 0, + streamCount = countStreams(manifest.streams), + } +end + +function SessionReplayPlugin:listReplays() + ensureDirectory(self.rootDir) + local replays = {} + for _, id in ipairs(listFiles(self.rootDir)) do + local manifestContent = readFile(joinPath(joinPath(self.rootDir, id), "manifest.json")) + if manifestContent then + local ok, manifest = pcall(json.decode, manifestContent) + if ok and type(manifest) == "table" then + replays[#replays + 1] = self:_replaySummary(id, manifest) + end + end + end + table.sort(replays, function(a, b) + return tostring(a.updatedAt or a.startedAt or a.id) > tostring(b.updatedAt or b.startedAt or b.id) + end) + return replays +end + +function SessionReplayPlugin:sendReplayList(feather) + if not feather then + return + end + feather:__sendWs(json.encode({ + type = "session_replay:list", + session = feather.sessionId, + data = { + replays = self:listReplays(), + selectedId = self.currentReplayId, + }, + })) +end + +function SessionReplayPlugin:sendStatus(feather) + if not feather then + return + end + local missing = {} + for name in pairs(self._missingRestorers) do + missing[#missing + 1] = name + end + table.sort(missing) + feather:__sendWs(json.encode({ + type = "session_replay:status", + session = feather.sessionId, + data = { + recording = self.recording, + replaying = self.replaying, + replayId = self.currentReplayId, + duration = self.manifest and self.manifest.duration or 0, + inputCount = #self.inputEvents, + stateCount = #self.stateEvents, + initialStateCount = #self.initialStates, + checkpointCount = #self.checkpoints, + streamCount = self.manifest and countStreams(self.manifest.streams) or 0, + missingRestorers = missing, + }, + })) +end + +function SessionReplayPlugin:importReplay(payload) + if type(payload) ~= "table" or type(payload.files) ~= "table" then + return false, "Replay import payload must include files" + end + local manifestFile + for _, file in ipairs(payload.files) do + if file.path == "manifest.json" then + manifestFile = file + break + end + end + if not manifestFile or type(manifestFile.content) ~= "string" then + return false, "Replay import is missing manifest.json" + end + local ok, manifest = pcall(json.decode, manifestFile.content) + if not ok or type(manifest) ~= "table" or not manifest.id then + return false, "Replay manifest is invalid" + end + local replayId = tostring(manifest.id) + local dir = joinPath(self.rootDir, replayId) + ensureDirectory(self.rootDir) + ensureDirectory(dir) + ensureDirectory(joinPath(dir, "checkpoints")) + for _, file in ipairs(payload.files) do + if + type(file.path) == "string" + and type(file.content) == "string" + and not file.path:find("%.%.", 1, true) + and not file.path:find("\\", 1, true) + and file.path:sub(1, 1) ~= "/" + then + writeFile(joinPath(dir, file.path), file.content) + end + end + self.currentReplayId = replayId + self.currentReplayDir = dir + self.manifest = manifest + self.initialStates = {} + local initialContent = readFile(joinPath(dir, "initial.json")) + if initialContent and #initialContent > 0 then + local initialOk, decoded = pcall(json.decode, initialContent) + if initialOk and type(decoded) == "table" then + self.initialStates = decoded + end + end + local replay = self:_loadReplay(replayId) + if replay then + self.inputEvents = replay.inputEvents or {} + self.stateEvents = replay.stateEvents or {} + self.checkpoints = replay.checkpoints or {} + end + return true +end + +function SessionReplayPlugin:deleteReplay(id) + id = id or self.currentReplayId + if not id then + return false, "No replay selected" + end + if not (love and love.filesystem and love.filesystem.remove) then + return false, "love.filesystem.remove is unavailable" + end + local dir = id:find("/") and id or joinPath(self.rootDir, id) + local checkpointDir = joinPath(dir, "checkpoints") + for _, file in ipairs(listFiles(checkpointDir)) do + love.filesystem.remove(joinPath(checkpointDir, file)) + end + love.filesystem.remove(checkpointDir) + for _, file in ipairs(listFiles(dir)) do + love.filesystem.remove(joinPath(dir, file)) + end + love.filesystem.remove(dir) + if self.currentReplayId == id then + self.currentReplayId = nil + self.currentReplayDir = nil + self.manifest = nil + self.inputEvents = {} + self.stateEvents = {} + self.initialStates = {} + self.checkpoints = {} + self._pendingInputLines = {} + self._pendingStateLines = {} + end + return true +end + +function SessionReplayPlugin:handleSessionReplayCommand(msg, feather) + if msg.type == "cmd:session_replay:start" then + local id, err = self:startRecording(msg.data or {}) + self:sendStatus(feather) + self:sendReplayList(feather) + return id ~= nil, err + elseif msg.type == "cmd:session_replay:stop" then + self:stopRecording(feather) + self:sendStatus(feather) + self:sendReplayList(feather) + return true + elseif msg.type == "cmd:session_replay:request" then + local ok, err = self:sendRecording(feather, msg.data and (msg.data.id or msg.data.path) or nil) + self:sendStatus(feather) + self:sendReplayList(feather) + return ok, err + elseif msg.type == "cmd:session_replay:list" then + self:sendReplayList(feather) + self:sendStatus(feather) + return true + elseif msg.type == "cmd:session_replay:play" then + local data = msg.data or {} + local ok, err = self:startReplay(data.id or data.path, data) + self:sendStatus(feather) + return ok, err + elseif msg.type == "cmd:session_replay:seek" then + local data = msg.data or {} + local ok, err = self:seekReplay(data.target or data.seekTo, { play = data.play == true }) + self:sendStatus(feather) + return ok, err + elseif msg.type == "cmd:session_replay:stop_replay" then + self:stopReplay() + self:sendStatus(feather) + return true + elseif msg.type == "cmd:session_replay:import" then + local ok, err = self:importReplay(msg.data or {}) + self:sendStatus(feather) + self:sendReplayList(feather) + return ok, err + elseif msg.type == "cmd:session_replay:delete" then + local ok, err = self:deleteReplay(msg.data and msg.data.id or nil) + self:sendStatus(feather) + self:sendReplayList(feather) + return ok, err + end + return false, "Unknown session replay command" +end + +function SessionReplayPlugin:handleActionRequest(request, feather) + local action = request.params and request.params.action + if action == "record" then + if self.recording then + self:stopRecording(feather) + else + local id, err = self:startRecording() + if not id then + return false, err + end + end + self:sendStatus(feather) + self:sendReplayList(feather) + return true + elseif action == "replay" then + if self.replaying then + self:stopReplay() + else + self:startReplay() + end + self:sendStatus(feather) + return true + elseif action == "load" then + self:sendRecording(feather) + self:sendReplayList(feather) + return true + elseif action == "clear" then + self.inputEvents = {} + self.stateEvents = {} + self.initialStates = {} + self.checkpoints = {} + self._lastState = {} + self._pendingInputLines = {} + self._pendingStateLines = {} + self._missingRestorers = {} + self:sendStatus(feather) + return true + end +end + +function SessionReplayPlugin:handleRequest(_request, _feather) + local status = self.recording and "recording" or self.replaying and "replaying" or "idle" + return { + type = "table", + loading = self.recording or self.replaying, + columns = { + { key = "field", label = "Field" }, + { key = "value", label = "Value" }, + }, + data = { + { field = "Status", value = status }, + { field = "Replay", value = self.currentReplayId or "none" }, + { field = "Inputs", value = tostring(#self.inputEvents) }, + { field = "States", value = tostring(#self.stateEvents) }, + { field = "Initial states", value = tostring(#self.initialStates) }, + { field = "Checkpoints", value = tostring(#self.checkpoints) }, + }, + _status = status, + _replayId = self.currentReplayId, + _inputCount = #self.inputEvents, + _stateCount = #self.stateEvents, + _initialStateCount = #self.initialStates, + _checkpointCount = #self.checkpoints, + } +end + +function SessionReplayPlugin:finish() + self:_flushReplayWrites() + self.recording = false + self.replaying = false + self:_removePollingHooks() + for _, dispose in ipairs(self._callbackDisposers) do + pcall(dispose) + end + self._callbackDisposers = {} +end + +function SessionReplayPlugin:getConfig() + return { + type = "session-replay", + icon = "repeat", + docs = "https://github.com/Kyonru/feather/tree/main/src-lua/plugins/session-replay", + recording = self.recording, + replaying = self.replaying, + replayId = self.currentReplayId, + actions = { + { + label = self.recording and "Stop Recording" or "Record", + key = "record", + icon = self.recording and "square" or "circle", + type = "button", + }, + { + label = self.replaying and "Stop Replay" or "Replay", + key = "replay", + icon = self.replaying and "square" or "play", + type = "button", + }, + { label = "Load", key = "load", icon = "download", type = "button" }, + { label = "Clear", key = "clear", icon = "trash-2", type = "button" }, + }, + } +end + +return SessionReplayPlugin diff --git a/src-lua/plugins/session-replay/manifest.lua b/src-lua/plugins/session-replay/manifest.lua new file mode 100644 index 00000000..8f753e19 --- /dev/null +++ b/src-lua/plugins/session-replay/manifest.lua @@ -0,0 +1,26 @@ +return { + id = "session-replay", + name = "Session Replay", + description = "Record input and developer-provided state checkpoints for reproducible playthroughs", + version = "0.1.0", + api = { min = 5 }, + capabilities = { "input", "filesystem", "binary" }, + optIn = true, + disabled = true, + opts = { + captureKeys = true, + captureMouse = true, + captureMouseMove = false, + captureTouch = true, + captureTouchMove = false, + captureJoystick = true, + captureJoystickAxis = false, + maxInputEvents = 20000, + keyframeInterval = 5, + chunkMaxEvents = 1000, + flushInterval = 0.2, + flushMaxLines = 128, + checkpointInterval = nil, + maxCheckpoints = 100, + }, +} diff --git a/src-lua/plugins/session-replay/replay_adapter.lua b/src-lua/plugins/session-replay/replay_adapter.lua new file mode 100644 index 00000000..8912a162 --- /dev/null +++ b/src-lua/plugins/session-replay/replay_adapter.lua @@ -0,0 +1,85 @@ +-- dev/replay.lua +-- Generated by feather replay init. +-- +-- Keep Session Replay integration here instead of scattering DEBUGGER calls +-- through gameplay code. This file is safe to require in production because it +-- no-ops when DEBUGGER is not available. + +local M = {} + +local STREAM = "game" + +local function capture() + -- Replace this with a compact game checkpoint. + -- Good candidates: + -- world = { seed = run.seed, levelId = currentLevel.id } + -- player = { x = player.x, y = player.y, health = player.health } + -- flags = saveSystem.captureFlags() + return { + world = { + -- seed = run.seed, + -- levelId = currentLevel.id, + }, + player = { + -- x = player.x, + -- y = player.y, + }, + } +end + +local function restore(state) + if not state then + return + end + + -- Restore fixed/generated content first, then mutable gameplay state. + -- Example: + -- if state.world and state.world.levelId then loadLevel(state.world.levelId) end + -- if state.world and state.world.seed then generateArenaFromSeed(state.world.seed) end + -- if state.player then restorePlayer(state.player) end +end + +function M.register(debugger) + debugger = debugger or rawget(_G, "DEBUGGER") + if not (debugger and debugger.replayRegister) then + return false + end + + debugger:replayRegister(STREAM, capture, restore, { + sampleInterval = 0.1, + }) + return true +end + +function M.start(debugger, opts) + debugger = debugger or rawget(_G, "DEBUGGER") + if not (debugger and debugger.startSessionReplay) then + return nil, "DEBUGGER is not available" + end + + opts = opts or {} + opts.initialStates = opts.initialStates or {} + opts.initialStates[STREAM] = opts.initialStates[STREAM] or capture() + return debugger:startSessionReplay(opts) +end + +function M.stop(debugger) + debugger = debugger or rawget(_G, "DEBUGGER") + if not (debugger and debugger.stopSessionReplay) then + return nil + end + return debugger:stopSessionReplay() +end + +function M.play(debugger, idOrPath) + debugger = debugger or rawget(_G, "DEBUGGER") + if not (debugger and debugger.playSessionReplay) then + return nil + end + return debugger:playSessionReplay(idOrPath) +end + +M.capture = capture +M.restore = restore + +return M diff --git a/src-lua/plugins/shader-graph/init.lua b/src-lua/plugins/shader-graph/init.lua index 8e4adc05..4b4fb1f6 100644 --- a/src-lua/plugins/shader-graph/init.lua +++ b/src-lua/plugins/shader-graph/init.lua @@ -48,4 +48,11 @@ function ShaderGraphPlugin:handleActionRequest(request) return { status = "error", pixelError = "Unknown shader graph action: " .. tostring(action) } end +function ShaderGraphPlugin:getConfig() + return { + type = "shader-graph", + icon = "blend", + } +end + return ShaderGraphPlugin diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8d970c88..65da2bf1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1008,7 +1008,7 @@ dependencies = [ [[package]] name = "feather" -version = "1.2.0" +version = "1.3.0" dependencies = [ "axum", "futures-util", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 203e0d8e..e9e83951 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "feather" -version = "1.2.0" +version = "1.3.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" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8496f747..af6107f6 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": "1.2.0", + "version": "1.3.0", "identifier": "com.kyonru.love.feather", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/app-sidebar/nav-main.tsx b/src/components/app-sidebar/nav-main.tsx index ca492422..bf93ad84 100644 --- a/src/components/app-sidebar/nav-main.tsx +++ b/src/components/app-sidebar/nav-main.tsx @@ -16,6 +16,7 @@ import { GitCompareArrowsIcon, ImagesIcon, LogsIcon, + RepeatIcon, SparklesIcon, TelescopeIcon, TerminalIcon, @@ -74,6 +75,11 @@ export function NavMain() { url: '/time-travel', icon: ClockIcon, }, + { + title: 'Session Replay', + url: '/session-replay', + icon: RepeatIcon, + }, { title: 'Compare', url: '/compare', diff --git a/src/hooks/use-plugin.ts b/src/hooks/use-plugin.ts index 7e891791..3ab49310 100644 --- a/src/hooks/use-plugin.ts +++ b/src/hooks/use-plugin.ts @@ -9,6 +9,8 @@ import { sessionQueryKey } from './use-ws-connection'; import { toast } from 'sonner'; import { isWeb } from '@/utils/platform'; +export type PluginParamValue = string | boolean; + /** Strip the /plugins/ prefix so the ID matches what Lua uses as plugin.identifier */ function normalizePluginId(pluginId: string): string { return pluginId.replace(/^\/plugins\//, ''); @@ -208,7 +210,7 @@ export type PluginContentProps = export const usePluginAction = (pluginId: string) => { const sessionId = useSessionStore((state) => state.sessionId); const normalized = normalizePluginId(pluginId); - const [params, setParams] = useState>({}); + const [params, setParams] = useState>({}); const paramsRef = useRef(params); paramsRef.current = params; @@ -258,8 +260,10 @@ export const usePluginAction = (pluginId: string) => { [sessionId, normalized], ); - const onActionChange = (action: string, value: string | boolean) => { - setParams((prev) => ({ ...prev, [action]: value })); + const onActionChange = (action: string, value: PluginParamValue) => { + const next = { ...paramsRef.current, [action]: value }; + paramsRef.current = next; + setParams(next); updateOptions(); }; @@ -267,7 +271,7 @@ export const usePluginAction = (pluginId: string) => { sendCommand({ type: 'cmd:plugin:toggle', plugin: normalized }); }; - return { onAction, onFileAction, onCancel, onActionChange, onToggle }; + return { onAction, onFileAction, onCancel, onActionChange, onToggle, pendingParams: params }; }; export function usePlugin(pluginId: string) { diff --git a/src/hooks/use-session-replay.ts b/src/hooks/use-session-replay.ts new file mode 100644 index 00000000..917aa53c --- /dev/null +++ b/src/hooks/use-session-replay.ts @@ -0,0 +1,282 @@ +import { useEffect } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { sendCommand } from '@/lib/send-command'; +import { useSessionStore } from '@/store/session'; +import { sessionQueryKey } from './use-ws-connection'; + +export type SessionReplayStatus = { + recording: boolean; + replaying: boolean; + replayId?: string | null; + duration: number; + inputCount: number; + stateCount: number; + initialStateCount: number; + checkpointCount: number; + streamCount: number; + missingRestorers: string[]; +}; + +export type SessionReplayFile = { + path: string; + content: string; + bytes?: number; + binary?: { id: string; mime: string }; +}; + +export type SessionReplayRecording = { + manifest?: Record; + files: SessionReplayFile[]; +}; + +export type SessionReplayCheckpoint = { + id: string; + time: number; + label?: string; + source?: string; + inputIndex?: number; + stateIndex?: number; + stateCount?: number; +}; + +export type SessionReplaySummary = { + id: string; + status?: string; + startedAt?: string; + updatedAt?: string; + duration: number; + inputCount: number; + stateCount: number; + initialStateCount?: number; + keyframeCount?: number; + checkpointCount?: number; + streamCount: number; +}; + +export type SessionReplayRecordings = Record; + +const DEFAULT_STATUS: SessionReplayStatus = { + recording: false, + replaying: false, + replayId: null, + duration: 0, + inputCount: 0, + stateCount: 0, + initialStateCount: 0, + checkpointCount: 0, + streamCount: 0, + missingRestorers: [], +}; + +export function replayRecordingId(recording: SessionReplayRecording | null | undefined): string | null { + return typeof recording?.manifest?.id === 'string' ? recording.manifest.id : null; +} + +function recordingFromFiles(files: SessionReplayFile[]): SessionReplayRecording | null { + const manifestFile = files.find((file) => file.path === 'manifest.json'); + if (!manifestFile) return null; + try { + const manifest = JSON.parse(manifestFile.content) as Record; + return { manifest, files }; + } catch { + return null; + } +} + +export function checkpointsFromRecording(recording: SessionReplayRecording | null | undefined): SessionReplayCheckpoint[] { + const manifestId = replayRecordingId(recording); + if (!recording || !manifestId) return []; + let initialStateCount: number | undefined; + const initialContent = recording.files.find((file) => file.path === 'initial.json')?.content; + if (initialContent) { + try { + const initialStates = JSON.parse(initialContent) as unknown; + initialStateCount = Array.isArray(initialStates) ? initialStates.length : undefined; + } catch { + initialStateCount = undefined; + } + } + const initial = { + id: '0', + time: 0, + label: 'Start', + source: 'initial', + inputIndex: 1, + stateIndex: 1, + stateCount: initialStateCount, + }; + const index = recording.files.find((file) => file.path === 'checkpoints.jsonl')?.content ?? ''; + const checkpoints: SessionReplayCheckpoint[] = []; + for (const line of index.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const checkpoint = JSON.parse(trimmed) as Partial; + if (checkpoint.id === undefined || checkpoint.id === null) continue; + const id = String(checkpoint.id); + checkpoints.push({ + ...checkpoint, + id, + label: checkpoint.label || id, + time: Number(checkpoint.time ?? 0), + }); + } catch { + // Ignore malformed checkpoint lines so older/corrupt replays can still load. + } + } + return [initial, ...checkpoints].sort((a, b) => (a.time ?? 0) - (b.time ?? 0)); +} + +export function useSessionReplay() { + const sessionId = useSessionStore((state) => state.sessionId); + const queryClient = useQueryClient(); + + const { data: status } = useQuery({ + queryKey: sessionQueryKey.sessionReplay(sessionId ?? ''), + queryFn: () => DEFAULT_STATUS, + enabled: !!sessionId, + initialData: DEFAULT_STATUS, + }); + + const { data: recording, dataUpdatedAt: recordingUpdatedAt } = useQuery({ + queryKey: sessionQueryKey.sessionReplayRecording(sessionId ?? ''), + queryFn: () => null, + enabled: false, + initialData: null, + }); + + const { data: recordings } = useQuery({ + queryKey: sessionQueryKey.sessionReplayRecordings(sessionId ?? ''), + queryFn: () => ({}), + enabled: !!sessionId, + initialData: {}, + }); + + const { data: replaySummaries } = useQuery({ + queryKey: sessionQueryKey.sessionReplayList(sessionId ?? ''), + queryFn: () => [], + enabled: !!sessionId, + initialData: [], + }); + + const { data: selectedReplayId } = useQuery({ + queryKey: sessionQueryKey.sessionReplaySelected(sessionId ?? ''), + queryFn: () => null, + enabled: !!sessionId, + initialData: null, + }); + + useEffect(() => { + if (!sessionId) return; + sendCommand(sessionId, { type: 'cmd:session_replay:list' }).catch(() => {}); + }, [sessionId]); + + const startRecording = () => { + if (!sessionId) return; + if (status?.recording) return; + sendCommand(sessionId, { type: 'cmd:session_replay:start' }).catch(() => {}); + }; + + const stopRecording = () => { + if (!sessionId) return; + queryClient.setQueryData(sessionQueryKey.sessionReplay(sessionId), (prev) => ({ + ...(prev ?? DEFAULT_STATUS), + recording: false, + })); + sendCommand(sessionId, { type: 'cmd:session_replay:stop' }).catch(() => {}); + }; + + const requestRecording = (id?: string | null) => { + if (!sessionId) return; + if (status?.recording || status?.replaying) return; + sendCommand(sessionId, { type: 'cmd:session_replay:request', data: id ? { id } : {} }).catch(() => {}); + }; + + const requestReplayList = () => { + if (!sessionId) return; + sendCommand(sessionId, { type: 'cmd:session_replay:list' }).catch(() => {}); + }; + + const selectReplay = (id: string | null) => { + if (!sessionId) return; + if (status?.recording || status?.replaying) return; + queryClient.setQueryData(sessionQueryKey.sessionReplaySelected(sessionId), id); + queryClient.setQueryData(sessionQueryKey.sessionReplayRecording(sessionId), id ? (recordings ?? {})[id] ?? null : null); + }; + + const playRecording = (id?: string, opts?: { seekTo?: string | number }) => { + if (!sessionId) return; + if (status?.recording) return; + const replayId = id ?? selectedReplayId ?? status?.replayId ?? undefined; + sendCommand(sessionId, { type: 'cmd:session_replay:play', data: { ...(replayId ? { id: replayId } : {}), ...opts } }).catch( + () => {}, + ); + }; + + const seekRecording = (target: string | number, play = false) => { + if (!sessionId) return; + if (status?.recording) return; + sendCommand(sessionId, { type: 'cmd:session_replay:seek', data: { target, play } }).catch(() => {}); + }; + + const stopReplay = () => { + if (!sessionId) return; + queryClient.setQueryData(sessionQueryKey.sessionReplay(sessionId), (prev) => ({ + ...(prev ?? DEFAULT_STATUS), + replaying: false, + })); + sendCommand(sessionId, { type: 'cmd:session_replay:stop_replay' }).catch(() => {}); + }; + + const importRecording = (files: SessionReplayFile[]) => { + if (!sessionId) return; + const imported = recordingFromFiles(files); + const id = replayRecordingId(imported); + if (imported && id) { + queryClient.setQueryData(sessionQueryKey.sessionReplayRecordings(sessionId), (prev) => ({ + ...(prev ?? {}), + [id]: imported, + })); + queryClient.setQueryData(sessionQueryKey.sessionReplayRecording(sessionId), imported); + queryClient.setQueryData(sessionQueryKey.sessionReplaySelected(sessionId), id); + } + sendCommand(sessionId, { type: 'cmd:session_replay:import', data: { files } }).catch(() => {}); + }; + + const deleteRecording = (id?: string | null) => { + if (!sessionId) return; + const replayId = id ?? selectedReplayId ?? status?.replayId ?? null; + sendCommand(sessionId, { type: 'cmd:session_replay:delete', data: replayId ? { id: replayId } : {} }).catch(() => {}); + if (replayId) { + queryClient.setQueryData(sessionQueryKey.sessionReplayRecordings(sessionId), (prev) => { + const next = { ...(prev ?? {}) }; + delete next[replayId]; + return next; + }); + queryClient.setQueryData(sessionQueryKey.sessionReplayList(sessionId), (prev) => + (prev ?? []).filter((item) => item.id !== replayId), + ); + } + queryClient.setQueryData(sessionQueryKey.sessionReplayRecording(sessionId), null); + queryClient.setQueryData(sessionQueryKey.sessionReplaySelected(sessionId), null); + }; + + return { + status: status ?? DEFAULT_STATUS, + recording: recording ?? null, + recordings: recordings ?? {}, + replaySummaries: replaySummaries ?? [], + selectedReplayId: selectedReplayId ?? replayRecordingId(recording), + selectReplay, + recordingUpdatedAt, + startRecording, + stopRecording, + requestRecording, + requestReplayList, + playRecording, + seekRecording, + stopReplay, + importRecording, + deleteRecording, + }; +} diff --git a/src/hooks/use-ws-connection.ts b/src/hooks/use-ws-connection.ts index 2d990fe4..b6ce405b 100644 --- a/src/hooks/use-ws-connection.ts +++ b/src/hooks/use-ws-connection.ts @@ -13,6 +13,12 @@ import type { PerformanceMetrics } from './use-performance'; import type { PluginContentProps, PluginDataType } from './use-plugin'; import type { AssetCatalog } from './use-assets'; import type { HotReloadState } from './use-hot-reload'; +import type { + SessionReplayRecording, + SessionReplayRecordings, + SessionReplayStatus, + SessionReplaySummary, +} from './use-session-replay'; import { unionBy } from '@/utils/arrays'; import { toast } from 'sonner'; import { isWeb } from '@/utils/platform'; @@ -33,6 +39,11 @@ export const sessionQueryKey = { console: (sessionId: string) => [sessionId, 'console'], timeTravel: (sessionId: string) => [sessionId, 'time-travel'], timeTravelFrames: (sessionId: string) => [sessionId, 'time-travel-frames'], + sessionReplay: (sessionId: string) => [sessionId, 'session-replay'], + sessionReplayRecording: (sessionId: string) => [sessionId, 'session-replay-recording'], + sessionReplayRecordings: (sessionId: string) => [sessionId, 'session-replay-recordings'], + sessionReplayList: (sessionId: string) => [sessionId, 'session-replay-list'], + sessionReplaySelected: (sessionId: string) => [sessionId, 'session-replay-selected'], hotReload: (sessionId: string) => [sessionId, 'hot-reload'], }; @@ -62,6 +73,7 @@ type BinaryTarget = | { type: 'plugin'; pluginId: string } | { type: 'observers' } | { type: 'timeTravelFrames' } + | { type: 'sessionReplayRecording'; replayId?: string | null } | { type: 'debuggerPaused' } | { type: 'console' }; @@ -95,6 +107,9 @@ export type TimeTravelFrame = { const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +const sessionReplayRecordingId = (recording: SessionReplayRecording | null | undefined): string | null => + typeof recording?.manifest?.id === 'string' ? recording.manifest.id : null; + const pushBinaryRef = (value: unknown, refs: BinaryRef[]) => { if (!isRecord(value) || typeof value.id !== 'string') return; @@ -231,6 +246,28 @@ export const useWsConnection = () => { return; } + if (pending.target.type === 'sessionReplayRecording') { + queryClient.setQueryData( + sessionQueryKey.sessionReplayRecording(sessionId), + (prev) => replaceBinaryValue(prev, pending.id, replacement) as SessionReplayRecording, + ); + const replayId = pending.target.replayId; + if (replayId) { + queryClient.setQueryData( + sessionQueryKey.sessionReplayRecordings(sessionId), + (prev) => { + const current = prev?.[replayId]; + if (!current) return prev ?? {}; + return { + ...(prev ?? {}), + [replayId]: replaceBinaryValue(current, pending.id, replacement) as SessionReplayRecording, + }; + }, + ); + } + return; + } + if (pending.target.type === 'debuggerPaused') { const current = useDebuggerStore.getState().pausedState[sessionId]; setPausedState(sessionId, replaceBinaryValue(current, pending.id, replacement) as PausedState); @@ -576,6 +613,48 @@ export const useWsConnection = () => { break; } + case 'session_replay:status': { + queryClient.setQueryData(sessionQueryKey.sessionReplay(sessionId), data as SessionReplayStatus); + const payload = data as SessionReplayStatus; + if (payload.replayId) { + queryClient.setQueryData(sessionQueryKey.sessionReplaySelected(sessionId), payload.replayId); + } + break; + } + + case 'session_replay:recording': { + const payload = data as SessionReplayRecording; + const replayId = sessionReplayRecordingId(payload); + queueBinaryRefs(sessionId, { type: 'sessionReplayRecording', replayId }, payload, 'text'); + queryClient.setQueryData(sessionQueryKey.sessionReplayRecording(sessionId), payload); + if (replayId) { + queryClient.setQueryData(sessionQueryKey.sessionReplaySelected(sessionId), replayId); + queryClient.setQueryData( + sessionQueryKey.sessionReplayRecordings(sessionId), + (prev) => ({ + ...(prev ?? {}), + [replayId]: payload, + }), + ); + } + break; + } + + case 'session_replay:list': { + const payload = data as { replays?: SessionReplaySummary[]; selectedId?: string | null }; + queryClient.setQueryData(sessionQueryKey.sessionReplayList(sessionId), payload.replays ?? []); + if (payload.selectedId !== undefined) { + queryClient.setQueryData(sessionQueryKey.sessionReplaySelected(sessionId), payload.selectedId); + } + break; + } + + case 'session_replay:error': { + const payload = data as { message?: string }; + toast.error(`Session replay failed: ${payload?.message || 'Unknown error'}`); + break; + } + case 'feather:bye': { // Graceful disconnect — preserve cached data (logs, perf history) so user // doesn't lose context. Only mark as disconnected and clear session routing. diff --git a/src/pages/plugins/content.tsx b/src/pages/plugins/content.tsx index 749b1d81..86c27a2a 100644 --- a/src/pages/plugins/content.tsx +++ b/src/pages/plugins/content.tsx @@ -23,6 +23,7 @@ import { PluginContentImageType, PluginContentProps, PluginDataType, + PluginParamValue, PluginTableCellValue, PluginTableColumn, PluginTableRow, @@ -373,6 +374,7 @@ export function PluginContentTree({ total, shown, onParamsChange, + pendingParams, }: { nodes: PluginTreeNode[]; sources: string[]; @@ -382,8 +384,11 @@ export function PluginContentTree({ total?: number; shown?: number; onParamsChange?: (params: Record) => void; + pendingParams?: Record; }) { const [expandedNodes, setExpandedNodes] = useState>(new Set()); + const selectedSourceValue = Number(pendingString(pendingParams?.selectedSource) ?? selectedSource); + const searchFilterValue = pendingString(pendingParams?.searchFilter) ?? searchFilter; const toggleNode = useCallback((path: string) => { setExpandedNodes((prev) => { @@ -403,7 +408,7 @@ export function PluginContentTree({ {sources.length > 1 && ( onParamsChange?.({ searchFilter: e.target.value })} /> {total != null && shown != null && ( @@ -563,6 +568,9 @@ const renderUiScalar = (value: PluginTableCellValue) => { const getUiControlName = (node: PluginUiNode) => node.name ?? node.id ?? node.action; +const pendingString = (value: PluginParamValue | undefined) => value === undefined ? undefined : String(value); +const pendingBool = (value: PluginParamValue | undefined) => value === true || value === 'true'; + const renderUiLabel = (node: PluginUiNode, control: ReactNode) => { const label = node.label ?? node.title; if (!label && !node.description) return control; @@ -579,16 +587,15 @@ const renderUiLabel = (node: PluginUiNode, control: ReactNode) => { function PluginUiCheckbox({ node, onParamsChange, + pendingParams, }: { node: PluginUiNode; onParamsChange?: (params: Record) => void; + pendingParams?: Record; }) { const name = getUiControlName(node); - const [checked, setChecked] = useState(node.checked === true); - - useEffect(() => { - setChecked(node.checked === true); - }, [node.checked]); + const pending = name ? pendingParams?.[name] : undefined; + const checked = pending === undefined ? node.checked === true : pendingBool(pending); return (
@@ -601,7 +608,6 @@ function PluginUiCheckbox({ onClick={() => { if (!name) return; const nextChecked = !checked; - setChecked(nextChecked); onParamsChange?.({ [name]: nextChecked ? 'true' : 'false' }); }} className={cn( @@ -622,10 +628,12 @@ function PluginUiRenderer({ node, onAction, onParamsChange, + pendingParams, }: { node: PluginUiNode; onAction?: (action: string) => void; onParamsChange?: (params: Record) => void; + pendingParams?: Record; }) { const children = node.children?.map((child, index) => ( )); @@ -684,11 +693,12 @@ function PluginUiRenderer({ if (node.type === 'input') { const name = getUiControlName(node); + const value = pendingString(name ? pendingParams?.[name] : undefined) ?? renderUiScalar(node.value) ?? ''; return renderUiLabel( node, name && onParamsChange?.({ [name]: event.target.value })} @@ -698,10 +708,11 @@ function PluginUiRenderer({ if (node.type === 'textarea') { const name = getUiControlName(node); + const value = pendingString(name ? pendingParams?.[name] : undefined) ?? renderUiScalar(node.value) ?? ''; return renderUiLabel( node, +
+ + + + + + + + +`; +} diff --git a/vscode-extension/src/catalog.ts b/vscode-extension/src/catalog.ts new file mode 100644 index 00000000..5f3233cb --- /dev/null +++ b/vscode-extension/src/catalog.ts @@ -0,0 +1,65 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type * as vscode from 'vscode'; + +export type PluginEntry = { + id: string; + name: string; + description: string; + capabilities: string[]; + optIn: boolean; +}; + +export type PackageEntry = { + id: string; + description: string; + version: string; + trust: string; + tags: string[]; + installed?: boolean; +}; + +type RegistryFile = { + packages: Record; +}; + +export async function loadPluginCatalog(context: vscode.ExtensionContext): Promise { + const file = join(context.extensionPath, 'bundled-bin', 'plugin-catalog.json'); + if (!existsSync(file)) return []; + const data = JSON.parse(readFileSync(file, 'utf8')) as PluginEntry[]; + return [...data].sort((a, b) => a.name.localeCompare(b.name)); +} + +export function loadPackageCatalog(context: vscode.ExtensionContext, installedIds = new Set()): PackageEntry[] { + const file = join(context.extensionPath, 'bundled-bin', 'registry.json'); + if (!existsSync(file)) return []; + const registry = JSON.parse(readFileSync(file, 'utf8')) as RegistryFile; + return Object.entries(registry.packages) + .filter(([, entry]) => !entry.parent) + .map(([id, entry]) => ({ + id, + description: entry.description, + version: entry.source?.tag ?? 'unknown', + trust: entry.trust, + tags: entry.tags ?? [], + installed: installedIds.has(id), + })) + .sort((a, b) => a.id.localeCompare(b.id)); +} + +export function readInstalledPackageIds(root: string): Set { + const file = join(root, 'feather.lock.json'); + if (!existsSync(file)) return new Set(); + try { + const parsed = JSON.parse(readFileSync(file, 'utf8')) as { packages?: Record }; + return new Set(Object.keys(parsed.packages ?? {})); + } catch { + return new Set(); + } +} diff --git a/vscode-extension/src/cli.ts b/vscode-extension/src/cli.ts new file mode 100644 index 00000000..39fa22dc --- /dev/null +++ b/vscode-extension/src/cli.ts @@ -0,0 +1,61 @@ +import * as vscode from 'vscode'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import * as os from 'node:os'; +import { shellQuote } from './command'; + +interface CliInvocation { + command: string; + argsPrefix: string[]; +} + +function getCliInvocation(context: vscode.ExtensionContext): CliInvocation { + const devLauncher = join(context.extensionPath, 'bundled-bin', 'feather-dev.mjs'); + if (existsSync(devLauncher)) { + return { command: 'node', argsPrefix: [devLauncher] }; + } + + const p = os.platform(); + const a = os.arch(); + const name = + p === 'darwin' && a === 'arm64' ? 'feather' : + p === 'darwin' ? 'feather-darwin-x64' : + p === 'win32' ? 'feather-win-x64.exe' : 'feather-linux-x64'; + return { command: join(context.extensionPath, 'bundled-bin', name), argsPrefix: [] }; +} + +export function runInTerminal( + context: vscode.ExtensionContext, + name: string, + args: string[], + cwd: string, +): vscode.Terminal { + return runCommandsInTerminal(context, name, [args], cwd); +} + +export function runCommandsInTerminal( + context: vscode.ExtensionContext, + name: string, + commands: string[][], + cwd: string, +): vscode.Terminal { + const cli = getCliInvocation(context); + const existing = vscode.window.terminals.find((t) => t.name === name); + existing?.dispose(); + const terminal = vscode.window.createTerminal({ name, cwd }); + for (const args of commands) { + terminal.sendText([cli.command, ...cli.argsPrefix, ...args].map(shellQuote).join(' ')); + } + terminal.show(); + return terminal; +} + +export function spawnFeather( + context: vscode.ExtensionContext, + args: string[], + cwd: string, +): ChildProcess { + const cli = getCliInvocation(context); + return spawn(cli.command, [...cli.argsPrefix, ...args], { cwd, shell: false }); +} diff --git a/vscode-extension/src/command.ts b/vscode-extension/src/command.ts new file mode 100644 index 00000000..b38278a6 --- /dev/null +++ b/vscode-extension/src/command.ts @@ -0,0 +1,15 @@ +export function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:=@%+-]+$/.test(value)) return value; + return `"${value.replace(/(["\\$`])/g, '\\$1')}"`; +} + +export function buildCommand(executable: string, script: string, args: string[]): string { + return [executable, script, ...args].map(shellQuote).join(' '); +} + +export function buildEnvCommand(env: Record, executable: string, script: string, args: string[]): string { + const assignments = Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`); + return [...assignments, executable, script, ...args].map((value, index) => ( + index < assignments.length ? value : shellQuote(value) + )).join(' '); +} diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts new file mode 100644 index 00000000..5450aa07 --- /dev/null +++ b/vscode-extension/src/extension.ts @@ -0,0 +1,936 @@ +import * as vscode from 'vscode'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { loadPackageCatalog, loadPluginCatalog, readInstalledPackageIds, type PackageEntry, type PluginEntry } from './catalog'; +import { runCommandsInTerminal, runInTerminal } from './cli'; +import { FeatherProjectProvider } from './featherPanel'; +import { getProjectStatus, resolveProjectDir } from './project'; +import { openBuildConfigPanel } from './buildConfigPanel'; +import { ALL_RUN_TARGETS, vendorPresent, vendorLabel, vendorArg, targetIcon, type RunTarget } from './vendor'; + +type Pick = vscode.QuickPickItem & { value: T }; +type PluginPick = vscode.QuickPickItem & { plugin: PluginEntry }; +type PackagePick = vscode.QuickPickItem & { pkg: PackageEntry }; +type InitModeValue = 'cli' | 'auto' | 'manual'; +type InitSecurityMode = 'appId' | 'insecure' | 'unset'; +type InitSourceMode = 'bundled' | 'remote' | 'local'; +type InitPluginMode = 'default' | 'select' | 'none'; +type ReleaseBuildTarget = 'android' | 'ios'; +type ReleaseBuildOption = 'clean' | 'dry-run' | 'no-cache' | 'verbose'; + +interface InitCommandPlan { + args: string[]; +} + +interface ReleaseBuildPlan { + target: ReleaseBuildTarget; + args: string[]; +} + +const DEFAULT_INIT_PLUGIN_IDS = new Set(['particle-system-playground', 'shader-graph']); +const DEFAULT_INIT_PLUGIN_ID_LIST = [...DEFAULT_INIT_PLUGIN_IDS]; + +function workspaceRoots(): string[] { + return vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath) ?? []; +} + +function featherConfig(): vscode.WorkspaceConfiguration { + return vscode.workspace.getConfiguration('feather'); +} + +function activeProjectDir(): string | undefined { + return resolveProjectDir(featherConfig().get('projectDir'), workspaceRoots()); +} + +function requireProjectDir(): string | undefined { + const root = activeProjectDir(); + if (!root) { + vscode.window.showErrorMessage('No workspace open.'); + return undefined; + } + return root; +} + +function ensureGitignoreEntry(dir: string, entry: string): void { + const gitignorePath = join(dir, '.gitignore'); + const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : ''; + const lines = existing.split('\n').map((l) => l.trimEnd()); + if (lines.some((l) => l === entry)) return; + const updated = existing.endsWith('\n') || existing === '' + ? existing + entry + '\n' + : existing + '\n' + entry + '\n'; + writeFileSync(gitignorePath, updated, 'utf8'); +} + +function isWatchMode(): boolean { + return featherConfig().get('watchMode') ?? false; +} + +function savedTargets(): RunTarget[] { + return (featherConfig().get('runTargets') ?? []) as RunTarget[]; +} + +function refreshProjectUi(provider: FeatherProjectProvider, updateStatus: () => void): void { + updateStatus(); + provider.refresh(); +} + +function registerRefreshWatcher( + context: vscode.ExtensionContext, + pattern: string, + provider: FeatherProjectProvider, + updateStatus: () => void, +): void { + const watcher = vscode.workspace.createFileSystemWatcher(pattern); + context.subscriptions.push( + watcher, + watcher.onDidCreate(() => refreshProjectUi(provider, updateStatus)), + watcher.onDidChange(() => refreshProjectUi(provider, updateStatus)), + watcher.onDidDelete(() => refreshProjectUi(provider, updateStatus)), + ); +} + +function pluginPick(plugin: PluginEntry, picked = false): PluginPick { + const caps = plugin.capabilities.length > 0 ? ` · ${plugin.capabilities.join(', ')}` : ''; + return { + label: plugin.name, + description: plugin.id, + detail: `${plugin.description}${caps}`, + picked, + plugin, + }; +} + +function packagePick(pkg: PackageEntry): PackagePick { + return { + label: pkg.id, + description: `${pkg.version} · ${pkg.trust}${pkg.installed ? ' · installed' : ''}`, + detail: pkg.description, + pkg, + }; +} + +async function pickPlugins( + context: vscode.ExtensionContext, + placeHolder: string, + defaultPickedIds: ReadonlySet = new Set(), +): Promise { + const catalog = await loadPluginCatalog(context); + const picked = await vscode.window.showQuickPick(catalog.map((plugin) => pluginPick(plugin, defaultPickedIds.has(plugin.id))), { + canPickMany: true, + matchOnDescription: true, + matchOnDetail: true, + placeHolder, + }); + return picked?.map((item) => item.plugin); +} + +function hotReloadModuleName(root: string, filePath: string): string | undefined { + const rel = relative(root, filePath).replace(/\\/g, '/'); + if (!rel || rel.startsWith('../') || rel === '..' || rel.startsWith('/')) return undefined; + if (!rel.endsWith('.lua')) return undefined; + const withoutExt = rel.slice(0, -'.lua'.length); + const modulePath = withoutExt.endsWith('/init') ? withoutExt.slice(0, -'/init'.length) : withoutExt; + return modulePath.split('/').filter(Boolean).join('.'); +} + +async function pickHotReloadAllowlist(root: string): Promise { + const uris = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: true, + defaultUri: vscode.Uri.file(root), + filters: { Lua: ['lua'] }, + openLabel: 'Allow hot reload', + title: 'Feather: Select Lua files hot reload may update', + }); + if (!uris) return undefined; + + const modules = [...new Set(uris.map((uri) => hotReloadModuleName(root, uri.fsPath)).filter((id): id is string => Boolean(id)))]; + if (modules.length === 0) { + vscode.window.showWarningMessage('Select Lua files inside the project folder for hot reload.'); + return undefined; + } + return modules; +} + +function nonEmptyInput(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function projectManagedMode(root: string): InitModeValue | undefined { + const configPath = join(root, 'feather.config.lua'); + if (!existsSync(configPath)) return undefined; + const source = readFileSync(configPath, 'utf8'); + const configMode = /\bmanaged\s*=\s*["'](cli|auto|manual)["']/.exec(source)?.[1]; + if (configMode === 'cli' || configMode === 'auto' || configMode === 'manual') return configMode; + const metadataMode = /^--\s*mode:\s*(cli|auto|manual)\s*$/m.exec(source)?.[1]; + return metadataMode === 'cli' || metadataMode === 'auto' || metadataMode === 'manual' ? metadataMode : undefined; +} + +async function pickInitMode(): Promise { + const mode = await vscode.window.showQuickPick>( + [ + { label: '$(terminal) CLI mode', description: 'Recommended. No game-code changes; use Feather: Run Project.', value: 'cli' }, + { label: '$(tools) Auto embedded mode', description: 'Advanced. Patch main.lua with guarded feather.auto loader.', value: 'auto' }, + { label: '$(file-code) Manual embedded mode', description: 'Advanced. Create feather.debugger.lua and guarded loader.', value: 'manual' }, + ], + { placeHolder: 'Select setup mode' }, + ); + return mode?.value; +} + +async function buildInitCommandPlan( + context: vscode.ExtensionContext, + root: string, + mode: InitModeValue, +): Promise { + const flow = await vscode.window.showQuickPick>( + [ + { label: 'Recommended setup', description: 'CLI-style defaults: bundled runtime and default creative plugins.', value: 'quick' }, + { label: 'Customize setup', description: 'Choose session name, security, runtime source, plugins, and hot reload.', value: 'custom' }, + ], + { placeHolder: 'Choose init setup' }, + ); + if (!flow) return undefined; + + if (flow.value === 'quick') { + return { + args: [ + 'init', + root, + '--mode', + mode, + '--yes', + '--plugins', + DEFAULT_INIT_PLUGIN_ID_LIST.join(','), + ], + }; + } + + const args = ['init', root, '--mode', mode, '--yes']; + + const sessionName = nonEmptyInput(await vscode.window.showInputBox({ + prompt: 'Session name shown in Feather (optional)', + value: '', + placeHolder: 'My Game', + })); + if (sessionName) args.push('--session-name', sessionName); + + const security = await vscode.window.showQuickPick>( + [ + { label: 'Desktop App ID', description: 'Restrict commands to your Feather desktop app.', value: 'appId' }, + { label: 'Insecure local dev', description: 'Allow any desktop connection. Use only for trusted development.', value: 'insecure' }, + { label: 'Leave unset', description: 'Doctor will warn until appId or insecure mode is configured.', value: 'unset' }, + ], + { placeHolder: 'Select connection security' }, + ); + if (!security) return undefined; + if (security.value === 'appId') { + const appId = nonEmptyInput(await vscode.window.showInputBox({ + prompt: 'Desktop App ID from Feather Settings > Security', + placeHolder: 'feather-app-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + validateInput: (value) => value.trim() ? undefined : 'App ID cannot be empty', + })); + if (!appId) return undefined; + args.push('--app-id', appId); + } else if (security.value === 'insecure') { + args.push('--allow-insecure-connection'); + } + + const source = await vscode.window.showQuickPick>( + [ + { label: 'Bundled runtime', description: 'Use the runtime packaged with this extension/CLI.', value: 'bundled' }, + { label: 'GitHub branch or tag', description: 'Download runtime files from GitHub.', value: 'remote' }, + { label: 'Local src-lua folder', description: 'Copy runtime files from a local source checkout.', value: 'local' }, + ], + { placeHolder: 'Select runtime source' }, + ); + if (!source) return undefined; + if (source.value === 'remote') { + const branch = nonEmptyInput(await vscode.window.showInputBox({ + prompt: 'GitHub branch or tag', + value: 'main', + validateInput: (value) => value.trim() ? undefined : 'Branch cannot be empty', + })); + if (!branch) return undefined; + args.push('--remote', '--branch', branch); + } else if (source.value === 'local') { + const uris = await vscode.window.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + defaultUri: vscode.Uri.file(root), + openLabel: 'Use src-lua', + title: 'Feather: Select local src-lua folder', + }); + const localSrc = uris?.[0]?.fsPath; + if (!localSrc) return undefined; + args.push('--local-src', localSrc); + } + + const pluginMode = await vscode.window.showQuickPick>( + [ + { label: 'Default creative plugins', description: DEFAULT_INIT_PLUGIN_ID_LIST.join(', '), value: 'default' }, + { label: 'Select plugins', description: 'Choose exactly which plugins init should include.', value: 'select' }, + { label: 'No plugins', description: 'Create Feather config/runtime only.', value: 'none' }, + ], + { placeHolder: 'Select plugin setup' }, + ); + if (!pluginMode) return undefined; + + let pluginIds = pluginMode.value === 'default' ? DEFAULT_INIT_PLUGIN_ID_LIST : []; + if (pluginMode.value === 'none') { + args.push('--no-plugins'); + } else if (pluginMode.value === 'select') { + const plugins = await pickPlugins(context, 'Select plugins to install now', DEFAULT_INIT_PLUGIN_IDS); + if (!plugins) return undefined; + pluginIds = plugins.map((plugin) => plugin.id); + } + + const hotReloadAllow = pluginIds.includes('hot-reload') ? await pickHotReloadAllowlist(root) : undefined; + if (pluginIds.includes('hot-reload') && !hotReloadAllow) return undefined; + if (pluginIds.length > 0) args.push('--plugins', pluginIds.join(',')); + if (hotReloadAllow && hotReloadAllow.length > 0) args.push('--hot-reload-allow', hotReloadAllow.join(',')); + + return { args }; +} + +async function pickTargets(currentTargets: RunTarget[], watchMode: boolean): Promise { + const available = watchMode + ? ALL_RUN_TARGETS.filter((t) => t !== 'web') + : ALL_RUN_TARGETS; + + type TargetPick = vscode.QuickPickItem & { target: RunTarget }; + const items: TargetPick[] = available.map((t) => ({ + label: `$(${targetIcon(t)}) ${t}`, + target: t, + picked: currentTargets.includes(t), + description: t === 'web' && watchMode ? 'not available in watch mode' : undefined, + })); + + const picked = await vscode.window.showQuickPick(items, { + canPickMany: true, + placeHolder: `Select run targets${watchMode ? ' (watch mode — web excluded)' : ''}`, + title: 'Feather: Select Run Targets', + }); + return picked?.map((i) => i.target); +} + +async function buildReleaseBuildPlan(root: string): Promise { + const target = await vscode.window.showQuickPick>( + [ + { + label: '$(device-mobile) Android release', + description: 'Build signed/store-oriented AAB and APK artifacts.', + value: 'android', + }, + { + label: '$(device-mobile) iOS release', + description: 'Build signed/store-oriented IPA and app artifacts.', + value: 'ios', + }, + ], + { placeHolder: 'Select release build target', title: 'Feather: Create Release Build' }, + ); + if (!target) return undefined; + + const options = await vscode.window.showQuickPick( + [ + { + label: '$(trash) Clean output', + description: 'Remove previous build output and native cache before building.', + value: 'clean', + picked: true, + }, + { + label: '$(beaker) Dry run', + description: 'Show the build plan without writing artifacts.', + value: 'dry-run', + }, + { + label: '$(database) Disable native cache', + description: 'Force native Android/iOS build steps to run fresh.', + value: 'no-cache', + }, + { + label: '$(list-unordered) Verbose logs', + description: 'Show native build commands and tool output.', + value: 'verbose', + }, + ], + { + canPickMany: true, + placeHolder: 'Select release build options', + title: `Feather: ${target.value} Release Options`, + }, + ); + if (!options) return undefined; + + const selected = new Set(options.map((option) => option.value)); + const args = ['build', target.value, '--dir', root, '--release', '--no-debugger']; + if (selected.has('clean')) args.push('--clean'); + if (selected.has('dry-run')) args.push('--dry-run'); + if (selected.has('no-cache')) args.push('--no-cache'); + if (selected.has('verbose')) args.push('--verbose'); + + return { target: target.value, args }; +} + +async function registerCommands( + context: vscode.ExtensionContext, + provider: FeatherProjectProvider, + updateStatus: () => void, +): Promise { + // ── Run / Watch ─────────────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand('feather.run', async () => { + const root = requireProjectDir(); + if (!root) return; + + const cfg = featherConfig(); + let targets = savedTargets(); + + if (targets.length === 0) { + const picked = await pickTargets([], isWatchMode()); + if (!picked || picked.length === 0) return; + targets = picked; + await cfg.update('runTargets', targets, vscode.ConfigurationTarget.Workspace); + updateStatus(); + provider.refresh(); + } + + // Vendor check + const missingVendors = targets.filter((t) => !vendorPresent(root, t)); + if (missingVendors.length > 0) { + const names = missingVendors.map(vendorLabel).join(', '); + const action = await vscode.window.showWarningMessage( + `Missing vendor files for: ${names}.`, + { modal: false }, + 'Fetch Vendors', + 'Run Anyway', + 'Change Targets', + ); + if (!action) return; + if (action === 'Change Targets') { + await vscode.commands.executeCommand('feather.selectTargets'); + return; + } + if (action === 'Fetch Vendors') { + const savedVendorDir = featherConfig().get('vendorDir')?.trim() ?? root; + for (const t of missingVendors) { + const arg = vendorArg(t)!; + runInTerminal(context, `Feather: Vendor (${t})`, ['build', 'vendor', 'add', arg, '--dir', savedVendorDir], savedVendorDir); + } + return; + } + // 'Run Anyway' falls through + } + + const watchMode = isWatchMode(); + const love = cfg.get('loveExecutable')?.trim(); + for (const target of targets) { + const label = targets.length > 1 ? ` (${target})` : ''; + if (watchMode) { + if (target === 'web') { + vscode.window.showInformationMessage('Watch mode does not support web — skipping web target.'); + continue; + } + const args = ['watch', '--target', target, root]; + if (love && target === 'desktop') args.push('--love', love); + runInTerminal(context, `Feather: Watch${label}`, args, root); + } else { + const args = ['run', '--target', target, root]; + if (love && target === 'desktop') args.push('--love', love); + runInTerminal(context, `Feather: Run${label}`, args, root); + } + } + }), + ); + + // ── Select targets ──────────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand('feather.selectTargets', async () => { + const picked = await pickTargets(savedTargets(), isWatchMode()); + if (!picked) return; + await featherConfig().update('runTargets', picked, vscode.ConfigurationTarget.Workspace); + updateStatus(); + provider.refresh(); + }), + ); + + // ── Toggle watch ────────────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand('feather.toggleWatch', async () => { + const next = !isWatchMode(); + await featherConfig().update('watchMode', next, vscode.ConfigurationTarget.Workspace); + updateStatus(); + provider.refresh(); + }), + ); + + // ── Configure ───────────────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand('feather.configure', async () => { + const cfg = featherConfig(); + const projectDir = cfg.get('projectDir') || ''; + const loveExec = cfg.get('loveExecutable') || ''; + const uploadTarget = cfg.get('defaultUploadTarget') || 'itch'; + const uploadChannel = cfg.get('defaultUploadChannel') || ''; + const watchMode = cfg.get('watchMode') ?? false; + + type SettingPick = vscode.QuickPickItem & { key: string }; + const setting = await vscode.window.showQuickPick( + [ + { + label: '$(folder) Project directory', + description: projectDir || '(workspace root)', + detail: 'Folder containing main.lua and feather.config.lua', + key: 'projectDir', + }, + { + label: '$(terminal) LÖVE executable', + description: loveExec || '(auto-detect)', + detail: 'Path to the love2d binary', + key: 'loveExecutable', + }, + { + label: '$(cloud-upload) Upload target', + description: uploadTarget, + detail: 'Default target for Feather: Upload Build (e.g. itch, steam)', + key: 'defaultUploadTarget', + }, + { + label: '$(tag) Upload channel', + description: uploadChannel || '(none)', + detail: 'Default channel passed to uploads', + key: 'defaultUploadChannel', + }, + { + label: watchMode ? '$(eye) Watch mode: ON' : '$(eye-closed) Watch mode: OFF', + description: 'Toggle between run and watch when clicking Run Project', + key: 'watchMode', + }, + ], + { placeHolder: 'Select a setting to configure', matchOnDescription: true, matchOnDetail: true }, + ); + if (!setting) return; + + if (setting.key === 'projectDir') { + const uris = await vscode.window.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + openLabel: 'Select project directory', + title: 'Feather: Select LÖVE project directory', + }); + if (!uris || uris.length === 0) return; + await cfg.update('projectDir', uris[0].fsPath, vscode.ConfigurationTarget.Workspace); + } else if (setting.key === 'loveExecutable') { + const choice = await vscode.window.showQuickPick>( + [ + { label: '$(folder-opened) Browse…', value: 'browse' }, + { label: '$(trash) Clear (use auto-detect)', value: 'clear' }, + ], + { placeHolder: loveExec || 'Select action' }, + ); + if (!choice) return; + if (choice.value === 'clear') { + await cfg.update('loveExecutable', '', vscode.ConfigurationTarget.Workspace); + } else { + const uris = await vscode.window.showOpenDialog({ + canSelectFolders: false, + canSelectFiles: true, + canSelectMany: false, + openLabel: 'Select LÖVE executable', + title: 'Feather: Select LÖVE executable', + }); + if (!uris || uris.length === 0) return; + await cfg.update('loveExecutable', uris[0].fsPath, vscode.ConfigurationTarget.Workspace); + } + } else if (setting.key === 'defaultUploadTarget') { + const target = await vscode.window.showInputBox({ + prompt: 'Upload target (e.g. itch, steam)', + value: uploadTarget, + validateInput: (v) => v.trim() ? undefined : 'Target cannot be empty', + }); + if (target === undefined) return; + await cfg.update('defaultUploadTarget', target.trim(), vscode.ConfigurationTarget.Workspace); + } else if (setting.key === 'defaultUploadChannel') { + const channel = await vscode.window.showInputBox({ + prompt: 'Upload channel (leave empty to unset)', + value: uploadChannel, + }); + if (channel === undefined) return; + await cfg.update('defaultUploadChannel', channel || '', vscode.ConfigurationTarget.Workspace); + } else if (setting.key === 'watchMode') { + await cfg.update('watchMode', !watchMode, vscode.ConfigurationTarget.Workspace); + } + + updateStatus(); + provider.refresh(); + }), + ); + + // ── Build config panel ──────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand('feather.buildConfig', () => { + const root = requireProjectDir(); + if (!root) return; + openBuildConfigPanel(context, root); + }), + ); + + // ── Release build ──────────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand('feather.buildRelease', async () => { + const root = requireProjectDir(); + if (!root) return; + + const plan = await buildReleaseBuildPlan(root); + if (!plan) return; + + runInTerminal(context, `Feather: ${plan.target} Release Build`, plan.args, root); + refreshProjectUi(provider, updateStatus); + setTimeout(() => refreshProjectUi(provider, updateStatus), 1500); + }), + ); + + // ── Init ────────────────────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand('feather.init', async () => { + const root = requireProjectDir(); + if (!root) return; + + const mode = await pickInitMode(); + if (!mode) return; + + const plan = await buildInitCommandPlan(context, root, mode); + if (!plan) return; + + runInTerminal(context, 'Feather: Init', plan.args, root); + refreshProjectUi(provider, updateStatus); + setTimeout(() => refreshProjectUi(provider, updateStatus), 1500); + }), + ); + + // ── Doctor ──────────────────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand('feather.doctor', () => { + const root = requireProjectDir(); + if (!root) return; + runInTerminal(context, 'Feather: Doctor', ['doctor', root], root); + provider.refresh(); + }), + ); + + // ── Plugins ─────────────────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand('feather.plugins', async () => { + const root = requireProjectDir(); + if (!root) return; + const cliManaged = projectManagedMode(root) === 'cli'; + const action = await vscode.window.showQuickPick>( + [ + { + label: 'Install plugins', + description: cliManaged ? 'Add bundled plugins to feather.config.lua include list.' : 'Copy plugins into the project and include them in config.', + value: 'install', + }, + { + label: 'Update plugins', + description: cliManaged ? 'CLI-managed plugin code is bundled with Feather.' : 'Refresh installed plugin files from the bundled runtime.', + value: 'update', + }, + { + label: 'Remove plugins', + description: cliManaged ? 'Remove plugins from feather.config.lua include list.' : 'Delete plugin folders and exclude them in config.', + value: 'remove', + }, + ], + { placeHolder: 'Plugin action' }, + ); + if (!action) return; + if (cliManaged && action.value === 'update') { + vscode.window.showInformationMessage('CLI-managed plugin code is bundled with Feather. Update the extension or CLI to update plugin files.'); + return; + } + + const plugins = await pickPlugins(context, `Select plugins to ${action.value}`); + if (!plugins || plugins.length === 0) return; + const ids = plugins.map((plugin) => plugin.id); + const hotReloadAllow = action.value === 'install' && ids.includes('hot-reload') + ? await pickHotReloadAllowlist(root) + : undefined; + if (action.value === 'install' && ids.includes('hot-reload') && !hotReloadAllow) return; + const commands = + action.value === 'install' && cliManaged + ? [ + ['plugin', 'install', ...ids, '--managed', 'cli', '--dir', root], + ...(hotReloadAllow && hotReloadAllow.length > 0 + ? [['config', 'hot-reload', '--dir', root, '--allow', hotReloadAllow.join(',')]] + : []), + ] + : action.value === 'install' + ? [ + ['plugin', 'install', ...ids, '--dir', root], + ['config', 'plugins', '--dir', root, '--include', ids.join(',')], + ...(hotReloadAllow && hotReloadAllow.length > 0 + ? [['config', 'hot-reload', '--dir', root, '--allow', hotReloadAllow.join(',')]] + : []), + ] + : action.value === 'remove' && cliManaged + ? ids.map((id) => ['plugin', 'remove', id, '--managed', 'cli', '--dir', root, '--yes']) + : action.value === 'remove' + ? [ + ...ids.map((id) => ['plugin', 'remove', id, '--dir', root, '--yes']), + ['config', 'plugins', '--dir', root, '--exclude', ids.join(',')], + ] + : ids.map((id) => ['plugin', 'update', id, '--dir', root, '--yes']); + + runCommandsInTerminal(context, `Feather: Plugins ${action.value}`, commands, root); + refreshProjectUi(provider, updateStatus); + setTimeout(() => refreshProjectUi(provider, updateStatus), 1500); + }), + ); + + // ── Packages ────────────────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand('feather.packages', async () => { + const root = requireProjectDir(); + if (!root) return; + const installedIds = readInstalledPackageIds(root); + const packages = loadPackageCatalog(context, installedIds); + const selected = await vscode.window.showQuickPick(packages.map(packagePick), { + canPickMany: true, + matchOnDescription: true, + matchOnDetail: true, + placeHolder: 'Select package(s)', + }); + if (!selected || selected.length === 0) return; + + if (selected.length > 1) { + const ids = selected.map((item) => item.pkg.id); + runInTerminal(context, 'Feather: Package install', ['package', 'install', ...ids, '--dir', root, '--yes'], root); + provider.refresh(); + setTimeout(() => provider.refresh(), 1500); + return; + } + + const pkg = selected[0].pkg; + const action = await vscode.window.showQuickPick>( + pkg.installed + ? [ + { label: 'Update', description: pkg.version, value: 'update' }, + { label: 'Remove', description: 'Delete package files and lockfile entry.', value: 'remove' }, + ] + : [ + { label: 'Install', description: pkg.version, value: 'install' }, + ], + { placeHolder: `${pkg.id}: choose action` }, + ); + if (!action) return; + + const command = + action.value === 'remove' + ? ['package', 'remove', pkg.id, '--dir', root, '--yes'] + : action.value === 'update' + ? ['package', 'update', pkg.id, '--dir', root] + : ['package', 'install', pkg.id, '--dir', root, '--yes']; + runInTerminal(context, `Feather: Package ${action.value}`, command, root); + provider.refresh(); + setTimeout(() => provider.refresh(), 1500); + }), + ); + + // ── Vendor ──────────────────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand('feather.vendor', async () => { + const root = requireProjectDir(); + if (!root) return; + const action = await vscode.window.showQuickPick>( + [ + { label: 'Add vendor templates', description: 'Fetch build vendor templates into the project.', value: 'add' }, + { label: 'List vendors', description: 'Show configured build vendors.', value: 'list' }, + ], + { placeHolder: 'Vendor action' }, + ); + if (!action) return; + + if (action.value === 'list') { + runInTerminal(context, 'Feather: Vendor list', ['build', 'vendor', 'list', root], root); + return; + } + + const vendor = await vscode.window.showQuickPick>( + [ + { label: 'All vendors', value: 'all' }, + { label: 'Web', value: 'web' }, + { label: 'Android', value: 'android' }, + { label: 'iOS', value: 'ios' }, + { label: 'Desktop (Windows, macOS, Linux)', value: 'desktop' }, + ], + { placeHolder: 'Select vendor targets to fetch' }, + ); + if (!vendor) return; + + const cfg = featherConfig(); + const savedVendorDir = cfg.get('vendorDir')?.trim(); + + let vendorDir: string; + if (savedVendorDir) { + vendorDir = savedVendorDir; + } else { + const defaultUri = vscode.Uri.file(root); + const dirUris = await vscode.window.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + defaultUri, + openLabel: 'Install vendors here', + title: 'Select vendor installation directory', + }); + vendorDir = dirUris?.[0]?.fsPath ?? root; + await cfg.update('vendorDir', vendorDir, vscode.ConfigurationTarget.Workspace); + } + + ensureGitignoreEntry(vendorDir, '/vendor'); + runInTerminal(context, 'Feather: Vendor add', ['build', 'vendor', 'add', vendor.value, '--dir', vendorDir], vendorDir); + }), + ); + + // ── Upload ──────────────────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand('feather.upload', async () => { + const root = requireProjectDir(); + if (!root) return; + const dryRun = await vscode.window.showQuickPick>( + [ + { label: 'Dry run', description: 'Show the upload plan without running the uploader.', value: 'dry' }, + { label: 'Upload', description: 'Run the uploader after confirmation.', value: 'upload' }, + ], + { placeHolder: 'Upload mode' }, + ); + if (!dryRun) return; + + if (dryRun.value === 'upload') { + const confirmed = await vscode.window.showWarningMessage('Upload this build?', { modal: true }, 'Upload'); + if (confirmed !== 'Upload') return; + } + + const build = await vscode.window.showQuickPick>( + [ + { label: 'Use existing build', description: 'Upload from feather-build-manifest.json.', value: 'none' }, + { label: 'Build web first', value: 'web' }, + { label: 'Build Windows first', value: 'windows' }, + { label: 'Build macOS first', value: 'macos' }, + { label: 'Build Linux first', value: 'linux' }, + { label: 'Build Android first', value: 'android' }, + { label: 'Build iOS first', value: 'ios' }, + { label: 'Build SteamOS first', value: 'steamos' }, + ], + { placeHolder: 'Build before upload?' }, + ); + if (!build) return; + + const target = featherConfig().get('defaultUploadTarget')?.trim() || 'itch'; + const channel = featherConfig().get('defaultUploadChannel')?.trim(); + const args = ['upload', target]; + if (build.value !== 'none') args.push(build.value, '--build'); + args.push('--dir', root, '--yes'); + if (dryRun.value === 'dry') args.push('--dry-run'); + if (channel) args.push('--channel', channel); + runInTerminal(context, 'Feather: Upload', args, root); + }), + ); + + // ── Remove ──────────────────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand('feather.remove', async () => { + const root = requireProjectDir(); + if (!root) return; + const confirmed = await vscode.window.showWarningMessage('Remove Feather from this project?', { modal: true }, 'Remove'); + if (confirmed !== 'Remove') return; + runInTerminal(context, 'Feather: Remove', ['remove', root, '--yes'], root); + refreshProjectUi(provider, updateStatus); + setTimeout(() => refreshProjectUi(provider, updateStatus), 1500); + }), + ); + + // ── Update ──────────────────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand('feather.update', () => { + const root = requireProjectDir(); + if (!root) return; + runInTerminal(context, 'Feather: Update', ['update', root, '--yes'], root); + refreshProjectUi(provider, updateStatus); + setTimeout(() => refreshProjectUi(provider, updateStatus), 1500); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('feather.refreshProjectView', () => provider.refresh()), + ); +} + +export async function activate(context: vscode.ExtensionContext): Promise { + try { + const provider = new FeatherProjectProvider(() => ({ + status: getProjectStatus(activeProjectDir()), + watchMode: isWatchMode(), + targets: savedTargets(), + })); + vscode.window.registerTreeDataProvider('feather.projectView', provider); + + const statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); + const updateStatus = () => { + const status = getProjectStatus(activeProjectDir()); + const watchMode = isWatchMode(); + const targets = savedTargets(); + const targetStr = targets.length > 0 ? ` · ${targets.join(', ')}` : ''; + if (!status.hasConfig) { + statusItem.text = '$(zap) Feather: Init'; + statusItem.tooltip = 'Initialize Feather project'; + statusItem.command = 'feather.init'; + } else if (watchMode) { + statusItem.text = `$(eye) Feather Watch${targetStr}`; + statusItem.tooltip = `Watching (${targets.join(', ') || 'no targets'}) — click to run`; + statusItem.command = 'feather.run'; + } else { + statusItem.text = `$(zap) Feather${targetStr}`; + statusItem.tooltip = `Run Feather (${targets.join(', ') || 'no target selected'})`; + statusItem.command = 'feather.run'; + } + }; + updateStatus(); + statusItem.show(); + registerRefreshWatcher(context, '**/feather.config.lua', provider, updateStatus); + registerRefreshWatcher(context, '**/.featherrc.lua', provider, updateStatus); + registerRefreshWatcher(context, '**/feather.lock.json', provider, updateStatus); + registerRefreshWatcher(context, '**/feather/plugins/**', provider, updateStatus); + + context.subscriptions.push( + statusItem, + vscode.workspace.onDidChangeWorkspaceFolders(() => { + refreshProjectUi(provider, updateStatus); + }), + vscode.workspace.onDidChangeConfiguration((event) => { + if ( + event.affectsConfiguration('feather.projectDir') || + event.affectsConfiguration('feather.watchMode') || + event.affectsConfiguration('feather.runTargets') + ) { + refreshProjectUi(provider, updateStatus); + } + }), + ); + + await registerCommands(context, provider, updateStatus); + } catch (err) { + vscode.window.showErrorMessage(`Feather failed to activate: ${(err as Error).message ?? err}`); + throw err; + } +} + +export function deactivate(): void {} diff --git a/vscode-extension/src/featherPanel.ts b/vscode-extension/src/featherPanel.ts new file mode 100644 index 00000000..3a6256be --- /dev/null +++ b/vscode-extension/src/featherPanel.ts @@ -0,0 +1,134 @@ +import * as vscode from 'vscode'; +import type { ProjectStatus } from './project'; +import type { RunTarget } from './vendor'; +import { targetIcon } from './vendor'; + +type FeatherViewState = { status: ProjectStatus; watchMode: boolean; targets: RunTarget[] }; + +type FeatherItemKind = + | 'section' + | 'status' + | 'actionRun' + | 'actionInit' + | 'actionDoctor' + | 'actionPlugins' + | 'actionPackages' + | 'actionUpload' + | 'actionBuildRelease' + | 'actionVendor' + | 'actionToggleWatch' + | 'actionConfigure' + | 'actionSelectTargets' + | 'actionBuildConfig'; + +export class FeatherProjectProvider implements vscode.TreeDataProvider { + private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; + + constructor(private getState: () => FeatherViewState) {} + + refresh(): void { + this.onDidChangeTreeDataEmitter.fire(); + } + + getTreeItem(item: FeatherTreeItem): vscode.TreeItem { + return item; + } + + getChildren(item?: FeatherTreeItem): FeatherTreeItem[] { + if (item?.children) return item.children; + + const { status, watchMode, targets } = this.getState(); + + if (!status.hasWorkspace) { + return [ + statusItem('No workspace open', 'info'), + actionItem('Configure Feather', 'feather.configure', 'settings-gear', 'actionConfigure'), + ]; + } + if (!status.hasMain) { + return [ + statusItem('No main.lua found', 'warning'), + statusItem('Open a LÖVE project folder', 'info'), + actionItem('Configure Feather', 'feather.configure', 'settings-gear', 'actionConfigure'), + ]; + } + if (!status.hasConfig) { + return [ + statusItem('feather.config.lua not found', 'warning'), + actionItem('Initialize Project', 'feather.init', 'add', 'actionInit'), + actionItem('Run Doctor', 'feather.doctor', 'pulse', 'actionDoctor'), + actionItem('Configure Feather', 'feather.configure', 'settings-gear', 'actionConfigure'), + ]; + } + + const targetLabel = targets.length > 0 + ? targets.map((t) => `$(${targetIcon(t)}) ${t}`).join(' ') + : 'No targets selected'; + + return [ + sectionItem('Status', 'info', [ + statusItem('feather.config.lua found', 'pass'), + statusItem(status.hasRuntime ? 'Embedded runtime found' : 'CLI mode project', 'info'), + statusItem(`${status.pluginCount} plugin${status.pluginCount === 1 ? '' : 's'} installed`, 'info'), + statusItem(`${status.packageCount} package${status.packageCount === 1 ? '' : 's'} installed`, 'info'), + ]), + sectionItem('Run', 'run-all', [ + watchMode + ? actionItem('Watch Project', 'feather.run', 'eye', 'actionRun') + : actionItem('Run Project', 'feather.run', 'play', 'actionRun'), + actionItem( + watchMode ? 'Watch mode: ON — click to disable' : 'Watch mode: OFF — click to enable', + 'feather.toggleWatch', + watchMode ? 'eye' : 'eye-closed', + 'actionToggleWatch', + ), + actionItem(`Targets: ${targetLabel}`, 'feather.selectTargets', 'target', 'actionSelectTargets'), + ]), + sectionItem('Build & Release', 'package', [ + actionItem('Build Config', 'feather.buildConfig', 'file-code', 'actionBuildConfig'), + actionItem('Create Release Build', 'feather.buildRelease', 'rocket', 'actionBuildRelease'), + actionItem('Upload Build', 'feather.upload', 'cloud-upload', 'actionUpload'), + actionItem('Manage Vendors', 'feather.vendor', 'archive', 'actionVendor'), + ]), + sectionItem('Project Tools', 'tools', [ + actionItem('Run Doctor', 'feather.doctor', 'pulse', 'actionDoctor'), + actionItem('Manage Plugins', 'feather.plugins', 'extensions', 'actionPlugins'), + actionItem('Manage Packages', 'feather.packages', 'package', 'actionPackages'), + actionItem('Configure Feather', 'feather.configure', 'settings-gear', 'actionConfigure'), + ]), + ]; + } +} + +export class FeatherTreeItem extends vscode.TreeItem { + constructor( + label: string, + public readonly kind: FeatherItemKind, + public readonly children?: FeatherTreeItem[], + collapsibleState: vscode.TreeItemCollapsibleState = vscode.TreeItemCollapsibleState.None, + ) { + super(label, collapsibleState); + this.contextValue = kind; + } +} + +function sectionItem(label: string, icon: string, children: FeatherTreeItem[]): FeatherTreeItem { + const item = new FeatherTreeItem(label, 'section', children, vscode.TreeItemCollapsibleState.Expanded); + item.iconPath = new vscode.ThemeIcon(icon); + return item; +} + +function statusItem(label: string, icon: 'pass' | 'warning' | 'info'): FeatherTreeItem { + const item = new FeatherTreeItem(label, 'status'); + const icons = { pass: 'check', warning: 'warning', info: 'info' }; + item.iconPath = new vscode.ThemeIcon(icons[icon]); + return item; +} + +function actionItem(label: string, command: string, icon: string, kind: FeatherItemKind): FeatherTreeItem { + const item = new FeatherTreeItem(label, kind); + item.iconPath = new vscode.ThemeIcon(icon); + item.command = { command, title: label }; + return item; +} diff --git a/vscode-extension/src/project.ts b/vscode-extension/src/project.ts new file mode 100644 index 00000000..3d6b6ac6 --- /dev/null +++ b/vscode-extension/src/project.ts @@ -0,0 +1,100 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +export type ProjectStatus = { + root: string | undefined; + hasWorkspace: boolean; + hasMain: boolean; + hasConfig: boolean; + hasRuntime: boolean; + pluginCount: number; + packageCount: number; +}; + +export function resolveProjectDir(configuredProjectDir: string | undefined, workspaceRoots: string[]): string | undefined { + const configured = configuredProjectDir?.trim(); + if (configured) return resolve(configured); + return workspaceRoots[0]; +} + +function countManifestFiles(dir: string): number { + if (!existsSync(dir)) return 0; + let count = 0; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + count += countManifestFiles(path); + } else if (entry.isFile() && entry.name === 'manifest.lua') { + count += 1; + } + } + return count; +} + +function packageCount(root: string): number { + const lockPath = join(root, 'feather.lock.json'); + if (!existsSync(lockPath)) return 0; + try { + const parsed = JSON.parse(readFileSync(lockPath, 'utf8')) as { packages?: Record }; + return Object.keys(parsed.packages ?? {}).length; + } catch { + return 0; + } +} + +function configArrayValues(source: string, key: 'include' | 'exclude'): string[] { + const withoutLineComments = source + .split('\n') + .map((line) => line.replace(/--.*$/, '')) + .join('\n'); + const match = new RegExp(`\\b${key}\\s*=\\s*\\{([\\s\\S]*?)\\}`).exec(withoutLineComments); + if (!match) return []; + return [...match[1].matchAll(/["']([^"']+)["']/g)].map((item) => item[1]); +} + +function managedMode(source: string): string | undefined { + return /\bmanaged\s*=\s*["']([^"']+)["']/.exec(source)?.[1]; +} + +function cliManagedPluginCount(root: string, hasRuntime: boolean): number | undefined { + const configPath = join(root, 'feather.config.lua'); + if (!existsSync(configPath)) return undefined; + + try { + const source = readFileSync(configPath, 'utf8'); + const mode = managedMode(source); + if (mode !== 'cli' && hasRuntime) return undefined; + + const excluded = new Set(configArrayValues(source, 'exclude')); + const included = new Set(configArrayValues(source, 'include').filter((id) => !excluded.has(id))); + return included.size; + } catch { + return undefined; + } +} + +export function getProjectStatus(root: string | undefined): ProjectStatus { + if (!root) { + return { + root, + hasWorkspace: false, + hasMain: false, + hasConfig: false, + hasRuntime: false, + pluginCount: 0, + packageCount: 0, + }; + } + + const hasRuntime = existsSync(join(root, 'feather', 'init.lua')); + + return { + root, + hasWorkspace: true, + hasMain: existsSync(join(root, 'main.lua')), + hasConfig: existsSync(join(root, 'feather.config.lua')), + hasRuntime, + pluginCount: cliManagedPluginCount(root, hasRuntime) ?? countManifestFiles(join(root, 'feather', 'plugins')), + packageCount: packageCount(root), + }; +} diff --git a/vscode-extension/src/vendor.ts b/vscode-extension/src/vendor.ts new file mode 100644 index 00000000..a4949973 --- /dev/null +++ b/vscode-extension/src/vendor.ts @@ -0,0 +1,54 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +export type RunTarget = 'desktop' | 'web' | 'android' | 'ios' | 'steamos'; + +export const ALL_RUN_TARGETS: RunTarget[] = ['desktop', 'web', 'android', 'ios', 'steamos']; + +type VendorRequirement = { + label: string; + checkPath: string; + vendorArg: string; +}; + +const VENDOR_REQUIREMENTS: Partial> = { + web: { + label: 'love.js (web)', + checkPath: join('vendor', 'love.js', 'index.html'), + vendorArg: 'web', + }, + android: { + label: 'love-android', + checkPath: join('vendor', 'love-android', 'gradlew'), + vendorArg: 'android', + }, + ios: { + label: 'love-ios', + checkPath: join('vendor', 'love-ios', 'platform', 'xcode', 'love.xcodeproj'), + vendorArg: 'ios', + }, +}; + +export function vendorPresent(root: string, target: RunTarget): boolean { + const req = VENDOR_REQUIREMENTS[target]; + if (!req) return true; + return existsSync(join(root, req.checkPath)); +} + +export function vendorLabel(target: RunTarget): string { + return VENDOR_REQUIREMENTS[target]?.label ?? target; +} + +export function vendorArg(target: RunTarget): string | undefined { + return VENDOR_REQUIREMENTS[target]?.vendorArg; +} + +export function targetIcon(target: RunTarget): string { + switch (target) { + case 'desktop': return 'desktop-download'; + case 'web': return 'globe'; + case 'android': return 'device-mobile'; + case 'ios': return 'device-mobile'; + case 'steamos': return 'game'; + } +} diff --git a/vscode-extension/test/e2e/run.mjs b/vscode-extension/test/e2e/run.mjs new file mode 100644 index 00000000..de249638 --- /dev/null +++ b/vscode-extension/test/e2e/run.mjs @@ -0,0 +1,20 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runTests } from '@vscode/test-electron'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const extensionDevelopmentPath = resolve(__dirname, '../..'); +const extensionTestsPath = resolve(__dirname, 'suite/index.cjs'); +const workspacePath = join(tmpdir(), 'feather-vscode-e2e-workspace'); + +mkdirSync(workspacePath, { recursive: true }); +writeFileSync(join(workspacePath, 'main.lua'), 'function love.draw() end\n'); +writeFileSync(join(workspacePath, 'feather.config.lua'), 'return { __DANGEROUS_INSECURE_CONNECTION__ = true }\n'); + +await runTests({ + extensionDevelopmentPath, + extensionTestsPath, + launchArgs: [workspacePath, '--disable-extensions'], +}); diff --git a/vscode-extension/test/e2e/suite/index.cjs b/vscode-extension/test/e2e/suite/index.cjs new file mode 100644 index 00000000..6957866c --- /dev/null +++ b/vscode-extension/test/e2e/suite/index.cjs @@ -0,0 +1,20 @@ +const assert = require('node:assert/strict'); +const vscode = require('vscode'); + +suite('Feather VS Code extension', () => { + test('registers core commands', async () => { + const commands = await vscode.commands.getCommands(true); + for (const command of [ + 'feather.run', + 'feather.init', + 'feather.doctor', + 'feather.plugins', + 'feather.packages', + 'feather.upload', + 'feather.remove', + 'feather.update', + ]) { + assert.ok(commands.includes(command), `${command} should be registered`); + } + }); +}); diff --git a/vscode-extension/test/helpers.test.mjs b/vscode-extension/test/helpers.test.mjs new file mode 100644 index 00000000..b8185cd5 --- /dev/null +++ b/vscode-extension/test/helpers.test.mjs @@ -0,0 +1,76 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { buildCommand, buildEnvCommand, shellQuote } = require('../out/command.js'); +const { getProjectStatus, resolveProjectDir } = require('../out/project.js'); + +test('command helpers quote paths with spaces', () => { + assert.equal(shellQuote('/tmp/Feather App/node'), '"/tmp/Feather App/node"'); + assert.equal( + buildCommand('/tmp/VS Code/node', '/tmp/ext/bundled-cli/index.js', ['run', '/tmp/My Game']), + '"/tmp/VS Code/node" /tmp/ext/bundled-cli/index.js run "/tmp/My Game"', + ); + assert.equal( + buildEnvCommand({ ELECTRON_RUN_AS_NODE: '1' }, '/tmp/VS Code/helper', '/tmp/ext/bundled-cli/launcher.js', ['run', '/tmp/My Game']), + 'ELECTRON_RUN_AS_NODE=1 "/tmp/VS Code/helper" /tmp/ext/bundled-cli/launcher.js run "/tmp/My Game"', + ); +}); + +test('project helpers resolve configured project before workspace root', () => { + assert.equal(resolveProjectDir('/tmp/game', ['/tmp/workspace']), '/tmp/game'); + assert.equal(resolveProjectDir('', ['/tmp/workspace']), '/tmp/workspace'); + assert.equal(resolveProjectDir(undefined, []), undefined); +}); + +test('project status reports config, runtime, plugins, and packages', () => { + const root = mkdtempSync(join(tmpdir(), 'feather-vscode-test-')); + try { + writeFileSync(join(root, 'main.lua'), ''); + writeFileSync(join(root, 'feather.config.lua'), 'return {}\n'); + mkdirSync(join(root, 'feather', 'plugins', 'console'), { recursive: true }); + writeFileSync(join(root, 'feather', 'init.lua'), 'return {}\n'); + writeFileSync(join(root, 'feather', 'plugins', 'console', 'manifest.lua'), 'return {}\n'); + writeFileSync(join(root, 'feather.lock.json'), JSON.stringify({ packages: { anim8: {}, baton: {} } })); + + const status = getProjectStatus(root); + assert.equal(status.hasWorkspace, true); + assert.equal(status.hasMain, true); + assert.equal(status.hasConfig, true); + assert.equal(status.hasRuntime, true); + assert.equal(status.pluginCount, 1); + assert.equal(status.packageCount, 2); + assert.equal(existsSync(root), true); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('project status counts CLI-managed included plugins from config', () => { + const root = mkdtempSync(join(tmpdir(), 'feather-vscode-test-')); + try { + writeFileSync(join(root, 'main.lua'), ''); + writeFileSync( + join(root, 'feather.config.lua'), + `return { + managed = "cli", + include = { "console", "hot-reload", "profiler" }, + exclude = { "profiler" }, + + -- include = { "commented-out" }, +} +`, + ); + + const status = getProjectStatus(root); + assert.equal(status.hasConfig, true); + assert.equal(status.hasRuntime, false); + assert.equal(status.pluginCount, 2); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/vscode-extension/tsconfig.json b/vscode-extension/tsconfig.json new file mode 100644 index 00000000..3b0f504e --- /dev/null +++ b/vscode-extension/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "CommonJS", + "target": "ES2022", + "lib": ["ES2022"], + "outDir": "out", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "moduleResolution": "node", + "ignoreDeprecations": "6.0", + "types": ["node", "vscode"] + }, + "include": ["src"] +}