diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 00000000..856d19e1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,84 @@ +name: Bug report +description: Report a crash, error, or unexpected behavior +labels: ["bug"] +body: + - type: dropdown + id: area + attributes: + label: Area + description: Which part of Feather is affected? + options: + - CLI (feather command) + - Package manager (feather package) + - Desktop app + - Lua runtime / game-side + - Plugin + - Other + validations: + required: true + + - type: textarea + id: what_happened + attributes: + label: What happened? + description: What did you do, what did you expect, and what actually occurred? + placeholder: | + I ran `feather package install anim8` and got an error instead of the files being installed. + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + placeholder: | + 1. Create a new LÖVE project directory + 2. Run `feather package install anim8` + 3. See error + validations: + required: true + + - type: textarea + id: error_output + attributes: + label: Error output or logs + description: Paste the full terminal output or error message. Use `feather --json` if available for machine-readable output. + render: shell + + - type: input + id: feather_version + attributes: + label: Feather CLI version + description: Run `feather --version` + placeholder: 0.9.3 + validations: + required: true + + - type: input + id: node_version + attributes: + label: Node.js version + description: Run `node --version` + placeholder: v22.0.0 + validations: + required: true + + - type: input + id: os + attributes: + label: OS and version + placeholder: macOS 15.4 / Windows 11 / Ubuntu 24.04 + validations: + required: true + + - type: input + id: love_version + attributes: + label: LÖVE version (if relevant) + placeholder: 11.5 + + - type: textarea + id: extra + attributes: + label: Anything else? + description: Lockfile contents, feather.config.lua, workarounds you tried, etc. diff --git a/.github/ISSUE_TEMPLATE/package-request.yml b/.github/ISSUE_TEMPLATE/package-request.yml new file mode 100644 index 00000000..d89fbc1e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/package-request.yml @@ -0,0 +1,75 @@ +name: Package request +description: Propose adding a library to the Feather catalog +labels: ["package-request"] +body: + - type: input + id: name + attributes: + label: Package name + description: The name you'd like to use with `feather package install ` + placeholder: anim8 + validations: + required: true + + - type: input + id: repo + attributes: + label: GitHub repository + description: Full repo path (owner/repo) + placeholder: kikito/anim8 + validations: + required: true + + - type: input + id: tag + attributes: + label: Release tag or commit to pin + description: The specific tag or commit SHA Feather should pin. Prefer a tagged release over a branch. + placeholder: v2.3.1 + validations: + required: true + + - type: dropdown + id: trust + attributes: + label: Suggested trust level + description: > + `verified` — you've read the source and consider it safe to use as-is. + `known` — popular and widely used but you haven't audited it line by line. + options: + - known + - verified + validations: + required: true + + - type: textarea + id: description + attributes: + label: What does this library do? + description: One or two sentences. This becomes the catalog description. + placeholder: A 2D animation library for LÖVE. Handles sprite sheets, frames, and playback. + validations: + required: true + + - type: textarea + id: files + attributes: + label: Files to install + description: List the files that should be copied into a project and their suggested target paths. + placeholder: | + anim8.lua → lib/anim8.lua + validations: + required: true + + - type: input + id: require + attributes: + label: require() path + description: How users load it in Lua code, matching the suggested target path. + placeholder: lib.anim8 + + - type: textarea + id: why + attributes: + label: Why should this be in the catalog? + description: What problem does it solve? Are there alternatives already in the catalog? diff --git a/.github/ISSUE_TEMPLATE/plugin-request.yml b/.github/ISSUE_TEMPLATE/plugin-request.yml new file mode 100644 index 00000000..df52d674 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/plugin-request.yml @@ -0,0 +1,76 @@ +name: Plugin request +description: Propose adding a plugin to the Feather catalog +labels: ["plugin-request"] +body: + - type: input + id: id + attributes: + label: Plugin ID + description: Snake-case identifier used with `feather plugin install ` + placeholder: collision-debug + validations: + required: true + + - type: input + id: name + attributes: + label: Display name + description: Human-readable name shown in `feather plugin list` + placeholder: Collision Debug + validations: + required: true + + - type: textarea + id: description + attributes: + label: What does this plugin do? + description: One or two sentences shown in the catalog. Be specific about what it displays or enables inside Feather. + placeholder: Draws AABB and circle collision shapes on top of your game world, color-coded by collision group. + validations: + required: true + + - type: textarea + id: integration + attributes: + label: How does it integrate with the game? + description: > + Describe what the game needs to do to support this plugin — e.g. does it hook into + love.update / love.draw automatically, or does the developer need to call specific APIs? + Does it require any particular library (bump, windfield, etc.)? + placeholder: | + Hooks into love.draw automatically. Requires bump.lua to be installed and passed in via opts.world. + validations: + required: true + + - type: dropdown + id: opt_in + attributes: + label: Opt-in? + description: Should the plugin be disabled by default and require the developer to explicitly enable it? + options: + - "Yes — opt-in (disabled by default)" + - "No — enabled by default" + validations: + required: true + + - type: textarea + id: capabilities + attributes: + label: Capabilities used + description: List any Feather capabilities this plugin relies on (e.g. overlay, inspector, command palette). Leave blank if none. + placeholder: | + overlay + inspector + + - type: input + id: repo + attributes: + label: Source repository (if external) + description: If the plugin lives in a separate repo rather than being contributed directly, link it here. + placeholder: github.com/yourname/feather-plugin-collision-debug + + - type: textarea + id: why + attributes: + label: Why should this be in the catalog? + description: What workflow or debugging scenario does it address? Is there overlap with an existing plugin? diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml index 1850e075..1a346b8c 100644 --- a/.github/workflows/cli-e2e.yml +++ b/.github/workflows/cli-e2e.yml @@ -5,7 +5,6 @@ on: paths: - "cli/**" - "src-lua/**" - - "scripts/cli-e2e.mjs" - "scripts/bundle-lua.sh" - "package.json" - "package-lock.json" @@ -15,7 +14,6 @@ on: paths: - "cli/**" - "src-lua/**" - - "scripts/cli-e2e.mjs" - "scripts/bundle-lua.sh" - "package.json" - "package-lock.json" diff --git a/.github/workflows/registry.yml b/.github/workflows/registry.yml new file mode 100644 index 00000000..d160c165 --- /dev/null +++ b/.github/workflows/registry.yml @@ -0,0 +1,57 @@ +name: Publish registry + +on: + push: + branches: [main] + paths: + - 'packages/**' + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Generate registry + run: node scripts/generate-registry.mjs + + - name: Push to packages branch + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Fetch packages branch if it already exists + git fetch origin packages 2>/dev/null || true + + # Set up a worktree pointing at the packages branch. + # If the branch doesn't exist yet, create it as an orphan. + if git ls-remote --exit-code origin packages > /dev/null 2>&1; then + git worktree add /tmp/registry-branch packages + else + git worktree add --orphan -b packages /tmp/registry-branch + fi + + cp cli/src/generated/registry.json /tmp/registry-branch/registry.json + + cd /tmp/registry-branch + git add registry.json + + if git diff --cached --quiet; then + echo "Registry unchanged — nothing to push." + else + # Surface the triggering commit so the packages branch log is traceable + TRIGGER_SHA="${{ github.sha }}" + TRIGGER_MSG=$(git -C "$GITHUB_WORKSPACE" log -1 --pretty=format:"%s" "$TRIGGER_SHA") + git commit -m "chore: update registry (${TRIGGER_SHA::7}) ${TRIGGER_MSG}" + git push origin packages + fi diff --git a/README.md b/README.md index 24dc4a1b..8de98619 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,29 @@ -# Feather 🪶 — Debug & Inspect Tool for LÖVE (love2d) +# Feather — CLI, Debugging & Inspection Tool for Löve2D -Feather is a real-time debugging and inspection tool for [LÖVE](https://love2d.org) games — like Flipper or React DevTools, but for your game. Inspired by [LoveBird](https://github.com/rxi/lovebird). +Feather is a CLI for debugging, inspecting, and manage packages for [LÖVE](https://love2d.org) games. + +It gives you a live window into your running game _(logs, variables, errors, performance)_ without touching your game's release build. Inspired by [LoveBird](https://github.com/rxi/lovebird) and [Flipper](https://github.com/facebook/flipper). + +The goal is to make the day-to-day loop of writing and testing a LÖVE game faster: less time adding print statements and restarting, more time actually building. **[📖 Documentation](https://kyonru.github.io/feather)** · [Releases](https://github.com/Kyonru/feather/releases) · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) --- -## Features - -- 📜 **Live log viewer** — See `print()` output instantly in the app. -- 🔍 **Variable inspection** — Watch values update in real-time. -- 🚨 **Error capturing** — Automatically catch and display errors. -- 📸 **Screenshots & GIF capture** — Capture and record via the built-in plugin. -- 🔌 **Plugin system** — +18 built-in plugins + custom ones. Server-driven UI: plugins define their actions in Lua, the desktop renders them automatically. -- 📱 **Multi-session support** — Connect multiple games simultaneously. -- 📲 **Mobile debugging** — Auto-detected local IP in Settings with copyable connection string. -- 💻 **Console / REPL** — Execute Lua code in the running game (opt-in, requires `apiKey`). -- 🐛 **Step Debugger** — Breakpoints, step over/into/out, call stack, local variable inspection. -- 📁 **Log file viewer** — Open `.featherlog` files for offline inspection. -- 🖥️ **CLI-first setup** — `feather init`, `feather run`, and `feather remove` manage the debugger lifecycle. -- ⚡ **Guarded in-game setup** — Generated imports load only when `USE_DEBUGGER` is enabled. +## What it does + +- **Live log viewer** — See `print()` output in real time without a terminal window. +- **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. +- **Console / REPL** — Execute Lua in the running game (opt-in, requires an `apiKey`). +- **Plugin system** — 18+ built-in plugins (collision debug, animation inspector, audio debug, particle editor, and more). Plugins define their UI in Lua; the desktop app renders it automatically. +- **Multi-session** — Connect multiple games at the same time. +- **Mobile debugging** — Auto-detected LAN IP for connecting phones, tablets, and Steam Deck. +- **Screenshots & GIF capture** — Built-in capture plugin. +- **Log file viewer** — Open `.featherlog` files for offline inspection. +- **CLI** — No Lua changes needed to run and debug love2d games. + - **Package Manager** — Install packages from a curated list of popular love2D packages. --- @@ -29,37 +33,43 @@ Feather is a real-time debugging and inspection tool for [LÖVE](https://love2d. --- -## Quick Start +## Setup -Install the CLI, initialize your game, then run with `USE_DEBUGGER=1` when you want Feather loaded: +Install the CLI, then initialize your project: ```sh npm install -g @kyonru/feather feather init --mode cli +``` + +Run your game with Feather loaded: + +```sh feather run path/to/my-game +# or +USE_DEBUGGER=1 love path/to/my-game ``` -For games running in an external device (iOS, Android, SteamDeck, etc), you need to include feather in the source code. Checkout the documentation for good practices and worfklow recommendations. +For games running on external devices (iOS, Android, Steam Deck, another machine), embed the runtime directly: ```sh -feather init path/to/my-game +feather init path/to/my-game # copies the Lua runtime into your project USE_DEBUGGER=1 love path/to/my-game ``` -`feather init` creates a `feather.config.lua` file for project settings: +`feather init` creates a `feather.config.lua` in your project: ```lua return { sessionName = "My Game", - -- For phones, tablets, Steam Deck, or another computer, set this to - -- the desktop app machine's LAN IP. + -- For phones, tablets, Steam Deck, or another computer: -- host = "192.168.1.50", -- include = { "console" }, - -- exclude = { "hump.signal", "lua-state-machine" }, + -- exclude = { "hump.signal" }, } ``` -Generated code always uses `DEBUGGER` safely: +All generated game-side code is guarded so it only runs when `USE_DEBUGGER` is set: ```lua function love.update(dt) @@ -69,9 +79,9 @@ function love.update(dt) end ``` -For production cleanup: +To strip Feather from a project before shipping: -```bash +```sh feather remove --dry-run feather remove --yes ``` @@ -80,17 +90,34 @@ Then download the desktop app from the [releases page](https://github.com/Kyonru --- -## Documentation +## Package manager + +Feather includes a curated installer for common LÖVE libraries. It is not a general package manager — it is a hand-picked catalog of known-good libraries with verified SHA-256 checksums and a lockfile you can commit. + +```sh +feather package install anim8 # install a library +feather package install # restore everything in feather.lock.json +feather package audit # verify checksums of installed files +feather package list # browse the catalog +``` + +Available libraries include anim8, bump, hump, lume, flux, inspect, middleclass, classic, push, sti, and windfield. See [packages/README.md](packages/README.md) for the full list and command reference. + +--- + +## [Documentation](https://kyonru.github.io/feather) - [Installation](docs/installation.md) +- [CLI](docs/cli.md) - [Configuration](docs/configuration.md) - [Usage](docs/usage.md) — observers, logging, console, step debugger - [Plugins](docs/plugins.md) +- [Packages](packages/README.md) - [Recommendations](docs/recommendations.md) — security, performance, release builds --- -## Built-in Lua Dependencies +## Built-in Lua dependencies - [Hump Class](https://github.com/vrld/hump/blob/master/class.lua) - [Inspect](https://github.com/kikito/inspect.lua) @@ -104,19 +131,16 @@ Then download the desktop app from the [releases page](https://github.com/Kyonru - [LoveBird](https://github.com/rxi/lovebird) by rxi — original inspiration - [Love-Dialogue](https://github.com/Miisan-png/Love-Dialogue) by Miisan-png — plugin system reference -- [Flipper](https://github.com/facebook/flipper) by Facebook — UI/tooling patterns - [HUMP](https://github.com/vrld/hump), [anim8](https://github.com/kikito/anim8), [flux](https://github.com/rxi/flux), [bump.lua](https://github.com/kikito/bump.lua), [lua-state-machine](https://github.com/kyleconroy/lua-state-machine) --- ## License -DISCLAIMER: The license only applies to products that directly replicate the logic/purpose of this tool. It does not apply to games created using it as a dev tool. +The license applies to products that directly replicate the logic or purpose of this tool. It does not apply to games built using it as a development tool. See [LICENSE.md](LICENSE.md). -## AI Usage - -The architecture and planing of this tool has been made by me, I take a lot of my time and effort to put on this tool, mostly because it helps my development experience with Love2D. Once i had the plugin system in place, using AI for autocompletion has allowed for fast plugin development, and fix my poorly written rust code. +## AI usage -Code is curated and carefully revised by me. While I don't think there is need to explain this, i understand some people might not feel comfortable with AI in any form. I respect that. +The architecture and planning of this tool is my own work. The main goal of this tool is to improve developer experience, meaning providing utilities to save time for my own developer experience and hopefully others. AI autocompletion is used for the creation of some of the features in this tool. This is not an AI driven tool nor a product designed by AI. This is a devtool made by an indie dev for indie devs nothing else. All generated code is reviewed by me. But I understand some people are not comfortable with AI involvement in any form. I respect that. diff --git a/cli/package.json b/cli/package.json index e2ed1b85..f429c1ec 100644 --- a/cli/package.json +++ b/cli/package.json @@ -30,11 +30,13 @@ "lua" ], "scripts": { - "build": "tsc", - "dev": "tsc --watch", + "build": "tsc && cp src/generated/registry.json dist/generated/registry.json", + "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", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "node --test test/package.test.mjs", + "test:e2e": "npm run build && node --test test/package.test.mjs" }, "dependencies": { "chalk": "^5.3.0", diff --git a/cli/src/commands/package.ts b/cli/src/commands/package.ts new file mode 100644 index 00000000..20c7774f --- /dev/null +++ b/cli/src/commands/package.ts @@ -0,0 +1,531 @@ +import { existsSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import chalk from 'chalk'; +import ora from 'ora'; +import { loadRegistry } from '../lib/package/registry.js'; +import { readLockfile, writeLockfile, removeFromLockfile } from '../lib/package/lockfile.js'; +import { resolveMany, type ResolvedPackage } from '../lib/package/resolve.js'; +import { installFromUrl, restorePackage } from '../lib/package/install.js'; +import { auditLockfile } from '../lib/package/audit.js'; +import { showPackageBrowser } from '../ui/package-workflow.js'; +import { showInstallProgress } from '../ui/package-progress.js'; + +function findProjectDir(cwd = process.cwd()): string { + if (existsSync(join(cwd, 'main.lua'))) return cwd; + return cwd; +} + +function trustBadge(trust: string): string { + if (trust === 'verified') return chalk.green('[verified]'); + if (trust === 'known') return chalk.yellow('[known]'); + return chalk.red('[experimental]'); +} + +export type PackageSearchOptions = { + offline?: boolean; + registryUrl?: string; +}; + +export async function packageSearchCommand(query: string | undefined, opts: PackageSearchOptions = {}): Promise { + const spinner = ora('Loading registry…').start(); + let registry; + try { + registry = await loadRegistry(opts); + spinner.stop(); + } catch (err) { + spinner.fail(`Failed to load registry: ${(err as Error).message}`); + process.exitCode = 1; + return; + } + + const q = query?.toLowerCase(); + const entries = Object.entries(registry.packages).filter(([, entry]) => !entry.parent); + + const matches = q + ? entries.filter( + ([id, entry]) => + id.includes(q) || entry.description.toLowerCase().includes(q) || entry.tags.some((t) => t.includes(q)), + ) + : entries; + + if (matches.length === 0) { + console.log(chalk.dim(`No packages found${q ? ` matching "${query}"` : ''}.`)); + return; + } + + const maxId = Math.max(...matches.map(([id]) => id.length)); + for (const [id, entry] of matches.sort(([a], [b]) => a.localeCompare(b))) { + const badge = trustBadge(entry.trust); + console.log(` ${chalk.bold(id.padEnd(maxId + 2))} ${badge} ${chalk.dim(entry.description)}`); + if (entry.subpackages?.length) { + console.log(chalk.dim(` ${''.padEnd(maxId + 2)} subpackages: ${entry.subpackages.join(', ')}`)); + } + } + console.log(chalk.dim(`\n${matches.length} package(s). Run \`feather package info \` for details.`)); +} + +export type PackageListOptions = { + installed?: boolean; + offline?: boolean; + refresh?: boolean; + dir?: string; + registryUrl?: string; +}; + +export async function packageListCommand(opts: PackageListOptions = {}): Promise { + const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); + + if (opts.installed) { + const lockfile = readLockfile(projectDir); + const entries = Object.entries(lockfile.packages); + if (entries.length === 0) { + console.log(chalk.dim('No packages installed. Run `feather package install `.')); + return; + } + for (const [id, entry] of entries) { + console.log(` ${trustBadge(entry.trust)} ${chalk.bold(id)} @ ${entry.version}`); + } + console.log(chalk.dim(`\n${entries.length} package(s) installed.`)); + return; + } + + const spinner = ora('Loading registry…').start(); + let registry; + try { + registry = await loadRegistry({ offline: opts.offline, refresh: opts.refresh, registryUrl: opts.registryUrl }); + spinner.stop(); + } catch (err) { + spinner.fail(`Failed to load registry: ${(err as Error).message}`); + process.exitCode = 1; + return; + } + + const lockfile = readLockfile(projectDir); + + // Fall back to plain text when stdin is not a TTY (scripts, piped output) + if (!process.stdin.isTTY || !process.stdout.isTTY) { + const entries = Object.entries(registry.packages).filter(([, e]) => !e.parent); + for (const [id, entry] of entries.sort(([a], [b]) => a.localeCompare(b))) { + const installed = lockfile.packages[id]; + const installedLabel = installed ? chalk.cyan(` (installed ${installed.version})`) : ''; + console.log(` ${trustBadge(entry.trust)} ${chalk.bold(id)}${installedLabel} ${chalk.dim(entry.description)}`); + } + console.log(chalk.dim(`\n${entries.length} available.`)); + return; + } + + const result = await showPackageBrowser({ registry, lockfile }); + if (result.action === 'cancel') return; + + const { resolved, errors } = resolveMany([result.id], registry); + if (errors.length) { + for (const e of errors) console.log(chalk.red(` ✖ ${e}`)); + process.exitCode = 1; + return; + } + + if (result.action === 'remove') { + await packageRemoveCommand(result.id, { dir: opts.dir }); + return; + } + + const installResults = await showInstallProgress({ packages: resolved, lockfile, projectDir }); + if (installResults.every((r) => r.ok)) { + writeLockfile(projectDir, lockfile); + } else { + process.exitCode = 1; + } +} + +export type PackageInfoOptions = { + offline?: boolean; + dir?: string; + registryUrl?: string; +}; + +export async function packageInfoCommand(name: string, opts: PackageInfoOptions = {}): Promise { + const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); + const lockfile = readLockfile(projectDir); + + const spinner = ora('Loading registry…').start(); + let registry; + try { + registry = await loadRegistry({ offline: opts.offline, registryUrl: opts.registryUrl }); + spinner.stop(); + } catch (err) { + spinner.fail(`Failed to load registry: ${(err as Error).message}`); + process.exitCode = 1; + return; + } + + const entry = registry.packages[name]; + if (!entry) { + console.log(chalk.red(`Package "${name}" not found.`)); + process.exitCode = 1; + return; + } + + const installed = lockfile.packages[name]; + console.log(); + console.log(`${chalk.bold(name)} ${trustBadge(entry.trust)}`); + console.log(chalk.dim(entry.description)); + console.log(); + console.log(` Source: ${chalk.cyan(`github.com/${entry.source.repo}`)}`); + console.log(` Version: ${entry.source.tag}`); + console.log(` Tags: ${entry.tags.join(', ') || '—'}`); + if (entry.homepage) console.log(` Docs: ${chalk.cyan(entry.homepage)}`); + if (installed) { + console.log(` Status: ${chalk.green('installed')} @ ${installed.version}`); + } + if (entry.subpackages?.length) { + console.log(` Modules: ${entry.subpackages.join(', ')}`); + } + console.log(); + console.log(' Files to install:'); + for (const f of entry.install.files) { + console.log(` ${chalk.dim(f.name)} → ${f.target}`); + } + console.log(); + console.log(' Usage:'); + console.log(` ${chalk.cyan(entry.example ?? `local lib = require("${entry.require}")`)}`); + console.log(); +} + +export type PackageInstallOptions = { + dryRun?: boolean; + allowUntrusted?: boolean; + target?: string; + fromUrl?: string; + dir?: string; + offline?: boolean; + yes?: boolean; + registryUrl?: string; +}; + +export async function packageInstallCommand(names: string[], opts: PackageInstallOptions = {}): Promise { + const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); + + if (opts.fromUrl) { + if (!opts.allowUntrusted && !opts.yes) { + console.log(); + console.log(chalk.yellow('Installing from untrusted URL')); + console.log(` URL: ${opts.fromUrl}`); + if (!opts.target) { + console.log(chalk.red(' --target is required with --from-url')); + process.exitCode = 1; + return; + } + console.log(` Target: ${opts.target}`); + console.log(); + console.log(chalk.red(' This package has NOT been reviewed by the Feather team.')); + console.log(chalk.dim(' Use --allow-untrusted to confirm you trust this source.')); + process.exitCode = 1; + return; + } + + if (!opts.target) { + console.log(chalk.red('--target is required with --from-url')); + process.exitCode = 1; + return; + } + + const spinner = ora(`Fetching ${opts.fromUrl}…`).start(); + const lockfile = readLockfile(projectDir); + const result = await installFromUrl(lockfile, { + projectDir, + url: opts.fromUrl, + target: opts.target, + dryRun: opts.dryRun, + }); + + if (!result.ok) { + spinner.fail(result.error ?? 'Install failed'); + process.exitCode = 1; + return; + } + + if (opts.dryRun) { + spinner.stop(); + console.log(); + console.log(chalk.yellow('Dry run — no files written')); + console.log(` URL: ${opts.fromUrl}`); + console.log(` SHA-256: ${result.sha256}`); + console.log(` Size: ${result.size} bytes`); + console.log(` Target: ${result.target}`); + console.log(chalk.yellow(' Trust: experimental ⚠')); + return; + } + + spinner.succeed('Installed from URL (experimental)'); + console.log(); + console.log(` SHA-256: ${result.sha256}`); + console.log(` Target: ${result.target}`); + console.log(chalk.yellow(' Trust: experimental ⚠ — not reviewed by the Feather team')); + writeLockfile(projectDir, lockfile); + return; + } + + // No names → restore everything recorded in feather.lock.json + if (names.length === 0) { + const lockfile = readLockfile(projectDir); + const entries = Object.entries(lockfile.packages).filter(([, e]) => !e.parent); + + if (entries.length === 0) { + console.log(chalk.dim('feather.lock.json is empty. Run `feather package install ` to add packages.')); + return; + } + + console.log(); + const auditResults = await auditLockfile(projectDir, lockfile); + const broken = new Set(auditResults.filter((r) => r.status !== 'verified').map((r) => r.id)); + + if (broken.size === 0) { + console.log(chalk.green(`✔ All ${entries.length} package(s) already up to date.`)); + return; + } + + let failed = false; + for (const [id, entry] of entries) { + if (!broken.has(id)) { + console.log(chalk.dim(` ${id} @ ${entry.version} — up to date`)); + continue; + } + const spinner = ora(` ${id} @ ${entry.version}`).start(); + const result = await restorePackage(id, entry, { projectDir, dryRun: opts.dryRun }); + if (result.ok) { + spinner.succeed(` ${id} @ ${entry.version}`); + } else { + spinner.fail(` ${id}: ${result.error}`); + failed = true; + } + } + + console.log(); + if (failed) process.exitCode = 1; + return; + } + + const spinner = ora('Loading registry…').start(); + let registry; + try { + registry = await loadRegistry({ offline: opts.offline, registryUrl: opts.registryUrl }); + spinner.stop(); + } catch (err) { + spinner.fail(`Failed to load registry: ${(err as Error).message}`); + process.exitCode = 1; + return; + } + + const lockfile = readLockfile(projectDir); + const { resolved, errors } = resolveMany(names, registry); + + if (errors.length) { + for (const e of errors) console.log(chalk.red(` ✖ ${e}`)); + process.exitCode = 1; + return; + } + + // Check already installed (skip check when a version override is requested) + const toInstall = resolved.filter((pkg) => { + if (pkg.versionOverride) return true; + const existing = lockfile.packages[pkg.id]; + if (existing && existing.version === pkg.entry.source.tag) { + console.log(chalk.dim(` ${pkg.id} is already installed at ${existing.version}`)); + return false; + } + return true; + }); + + if (toInstall.length === 0) return; + + // Block experimental or version-overridden packages without --allow-untrusted + for (const pkg of toInstall) { + if (pkg.entry.trust === 'experimental' && !opts.allowUntrusted) { + console.log(chalk.red(` "${pkg.id}" requires --allow-untrusted (trust: experimental)`)); + process.exitCode = 1; + return; + } + if (pkg.versionOverride && !opts.allowUntrusted) { + console.log( + chalk.red( + ` "${pkg.id}@${pkg.versionOverride}" requires --allow-untrusted — this version has not been reviewed by Feather`, + ), + ); + process.exitCode = 1; + return; + } + } + + if (opts.dryRun) { + console.log(); + for (const pkg of toInstall) { + const displayVersion = pkg.versionOverride ?? pkg.entry.source.tag; + console.log(` ${chalk.bold(pkg.id)} ${trustBadge(pkg.versionOverride ? 'experimental' : pkg.entry.trust)}`); + console.log(` Source: github.com/${pkg.entry.source.repo} Version: ${displayVersion}`); + for (const f of pkg.files) { + console.log(` ${chalk.dim(f.name)} → ${f.target}`); + } + console.log(); + } + console.log(chalk.yellow('Dry run — no files written.')); + return; + } + + const results = await showInstallProgress({ packages: toInstall, lockfile, projectDir, targetOverride: opts.target }); + if (results.every((r) => r.ok)) { + writeLockfile(projectDir, lockfile); + } else { + process.exitCode = 1; + } +} + +export type PackageUpdateOptions = { + dryRun?: boolean; + dir?: string; + offline?: boolean; + registryUrl?: string; +}; + +export async function packageUpdateCommand(name: string | undefined, opts: PackageUpdateOptions = {}): Promise { + const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); + const lockfile = readLockfile(projectDir); + + const installed = Object.entries(lockfile.packages); + if (installed.length === 0) { + console.log(chalk.dim('No packages installed.')); + return; + } + + const spinner = ora('Loading registry…').start(); + let registry; + try { + registry = await loadRegistry({ offline: opts.offline, registryUrl: opts.registryUrl }); + spinner.stop(); + } catch (err) { + spinner.fail(`Failed to load registry: ${(err as Error).message}`); + process.exitCode = 1; + return; + } + + const targets = name ? installed.filter(([id]) => id === name) : installed; + + if (name && targets.length === 0) { + console.log(chalk.red(`"${name}" is not installed.`)); + process.exitCode = 1; + return; + } + + const toUpdate: ResolvedPackage[] = []; + for (const [id, current] of targets) { + if (current.trust === 'experimental') { + console.log(chalk.dim(` Skipping "${id}" (experimental — re-install with --from-url to update)`)); + continue; + } + const entry = registry.packages[id]; + if (!entry) { + console.log(chalk.yellow(` "${id}" not found in registry — skipping`)); + continue; + } + if (entry.source.tag === current.version) { + console.log(chalk.dim(` ${id} is already up to date (${current.version})`)); + continue; + } + console.log(` ${chalk.bold(id)}: ${current.version} → ${entry.source.tag}`); + toUpdate.push({ id, entry, files: entry.install.files }); + } + + if (opts.dryRun || toUpdate.length === 0) return; + + const results = await showInstallProgress({ packages: toUpdate, lockfile, projectDir }); + if (results.every((r) => r.ok)) { + writeLockfile(projectDir, lockfile); + } else { + process.exitCode = 1; + } +} + +export type PackageRemoveOptions = { + dir?: string; + yes?: boolean; +}; + +export async function packageRemoveCommand(name: string, opts: PackageRemoveOptions = {}): Promise { + const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); + const lockfile = readLockfile(projectDir); + + const entry = lockfile.packages[name]; + if (!entry) { + console.log(chalk.red(`"${name}" is not installed.`)); + process.exitCode = 1; + return; + } + + for (const file of entry.files) { + const abs = join(projectDir, file.target); + if (existsSync(abs)) { + rmSync(abs); + console.log(chalk.dim(` removed ${file.target}`)); + } + } + + removeFromLockfile(lockfile, name); + writeLockfile(projectDir, lockfile); + console.log(` ${chalk.bold(name)} removed.`); +} + +export type PackageAuditOptions = { + dir?: string; + json?: boolean; +}; + +export async function packageAuditCommand(opts: PackageAuditOptions = {}): Promise { + const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); + const lockfile = readLockfile(projectDir); + + const entries = Object.values(lockfile.packages); + if (entries.length === 0) { + console.log(chalk.dim('No packages installed.')); + return; + } + + const spinner = ora('Auditing…').start(); + const results = await auditLockfile(projectDir, lockfile); + spinner.stop(); + + if (opts.json) { + console.log(JSON.stringify(results, null, 2)); + if (results.some((r) => r.status !== 'verified')) process.exitCode = 1; + return; + } + + console.log(chalk.bold(`\nAuditing ${entries.length} installed package(s)…\n`)); + + const maxId = Math.max(...results.map((r) => r.id.length)); + for (const r of results) { + if (r.status === 'verified') { + console.log(` ${chalk.green('✔')} ${r.id.padEnd(maxId + 2)} ${chalk.dim(r.target)} ${chalk.green('verified')}`); + } else if (r.status === 'missing') { + console.log( + ` ${chalk.yellow('!')} ${r.id.padEnd(maxId + 2)} ${chalk.dim(r.target)} ${chalk.yellow('missing')}`, + ); + } else { + console.log( + ` ${chalk.red('✖')} ${r.id.padEnd(maxId + 2)} ${chalk.dim(r.target)} ${chalk.red('MODIFIED ← SHA-256 mismatch')}`, + ); + } + } + + const bad = results.filter((r) => r.status !== 'verified'); + console.log(); + if (bad.length === 0) { + console.log(chalk.green.bold('All packages verified.')); + } else { + console.log( + chalk.red.bold( + `${bad.length} issue(s) found. Re-install affected packages with \`feather package install \`.`, + ), + ); + process.exitCode = 1; + } + console.log(); +} diff --git a/cli/src/generated/registry.json b/cli/src/generated/registry.json new file mode 100644 index 00000000..d3db219b --- /dev/null +++ b/cli/src/generated/registry.json @@ -0,0 +1,514 @@ +{ + "version": 1, + "updatedAt": "2026-05-12", + "packages": { + "anim8": { + "type": "love2d-library", + "trust": "verified", + "description": "A 2D animation library for LÖVE. Handles sprite sheets, frames, tags, and playback.", + "tags": [ + "animation", + "sprites" + ], + "homepage": "https://github.com/kikito/anim8", + "source": { + "repo": "kikito/anim8", + "tag": "v2.3.1", + "baseUrl": "https://raw.githubusercontent.com/kikito/anim8/v2.3.1/" + }, + "install": { + "files": [ + { + "name": "anim8.lua", + "sha256": "c6581c30225a4297924a4f27e56edbc12894fb2e168232fa3a19d21ac149e83f", + "target": "lib/anim8.lua" + } + ] + }, + "require": "lib.anim8", + "example": "local anim8 = require('lib.anim8')" + }, + "bump": { + "type": "love2d-library", + "trust": "verified", + "description": "A Lua library for simple AABB collision detection and resolution.", + "tags": [ + "collision", + "physics" + ], + "homepage": "https://github.com/kikito/bump.lua", + "source": { + "repo": "kikito/bump.lua", + "tag": "v3.1.7", + "baseUrl": "https://raw.githubusercontent.com/kikito/bump.lua/v3.1.7/" + }, + "install": { + "files": [ + { + "name": "bump.lua", + "sha256": "b947cc4449554816831d5c45b61a169e1559548139478d980c8eef14c7b362a6", + "target": "lib/bump.lua" + } + ] + }, + "require": "lib.bump", + "example": "local bump = require('lib.bump')" + }, + "classic": { + "type": "love2d-library", + "trust": "verified", + "description": "A tiny class module for Lua. Minimal OOP with inheritance.", + "tags": [ + "oop", + "utilities" + ], + "homepage": "https://github.com/rxi/classic", + "source": { + "repo": "rxi/classic", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/rxi/classic/master/" + }, + "install": { + "files": [ + { + "name": "classic.lua", + "sha256": "52827561440f22ff3f34f926b2f4bdc1b74cbbaafa3b3b8252b530b12387d48b", + "target": "lib/classic.lua" + } + ] + }, + "require": "lib.classic", + "example": "local Object = require('lib.classic')" + }, + "flux": { + "type": "love2d-library", + "trust": "verified", + "description": "A fast, lightweight tweening library for Lua with chainable API and easing functions.", + "tags": [ + "animation", + "tweening" + ], + "homepage": "https://github.com/rxi/flux", + "source": { + "repo": "rxi/flux", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/rxi/flux/master/" + }, + "install": { + "files": [ + { + "name": "flux.lua", + "sha256": "11542ab78001f99d94d0b9ca3f99072494e1e4f4ce395db203bf4f7e6f7889d5", + "target": "lib/flux.lua" + } + ] + }, + "require": "lib.flux", + "example": "local flux = require('lib.flux')" + }, + "hump.camera": { + "parent": "hump", + "type": "love2d-library", + "trust": "verified", + "description": "hump — camera module", + "tags": [ + "utilities", + "camera", + "timer", + "oop", + "math" + ], + "homepage": "https://github.com/vrld/hump", + "source": { + "repo": "vrld/hump", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + }, + "install": { + "files": [ + { + "name": "camera.lua", + "sha256": "b5f0c086fc652f7f97eef84b6be358f889ab9b8d28cf7f295b46d48638014099", + "target": "lib/hump/camera.lua" + } + ] + }, + "require": "lib.hump.camera", + "example": "local camera = require('lib.hump.camera')" + }, + "hump.timer": { + "parent": "hump", + "type": "love2d-library", + "trust": "verified", + "description": "hump — timer module", + "tags": [ + "utilities", + "camera", + "timer", + "oop", + "math" + ], + "homepage": "https://github.com/vrld/hump", + "source": { + "repo": "vrld/hump", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + }, + "install": { + "files": [ + { + "name": "timer.lua", + "sha256": "8931203b027ecce889692e67323e7f333881fb230ed9dd735c785f5e3a7def03", + "target": "lib/hump/timer.lua" + } + ] + }, + "require": "lib.hump.timer", + "example": "local timer = require('lib.hump.timer')" + }, + "hump.signal": { + "parent": "hump", + "type": "love2d-library", + "trust": "verified", + "description": "hump — signal module", + "tags": [ + "utilities", + "camera", + "timer", + "oop", + "math" + ], + "homepage": "https://github.com/vrld/hump", + "source": { + "repo": "vrld/hump", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + }, + "install": { + "files": [ + { + "name": "signal.lua", + "sha256": "4f311197119177453d8759add39531905da2d3590a7e3a7befc618e46f31b051", + "target": "lib/hump/signal.lua" + } + ] + }, + "require": "lib.hump.signal", + "example": "local signal = require('lib.hump.signal')" + }, + "hump.class": { + "parent": "hump", + "type": "love2d-library", + "trust": "verified", + "description": "hump — class module", + "tags": [ + "utilities", + "camera", + "timer", + "oop", + "math" + ], + "homepage": "https://github.com/vrld/hump", + "source": { + "repo": "vrld/hump", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + }, + "install": { + "files": [ + { + "name": "class.lua", + "sha256": "b0b97bb7cc51b9234dfe3dce0f6da12beaebbb6335eaeaa38de8e537a157cb4e", + "target": "lib/hump/class.lua" + } + ] + }, + "require": "lib.hump.class", + "example": "local class = require('lib.hump.class')" + }, + "hump.vector": { + "parent": "hump", + "type": "love2d-library", + "trust": "verified", + "description": "hump — vector module", + "tags": [ + "utilities", + "camera", + "timer", + "oop", + "math" + ], + "homepage": "https://github.com/vrld/hump", + "source": { + "repo": "vrld/hump", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + }, + "install": { + "files": [ + { + "name": "vector.lua", + "sha256": "70a4d9437e0c7bdf718160eb4a3464f54705129b854f1c9bd60a5adea8d197cc", + "target": "lib/hump/vector.lua" + } + ] + }, + "require": "lib.hump.vector", + "example": "local vector = require('lib.hump.vector')" + }, + "hump.gamestate": { + "parent": "hump", + "type": "love2d-library", + "trust": "verified", + "description": "hump — gamestate module", + "tags": [ + "utilities", + "camera", + "timer", + "oop", + "math" + ], + "homepage": "https://github.com/vrld/hump", + "source": { + "repo": "vrld/hump", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + }, + "install": { + "files": [ + { + "name": "gamestate.lua", + "sha256": "7e11c9f4909ececc3b61fa4313b001b25f33b5ac36bc96b641aedff0328a4406", + "target": "lib/hump/gamestate.lua" + } + ] + }, + "require": "lib.hump.gamestate", + "example": "local gamestate = require('lib.hump.gamestate')" + }, + "hump": { + "type": "love2d-library", + "trust": "verified", + "description": "Helper utilities for LÖVE: camera, timer, signal, class, vector, gamestate.", + "tags": [ + "utilities", + "camera", + "timer", + "oop", + "math" + ], + "homepage": "https://github.com/vrld/hump", + "source": { + "repo": "vrld/hump", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + }, + "install": { + "files": [ + { + "name": "camera.lua", + "sha256": "b5f0c086fc652f7f97eef84b6be358f889ab9b8d28cf7f295b46d48638014099", + "target": "lib/hump/camera.lua" + }, + { + "name": "timer.lua", + "sha256": "8931203b027ecce889692e67323e7f333881fb230ed9dd735c785f5e3a7def03", + "target": "lib/hump/timer.lua" + }, + { + "name": "signal.lua", + "sha256": "4f311197119177453d8759add39531905da2d3590a7e3a7befc618e46f31b051", + "target": "lib/hump/signal.lua" + }, + { + "name": "class.lua", + "sha256": "b0b97bb7cc51b9234dfe3dce0f6da12beaebbb6335eaeaa38de8e537a157cb4e", + "target": "lib/hump/class.lua" + }, + { + "name": "vector.lua", + "sha256": "70a4d9437e0c7bdf718160eb4a3464f54705129b854f1c9bd60a5adea8d197cc", + "target": "lib/hump/vector.lua" + }, + { + "name": "gamestate.lua", + "sha256": "7e11c9f4909ececc3b61fa4313b001b25f33b5ac36bc96b641aedff0328a4406", + "target": "lib/hump/gamestate.lua" + } + ] + }, + "subpackages": [ + "hump.camera", + "hump.timer", + "hump.signal", + "hump.class", + "hump.vector", + "hump.gamestate" + ], + "require": "lib.hump", + "example": "local Camera = require('lib.hump.camera')" + }, + "inspect": { + "type": "love2d-library", + "trust": "verified", + "description": "Human-readable table serializer and pretty-printer for Lua. Useful for debugging.", + "tags": [ + "debug", + "utilities" + ], + "homepage": "https://github.com/kikito/inspect.lua", + "source": { + "repo": "kikito/inspect.lua", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/kikito/inspect.lua/master/" + }, + "install": { + "files": [ + { + "name": "inspect.lua", + "sha256": "656e4be83802750cfe13156810f7990d49893c86518ed5d882f0709d2901dfc5", + "target": "lib/inspect.lua" + } + ] + }, + "require": "lib.inspect", + "example": "local inspect = require('lib.inspect')" + }, + "lume": { + "type": "love2d-library", + "trust": "verified", + "description": "A collection of Lua functions for gamedev: math, table manipulation, string utilities, and more.", + "tags": [ + "utilities", + "math" + ], + "homepage": "https://github.com/rxi/lume", + "source": { + "repo": "rxi/lume", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/rxi/lume/master/" + }, + "install": { + "files": [ + { + "name": "lume.lua", + "sha256": "fb2856d60eb48516c8aa04de1e8b58b355a56fb7a05fd1170cfe330f8ad69b1a", + "target": "lib/lume.lua" + } + ] + }, + "require": "lib.lume", + "example": "local lume = require('lib.lume')" + }, + "middleclass": { + "type": "love2d-library", + "trust": "verified", + "description": "A simple OOP library for Lua. Supports inheritance, mixins, and interfaces.", + "tags": [ + "oop", + "utilities" + ], + "homepage": "https://github.com/kikito/middleclass", + "source": { + "repo": "kikito/middleclass", + "tag": "v4.1.1", + "baseUrl": "https://raw.githubusercontent.com/kikito/middleclass/v4.1.1/" + }, + "install": { + "files": [ + { + "name": "middleclass.lua", + "sha256": "b4f0060e16daa070a0da33b6808f6e4cb9ee819205321d019a8e770af5e77c07", + "target": "lib/middleclass.lua" + } + ] + }, + "require": "lib.middleclass", + "example": "local Class = require('lib.middleclass')" + }, + "push": { + "type": "love2d-library", + "trust": "verified", + "description": "Resolution-independence library for LÖVE. Lets you draw at a fixed resolution and scale to any window size.", + "tags": [ + "rendering", + "resolution" + ], + "homepage": "https://github.com/Ulydev/push", + "source": { + "repo": "Ulydev/push", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/Ulydev/push/master/" + }, + "install": { + "files": [ + { + "name": "push.lua", + "sha256": "c5702771a3275ce3e3909a8414d1fe76024b30ca3943382d06a9b54f5ee3c517", + "target": "lib/push.lua" + } + ] + }, + "require": "lib.push", + "example": "local push = require('lib.push')" + }, + "sti": { + "type": "love2d-library", + "trust": "known", + "description": "Simple Tiled Implementation — loads and renders Tiled maps (.tmx) in LÖVE.", + "tags": [ + "maps", + "tiled", + "tilemaps" + ], + "homepage": "https://github.com/karai17/Simple-Tiled-Implementation", + "source": { + "repo": "karai17/Simple-Tiled-Implementation", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/karai17/Simple-Tiled-Implementation/master/" + }, + "install": { + "files": [ + { + "name": "sti/init.lua", + "sha256": "b7542ac156b3c2ee24d256ce260f73dc01ab2d1245c4eb4b14c92fcdf487c814", + "target": "lib/sti/init.lua" + }, + { + "name": "sti/utils.lua", + "sha256": "ba8abf5a3f5680c6a020234afc248c4fb8945cda993d4cd82b292510e4dab17c", + "target": "lib/sti/utils.lua" + } + ] + }, + "require": "lib.sti", + "example": "local sti = require('lib.sti')" + }, + "windfield": { + "type": "love2d-library", + "trust": "known", + "description": "A physics module for LÖVE that makes Box2D easier to use. Wraps love.physics with a simpler API.", + "tags": [ + "physics", + "box2d" + ], + "homepage": "https://github.com/SSYGEN/windfield", + "source": { + "repo": "SSYGEN/windfield", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/SSYGEN/windfield/master/" + }, + "install": { + "files": [ + { + "name": "windfield/init.lua", + "sha256": "687c789dceeba3ebb6f5b152b94e9c085268695ed8cc578780d24ec1696f0388", + "target": "lib/windfield/init.lua" + } + ] + }, + "require": "lib.windfield", + "example": "local wf = require('lib.windfield')" + } + } +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 5f40a84a..c983bdde 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,44 +1,52 @@ #!/usr/bin/env node -import { Command } from "commander"; -import { runCommand } from "./commands/run.js"; -import { initCommand } from "./commands/init.js"; -import { removeCommand } from "./commands/remove.js"; -import { doctorCommand } from "./commands/doctor.js"; -import { updateCommand } from "./commands/update.js"; +import { Command } from 'commander'; +import { runCommand } from './commands/run.js'; +import { initCommand } from './commands/init.js'; +import { removeCommand } from './commands/remove.js'; +import { doctorCommand } from './commands/doctor.js'; +import { updateCommand } from './commands/update.js'; import { pluginListCommand, pluginInstallCommand, pluginRemoveCommand, pluginUpdateCommand, pluginWorkflowCommand, -} from "./commands/plugin.js"; -import type { InitMode } from "./ui/init-mode.js"; +} from './commands/plugin.js'; +import { + packageSearchCommand, + packageListCommand, + packageInfoCommand, + packageInstallCommand, + packageUpdateCommand, + packageRemoveCommand, + packageAuditCommand, +} from './commands/package.js'; +import type { InitMode } from './ui/init-mode.js'; const program = new Command(); -const initModes = new Set(["cli", "auto", "manual"]); +const initModes = new Set(['cli', 'auto', 'manual']); function parseInitMode(value: string): InitMode { if (!initModes.has(value)) { - throw new Error("Mode must be one of: cli, auto, manual"); + throw new Error('Mode must be one of: cli, auto, manual'); } return value as InitMode; } program - .name("feather") - .description("Run and debug Love2D games with Feather — zero game-side changes required") - .version("0.7.0"); + .name('feather') + .description('Run and debug Love2D games with Feather — zero game-side changes required') + .version('0.7.0'); -// ── feather run ────────────────────────────────────────────────────────────── program - .command("run [game-path] [game-args...]") - .description("Inject Feather into a Love2D game and run it") - .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("--config ", "Path to feather.config.lua") - .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") + .command('run [game-path] [game-args...]') + .description('Inject Feather into a Love2D game and run it') + .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('--config ', 'Path to feather.config.lua') + .option('--feather-path ', 'Use a local feather install instead of the bundled one') + .option('--plugins-dir ', 'Use a custom plugins directory instead of the bundled one') .action(async (gamePath: string | undefined, gameArgs: string[], opts) => { await runCommand(gamePath, { love: opts.love as string | undefined, @@ -51,46 +59,45 @@ program }); }); -// ── feather init ───────────────────────────────────────────────────────────── 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") + .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') .action(async (dir: string | undefined, opts) => { - await initCommand(dir ?? ".", { + await 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, + 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, }); }); -// ── feather remove ─────────────────────────────────────────────────────────── 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") + .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(async (dir: string | undefined, opts) => { - await removeCommand(dir ?? ".", { + await removeCommand(dir ?? '.', { installDir: opts.installDir as string | undefined, dryRun: opts.dryRun as boolean | undefined, keepConfig: opts.keepConfig as boolean | undefined, @@ -101,14 +108,13 @@ program }); }); -// ── feather doctor ─────────────────────────────────────────────────────────── program - .command("doctor [dir]") - .description("Check the environment and project setup") - .option("--install-dir ", "Feather install directory override") - .option("--host ", "Host to check for the Feather desktop WebSocket", "127.0.0.1") - .option("--port ", "Port to check for the Feather desktop WebSocket", (value) => Number(value)) - .option("--json", "Print machine-readable diagnostics") + .command('doctor [dir]') + .description('Check the environment and project setup') + .option('--install-dir ', 'Feather install directory override') + .option('--host ', 'Host to check for the Feather desktop WebSocket', '127.0.0.1') + .option('--port ', 'Port to check for the Feather desktop WebSocket', (value) => Number(value)) + .option('--json', 'Print machine-readable diagnostics') .action(async (dir: string | undefined, opts) => { await doctorCommand(dir, { installDir: opts.installDir as string | undefined, @@ -118,17 +124,16 @@ program }); }); -// ── feather update ─────────────────────────────────────────────────────────── 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") + .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) => { - updateCommand(dir ?? ".", { + updateCommand(dir ?? '.', { branch: opts.branch as string, remote: opts.remote as boolean | undefined, localSrc: opts.localSrc as string | undefined, @@ -137,15 +142,14 @@ program }); }); -// ── feather plugin ─────────────────────────────────────────────────────────── 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") + .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(async (opts) => { await pluginWorkflowCommand({ dir: opts.dir as string | undefined, @@ -159,22 +163,22 @@ const plugin = program const pluginCommandOptions = (opts: Record) => ({ ...opts, ...plugin.opts() }); plugin - .command("list [dir]") - .description("List installed plugins") - .option("--install-dir ", "Feather install directory", "feather") + .command('list [dir]') + .description('List installed plugins') + .option('--install-dir ', 'Feather install directory', 'feather') .action(async (dir: string | undefined, opts) => { const merged = pluginCommandOptions(opts); await 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") + .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(async (id: string, opts) => { const merged = pluginCommandOptions(opts); await pluginInstallCommand(id, { @@ -187,24 +191,24 @@ plugin }); plugin - .command("remove ") - .description("Remove an installed plugin") - .option("--dir ", "Project directory (default: current directory)") - .option("--install-dir ", "Feather install directory", "feather") + .command('remove ') + .description('Remove an installed plugin') + .option('--dir ', 'Project directory (default: current directory)') + .option('--install-dir ', 'Feather install directory', 'feather') .action(async (id: string, opts) => { const merged = pluginCommandOptions(opts); await pluginRemoveCommand(id, { dir: merged.dir as string | undefined, installDir: merged.installDir as string }); }); 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") + .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(async (id: string | undefined, opts) => { const merged = pluginCommandOptions(opts); await pluginUpdateCommand(id, { @@ -217,4 +221,114 @@ plugin }); }); +const pkg = program.command('package').description('Install and manage LÖVE packages from the Feather catalog'); + +pkg + .command('search [query]') + .description('Search the package catalog') + .option('--offline', 'Use bundled registry snapshot') + .option('--registry ', 'Override registry URL') + .action(async (query: string | undefined, opts) => { + await packageSearchCommand(query, { + 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(async (opts) => { + await 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(async (name: string, opts) => { + await 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(async (names: string[], opts) => { + await 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(async (name: string | undefined, opts) => { + await packageUpdateCommand(name, { + dryRun: opts.dryRun as boolean | undefined, + offline: opts.offline as boolean | undefined, + dir: opts.dir as string | undefined, + registryUrl: opts.registry as string | undefined, + }); + }); + +pkg + .command('remove ') + .description('Remove an installed package') + .option('--dir ', 'Project directory') + .option('-y, --yes', 'Skip confirmation prompt') + .action(async (name: string, opts) => { + await packageRemoveCommand(name, { + dir: opts.dir as string | undefined, + yes: opts.yes as boolean | undefined, + }); + }); + +pkg + .command('audit') + .description('Verify SHA-256 checksums of all installed packages') + .option('--dir ', 'Project directory') + .option('--json', 'Output machine-readable JSON') + .action(async (opts) => { + await packageAuditCommand({ + dir: opts.dir as string | undefined, + json: opts.json as boolean | undefined, + }); + }); + program.parse(); diff --git a/cli/src/lib/package/audit.ts b/cli/src/lib/package/audit.ts new file mode 100644 index 00000000..b1b1d801 --- /dev/null +++ b/cli/src/lib/package/audit.ts @@ -0,0 +1,39 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { sha256File } from "./checksum.js"; +import type { Lockfile } from "./lockfile.js"; + +export type AuditStatus = "verified" | "modified" | "missing"; + +export type AuditEntry = { + id: string; + file: string; + target: string; + status: AuditStatus; + expected: string; + actual: string | null; +}; + +export async function auditLockfile( + projectDir: string, + lockfile: Lockfile +): Promise { + const results: AuditEntry[] = []; + + for (const [id, entry] of Object.entries(lockfile.packages)) { + for (const file of entry.files) { + const absPath = join(projectDir, file.target); + + if (!existsSync(absPath)) { + results.push({ id, file: file.name, target: file.target, status: "missing", expected: file.sha256, actual: null }); + continue; + } + + const actual = await sha256File(absPath); + const status: AuditStatus = actual === file.sha256 ? "verified" : "modified"; + results.push({ id, file: file.name, target: file.target, status, expected: file.sha256, actual }); + } + } + + return results; +} diff --git a/cli/src/lib/package/checksum.ts b/cli/src/lib/package/checksum.ts new file mode 100644 index 00000000..afe71fb4 --- /dev/null +++ b/cli/src/lib/package/checksum.ts @@ -0,0 +1,17 @@ +import { createHash } from "node:crypto"; +import { createReadStream, existsSync } from "node:fs"; + +export async function sha256File(filePath: string): Promise { + if (!existsSync(filePath)) return null; + return new Promise((resolve, reject) => { + const hash = createHash("sha256"); + createReadStream(filePath) + .on("data", (chunk) => hash.update(chunk)) + .on("end", () => resolve(hash.digest("hex"))) + .on("error", reject); + }); +} + +export function sha256Buffer(buf: Buffer): string { + return createHash("sha256").update(buf).digest("hex"); +} diff --git a/cli/src/lib/package/install.ts b/cli/src/lib/package/install.ts new file mode 100644 index 00000000..3b8a23ee --- /dev/null +++ b/cli/src/lib/package/install.ts @@ -0,0 +1,255 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve as resolvePath } from "node:path"; +import { sha256Buffer } from "./checksum.js"; +import { addToLockfile, type Lockfile, type LockfileEntry } from "./lockfile.js"; +import type { ResolvedPackage } from "./resolve.js"; + +export type InstallOptions = { + projectDir: string; + dryRun?: boolean; + targetOverride?: string; + onFileStart?: (name: string) => void; + onFileComplete?: (result: InstallFileResult) => void; +}; + +export type InstallFileResult = { + name: string; + target: string; + sha256: string; + ok: boolean; + error?: string; +}; + +export type InstallResult = { + id: string; + ok: boolean; + files: InstallFileResult[]; + error?: string; +}; + +async function downloadVerified(url: string, expectedSha256: string): Promise { + let res: Response; + try { + res = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + } catch (err) { + return { error: `Network error: ${(err as Error).message}` }; + } + if (!res.ok) return { error: `HTTP ${res.status} fetching ${url}` }; + + const buf = Buffer.from(await res.arrayBuffer()); + const actual = sha256Buffer(buf); + if (actual !== expectedSha256) { + return { error: `Checksum mismatch for ${url}\n expected: ${expectedSha256}\n got: ${actual}` }; + } + return buf; +} + +async function downloadLive(url: string, repo: string, version: string): Promise { + let res: Response; + try { + res = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + } catch (err) { + return { error: `Network error: ${(err as Error).message}` }; + } + if (res.status === 404) { + return { error: `Version ${version} not found for ${repo} (HTTP 404). Check available releases at github.com/${repo}/releases` }; + } + if (!res.ok) return { error: `HTTP ${res.status} fetching ${url}` }; + return Buffer.from(await res.arrayBuffer()); +} + +function safeTarget(projectDir: string, relTarget: string): string | null { + const abs = resolvePath(projectDir, relTarget); + if (!abs.startsWith(resolvePath(projectDir))) return null; + return abs; +} + +export async function installPackage( + pkg: ResolvedPackage, + lockfile: Lockfile, + opts: InstallOptions +): Promise { + const { projectDir, dryRun, targetOverride, onFileStart, onFileComplete } = opts; + const fileResults: InstallFileResult[] = []; + const lockedFiles: LockfileEntry["files"] = []; + + const src = pkg.entry.source; + const effectiveTag = pkg.versionOverride ?? src.tag; + const baseUrl = pkg.versionOverride + ? src.baseUrl.replace(src.tag, pkg.versionOverride) + : src.baseUrl; + + for (const file of pkg.files) { + const relTarget = targetOverride + ? join(targetOverride, file.name.split("/").pop()!) + : file.target; + + const absTarget = safeTarget(projectDir, relTarget); + if (!absTarget) { + fileResults.push({ name: file.name, target: relTarget, sha256: "", ok: false, error: "Target path escapes project root" }); + continue; + } + + const url = baseUrl + file.name; + + if (dryRun) { + fileResults.push({ name: file.name, target: relTarget, sha256: file.sha256, ok: true }); + continue; + } + + onFileStart?.(file.name); + + let fileSha256: string; + if (pkg.versionOverride) { + const result = await downloadLive(url, src.repo ?? pkg.id, pkg.versionOverride); + if ("error" in result) { + const fileResult: InstallFileResult = { name: file.name, target: relTarget, sha256: "", ok: false, error: result.error }; + onFileComplete?.(fileResult); + return { id: pkg.id, ok: false, files: fileResults, error: result.error }; + } + fileSha256 = sha256Buffer(result); + mkdirSync(dirname(absTarget), { recursive: true }); + writeFileSync(absTarget, result); + } else { + const result = await downloadVerified(url, file.sha256); + if ("error" in result) { + const fileResult: InstallFileResult = { name: file.name, target: relTarget, sha256: "", ok: false, error: result.error }; + onFileComplete?.(fileResult); + return { id: pkg.id, ok: false, files: fileResults, error: result.error }; + } + fileSha256 = file.sha256; + mkdirSync(dirname(absTarget), { recursive: true }); + writeFileSync(absTarget, result); + } + + const fileResult: InstallFileResult = { name: file.name, target: relTarget, sha256: fileSha256, ok: true }; + onFileComplete?.(fileResult); + fileResults.push(fileResult); + lockedFiles.push({ name: file.name, target: relTarget, sha256: fileSha256 }); + } + + const allOk = fileResults.every((f) => f.ok); + + if (allOk && !dryRun) { + addToLockfile(lockfile, pkg.id, { + parent: pkg.entry.parent, + version: effectiveTag, + trust: pkg.versionOverride ? "experimental" : pkg.entry.trust, + source: { repo: src.repo, tag: effectiveTag }, + files: lockedFiles, + }); + } + + return { id: pkg.id, ok: allOk, files: fileResults }; +} + +export type ExperimentalInstallOptions = { + projectDir: string; + url: string; + target: string; + dryRun?: boolean; +}; + +export type ExperimentalInstallResult = { + ok: boolean; + sha256: string; + size: number; + error?: string; +}; + +export async function installFromUrl( + lockfile: Lockfile, + opts: ExperimentalInstallOptions +): Promise { + const { projectDir, url, target, dryRun } = opts; + + let res: Response; + try { + res = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + } catch (err) { + return { ok: false, sha256: "", size: 0, target, error: `Network error: ${(err as Error).message}` }; + } + if (!res.ok) return { ok: false, sha256: "", size: 0, target, error: `HTTP ${res.status}` }; + + const buf = Buffer.from(await res.arrayBuffer()); + const hash = sha256Buffer(buf); + + if (dryRun) { + return { ok: true, sha256: hash, size: buf.byteLength, target }; + } + + const absTarget = safeTarget(projectDir, target); + if (!absTarget) return { ok: false, sha256: hash, size: buf.byteLength, target, error: "Target path escapes project root" }; + + mkdirSync(dirname(absTarget), { recursive: true }); + writeFileSync(absTarget, buf); + + const name = url.split("/").pop() ?? "package.lua"; + addToLockfile(lockfile, name.replace(/\.lua$/, ""), { + version: "0.0.0", + trust: "experimental", + source: { url }, + files: [{ name, target, sha256: hash }], + }); + + return { ok: true, sha256: hash, size: buf.byteLength, target }; +} + +export type RestoreOptions = { + projectDir: string; + dryRun?: boolean; + onFileStart?: (name: string) => void; + onFileComplete?: (result: InstallFileResult) => void; +}; + +export async function restorePackage( + id: string, + entry: LockfileEntry, + opts: RestoreOptions, +): Promise { + const { projectDir, dryRun, onFileStart, onFileComplete } = opts; + const fileResults: InstallFileResult[] = []; + + for (const file of entry.files) { + const absTarget = safeTarget(projectDir, file.target); + if (!absTarget) { + fileResults.push({ name: file.name, target: file.target, sha256: "", ok: false, error: "Target path escapes project root" }); + continue; + } + + // Skip files already on disk with the correct locked checksum + if (!dryRun && existsSync(absTarget)) { + if (sha256Buffer(readFileSync(absTarget)) === file.sha256) { + fileResults.push({ name: file.name, target: file.target, sha256: file.sha256, ok: true }); + continue; + } + } + + if (dryRun) { + fileResults.push({ name: file.name, target: file.target, sha256: file.sha256, ok: true }); + continue; + } + + onFileStart?.(file.name); + + const url = "url" in entry.source + ? entry.source.url + : `https://raw.githubusercontent.com/${entry.source.repo}/${entry.source.tag}/${file.name}`; + + const result = await downloadVerified(url, file.sha256); + if ("error" in result) { + const fileResult: InstallFileResult = { name: file.name, target: file.target, sha256: "", ok: false, error: result.error }; + onFileComplete?.(fileResult); + return { id, ok: false, files: fileResults, error: result.error }; + } + + mkdirSync(dirname(absTarget), { recursive: true }); + writeFileSync(absTarget, result); + + const fileResult: InstallFileResult = { name: file.name, target: file.target, sha256: file.sha256, ok: true }; + onFileComplete?.(fileResult); + fileResults.push(fileResult); + } + + return { id, ok: fileResults.every((f) => f.ok), files: fileResults }; +} diff --git a/cli/src/lib/package/lockfile.ts b/cli/src/lib/package/lockfile.ts new file mode 100644 index 00000000..bfa58b00 --- /dev/null +++ b/cli/src/lib/package/lockfile.ts @@ -0,0 +1,56 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export type LockfileEntry = { + parent?: string; + version: string; + trust: "verified" | "known" | "experimental"; + source: + | { repo: string; tag: string } + | { url: string }; + files: { name: string; target: string; sha256: string }[]; + installedAt: string; +}; + +export type Lockfile = { + lockfileVersion: 1; + generatedAt: string; + packages: Record; +}; + +const LOCKFILE_NAME = "feather.lock.json"; + +export function lockfilePath(projectDir: string): string { + return join(projectDir, LOCKFILE_NAME); +} + +export function readLockfile(projectDir: string): Lockfile { + const path = lockfilePath(projectDir); + if (!existsSync(path)) { + return { lockfileVersion: 1, generatedAt: new Date().toISOString(), packages: {} }; + } + try { + return JSON.parse(readFileSync(path, "utf8")) as Lockfile; + } catch { + throw new Error(`Failed to parse ${LOCKFILE_NAME}. Fix or delete it and re-run.`); + } +} + +export function writeLockfile(projectDir: string, lockfile: Lockfile): void { + lockfile.generatedAt = new Date().toISOString(); + writeFileSync(lockfilePath(projectDir), JSON.stringify(lockfile, null, 2) + "\n"); +} + +export function addToLockfile( + lockfile: Lockfile, + id: string, + entry: Omit +): void { + lockfile.packages[id] = { ...entry, installedAt: new Date().toISOString() }; +} + +export function removeFromLockfile(lockfile: Lockfile, id: string): boolean { + if (!(id in lockfile.packages)) return false; + delete lockfile.packages[id]; + return true; +} diff --git a/cli/src/lib/package/registry.ts b/cli/src/lib/package/registry.ts new file mode 100644 index 00000000..e221a702 --- /dev/null +++ b/cli/src/lib/package/registry.ts @@ -0,0 +1,110 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; +import { fileURLToPath } from "node:url"; + +export type PackageFile = { + name: string; + sha256: string; + target: string; +}; + +export type SubpackageEntry = { + files: string[]; + require: string; + example?: string; +}; + +export type RegistryEntry = { + parent?: string; + type: string; + trust: "verified" | "known" | "experimental"; + description: string; + tags: string[]; + homepage?: string; + source: { + repo: string; + tag: string; + baseUrl: string; + }; + install: { files: PackageFile[] }; + subpackages?: string[]; + require: string; + example?: string; +}; + +export type Registry = { + version: number; + updatedAt: string; + packages: Record; +}; + +const REGISTRY_URL = + "https://raw.githubusercontent.com/Kyonru/feather/packages/registry.json"; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +function cacheDir(): string { + return join(homedir(), ".feather"); +} + +function cachePath(): string { + return join(cacheDir(), "registry-cache.json"); +} + +function bundledRegistryPath(): string { + const __dirname = dirname(fileURLToPath(import.meta.url)); + return join(__dirname, "../../generated/registry.json"); +} + +type CacheFile = { fetchedAt: string; registry: Registry }; + +function readCache(): Registry | null { + const path = cachePath(); + if (!existsSync(path)) return null; + try { + const { fetchedAt, registry } = JSON.parse(readFileSync(path, "utf8")) as CacheFile; + if (Date.now() - new Date(fetchedAt).getTime() < CACHE_TTL_MS) return registry; + } catch { + // ignore corrupt cache + } + return null; +} + +function writeCache(registry: Registry): void { + const dir = cacheDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const data: CacheFile = { fetchedAt: new Date().toISOString(), registry }; + writeFileSync(cachePath(), JSON.stringify(data)); +} + +function readBundled(): Registry { + return JSON.parse(readFileSync(bundledRegistryPath(), "utf8")) as Registry; +} + +export type RegistryLoadOptions = { + offline?: boolean; + refresh?: boolean; + registryUrl?: string; +}; + +export async function loadRegistry(opts: RegistryLoadOptions = {}): Promise { + if (opts.offline) return readBundled(); + + if (!opts.refresh) { + const cached = readCache(); + if (cached) return cached; + } + + try { + const url = opts.registryUrl ?? REGISTRY_URL; + const res = await fetch(url, { signal: AbortSignal.timeout(8000) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const registry = (await res.json()) as Registry; + writeCache(registry); + return registry; + } catch { + // Fall through to bundled + } + + return readBundled(); +} diff --git a/cli/src/lib/package/resolve.ts b/cli/src/lib/package/resolve.ts new file mode 100644 index 00000000..1fda5df7 --- /dev/null +++ b/cli/src/lib/package/resolve.ts @@ -0,0 +1,80 @@ +import type { Registry, RegistryEntry, PackageFile } from "./registry.js"; + +export type ResolvedPackage = { + id: string; + entry: RegistryEntry; + files: PackageFile[]; + /** Set when the user requested a version different from the registry pin (e.g. `anim8@v2.2.0`). */ + versionOverride?: string; +}; + +/** + * Parse a package spec that may include a version pin: "anim8@v2.2.0" → { id: "anim8", version: "v2.2.0" }. + * Plain names ("anim8") return version undefined, meaning "use registry version". + */ +export function parseSpec(spec: string): { id: string; version: string | undefined } { + const at = spec.lastIndexOf("@"); + if (at <= 0) return { id: spec, version: undefined }; + return { id: spec.slice(0, at), version: spec.slice(at + 1) }; +} + +export type ResolveResult = + | { ok: true; packages: ResolvedPackage[] } + | { ok: false; error: string }; + +/** + * Resolve a package spec (e.g. "anim8" or "anim8@v2.2.0") to install entries. + * When a version override is requested that differs from the registry pin, + * `versionOverride` is set on the returned package so the installer knows to + * fetch that tag and compute the checksum live. + */ +export function resolvePackage(spec: string, registry: Registry): ResolveResult { + const { id, version } = parseSpec(spec); + + const entry = registry.packages[id]; + if (!entry) { + return { ok: false, error: `Package "${id}" not found in the registry. Run \`feather package search ${id}\` to check.` }; + } + + const versionOverride = version && version !== entry.source.tag ? version : undefined; + + return { ok: true, packages: [{ id, entry, files: entry.install.files, versionOverride }] }; +} + +export function resolveMany(specs: string[], registry: Registry): { resolved: ResolvedPackage[]; errors: string[] } { + const resolved: ResolvedPackage[] = []; + const errors: string[] = []; + + for (const spec of specs) { + const result = resolvePackage(spec, registry); + if (result.ok) { + resolved.push(...result.packages); + } else { + errors.push(result.error); + } + } + + return { resolved, errors }; +} + +export function filterTrust( + packages: ResolvedPackage[], + allowUntrusted: boolean +): { allowed: ResolvedPackage[]; blocked: ResolvedPackage[] } { + const allowed: ResolvedPackage[] = []; + const blocked: ResolvedPackage[] = []; + + for (const pkg of packages) { + if (pkg.entry.trust === "experimental" && !allowUntrusted) { + blocked.push(pkg); + } else { + allowed.push(pkg); + } + } + + return { allowed, blocked }; +} + +export function formatRequireHint(pkg: ResolvedPackage): string { + return pkg.entry.example ?? `local ${pkg.id.replace(/\W/g, "_")} = require("${pkg.entry.require}")`; +} diff --git a/cli/src/ui/package-progress.tsx b/cli/src/ui/package-progress.tsx new file mode 100644 index 00000000..8093348c --- /dev/null +++ b/cli/src/ui/package-progress.tsx @@ -0,0 +1,230 @@ +import React, { useState, useEffect } from "react"; +import { Box, Text, render, useApp } from "ink"; +import { installPackage } from "../lib/package/install.js"; +import { formatRequireHint } from "../lib/package/resolve.js"; +import type { ResolvedPackage } from "../lib/package/resolve.js"; +import type { Lockfile } from "../lib/package/lockfile.js"; +import type { InstallResult } from "../lib/package/install.js"; + +type FileStatus = "pending" | "downloading" | "ok" | "error"; +type PkgStatus = "pending" | "installing" | "ok" | "error"; + +type FileState = { + name: string; + target: string; + status: FileStatus; + liveComputed?: boolean; + error?: string; +}; + +type PkgState = { + id: string; + version: string; + liveComputed: boolean; + status: PkgStatus; + files: FileState[]; + error?: string; +}; + +const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +function useSpinner() { + const [frame, setFrame] = useState(0); + useEffect(() => { + const t = setInterval(() => setFrame((f) => (f + 1) % SPINNER.length), 80); + return () => clearInterval(t); + }, []); + return SPINNER[frame]; +} + +function FileRow({ file, spinner }: { file: FileState; spinner: string }) { + const fileName = file.name.split("/").pop() ?? file.name; + if (file.status === "pending") { + return ( + + ○ {fileName} + + ); + } + if (file.status === "downloading") { + return ( + + {spinner} {fileName} + → {file.target} + downloading… + + ); + } + if (file.status === "error") { + return ( + + + ✖ {fileName} + → {file.target} + + {file.error && {file.error}} + + ); + } + return ( + + + {file.target} + {file.liveComputed ? ( + checksum: live-computed ⚠ + ) : ( + checksum: verified + )} + + ); +} + +function PkgRow({ pkg, spinner }: { pkg: PkgState; spinner: string }) { + const statusIcon = + pkg.status === "pending" ? : + pkg.status === "installing" ? {spinner} : + pkg.status === "ok" ? : + ; + + const versionLabel = pkg.liveComputed + ? @ {pkg.version} [experimental] + : @ {pkg.version}; + + return ( + + + {statusIcon} + {pkg.id} + {versionLabel} + {pkg.status === "pending" && waiting…} + {pkg.status === "error" && pkg.error && {pkg.error}} + + {(pkg.status === "installing" || pkg.status === "ok" || pkg.status === "error") && + pkg.files.map((f) => )} + + ); +} + +function InstallProgress({ + packages, + lockfile, + projectDir, + targetOverride, + dryRun, + onComplete, +}: { + packages: ResolvedPackage[]; + lockfile: Lockfile; + projectDir: string; + targetOverride?: string; + dryRun?: boolean; + onComplete: (results: InstallResult[]) => void; +}) { + const { exit } = useApp(); + const spinner = useSpinner(); + + const [states, setStates] = useState( + packages.map((pkg) => ({ + id: pkg.id, + version: pkg.versionOverride ?? pkg.entry.source.tag, + liveComputed: !!pkg.versionOverride, + status: "pending", + files: pkg.files.map((f) => ({ + name: f.name, + target: pkg.versionOverride + ? (targetOverride ? `${targetOverride}/${f.name.split("/").pop()}` : f.target) + : (targetOverride ? `${targetOverride}/${f.name.split("/").pop()}` : f.target), + status: "pending", + liveComputed: !!pkg.versionOverride, + })), + })), + ); + + const [done, setDone] = useState(false); + const [allResults, setAllResults] = useState([]); + + const update = (i: number, patch: Partial) => + setStates((prev) => prev.map((s, j) => (j === i ? { ...s, ...patch } : s))); + + const updateFile = (i: number, name: string, patch: Partial) => + setStates((prev) => + prev.map((s, j) => + j === i ? { ...s, files: s.files.map((f) => (f.name === name ? { ...f, ...patch } : f)) } : s, + ), + ); + + useEffect(() => { + let cancelled = false; + const results: InstallResult[] = []; + + (async () => { + for (let i = 0; i < packages.length; i++) { + if (cancelled) break; + const pkg = packages[i]; + + update(i, { status: "installing" }); + + const result = await installPackage(pkg, lockfile, { + projectDir, + targetOverride, + dryRun, + onFileStart: (name) => { + if (!cancelled) updateFile(i, name, { status: "downloading" }); + }, + onFileComplete: (fileResult) => { + if (!cancelled) + updateFile(i, fileResult.name, { + status: fileResult.ok ? "ok" : "error", + error: fileResult.error, + }); + }, + }); + + results.push(result); + update(i, { status: result.ok ? "ok" : "error", error: result.error }); + } + + if (!cancelled) { + setAllResults(results); + setDone(true); + onComplete(results); + exit(); + } + })(); + + return () => { cancelled = true; }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const succeededPkgs = allResults.length > 0 ? packages.filter((_, i) => allResults[i]?.ok) : []; + + return ( + + {dryRun ? "Dry run —" : "Installing"} {packages.length} package{packages.length > 1 ? "s" : ""}… + + {states.map((s) => ( + + ))} + + {done && succeededPkgs.length > 0 && ( + + Add to your code: + {succeededPkgs.map((pkg) => ( + {formatRequireHint(pkg)} + ))} + + )} + + ); +} + +export async function showInstallProgress(input: { + packages: ResolvedPackage[]; + lockfile: Lockfile; + projectDir: string; + targetOverride?: string; + dryRun?: boolean; +}): Promise { + return new Promise((resolve) => { + render(); + }); +} diff --git a/cli/src/ui/package-workflow.tsx b/cli/src/ui/package-workflow.tsx new file mode 100644 index 00000000..e22457ba --- /dev/null +++ b/cli/src/ui/package-workflow.tsx @@ -0,0 +1,282 @@ +import { useState, useMemo, useEffect } from 'react'; +import { Box, Text, render, useApp, useInput } from 'ink'; +import type { Registry, RegistryEntry } from '../lib/package/registry.js'; +import type { Lockfile, LockfileEntry } from '../lib/package/lockfile.js'; + +export type PackageWorkflowResult = { action: 'install' | 'update' | 'remove'; id: string } | { action: 'cancel' }; + +type Phase = 'browse' | 'action'; +type ActionOption = { value: 'install' | 'update' | 'remove' | 'cancel'; label: string }; + +type PackageRow = { + id: string; + entry: RegistryEntry; + installed?: LockfileEntry; +}; + +const VISIBLE = 14; +const VER_W = 10; +const TRUST_W = 13; + +function trustLabel(trust: string) { + if (trust === 'verified') return 'verified'; + if (trust === 'known') return 'known'; + return 'experimental'; +} + +function trustColor(trust: string): string { + if (trust === 'verified') return 'green'; + if (trust === 'known') return 'yellow'; + return 'red'; +} + +function PackageWorkflow({ + registry, + lockfile, + onComplete, +}: { + registry: Registry; + lockfile: Lockfile; + onComplete: (result: PackageWorkflowResult) => void; +}) { + const { exit } = useApp(); + + // Delay input activation so the Enter that launched the command isn't + // replayed into useInput. Using isActive (not a ref) is the canonical ink + // approach — when false, ink doesn't register the handler at all. + const [inputActive, setInputActive] = useState(false); + useEffect(() => { + const t = setTimeout(() => setInputActive(true), 150); + return () => clearTimeout(t); + }, []); + + const [phase, setPhase] = useState('browse'); + const [filter, setFilter] = useState(''); + const [cursor, setCursor] = useState(0); + const [selectedId, setSelectedId] = useState(null); + const [actionCursor, setActionCursor] = useState(0); + + const allPackages = useMemo( + () => + Object.entries(registry.packages) + .filter(([, e]) => !e.parent) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([id, entry]) => ({ id, entry, installed: lockfile.packages[id] })), + [registry, lockfile], + ); + + // Column width: longest name + 2 spaces of padding + const NAME_W = useMemo(() => Math.max(8, ...allPackages.map((p) => p.id.length)) + 2, [allPackages]); + + const filtered = useMemo(() => { + const q = filter.trim().toLowerCase(); + if (!q) return allPackages; + return allPackages.filter( + ({ id, entry }) => + id.toLowerCase().includes(q) || + entry.description.toLowerCase().includes(q) || + entry.tags.some((t) => t.toLowerCase().includes(q)), + ); + }, [allPackages, filter]); + + // Clamp cursor when the filtered list shrinks + useEffect(() => { + setCursor((c) => (filtered.length === 0 ? 0 : Math.min(c, filtered.length - 1))); + }, [filtered.length]); + + const windowStart = Math.max(0, Math.min(cursor - 4, Math.max(0, filtered.length - VISIBLE))); + const visible = filtered.slice(windowStart, windowStart + VISIBLE); + + const selected = selectedId ? allPackages.find((p) => p.id === selectedId) : null; + + const actions = useMemo(() => { + if (!selected) return [{ value: 'cancel', label: 'Cancel' }]; + const { installed, entry } = selected; + const acts: ActionOption[] = []; + if (!installed) { + acts.push({ value: 'install', label: `Install @ ${entry.source.tag}` }); + } else if (installed.version !== entry.source.tag) { + acts.push({ value: 'update', label: `Update ${installed.version} →${entry.source.tag}` }); + acts.push({ value: 'remove', label: 'Remove' }); + } else { + acts.push({ value: 'remove', label: 'Remove' }); + } + acts.push({ value: 'cancel', label: 'Cancel' }); + return acts; + }, [selected]); + + const finish = (result: PackageWorkflowResult) => { + onComplete(result); + exit(); + }; + + useInput( + (input, key) => { + if (phase === 'browse') { + if (key.escape) { + if (filter) { + setFilter(''); + setCursor(0); + } else finish({ action: 'cancel' }); + return; + } + if (key.upArrow || input === 'k') { + setCursor((c) => Math.max(0, c - 1)); + } else if (key.downArrow || input === 'j') { + setCursor((c) => (filtered.length === 0 ? 0 : Math.min(filtered.length - 1, c + 1))); + } else if (key.return) { + const pkg = filtered[cursor]; + if (pkg) { + setSelectedId(pkg.id); + setActionCursor(0); + setPhase('action'); + } + } else if (key.backspace || key.delete) { + setFilter((f) => f.slice(0, -1)); + setCursor(0); + } else if (input && input.length === 1 && !key.ctrl && !key.meta && !key.tab) { + setFilter((f) => f + input); + setCursor(0); + } + return; + } + + if (phase === 'action') { + if (key.escape || key.backspace) { + setPhase('browse'); + return; + } + if (key.upArrow || input === 'k') { + setActionCursor((c) => Math.max(0, c - 1)); + } else if (key.downArrow || input === 'j') { + setActionCursor((c) => Math.min(actions.length - 1, c + 1)); + } else if (key.return) { + const act = actions[actionCursor]; + if (!act) return; + if (act.value === 'cancel') { + setPhase('browse'); + return; + } + finish({ action: act.value, id: selectedId! }); + } + } + }, + { isActive: inputActive }, + ); + + if (phase === 'action' && selected) { + const { entry, installed } = selected; + const hasUpdate = !!installed && installed.version !== entry.source.tag; + return ( + + + + {selected.id} + + {trustLabel(entry.trust)} + {entry.source.tag} + {installed && + (hasUpdate ? ( + + update available ({installed.version} →{entry.source.tag}) + + ) : ( + installed + ))} + + {entry.description} + github.com/{entry.source.repo} + {entry.subpackages?.length ? modules: {entry.subpackages.join(', ')} : null} + + {actions.map((act, i) => ( + + {i === actionCursor ? '> ' : ' '} + {act.label} + + ))} + + up/down Enter confirm Esc back + + ); + } + + // Column header — must use identical widths as the rows below. + const colHeader = ' ' + 'NAME'.padEnd(NAME_W) + 'VERSION'.padEnd(VER_W) + 'TRUST'.padEnd(TRUST_W) + 'DESCRIPTION'; + + return ( + + + Packages + + {filtered.length === allPackages.length + ? `${allPackages.length} total` + : `${filtered.length} of ${allPackages.length}`} + + + filter: {filter || '--'} + + + + {/* Column header — single Text so widths match exactly */} + {colHeader} + + {/* Package rows — each row is a single with nested children. + This renders as a single inline flow, avoiding Yoga flex spacing that + would misalign the padEnd columns. */} + + {filtered.length === 0 && no packages match "{filter}"} + {visible.map((pkg, i) => { + const idx = windowStart + i; + const active = idx === cursor; + const { installed, entry } = pkg; + const hasUpdate = !!installed && installed.version !== entry.source.tag; + + // All prefixes are exactly 3 chars to match the 3-space header indent. + const prefix = active ? '>> ' : installed ? ' * ' : ' '; + const nameColor = active ? 'cyan' : hasUpdate ? 'yellow' : installed ? 'green' : undefined; + const nameText = pkg.id.padEnd(NAME_W); + const verText = entry.source.tag.padEnd(VER_W); + const trustText = trustLabel(entry.trust).padEnd(TRUST_W); + + return ( + + {prefix} + + {nameText} + + {verText} + {trustText} + {entry.description} + + ); + })} + + + {filtered.length > VISIBLE && ( + + {' '} + {windowStart > 0 ? '^ ' : ' '} + {`${filtered.length - windowStart - Math.min(VISIBLE, filtered.length - windowStart)} more`} + {windowStart + VISIBLE < filtered.length ? ' v' : ''} + + )} + + + up/down or j/k type to filter Backspace erase Enter select Esc cancel + + + ); +} + +export async function showPackageBrowser(input: { + registry: Registry; + lockfile: Lockfile; +}): Promise { + let resolveResult!: (r: PackageWorkflowResult) => void; + const resultPromise = new Promise((resolve) => { + resolveResult = resolve; + }); + const { waitUntilExit } = render(); + await waitUntilExit(); + return resultPromise; +} diff --git a/scripts/cli-e2e.mjs b/cli/test/e2e.mjs similarity index 68% rename from scripts/cli-e2e.mjs rename to cli/test/e2e.mjs index a76684cf..91631dcd 100644 --- a/scripts/cli-e2e.mjs +++ b/cli/test/e2e.mjs @@ -6,7 +6,7 @@ import { dirname, join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); const cli = join(root, 'cli', 'dist', 'index.js'); const localSrc = join(root, 'src-lua'); @@ -158,6 +158,59 @@ try { run(['doctor', '--help']); run(['plugin', '--help']); + log('package: loading registry'); + const registryPath = join(root, 'cli', 'dist', 'generated', 'registry.json'); + assert(existsSync(registryPath), 'cli/dist/generated/registry.json missing — run build first'); + const registry = JSON.parse(read(registryPath)); + + const topLevel = Object.entries(registry.packages).filter(([, e]) => !e.parent); + log(`package: ${topLevel.length} top-level packages to test`); + + for (const [pkgId, entry] of topLevel) { + log(`package install ${pkgId} @ ${entry.source.tag}`); + const pkgDir = mkdtempSync(join(tmpdir(), `feather-pkg-${pkgId}-`)); + + // install + run(['package', 'install', pkgId, '--dir', pkgDir]); + + // every file must exist on disk + for (const file of entry.install.files) { + assert(existsSync(join(pkgDir, file.target)), `${pkgId}: ${file.target} not on disk after install`); + } + + // lockfile entry must be correct + const lock = JSON.parse(read(join(pkgDir, 'feather.lock.json'))); + assert(lock.packages[pkgId], `${pkgId}: missing lockfile entry`); + assert( + lock.packages[pkgId].version === entry.source.tag, + `${pkgId}: lockfile version ${lock.packages[pkgId].version} !== registry ${entry.source.tag}`, + ); + assert( + lock.packages[pkgId].trust === entry.trust, + `${pkgId}: lockfile trust ${lock.packages[pkgId].trust} !== registry ${entry.trust}`, + ); + + // audit must pass (--json gives clean stdout even when piped) + const auditOut = run(['package', 'audit', '--json', '--dir', pkgDir]); + const auditResults = JSON.parse(auditOut); + for (const r of auditResults) { + assert(r.status === 'verified', `${pkgId}: audit ${r.target} → ${r.status}`); + } + + // remove + run(['package', 'remove', pkgId, '--dir', pkgDir]); + for (const file of entry.install.files) { + assert(!existsSync(join(pkgDir, file.target)), `${pkgId}: ${file.target} still on disk after remove`); + } + const lockAfter = JSON.parse(read(join(pkgDir, 'feather.lock.json'))); + assert(!lockAfter.packages[pkgId], `${pkgId}: lockfile entry remains after remove`); + + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(pkgDir, { recursive: true, force: true }); + } + log(`package install ${pkgId} ✔`); + } + log('passed'); } finally { if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { diff --git a/cli/test/package.test.mjs b/cli/test/package.test.mjs new file mode 100644 index 00000000..834a81e2 --- /dev/null +++ b/cli/test/package.test.mjs @@ -0,0 +1,399 @@ +/* eslint-disable no-undef */ +/** + * E2E tests for `feather package` subcommands. + * + * Runs the compiled CLI binary (dist/index.js) so build must be current. + * Uses --offline everywhere registry access is needed to avoid network I/O. + * Pre-populates temp dirs with lockfiles / files for audit & remove scenarios. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const CLI = fileURLToPath(new URL('../dist/index.js', import.meta.url)); +const sha256 = (s) => createHash('sha256').update(s).digest('hex'); + +/** Run the CLI and return { stdout, stderr, exitCode }. Never throws. */ +function run(args, extra = {}) { + try { + const stdout = execFileSync('node', [CLI, ...args], { + encoding: 'utf8', + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + ...extra, + }); + return { stdout, stderr: '', exitCode: 0 }; + } catch (err) { + return { stdout: err.stdout ?? '', stderr: err.stderr ?? '', exitCode: err.status ?? 1 }; + } +} + +function makeTmp() { + return mkdtempSync(join(tmpdir(), 'feather-e2e-')); +} + +/** + * Write a minimal feather.lock.json to dir. + * @param {string} dir + * @param {Record} packages + */ +function writeLock(dir, packages) { + writeFileSync(join(dir, 'feather.lock.json'), JSON.stringify({ lockfileVersion: 1, packages }, null, 2)); +} + +test('search: no query lists all registry packages', () => { + const { stdout, exitCode } = run(['package', 'search', '--offline']); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('anim8'), 'should include anim8'); + assert.ok(stdout.includes('bump'), 'should include bump'); + assert.ok(stdout.includes('package(s)'), 'should print count'); +}); + +test('search: query filters by package name', () => { + const { stdout, exitCode } = run(['package', 'search', 'anim', '--offline']); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('anim8')); + assert.ok(!stdout.includes('bump')); +}); + +test('search: query matches tags', () => { + const { stdout, exitCode } = run(['package', 'search', 'animation', '--offline']); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('anim8')); +}); + +test('search: unmatched query reports no results', () => { + const { stdout, exitCode } = run(['package', 'search', 'zzz_no_such_pkg_xyz', '--offline']); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('No packages found')); +}); + +test('list --installed: empty project prints hint', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'list', '--installed', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('No packages installed')); +}); + +test('list --installed: shows installed package with version', () => { + const dir = makeTmp(); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [], + }, + }); + const { stdout, exitCode } = run(['package', 'list', '--installed', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('anim8')); + assert.ok(stdout.includes('v2.3.1')); +}); + +test('info: shows package details for known package', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'info', 'anim8', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('anim8')); + assert.ok(stdout.includes('kikito/anim8')); + assert.ok(stdout.includes('anim8.lua')); + assert.ok(stdout.includes('lib/anim8.lua')); +}); + +test('info: unknown package exits 1 with message', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'info', 'zzz_no_such_pkg', '--offline', '--dir', dir]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('not found')); +}); + +test('install: no names + no lockfile prints empty hint and exits 0', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'install', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('empty')); +}); + +test('install: no names + empty lockfile prints hint', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'install', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('empty')); +}); + +test('install: no names + all verified skips restore', () => { + const dir = makeTmp(); + const content = 'return {}'; + const hash = sha256(content); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'anim8.lua'), content); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: hash }], + }, + }); + const { stdout, exitCode } = run(['package', 'install', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('up to date')); +}); + +test('install: unknown package exits 1', () => { + const dir = makeTmp(); + const { exitCode } = run(['package', 'install', 'zzz_no_such_pkg', '--offline', '--dir', dir]); + assert.equal(exitCode, 1); +}); + +test('install: dry-run shows plan without writing any files', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'install', 'anim8', '--dry-run', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('anim8')); + assert.ok(stdout.includes('Dry run')); + assert.ok(!existsSync(join(dir, 'lib', 'anim8.lua')), 'must not write any file'); + assert.ok(!existsSync(join(dir, 'feather.lock.json')), 'must not write lockfile'); +}); + +test('install: already-installed package is skipped', () => { + const dir = makeTmp(); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [], + }, + }); + const { stdout, exitCode } = run(['package', 'install', 'anim8', '--dry-run', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('already installed')); +}); + +test('install: version override without --allow-untrusted exits 1', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'install', 'anim8@v2.2.0', '--offline', '--dir', dir]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('allow-untrusted')); +}); + +test('install --from-url: missing --allow-untrusted exits 1 with warning', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run([ + 'package', + 'install', + '--from-url', + 'https://example.com/helper.lua', + '--target', + 'lib/helper.lua', + '--dir', + dir, + ]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('NOT been reviewed') || stdout.includes('allow-untrusted')); +}); + +test('install --from-url: missing --target exits 1', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run([ + 'package', + 'install', + '--from-url', + 'https://example.com/helper.lua', + '--allow-untrusted', + '--dir', + dir, + ]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('--target')); +}); + +test('audit: no packages installed prints hint', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('No packages installed')); +}); + +test('audit: correct file exits 0 and reports verified', () => { + const dir = makeTmp(); + const content = 'return {}'; + const hash = sha256(content); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'anim8.lua'), content); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: hash }], + }, + }); + const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); + assert.equal(exitCode, 0, `unexpected failure:\n${stdout}`); + assert.ok(stdout.includes('verified')); +}); + +test('audit: missing file exits 1 and reports missing', () => { + const dir = makeTmp(); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: 'abc123' }], + }, + }); + const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('missing')); +}); + +test('audit: tampered file exits 1 and reports MODIFIED', () => { + const dir = makeTmp(); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'anim8.lua'), 'tampered content'); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: sha256('original content') }], + }, + }); + const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('MODIFIED')); +}); + +test('audit --json: outputs valid JSON', () => { + const dir = makeTmp(); + const content = 'return {}'; + const hash = sha256(content); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'anim8.lua'), content); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: hash }], + }, + }); + const { stdout, exitCode } = run(['package', 'audit', '--json', '--dir', dir]); + assert.equal(exitCode, 0); + const parsed = JSON.parse(stdout); + assert.ok(Array.isArray(parsed)); + assert.equal(parsed[0].status, 'verified'); +}); + +test('remove: not installed exits 1 with message', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'remove', 'anim8', '--dir', dir]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('not installed')); +}); + +test('remove: deletes file and removes lockfile entry', () => { + const dir = makeTmp(); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'anim8.lua'), 'return {}'); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: 'any' }], + }, + }); + const { exitCode } = run(['package', 'remove', 'anim8', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(!existsSync(join(dir, 'lib', 'anim8.lua')), 'file should be deleted'); + const lock = JSON.parse(readFileSync(join(dir, 'feather.lock.json'), 'utf8')); + assert.ok(!lock.packages.anim8, 'lockfile entry should be removed'); +}); + +test('remove: does not touch files belonging to other packages', () => { + const dir = makeTmp(); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'anim8.lua'), 'return {}'); + writeFileSync(join(dir, 'lib', 'bump.lua'), 'return {}'); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: 'any' }], + }, + bump: { + version: 'v3.1.7', + trust: 'verified', + source: { repo: 'kikito/bump.lua', tag: 'v3.1.7' }, + files: [{ name: 'bump.lua', target: 'lib/bump.lua', sha256: 'any' }], + }, + }); + run(['package', 'remove', 'anim8', '--dir', dir]); + assert.ok(existsSync(join(dir, 'lib', 'bump.lua')), 'bump.lua must not be touched'); + const lock = JSON.parse(readFileSync(join(dir, 'feather.lock.json'), 'utf8')); + assert.ok(lock.packages.bump, 'bump lockfile entry must remain'); +}); + +test('update: no packages installed prints hint', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'update', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('No packages installed')); +}); + +test('update: specific name not in lockfile exits 1', () => { + const dir = makeTmp(); + // lockfile has another package so the empty-lockfile early-return is bypassed + writeLock(dir, { + bump: { + version: 'v3.1.7', + trust: 'verified', + source: { repo: 'kikito/bump.lua', tag: 'v3.1.7' }, + files: [], + }, + }); + const { stdout, exitCode } = run(['package', 'update', 'anim8', '--offline', '--dir', dir]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('not installed')); +}); + +test('update: already up-to-date package is reported', () => { + const dir = makeTmp(); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [], + }, + }); + const { stdout, exitCode } = run(['package', 'update', 'anim8', '--offline', '--dry-run', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('up to date')); +}); + +test('update: experimental packages are skipped', () => { + const dir = makeTmp(); + writeLock(dir, { + 'my-helper': { + version: '0.0.0', + trust: 'experimental', + source: { url: 'https://example.com/my-helper.lua' }, + files: [], + }, + }); + const { stdout, exitCode } = run(['package', 'update', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('Skipping') || stdout.includes('experimental')); +}); diff --git a/docs/hot-reload.md b/docs/hot-reload.md deleted file mode 100644 index 30bd73e0..00000000 --- a/docs/hot-reload.md +++ /dev/null @@ -1,163 +0,0 @@ -# Hot Reload - -Feather Hot Reload lets the desktop app send Lua source for a specific module over the existing WebSocket connection, then replace that module in the running game. - -> [!WARNING] -> Hot reload is controlled remote code execution. Keep it disabled by default, use a narrow allowlist, and never enable it in production builds. - -> [!CAUTION] -> Do not copy the example config into a real project without changing the allowlist. Every allowed module can be replaced by whoever can send commands to that Feather session. - -## Before Enabling - -Only enable hot reload when all of this is true: - -- You are running a trusted development build. -- The Feather desktop app and game are on a trusted local machine or trusted LAN. -- `allow` names only the modules you intend to edit. -- `persistToDisk` is off unless you deliberately want patches written into the LÖVE save directory. -- You have explicitly installed and included the opt-in `hot-reload` plugin. -- You have a clear release path where the `hot-reload` plugin is excluded or the Feather integration is removed. - -## Enable It - -Configure it from `feather.config.lua`: - -```lua -return { - debug = true, - - -- Hot reload is an opt-in plugin. Without this include, Feather ignores - -- cmd:hot_reload messages even if debugger.hotReload is configured. - include = { "hot-reload" }, - - debugger = { - enabled = true, - hotReload = { - -- Development-only remote code execution. Keep this false unless you - -- understand that Feather can replace allowlisted Lua modules at runtime. - enabled = true, - - -- Prefer exact modules. Avoid broad patterns unless you really mean it. - allow = { - "game.player", - "game.enemy", - "game.systems.*", - }, - - deny = { - "main", - "conf", - "feather.*", - }, - - persistToDisk = false, - clearOnBoot = false, - requireLocalNetwork = true, - showOverlay = true, - toastDuration = 2.5, - }, - }, -} -``` - -For manual plugin registration, require and register the plugin explicitly: - -```lua -local FeatherPluginManager = require("feather.plugin_manager") -local HotReloadPlugin = require("plugins.hot-reload") - -DEBUGGER = FeatherDebugger({ - debug = true, - debugger = { enabled = true }, - plugins = { - FeatherPluginManager.createPlugin(HotReloadPlugin, "hot-reload", { - enabled = true, - allow = { "game.player" }, - deny = { "main", "conf", "feather.*" }, - }), - }, -}) -``` - -> [!IMPORTANT] -> Installing Feather core alone does not enable hot reload. The `hot-reload` plugin must be installed and registered, which keeps the remote-code-loading path out of normal sessions. - -| Field | Default | Description | -| --------------------- | ------- | --------------------------------------------------------------------------- | -| `enabled` | `false` | Enables hot reload commands for the session. | -| `allow` | `{}` | Module allowlist. Supports exact names and `prefix.*` patterns. | -| `deny` | `{}` | Explicit denylist. `main`, `conf`, and `feather.*` are always denied. | -| `persistToDisk` | `false` | Writes hot patches to `.feather/hot/.lua` in LÖVE's save directory. | -| `clearOnBoot` | `false` | Clears persisted hot patches when Feather starts. | -| `requireLocalNetwork` | `true` | Accepts reload commands only when the configured Feather host is local/LAN. | -| `showOverlay` | `true` | Draws a temporary in-game toast when hot reload succeeds, fails, or restores. | -| `toastDuration` | `2.5` | Seconds the in-game hot reload toast remains visible. | - -> [!IMPORTANT] -> `allow = { "game.*" }` is convenient, but a smaller list is safer. Prefer only the modules you are actively editing. - -> [!WARNING] -> `persistToDisk = true` writes patched Lua source into `.feather/hot` in the save directory. That is useful for mobile development, but it also means a patch can survive an app restart until restored or cleared. - -## Use It - -1. Start the game with Feather enabled. -2. Open **Debugger**. -3. Open the game source folder if Feather cannot auto-detect it. -4. Select a `.lua` file from the file tree. -5. Press **Reload**. - -The **Watch** toggle polls the selected file and reloads it when the source changes. It intentionally watches only the selected module so changes are explicit and easy to reason about. - -> [!TIP] -> A file path like `game/player.lua` maps to the Lua module `game.player`. A folder entry like `game/systems/init.lua` maps to `game.systems`. - -## Example - -From the repository root: - -```bash -love src-lua --hot-reload -``` - -Then open **Debugger**, select `example/hot_reload/gameplay.lua`, edit the file, and press **Reload**. You can also enable **Watch** to reload the selected module whenever the file changes. - -> [!NOTE] -> The example allowlist contains exactly one module: `example.hot_reload.gameplay`. That narrow shape is intentional. - -## Rollback - -Before replacing a module, Feather keeps the original `package.loaded[module]` value. Press **Restore** in the Debugger toolbar to restore all modules replaced by hot reload and clear persisted patches. - -If a new module has a syntax error, runtime error, or failing migration hook, Feather restores the previous module immediately and reports the error in the app. - -## Migration Hook - -Reloaded modules can expose `__feather_reload`: - -```lua -local Player = {} - -function Player.__feather_reload(newModule, oldModule) - newModule.instances = oldModule and oldModule.instances or {} -end - -return Player -``` - -Use this for state migration, metatable updates, cache rebuilds, or system rebinding. - -## Mobile And Remote Devices - -Hot reload works on mobile and handheld devices because Feather sends source code through WebSocket. When `persistToDisk = true`, patched modules are written to LÖVE's save directory using `love.filesystem.write`. - -> [!NOTE] -> This does not modify the application bundle. Persisted patches live under `.feather/hot` in the save directory and are meant for development sessions only. - -> [!IMPORTANT] -> For mobile, Steam Deck, or second-computer workflows, only use hot reload on a trusted private network. Do not expose Feather's WebSocket port to public or shared networks. - -## Limits - -Hot reload does not support native libraries, arbitrary filesystem writes, unrestricted Lua execution, reloading Feather internals, or replacing `main.lua` / `conf.lua`. diff --git a/docs/hot-reload.md b/docs/hot-reload.md new file mode 120000 index 00000000..68f44e78 --- /dev/null +++ b/docs/hot-reload.md @@ -0,0 +1 @@ +../src-lua/plugins/hot-reload/README.md \ No newline at end of file diff --git a/docs/packages.md b/docs/packages.md new file mode 120000 index 00000000..21129014 --- /dev/null +++ b/docs/packages.md @@ -0,0 +1 @@ +../packages/README.md \ No newline at end of file diff --git a/docs/plugins-ui.md b/docs/plugins-ui.md new file mode 120000 index 00000000..5221f2dc --- /dev/null +++ b/docs/plugins-ui.md @@ -0,0 +1 @@ +../src-lua/plugins/plugin-ui.md \ No newline at end of file diff --git a/docs/plugins.md b/docs/plugins.md deleted file mode 100644 index 78e68b81..00000000 --- a/docs/plugins.md +++ /dev/null @@ -1,777 +0,0 @@ -# Feather Plugins - -Feather plugins are Lua modules that extend the functionality of the debugger. Plugins use a **server-driven UI** approach: they declare their actions and configuration in Lua via `getConfig()`, and the Feather desktop app renders the UI automatically — no TypeScript code needed. - -## Creating a plugin - -To create a plugin, extend the `FeatherPlugin` base class and implement the lifecycle methods you need: - -- `init(config)`: Called when the plugin is initialized. -- `update(dt, feather)`: Called every frame. Wrapped in `pcall` for error isolation — a crashing plugin won't take down the game. -- `onerror(msg, feather)`: Called when a game error occurs. -- `handleRequest(request, feather)`: Called on each push cycle to get data for the desktop (GET). Return data to be sent, or `nil` to skip. -- `handleActionRequest(request, feather)`: Called when a plugin action is triggered from the desktop (POST). Returns `(data, err)` tuple. -- `handleActionCancel(request, feather)`: Called when an in-flight action is cancelled from the desktop (DELETE). -- `handleParamsUpdate(request, feather)`: Called when plugin parameters are updated from the desktop (PUT). -- `finish(feather)`: Called when the game disconnects. -- `getConfig()`: Returns the plugin configuration including server-driven UI actions. Sent to the desktop on connect. - -To help with the implementation, Feather provides the `FeatherPlugin` class, which you can extend to create your plugin. - -```lua -local FeatherPlugin = require(FEATHER_PATH .. ".core.base") - -local MyPlugin = Class({ - __includes = FeatherPlugin, -}) - -function MyPlugin:init(config) - -- config.options contains the options passed to createPlugin - -- config.logger and config.observer are always available -end - -function MyPlugin:update(dt, feather) - -- Called every frame (error-isolated via pcall) -end - -function MyPlugin:onerror(msg, feather) - -- Called when a game error occurs -end - -function MyPlugin:handleRequest(request, feather) - -- Return data to push to desktop each cycle - return { - type = "gallery", -- content type: "gallery", etc. - data = { ... }, - loading = false, - } -end - -function MyPlugin:handleActionRequest(request, feather) - local action = request.params.action - -- Handle the action, return (data, err) tuple - return "success" -end - -function MyPlugin:handleActionCancel(request, feather) - -- Cancel an in-flight action (e.g. stop GIF recording) -end - -function MyPlugin:handleParamsUpdate(request, feather) - -- Handle parameter changes from desktop UI - return {} -end - -function MyPlugin:isSupported(version) - return version > 0 -end - -function MyPlugin:finish(feather) - -- Clean up resources -end - -function MyPlugin:getConfig() - return { - type = "my-plugin", - color = "#ff0000", - icon = "puzzle", -- Lucide icon name - tabName = "My Plugin", -- Display name in sidebar - actions = { - { label = "Do Thing", key = "do-thing", icon = "play", type = "button" }, - { label = "Count", key = "count", icon = "hash", type = "input", value = 10, props = { type = "number", min = 1, max = 100 } }, - { label = "Enabled", key = "enabled", icon = "toggle-left", type = "checkbox", value = true }, - }, - } -end - -return MyPlugin -``` - -### manifest.lua - -Every plugin that should be auto-discovered by `feather.auto` needs a `manifest.lua` at the root of its directory. Without it the directory is silently skipped. - -```lua --- plugins/my-plugin/manifest.lua -return { - id = "my-plugin", -- must match the directory name and createPlugin id - name = "My Plugin", -- display name (for tooling / future UI) - description = "What this plugin does", - version = "1.0.0", - capabilities = { "filesystem" }, -- tokens this plugin requires (see Capabilities) - opts = { speed = 1.0 }, -- default options merged with user pluginOptions - optIn = false, -- true = not registered unless in config.include - disabled = true, -- true = registered but inactive by default -} -``` - -| Field | Type | Description | -| -------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | `string` | Unique plugin identifier. Must match the directory name and the `id` passed to `createPlugin`. | -| `name` | `string` | Human-readable display name. | -| `description` | `string` | Short description shown in tooling. | -| `version` | `string` | Semver string. | -| `capabilities` | `string[]` | Capability tokens this plugin needs (e.g. `"filesystem"`, `"network"`). Checked against the user's `config.capabilities` at load time. | -| `opts` | `table` | Default plugin options. Merged with (and overridden by) the user's `config.pluginOptions[id]`. | -| `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`. | - -### 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. - -```lua -function MyPlugin:onDraw() - -- Runs after love.draw(); use love.graphics here -end - -function MyPlugin:onKeypressed(key, scancode, isrepeat) - if key == "f5" then self:doSomething() end -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:onTouchpressed(id, x, y, dx, dy, pressure) end -function MyPlugin:onTouchreleased(id, x, y, dx, dy, pressure) end -function MyPlugin:onJoystickpressed(joystick, button) end -function MyPlugin:onJoystickreleased(joystick, button) 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. - -### Capabilities - -Declare the system access your plugin needs in `manifest.lua`. At load time, `FeatherPluginManager` checks these against the user's `config.capabilities` allowlist and logs a warning for any undeclared capability. Loading is not blocked — warnings are informational. - -Available capability tokens: - -| Token | Meaning | -| -------------- | -------------------------------------------- | -| `"filesystem"` | Reads or writes via `love.filesystem` | -| `"network"` | Uses LuaSocket / HTTP | -| `"audio"` | Accesses `love.audio` | -| `"physics"` | Accesses `love.physics` | -| `"input"` | Hooks input callbacks (`onKeypressed`, etc.) | -| `"draw"` | Hooks `onDraw` to render overlays | - -Users can restrict which capabilities are allowed: - -```lua -require("feather.auto").setup({ - -- Only allow filesystem and draw; plugins requesting anything else get a warning - capabilities = { "filesystem", "draw" }, -}) -``` - -Pass `"all"` (the default) to skip capability checking entirely. - -### API Compatibility - -Feather has a plugin UI/protocol API number. Plugins can declare which desktop API versions they support in `manifest.lua`; the runtime forwards that metadata to the desktop and disables incompatible plugins instead of letting them fail later. - -The current Lua runtime exposes the number as `require("feather").API`. - -```lua --- plugins/my-plugin/manifest.lua -return { - id = "my-plugin", - name = "My Plugin", - version = "1.0.0", - - -- Exact API - api = 5, - - -- Or a range - -- minApi = 5, - -- maxApi = 6, -} -``` - -For manually registered plugins, pass the same metadata through `createPlugin`: - -```lua -local plugin = FeatherPluginManager.createPlugin(MyPlugin, "my-plugin", {}, false, {}, { - minApi = 5, - maxApi = 6, - name = "My Plugin", - version = "1.0.0", -}) -``` - -> [!IMPORTANT] -> Declare an API range when your plugin depends on specific Feather UI nodes, binary attachments, action semantics, or table/gallery formats. Incompatible plugins are shown in the desktop app with an API mismatch message and cannot be enabled. - -### Plugin lifecycle - -The FeatherPluginManager handles the lifecycle of each plugin. Each plugin's `update()` is wrapped in `pcall` for error isolation — if a plugin crashes, it won't affect the game or other plugins. After 10 consecutive errors, a plugin is automatically disabled. Use `pluginManager:enablePlugin(id)` to re-enable it. - -#### 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. -- `getConfig()`: Returns the plugin configuration (type, icon, tab name, actions). Sent to the desktop app on connect. - -#### Data Push (every cycle) - -- `handleRequest(request, feather)`: Called on each push cycle at the configured `sampleRate`. Return data to be sent to the desktop, or `nil` to skip. The return value should include `type` (content type), `data`, and optionally `loading` and `persist`. - -#### Action Handling (from desktop UI) - -- `handleActionRequest(request, feather)`: Called when a button action is triggered from the desktop. `request.params.action` contains the action key. Returns a `(data, err)` tuple — errors are sent back to the desktop as toast notifications. -- `handleActionCancel(request, feather)`: Called when the user cancels an in-flight action from the desktop (e.g. stopping a GIF recording mid-way). -- `handleParamsUpdate(request, feather)`: Called when the user changes an input or checkbox value in the desktop UI. `request.params` contains the updated key-value pairs. - -#### Update - -- `update(dt, feather)`: Called every frame, error-isolated via `pcall`. Use for ongoing work like batch encoding, timers, or frame capture. - -#### Error Handling - -- `onerror(msg, feather)`: Called when a game-level error occurs (not plugin errors). Use to capture state or clean up. - -#### Finish - -- `finish(feather)`: Called when the game disconnects or shuts down. - -## Registering a plugin - -To register a plugin, you need to create an instance of it and pass it to the FeatherPluginManager. The FeatherPluginManager will handle the lifecycle of the plugin and call the appropriate functions. - -```lua -local MyPlugin = require("my-plugin") - -local plugin = FeatherPluginManager.createPlugin(MyPlugin, "my-plugin", { - -- Plugin options -}) -``` - -## Plugin options - -The plugin options are passed to the plugin's constructor. Here's an example of a plugin with options: - -```lua -local MyPlugin = require("my-plugin") - -local plugin = FeatherPluginManager.createPlugin(MyPlugin, "my-plugin", { - option1 = "value1", - option2 = "value2", -}) -``` - -### Feather Options - -By default, every plugin has the following properties available: - -- `self.logger`: A logger that logs messages to the Feather logger. -- `self.observer`: A logger that logs messages to the Feather observer. - -## Plugin configuration - -Plugins return configuration via `getConfig()` that drives the desktop UI. This uses a **server-driven UI** pattern — the Lua side declares what controls are needed, and the desktop renders them as native components. - -### Config fields - -| Field | Type | Description | -| --------- | -------- | ---------------------------------------------------------------- | -| `type` | `string` | Unique plugin type identifier. | -| `color` | `string` | Hex color for the plugin's sidebar icon. | -| `icon` | `string` | [Lucide](https://lucide.dev/icons) icon name. | -| `tabName` | `string` | Display name shown in the sidebar navigation. | -| `actions` | `table` | Array of action definitions (see [Action types](#action-types)). | - -### Action types - -Actions define the interactive controls rendered in the plugin page. Each action requires `label`, `key`, `icon`, and `type`. Some types support an optional `value` (default/initial value) and `props` (extra configuration). - -| Field | Type | Required | Description | -| ------- | -------- | -------- | ----------------------------------------------------------------------------- | -| `label` | `string` | Yes | Display text for the control. | -| `key` | `string` | Yes | Unique identifier. Sent as the action/param key. | -| `icon` | `string` | Yes | [Lucide](https://lucide.dev/icons) icon name. | -| `type` | `string` | Yes | One of: `button`, `input`, `checkbox`, `select`, `vector`, `file`. | -| `value` | `any` | No | Default value (string, number, or boolean). | -| `props` | `table` | No | Extra configuration — varies by type (see below). | -| `group` | `string` | No | Group name for card layout (see [Grouped card layout](#grouped-card-layout)). | - -#### `type = "button"` - -Renders a button. Clicking sends `cmd:plugin:action` with `params.action` set to the action `key`. The plugin's `handleActionRequest` receives it. - -```lua -{ label = "Capture", key = "screenshot", icon = "camera", type = "button" } -``` - -#### `type = "input"` - -Renders a text input field. Value changes send `cmd:plugin:params`. The plugin's `handleParamsUpdate` receives the key-value pair. - -```lua -{ label = "FPS", key = "fps", icon = "gauge", type = "input", value = 30, - props = { type = "number", min = 5, max = 60, step = 1 } } -``` - -**Supported `props`:** - -| Prop | Type | Description | -| ------------- | --------- | ------------------------------------------------------------------------------------------ | -| `type` | `string` | HTML input type (`"number"`, `"text"`, etc.). Use `"number"` to restrict to numeric entry. | -| `min` | `number` | Minimum value (for `type = "number"`). | -| `max` | `number` | Maximum value (for `type = "number"`). | -| `step` | `number` | Step increment (for `type = "number"`). | -| `placeholder` | `string` | Placeholder text. | -| `disabled` | `boolean` | Disable the input. | -| `readOnly` | `boolean` | Make the input read-only. | - -#### `type = "checkbox"` - -Renders a checkbox. Toggle sends `cmd:plugin:params` with `"true"` or `"false"` as the value. - -```lua -{ label = "Persist", key = "persist", icon = "save", type = "checkbox", value = true } -``` - -#### `type = "select"` - -Renders a dropdown select menu. Value changes send `cmd:plugin:params`. - -```lua -{ label = "Mode", key = "mode", icon = "settings", type = "select", value = "normal", - props = { options = { "normal", "additive", "multiply" } } } -``` - -**Supported `props`:** - -| Prop | Type | Description | -| --------- | ------- | -------------------------------------- | -| `options` | `table` | Array of string values to choose from. | - -#### `type = "vector"` - -Renders a multi-field input for comma-separated values (e.g. coordinates, colors). Each value gets its own labeled input field. Value changes send the joined comma-separated string via `cmd:plugin:params`. - -```lua --- 3 labeled fields: Start, Mid, End -{ label = "Sizes", key = "sizes", icon = "ruler", type = "vector", - value = "1.0, 0.5, 0.0", - props = { labels = { "Start", "Mid", "End" }, type = "number", min = 0, max = 10, step = 0.01 } } - --- Repeating labels cycle through the array (e.g. RGBA groups) -{ label = "Colors", key = "colors", icon = "palette", type = "vector", - value = "1, 0.5, 0, 1, 0, 0.2, 0.8, 0", - props = { labels = { "R", "G", "B", "A" }, repeating = true, type = "number", min = 0, max = 1, step = 0.01 } } -``` - -**Supported `props`:** - -| Prop | Type | Description | -| ----------- | --------- | --------------------------------------------------------------------------------------------------------------- | -| `labels` | `table` | Array of labels for each field (e.g. `{"X", "Y"}`, `{"R", "G", "B", "A"}`). | -| `repeating` | `boolean` | When `true`, labels cycle through the array. Groups values into rows of `#labels` size (e.g. RGBA → rows of 4). | -| `type` | `string` | HTML input type for each field (default: `"number"`). | -| `min` | `number` | Minimum value per field. | -| `max` | `number` | Maximum value per field. | -| `step` | `number` | Step increment per field. | - -#### `type = "file"` - -Renders a button that opens a native file picker dialog. The selected file's contents are sent to `handleActionRequest`. - -```lua -{ label = "Import", key = "import", icon = "upload", type = "file", - props = { filters = { { name = "Lua files", extensions = { "lua" } } } } } -``` - -**Supported `props`:** - -| Prop | Type | Description | -| --------- | ------- | -------------------------------------------------------------------- | -| `filters` | `table` | Array of file filters: `{ name = "Label", extensions = { "ext" } }`. | - -### Grouped card layout - -By default, actions render in a horizontal toolbar at the top of the plugin page. For plugins with many controls, you can organize actions into **named groups** using the `group` field. Grouped actions render in a responsive card grid below the toolbar. - -```lua -actions = { - -- These appear in the toolbar (no group) - { label = "Export", key = "export", icon = "download", type = "button" }, - { label = "Reset", key = "reset", icon = "rotate-ccw", type = "button" }, - - -- These appear in a "Speed" card - { label = "Min Speed", key = "speedMin", icon = "gauge", type = "input", value = 100, - props = { type = "number", min = 0, max = 1000 }, group = "Speed" }, - { label = "Max Speed", key = "speedMax", icon = "gauge", type = "input", value = 500, - props = { type = "number", min = 0, max = 1000 }, group = "Speed" }, - - -- These appear in a "Visual" card - { label = "Sizes", key = "sizes", icon = "ruler", type = "vector", value = "1, 0.5, 0", - props = { labels = { "Start", "Mid", "End" }, type = "number" }, group = "Visual" }, - { label = "Colors", key = "colors", icon = "palette", type = "vector", - value = "1, 0, 0, 1", - props = { labels = { "R", "G", "B", "A" }, repeating = true, type = "number" }, group = "Visual" }, -} -``` - -**Layout behavior:** - -- Actions **without** `group` render in the toolbar (top row, horizontal). -- Actions **with** `group` render in a responsive card grid below the toolbar. -- Cards are ordered by the first appearance of each group name. -- The grid is responsive: 1 column on small screens, up to 4 on wide screens. -- All action types (`button`, `input`, `checkbox`, `select`, `vector`, `file`) work inside cards. Buttons and selects expand to full width within a card. - -### Action response types - -When a plugin's `handleActionRequest` returns data, the desktop app handles it based on the response shape: - -#### Error response - -Return an error string as the second value of the `(data, err)` tuple. The error is shown as a toast notification. - -```lua -function MyPlugin:handleActionRequest(request, feather) - return nil, "Something went wrong" -end -``` - -#### Download response - -Return a `download` table to trigger a native save-file dialog on the desktop. - -```lua -function MyPlugin:handleActionRequest(request, feather) - return { - download = { - filename = "export.lua", -- suggested filename - content = "-- exported", -- file content (string) - extension = "lua", -- file extension for the filter - }, - } -end -``` - -#### Clipboard response - -Return a `clipboard` string to copy text to the user's clipboard with a success toast. - -```lua -function MyPlugin:handleActionRequest(request, feather) - return { - clipboard = "local ps = love.graphics.newParticleSystem(image, 100)", - } -end -``` - -### Content types - -The `handleRequest` method returns data that the desktop renders below the actions. The `type` field determines the layout: - -| Type | Description | -| ---------- | ------------------------------------------------------------------------------ | -| `ui` | Declarative Feather UI tree rendered by the desktop. | -| `gallery` | Image grid with download buttons. Supports PNG screenshots and GIF animations. | -| `table` | Data table with sortable columns. | -| `tree` | Collapsible tree view with properties on each node. | -| `timeline` | Chronological event timeline with categories and optional screenshots. | - -#### `ui` - -Lua plugins can describe UI declaratively with `feather.ui` primitives. React is only the renderer; plugins should send stable Feather UI nodes and action keys, not raw React components or JavaScript. - -For the full schema, supported nodes, and complete examples, see [Plugin UI](plugin-ui.md). - -```lua -function MyPlugin:handleRequest(request, feather) - return feather.ui.render( - feather.ui.panel({ - title = "Player Stats", - - feather.ui.row({ - feather.ui.badge({ value = "HP" }), - feather.ui.text({ value = tostring(player.health) }), - }), - - feather.ui.button({ - label = "Kill Player", - action = "kill-player", - }), - }) - ) -end - -function MyPlugin:handleActionRequest(request) - if request.params.action == "kill-player" then - player.health = 0 - end -end -``` - -Supported node types in the first schema pass: `panel`, `row`, `column`, `tabs`, `tab`, `text`, `button`, `input`, `textarea`, `checkbox`, `switch`, `select`, `badge`, `stat`, `progress`, `alert`, `list`, `link`, `separator`, `image`, `code`, `table`, `timeline`, and `inspector`. - -Buttons use `action = "some-key"` (or `onClick = "some-key"`) and are routed through the existing `handleActionRequest()` path. Function callbacks are intentionally not serialized. - -#### `gallery` - -```lua -return { - type = "gallery", - data = { - { type = "image", name = "shot1", downloadable = true, metadata = { type = "png", src = base64data, width = 800, height = 600 } }, - }, - loading = false, - persist = true, -- merge with previous data instead of replacing -} -``` - -For large images or binary payloads, prefer Feather's hybrid protocol instead of embedding base64 in JSON. Call `feather:attachBinary(mime, bytes)` and place the returned `src` and `binary` fields in your normal plugin data. Feather sends the JSON first, then streams the bytes on a binary WebSocket frame; the desktop swaps `feather-binary:` into a local blob URL automatically. - -```lua -function MyPlugin:handleRequest(request, feather) - local pngBytes = self:getLatestPreviewPng() - local preview = feather:attachBinary("image/png", pngBytes) - - return { - type = "gallery", - data = { - { - type = "image", - name = "preview.png", - downloadable = true, - metadata = { - type = "png", - src = preview.src, - binary = preview.binary, - width = 800, - height = 600, - }, - }, - }, - loading = false, - } -end -``` - -For GIF-style frame lists, put the placeholder URLs in `src` and the matching binary refs in `binary` using the same order: - -```lua -metadata = { - type = "gif", - src = { frame1.src, frame2.src }, - binary = { frame1.binary, frame2.binary }, - width = 800, - height = 600, - fps = 30, -} -``` - -The same works in table rows. The desktop resolves `src` to a blob URL and renders it as a download/open control when that column is visible; the `binary` field can stay hidden metadata if you do not add a matching column. - -```lua -function MyPlugin:handleRequest(request, feather) - local file = feather:attachBinary("application/octet-stream", bytes) - - return { - type = "table", - columns = { - { key = "name", label = "Name" }, - { key = "size", label = "Size" }, - { key = "src", label = "File" }, - }, - data = { - { name = "dump.bin", size = tostring(#bytes), src = file.src, binary = file.binary }, - }, - loading = false, - } -end -``` - -#### `table` - -```lua -return { - type = "table", - columns = { - { key = "name", label = "Name" }, - { key = "value", label = "Value" }, - }, - data = { - { name = "FPS", value = "60" }, - { name = "Memory", value = "12.5 MB" }, - }, - loading = false, -} -``` - -#### `tree` - -```lua -return { - type = "tree", - nodes = { - { - name = "Player", - properties = { { key = "health", value = "100" }, { key = "x", value = "320" } }, - children = { - { name = "Inventory", properties = { { key = "items", value = "5" } } }, - }, - }, - }, - sources = { "ECS", "Scene" }, -- data source names for the source selector - selectedSource = 0, -- currently selected source index - searchFilter = "", -- current search filter text - loading = false, -} -``` - -#### `timeline` - -```lua -return { - type = "timeline", - items = { - { id = 1, label = "Game started", category = "lifecycle", time = 0, gameTime = "0:00" }, - { id = 2, label = "Level loaded", category = "lifecycle", color = "#4ade80", time = 1.5, gameTime = "0:01" }, - }, - categories = { "lifecycle", "error" }, - loading = false, -} -``` - -## Adding plugins to `feather.auto` - -`feather.auto` registers all built-in plugins for you. You can extend it with your own plugins through the `plugins` key in `setup()`, without giving up the zero-config defaults. - -### Append a custom plugin - -Pass additional plugins via `config.plugins`. They are appended after all built-in plugins: - -```lua -local FeatherPluginManager = require("feather.plugin_manager") -local MyPlugin = require("my-plugin") - -require("feather.auto").setup({ - plugins = { - FeatherPluginManager.createPlugin(MyPlugin, "my-plugin", { - option1 = "value1", - }), - }, -}) -``` - -`DEBUGGER` is created as usual, and your plugin appears in the sidebar alongside the built-in ones. - -### Start a custom plugin disabled - -Pass `true` as the fourth argument to `createPlugin`. The plugin is registered and visible in the desktop UI, but starts inactive — users can enable it from the desktop without restarting. - -```lua -FeatherPluginManager.createPlugin(MyPlugin, "my-plugin", { option1 = "value1" }, true) -``` - -### Force-enable a built-in plugin - -Built-in plugins that are `optIn` (like `console`) or start `disabled` are not active by default. Add their IDs to `config.include` to force-enable them: - -```lua -require("feather.auto").setup({ - include = { "console", "physics-debug" }, -}) -``` - -`include` both registers `optIn` plugins (which would otherwise be skipped entirely) and activates `disabled` ones. - -### Exclude a built-in plugin - -Remove a built-in plugin entirely with `config.exclude`. It won't be registered at all: - -```lua -require("feather.auto").setup({ - exclude = { "network-inspector", "memory-snapshot" }, -}) -``` - -### Override built-in plugin options - -Pass per-plugin option overrides via `config.pluginOptions`, keyed by plugin ID. Options are merged over the built-in defaults: - -```lua -require("feather.auto").setup({ - pluginOptions = { - screenshots = { fps = 60, gifDuration = 10 }, - ["memory-snapshot"] = { autoInterval = 5 }, - }, -}) -``` - -### Full example - -```lua -local FeatherPluginManager = require("feather.plugin_manager") -local MyPlugin = require("my-plugin") -local AnotherPlugin = require("another-plugin") - -require("feather.auto").setup({ - sessionName = "My Game", - -- Force-enable built-in opt-in plugins - include = { "console" }, - -- Remove plugins you don't need - exclude = { "hump.signal", "lua-state-machine" }, - -- Override options for specific built-in plugins - pluginOptions = { - screenshots = { fps = 60 }, - }, - -- Append your own plugins - plugins = { - FeatherPluginManager.createPlugin(MyPlugin, "my-plugin", { debug = true }), - FeatherPluginManager.createPlugin(AnotherPlugin, "another-plugin", {}, true), -- starts disabled - }, -}) -``` - -## Using Plugin Actions - -Plugins can be triggered from game code at runtime using `debugger:action()`: - -```lua -local debugger = FeatherDebugger({ - debug = true, - plugins = { - FeatherPluginManager.createPlugin(ScreenshotPlugin, "screenshots", { - screenshotDirectory = "screenshots", - fps = 30, - gifDuration = 5, - }), - }, -}) - -function love.keypressed(key) - if key == "f1" then - debugger:action("screenshots", "screenshot", {}) - elseif key == "f2" then - debugger:action("screenshots", "gif", { duration = 3, fps = 30 }) - end -end -``` - -Actions can also be triggered from the desktop UI — button actions in `getConfig()` send `cmd:plugin:action` messages over WebSocket, and the response (success/error) is shown as a toast notification in the desktop app. - -## Plugin examples - -Here are some examples of Feather plugins: - -- [Hump's Signal Plugin](../src-lua/plugins/hump/signal/README.md) -- [Lua State Machine Plugin](../src-lua/plugins/lua-state-machine/README.md) -- [Screenshot Plugin](../src-lua/plugins/screenshots/README.md) -- [Particle Editor Plugin](../src-lua/plugins/particle-editor/README.md) -- [Audio Debug Plugin](../src-lua/plugins/audio-debug/README.md) -- [Coroutine Monitor Plugin](../src-lua/plugins/coroutine-monitor/README.md) -- [Collision Debug Plugin](../src-lua/plugins/collision-debug/README.md) -- [Animation Inspector Plugin](../src-lua/plugins/animation-inspector/README.md) -- [Timer Inspector Plugin](../src-lua/plugins/timer-inspector/README.md) - -## Plugin documentation - -Each plugin should have a README file that explains how to use it and provides examples. diff --git a/docs/plugins.md b/docs/plugins.md new file mode 120000 index 00000000..aa220ce4 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1 @@ +../src-lua/plugins/README.md \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 18ac442f..b02717c1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -34,7 +34,9 @@ nav: - Debugger: debugger.md - Hot Reload: hot-reload.md - Assets: assets.md - - CLI: cli.md + - CLI: + - Overview: cli.md + - Package Management: packages.md - Time Travel: time-travel.md - Console: console.md - Recommendations: recommendations.md diff --git a/package.json b/package.json index 002b7cf9..7bbe47bb 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "cli:build": "npm run build --workspace=cli", "cli:dev": "npm run dev --workspace=cli", - "test:cli:e2e": "npm run cli:build && node scripts/cli-e2e.mjs", + "test:cli:e2e": "npm run cli:build && node cli/test/e2e.mjs && node cli/test/package.test.mjs", "test:lua:e2e": "node scripts/lua-e2e.mjs", "test:app:e2e": "playwright test", "test:tauri:e2e": "cargo test --manifest-path src-tauri/Cargo.toml ws_server", @@ -28,6 +28,9 @@ "typecheck": "npm run typecheck:web && npm run typecheck:lua", "generate:plugin-catalog": "node scripts/generate-plugin-catalog.mjs", "check:plugin-catalog": "npm run generate:plugin-catalog && git diff --exit-code cli/src/generated/plugin-catalog.ts", + "generate:registry": "node scripts/generate-registry.mjs", + "check:registry": "npm run generate:registry && git diff --exit-code cli/src/generated/registry.json", + "verify:packages": "node scripts/compute-checksums.mjs --all", "lint": "eslint src --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0", "tauri": "tauri", "prepare": "husky" diff --git a/packages/README.md b/packages/README.md new file mode 100644 index 00000000..57d6e580 --- /dev/null +++ b/packages/README.md @@ -0,0 +1,245 @@ +# Package Manager + +Feather includes a curated package installer for LÖVE libraries. It is not a general package manager. It is a hand-curated catalog of known-good libraries with verified checksums. + +## Concepts + +### Trust levels + +| Level | Meaning | +| -------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `verified` | Feather-reviewed. Pinned release + per-file SHA-256. | +| `known` | Popular community library. Checksum-pinned but not audited in depth. | +| `experimental` | Installed via `--from-url` or a version override (`name@version`). SHA-256 computed live. Requires `--allow-untrusted`. | + +By default, `verified` and `known` packages install without extra flags. `experimental` requires `--allow-untrusted`. + +Version overrides (`feather package install anim8@v2.2.0`) are treated as `experimental` even when the package itself is `verified` — the source (name, repo) is curated, but Feather has not reviewed that specific version. + +### Lockfile + +Every install, update, or remove updates `feather.lock.json` in your project root. Commit this file — it records the exact version and SHA-256 of every installed file so anyone cloning your project can restore dependencies with `feather package install` and verify them with `feather package audit`. + +--- + +## Commands + +### `feather package search [query]` + +Search the catalog by name, description, or tag. + +```sh +feather package search animation +feather package search collision +feather package search oop +``` + +### `feather package list` + +List all packages in the catalog. Add `--installed` to show only what's in your project. + +```sh +feather package list +feather package list --installed +feather package list --refresh # force re-fetch the registry +``` + +### `feather package info ` + +Show full details for a package: description, trust, source, files, and usage snippet. + +```sh +feather package info anim8 +feather package info hump +``` + +### `feather package install` + +Run with no arguments to restore all packages recorded in `feather.lock.json`. Files already on disk with the correct checksum are skipped; only missing or tampered files are re-downloaded. + +```sh +feather package install +``` + +This is the command to run after cloning a project that has a lockfile. + +### `feather package install [name2...]` + +Install one or more packages. Files are verified against their SHA-256 before being written. The lockfile is updated on success. + +```sh +feather package install anim8 +feather package install bump lume flux +feather package install hump # installs all hump modules +feather package install hump.camera # installs only the camera module +``` + +Options: + +| Flag | Description | +| ------------------- | ---------------------------------------------------------- | +| `--dry-run` | Show what would be installed without writing files | +| `--target ` | Override the install directory | +| `--offline` | Use the bundled registry snapshot instead of fetching | +| `--allow-untrusted` | Required for `experimental` packages and version overrides | + +**Example output:** + +``` + anim8 + Source: github.com/kikito/anim8 + Version: v2.3.1 + Trust: [verified] + Files: + anim8.lua → lib/anim8.lua + +✔ anim8 @ v2.3.1 + → lib/anim8.lua checksum: verified + + Add to your code: + local anim8 = require("lib.anim8") +``` + +### `feather package install @` + +Install a specific version of a registry package that differs from the Feather-pinned release. Because that version has not been reviewed, the SHA-256 is computed live at install time rather than checked against the registry. **Requires `--allow-untrusted`.** + +```sh +feather package install anim8@v2.2.0 --allow-untrusted +``` + +The package source (name, GitHub repo) is registry-curated, but the specific version is not. The install is stored in the lockfile with `trust: experimental` so it is clearly visible in `list --installed` and `audit` output. + +**Example output:** + +``` + anim8 + Source: github.com/kikito/anim8 + Version: v2.2.0 + Trust: [experimental] (source is registry-curated, but v2.2.0 has not been reviewed) + Files: + anim8.lua → lib/anim8.lua + +✔ anim8 @ v2.2.0 + → lib/anim8.lua checksum: live-computed ⚠ +``` + +If the requested version does not exist on GitHub, the install aborts with a clear error and a link to the package's releases page. + +> [!NOTE] +> To go back to the Feather-reviewed version, run `feather package install anim8` (no version pin). This re-installs the registry-pinned release and resets the lockfile entry to `trust: verified`. + +### `feather package install --from-url --target ` + +Install a single Lua file from an arbitrary URL. The SHA-256 is computed and shown before the file is written. The package is stored in the lockfile with `trust: experimental`. + +**Requires `--allow-untrusted`** — this is intentional. The flag name makes the risk explicit. + +```sh +feather package install --from-url https://example.com/mylib.lua \ + --target lib/mylib.lua \ + --allow-untrusted +``` + +> [!CAUTION] +> Experimental packages have not been reviewed by the Feather team. Only install files from sources you trust. The SHA-256 shown at install time is what will be verified by future `feather package audit` runs. + +### `feather package update [name]` + +Update an installed package to the latest version in the registry. Omit the name to update all installed packages. + +```sh +feather package update anim8 +feather package update # update all +feather package update --dry-run +``` + +`experimental` packages (installed via `--from-url`) cannot be updated — re-install with `--from-url` to replace them. + +### `feather package remove ` + +Remove an installed package and its files. Updates the lockfile. + +```sh +feather package remove anim8 +feather package remove hump.camera +``` + +### `feather package audit` + +Verify the SHA-256 of every installed file against the lockfile. Exits with code 1 if any file is modified or missing. + +```sh +feather package audit +feather package audit --json # machine-readable output +``` + +**Example output:** + +``` +Auditing 4 installed packages… + + ✔ anim8 lib/anim8.lua verified + ✔ hump.camera lib/hump/camera.lua verified + ✖ baton lib/baton.lua MODIFIED ← SHA-256 mismatch + ! sti lib/sti/init.lua missing + +1 issue(s) found. Re-install affected packages with `feather package install `. +``` + +--- + +## Available Packages + +| Package | Trust | Description | +| ---------------- | -------- | ----------------------------------------------- | +| `anim8` | verified | 2D sprite animation | +| `bump` | verified | AABB collision detection | +| `classic` | verified | Minimal OOP class library | +| `flux` | verified | Tweening / easing | +| `hump` | verified | Camera, timer, signal, class, vector, gamestate | +| `hump.camera` | verified | Camera module only | +| `hump.class` | verified | Class module only | +| `hump.gamestate` | verified | Gamestate module only | +| `hump.signal` | verified | Signal module only | +| `hump.timer` | verified | Timer module only | +| `hump.vector` | verified | Vector module only | +| `inspect` | verified | Human-readable table printer | +| `lume` | verified | Functional utilities | +| `middleclass` | verified | Full-featured OOP class library | +| `push` | verified | Resolution-independence | +| `sti` | known | Simple Tiled Implementation (map loader) | +| `windfield` | known | Box2D physics wrapper | + +Run `feather package list` for the most up-to-date listing including any newly added packages. + +--- + +## Security model + +- **SHA-256 is verified before any file is written.** For registry installs, the checksum is checked against the Feather-pinned value before anything touches disk. A mismatch aborts the install. +- **Version overrides and `--from-url` compute SHA-256 live.** The hash is shown at install time and stored in the lockfile, so future `audit` runs detect tampering — but the initial download is not checked against a known-good value. +- **No post-install scripts.** No code runs during install, update, or remove. +- **Files stay inside your project.** Target paths are resolved and validated against the project root. Paths that would escape (e.g. `../../`) are rejected. +- **The registry is fetched over HTTPS only.** +- **`experimental` packages are flagged everywhere** — in install output, `list --installed`, and `audit` results. This includes version overrides. + +--- + +## Contributing packages + +To propose adding a package to the Feather catalog, open an issue at [github.com/Kyonru/feather](https://github.com/Kyonru/feather) with: + +- The library name and GitHub repo +- The specific release tag or commit to pin +- Whether it meets the criteria for `verified` or `known` trust + +Maintainers will compute checksums, review the library, and add it to `packages/`. + +To compute checksums locally while developing: + +```sh +node scripts/compute-checksums.mjs --all # verify all package files +node scripts/compute-checksums.mjs https://raw.githubusercontent.com/example/lib/main/lib.lua +node scripts/compute-checksums.mjs --package packages/anim8.json +``` diff --git a/packages/anim8.json b/packages/anim8.json new file mode 100644 index 00000000..7fe0b3ce --- /dev/null +++ b/packages/anim8.json @@ -0,0 +1,19 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "A 2D animation library for LÖVE. Handles sprite sheets, frames, tags, and playback.", + "tags": ["animation", "sprites"], + "homepage": "https://github.com/kikito/anim8", + "source": { + "repo": "kikito/anim8", + "tag": "v2.3.1", + "baseUrl": "https://raw.githubusercontent.com/kikito/anim8/v2.3.1/" + }, + "install": { + "files": [ + { "name": "anim8.lua", "sha256": "c6581c30225a4297924a4f27e56edbc12894fb2e168232fa3a19d21ac149e83f", "target": "lib/anim8.lua" } + ] + }, + "require": "lib.anim8", + "example": "local anim8 = require('lib.anim8')" +} diff --git a/packages/bump.json b/packages/bump.json new file mode 100644 index 00000000..92b51e9e --- /dev/null +++ b/packages/bump.json @@ -0,0 +1,19 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "A Lua library for simple AABB collision detection and resolution.", + "tags": ["collision", "physics"], + "homepage": "https://github.com/kikito/bump.lua", + "source": { + "repo": "kikito/bump.lua", + "tag": "v3.1.7", + "baseUrl": "https://raw.githubusercontent.com/kikito/bump.lua/v3.1.7/" + }, + "install": { + "files": [ + { "name": "bump.lua", "sha256": "b947cc4449554816831d5c45b61a169e1559548139478d980c8eef14c7b362a6", "target": "lib/bump.lua" } + ] + }, + "require": "lib.bump", + "example": "local bump = require('lib.bump')" +} diff --git a/packages/classic.json b/packages/classic.json new file mode 100644 index 00000000..7fe3b2c0 --- /dev/null +++ b/packages/classic.json @@ -0,0 +1,19 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "A tiny class module for Lua. Minimal OOP with inheritance.", + "tags": ["oop", "utilities"], + "homepage": "https://github.com/rxi/classic", + "source": { + "repo": "rxi/classic", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/rxi/classic/master/" + }, + "install": { + "files": [ + { "name": "classic.lua", "sha256": "52827561440f22ff3f34f926b2f4bdc1b74cbbaafa3b3b8252b530b12387d48b", "target": "lib/classic.lua" } + ] + }, + "require": "lib.classic", + "example": "local Object = require('lib.classic')" +} diff --git a/packages/flux.json b/packages/flux.json new file mode 100644 index 00000000..1fbd41ce --- /dev/null +++ b/packages/flux.json @@ -0,0 +1,19 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "A fast, lightweight tweening library for Lua with chainable API and easing functions.", + "tags": ["animation", "tweening"], + "homepage": "https://github.com/rxi/flux", + "source": { + "repo": "rxi/flux", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/rxi/flux/master/" + }, + "install": { + "files": [ + { "name": "flux.lua", "sha256": "11542ab78001f99d94d0b9ca3f99072494e1e4f4ce395db203bf4f7e6f7889d5", "target": "lib/flux.lua" } + ] + }, + "require": "lib.flux", + "example": "local flux = require('lib.flux')" +} diff --git a/packages/hump.json b/packages/hump.json new file mode 100644 index 00000000..9c334255 --- /dev/null +++ b/packages/hump.json @@ -0,0 +1,32 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "Helper utilities for LÖVE: camera, timer, signal, class, vector, gamestate.", + "tags": ["utilities", "camera", "timer", "oop", "math"], + "homepage": "https://github.com/vrld/hump", + "source": { + "repo": "vrld/hump", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + }, + "install": { + "files": [ + { "name": "camera.lua", "sha256": "b5f0c086fc652f7f97eef84b6be358f889ab9b8d28cf7f295b46d48638014099", "target": "lib/hump/camera.lua" }, + { "name": "timer.lua", "sha256": "8931203b027ecce889692e67323e7f333881fb230ed9dd735c785f5e3a7def03", "target": "lib/hump/timer.lua" }, + { "name": "signal.lua", "sha256": "4f311197119177453d8759add39531905da2d3590a7e3a7befc618e46f31b051", "target": "lib/hump/signal.lua" }, + { "name": "class.lua", "sha256": "b0b97bb7cc51b9234dfe3dce0f6da12beaebbb6335eaeaa38de8e537a157cb4e", "target": "lib/hump/class.lua" }, + { "name": "vector.lua", "sha256": "70a4d9437e0c7bdf718160eb4a3464f54705129b854f1c9bd60a5adea8d197cc", "target": "lib/hump/vector.lua" }, + { "name": "gamestate.lua", "sha256": "7e11c9f4909ececc3b61fa4313b001b25f33b5ac36bc96b641aedff0328a4406", "target": "lib/hump/gamestate.lua" } + ] + }, + "subpackages": { + "hump.camera": { "files": ["camera.lua"], "require": "lib.hump.camera" }, + "hump.timer": { "files": ["timer.lua"], "require": "lib.hump.timer" }, + "hump.signal": { "files": ["signal.lua"], "require": "lib.hump.signal" }, + "hump.class": { "files": ["class.lua"], "require": "lib.hump.class" }, + "hump.vector": { "files": ["vector.lua"], "require": "lib.hump.vector" }, + "hump.gamestate": { "files": ["gamestate.lua"], "require": "lib.hump.gamestate" } + }, + "require": "lib.hump", + "example": "local Camera = require('lib.hump.camera')" +} diff --git a/packages/inspect.json b/packages/inspect.json new file mode 100644 index 00000000..b5f48a85 --- /dev/null +++ b/packages/inspect.json @@ -0,0 +1,19 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "Human-readable table serializer and pretty-printer for Lua. Useful for debugging.", + "tags": ["debug", "utilities"], + "homepage": "https://github.com/kikito/inspect.lua", + "source": { + "repo": "kikito/inspect.lua", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/kikito/inspect.lua/master/" + }, + "install": { + "files": [ + { "name": "inspect.lua", "sha256": "656e4be83802750cfe13156810f7990d49893c86518ed5d882f0709d2901dfc5", "target": "lib/inspect.lua" } + ] + }, + "require": "lib.inspect", + "example": "local inspect = require('lib.inspect')" +} diff --git a/packages/lume.json b/packages/lume.json new file mode 100644 index 00000000..589b2928 --- /dev/null +++ b/packages/lume.json @@ -0,0 +1,19 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "A collection of Lua functions for gamedev: math, table manipulation, string utilities, and more.", + "tags": ["utilities", "math"], + "homepage": "https://github.com/rxi/lume", + "source": { + "repo": "rxi/lume", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/rxi/lume/master/" + }, + "install": { + "files": [ + { "name": "lume.lua", "sha256": "fb2856d60eb48516c8aa04de1e8b58b355a56fb7a05fd1170cfe330f8ad69b1a", "target": "lib/lume.lua" } + ] + }, + "require": "lib.lume", + "example": "local lume = require('lib.lume')" +} diff --git a/packages/middleclass.json b/packages/middleclass.json new file mode 100644 index 00000000..c2f06ac4 --- /dev/null +++ b/packages/middleclass.json @@ -0,0 +1,19 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "A simple OOP library for Lua. Supports inheritance, mixins, and interfaces.", + "tags": ["oop", "utilities"], + "homepage": "https://github.com/kikito/middleclass", + "source": { + "repo": "kikito/middleclass", + "tag": "v4.1.1", + "baseUrl": "https://raw.githubusercontent.com/kikito/middleclass/v4.1.1/" + }, + "install": { + "files": [ + { "name": "middleclass.lua", "sha256": "b4f0060e16daa070a0da33b6808f6e4cb9ee819205321d019a8e770af5e77c07", "target": "lib/middleclass.lua" } + ] + }, + "require": "lib.middleclass", + "example": "local Class = require('lib.middleclass')" +} diff --git a/packages/push.json b/packages/push.json new file mode 100644 index 00000000..d0b8b78b --- /dev/null +++ b/packages/push.json @@ -0,0 +1,19 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "Resolution-independence library for LÖVE. Lets you draw at a fixed resolution and scale to any window size.", + "tags": ["rendering", "resolution"], + "homepage": "https://github.com/Ulydev/push", + "source": { + "repo": "Ulydev/push", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/Ulydev/push/master/" + }, + "install": { + "files": [ + { "name": "push.lua", "sha256": "c5702771a3275ce3e3909a8414d1fe76024b30ca3943382d06a9b54f5ee3c517", "target": "lib/push.lua" } + ] + }, + "require": "lib.push", + "example": "local push = require('lib.push')" +} diff --git a/packages/sti.json b/packages/sti.json new file mode 100644 index 00000000..20166453 --- /dev/null +++ b/packages/sti.json @@ -0,0 +1,20 @@ +{ + "type": "love2d-library", + "trust": "known", + "description": "Simple Tiled Implementation — loads and renders Tiled maps (.tmx) in LÖVE.", + "tags": ["maps", "tiled", "tilemaps"], + "homepage": "https://github.com/karai17/Simple-Tiled-Implementation", + "source": { + "repo": "karai17/Simple-Tiled-Implementation", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/karai17/Simple-Tiled-Implementation/master/" + }, + "install": { + "files": [ + { "name": "sti/init.lua", "sha256": "b7542ac156b3c2ee24d256ce260f73dc01ab2d1245c4eb4b14c92fcdf487c814", "target": "lib/sti/init.lua" }, + { "name": "sti/utils.lua", "sha256": "ba8abf5a3f5680c6a020234afc248c4fb8945cda993d4cd82b292510e4dab17c", "target": "lib/sti/utils.lua" } + ] + }, + "require": "lib.sti", + "example": "local sti = require('lib.sti')" +} diff --git a/packages/windfield.json b/packages/windfield.json new file mode 100644 index 00000000..0aea9a9e --- /dev/null +++ b/packages/windfield.json @@ -0,0 +1,19 @@ +{ + "type": "love2d-library", + "trust": "known", + "description": "A physics module for LÖVE that makes Box2D easier to use. Wraps love.physics with a simpler API.", + "tags": ["physics", "box2d"], + "homepage": "https://github.com/SSYGEN/windfield", + "source": { + "repo": "SSYGEN/windfield", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/SSYGEN/windfield/master/" + }, + "install": { + "files": [ + { "name": "windfield/init.lua", "sha256": "687c789dceeba3ebb6f5b152b94e9c085268695ed8cc578780d24ec1696f0388", "target": "lib/windfield/init.lua" } + ] + }, + "require": "lib.windfield", + "example": "local wf = require('lib.windfield')" +} diff --git a/scripts/compute-checksums.mjs b/scripts/compute-checksums.mjs new file mode 100644 index 00000000..d14dad4a --- /dev/null +++ b/scripts/compute-checksums.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/** + * Dev helper: fetch files and compute SHA-256 checksums. + * + * Usage: + * node scripts/compute-checksums.mjs [url2] ... + * node scripts/compute-checksums.mjs --package + * node scripts/compute-checksums.mjs --all + * + * Output is JSON, one entry per file: + * { "url": "...", "sha256": "...", "size": 1234 } + * + * Use this to compute checksums before adding new packages to packages/*.json. + */ + +import { createHash } from "node:crypto"; +import { readFileSync, readdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const packagesDir = join(__dirname, "../packages"); + +async function sha256FromUrl(url) { + const res = await fetch(url); + if (!res.ok) return { url, error: `HTTP ${res.status}` }; + const buf = await res.arrayBuffer(); + const hash = createHash("sha256").update(Buffer.from(buf)).digest("hex"); + return { url, sha256: hash, size: buf.byteLength }; +} + +async function checkPackageFile(filePath) { + const pkg = JSON.parse(readFileSync(filePath, "utf8")); + const baseUrl = pkg.source?.baseUrl; + if (!baseUrl) return []; + + const results = []; + for (const file of pkg.install?.files ?? []) { + const url = baseUrl + file.name; + const result = await sha256FromUrl(url); + result.name = file.name; + result.expected = file.sha256; + result.match = result.sha256 === file.sha256; + results.push(result); + } + return results; +} + +const args = process.argv.slice(2); + +if (args.length === 0) { + console.error("Usage: node scripts/compute-checksums.mjs [url2] ..."); + console.error(" node scripts/compute-checksums.mjs --package "); + console.error(" node scripts/compute-checksums.mjs --all"); + process.exit(1); +} + +if (args[0] === "--all") { + const files = readdirSync(packagesDir).filter((f) => f.endsWith(".json")); + let anyMismatch = false; + for (const file of files) { + const results = await checkPackageFile(join(packagesDir, file)); + console.log(`\n${file}:`); + for (const r of results) { + if (r.error) { + console.log(` ERROR ${r.name}: ${r.error}`); + anyMismatch = true; + } else if (r.match) { + console.log(` OK ${r.name} ${r.sha256}`); + } else { + console.log(` FAIL ${r.name}`); + console.log(` expected: ${r.expected ?? "(none)"}`); + console.log(` got: ${r.sha256}`); + anyMismatch = true; + } + } + } + process.exit(anyMismatch ? 1 : 0); +} else if (args[0] === "--package") { + const results = await checkPackageFile(args[1]); + results.forEach((r) => console.log(JSON.stringify(r, null, 2))); +} else { + const results = await Promise.all(args.map(sha256FromUrl)); + results.forEach((r) => console.log(JSON.stringify(r, null, 2))); +} diff --git a/scripts/generate-registry.mjs b/scripts/generate-registry.mjs new file mode 100644 index 00000000..ebc1b0bb --- /dev/null +++ b/scripts/generate-registry.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +/** + * Compose cli/src/generated/registry.json from packages/*.json. + * + * Usage: + * node scripts/generate-registry.mjs + * + * Run this before publishing a new CLI release or when adding/updating packages. + * The Husky pre-commit hook runs this automatically. + */ + +import { readFileSync, writeFileSync, readdirSync } from "node:fs"; +import { join, basename, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const packagesDir = join(__dirname, "../packages"); +const outputPath = join(__dirname, "../cli/src/generated/registry.json"); + +const packageFiles = readdirSync(packagesDir) + .filter((f) => f.endsWith(".json")) + .sort(); + +const packages = {}; +let warnings = 0; + +for (const file of packageFiles) { + const id = basename(file, ".json"); + const pkg = JSON.parse(readFileSync(join(packagesDir, file), "utf8")); + + // Validate required fields + if (!pkg.trust) { + console.warn(` WARN ${id}: missing "trust" field`); + warnings++; + } + if (!pkg.source?.baseUrl) { + console.warn(` WARN ${id}: missing source.baseUrl`); + warnings++; + } + + // Warn about missing or placeholder checksums + for (const f of pkg.install?.files ?? []) { + if (!f.sha256 || f.sha256 === "TODO") { + console.warn(` WARN ${id}/${f.name}: missing sha256 — run scripts/compute-checksums.mjs --all`); + warnings++; + } + } + + // Register subpackages as top-level aliases pointing to parent + if (pkg.subpackages) { + for (const [subId, sub] of Object.entries(pkg.subpackages)) { + packages[subId] = { + parent: id, + type: pkg.type, + trust: pkg.trust, + description: sub.description ?? `${id} — ${subId.split(".").pop()} module`, + tags: pkg.tags ?? [], + homepage: pkg.homepage, + source: pkg.source, + install: { + files: (pkg.install?.files ?? []).filter((f) => + sub.files.includes(f.name) + ), + }, + require: sub.require, + example: sub.example ?? `local ${subId.split(".").pop()} = require('${sub.require}')`, + }; + } + } + + packages[id] = { + type: pkg.type, + trust: pkg.trust, + description: pkg.description, + tags: pkg.tags ?? [], + homepage: pkg.homepage, + source: pkg.source, + install: pkg.install, + subpackages: pkg.subpackages ? Object.keys(pkg.subpackages) : undefined, + require: pkg.require, + example: pkg.example, + }; +} + +const registry = { + version: 1, + updatedAt: new Date().toISOString().slice(0, 10), + packages, +}; + +writeFileSync(outputPath, JSON.stringify(registry, null, 2) + "\n"); +console.log(`Generated registry with ${packageFiles.length} packages (${Object.keys(packages).length} entries including subpackages).`); +if (warnings > 0) { + console.warn(`${warnings} warning(s) — see above.`); + process.exit(1); +} diff --git a/src-lua/plugins/README.md b/src-lua/plugins/README.md new file mode 100644 index 00000000..78e68b81 --- /dev/null +++ b/src-lua/plugins/README.md @@ -0,0 +1,777 @@ +# Feather Plugins + +Feather plugins are Lua modules that extend the functionality of the debugger. Plugins use a **server-driven UI** approach: they declare their actions and configuration in Lua via `getConfig()`, and the Feather desktop app renders the UI automatically — no TypeScript code needed. + +## Creating a plugin + +To create a plugin, extend the `FeatherPlugin` base class and implement the lifecycle methods you need: + +- `init(config)`: Called when the plugin is initialized. +- `update(dt, feather)`: Called every frame. Wrapped in `pcall` for error isolation — a crashing plugin won't take down the game. +- `onerror(msg, feather)`: Called when a game error occurs. +- `handleRequest(request, feather)`: Called on each push cycle to get data for the desktop (GET). Return data to be sent, or `nil` to skip. +- `handleActionRequest(request, feather)`: Called when a plugin action is triggered from the desktop (POST). Returns `(data, err)` tuple. +- `handleActionCancel(request, feather)`: Called when an in-flight action is cancelled from the desktop (DELETE). +- `handleParamsUpdate(request, feather)`: Called when plugin parameters are updated from the desktop (PUT). +- `finish(feather)`: Called when the game disconnects. +- `getConfig()`: Returns the plugin configuration including server-driven UI actions. Sent to the desktop on connect. + +To help with the implementation, Feather provides the `FeatherPlugin` class, which you can extend to create your plugin. + +```lua +local FeatherPlugin = require(FEATHER_PATH .. ".core.base") + +local MyPlugin = Class({ + __includes = FeatherPlugin, +}) + +function MyPlugin:init(config) + -- config.options contains the options passed to createPlugin + -- config.logger and config.observer are always available +end + +function MyPlugin:update(dt, feather) + -- Called every frame (error-isolated via pcall) +end + +function MyPlugin:onerror(msg, feather) + -- Called when a game error occurs +end + +function MyPlugin:handleRequest(request, feather) + -- Return data to push to desktop each cycle + return { + type = "gallery", -- content type: "gallery", etc. + data = { ... }, + loading = false, + } +end + +function MyPlugin:handleActionRequest(request, feather) + local action = request.params.action + -- Handle the action, return (data, err) tuple + return "success" +end + +function MyPlugin:handleActionCancel(request, feather) + -- Cancel an in-flight action (e.g. stop GIF recording) +end + +function MyPlugin:handleParamsUpdate(request, feather) + -- Handle parameter changes from desktop UI + return {} +end + +function MyPlugin:isSupported(version) + return version > 0 +end + +function MyPlugin:finish(feather) + -- Clean up resources +end + +function MyPlugin:getConfig() + return { + type = "my-plugin", + color = "#ff0000", + icon = "puzzle", -- Lucide icon name + tabName = "My Plugin", -- Display name in sidebar + actions = { + { label = "Do Thing", key = "do-thing", icon = "play", type = "button" }, + { label = "Count", key = "count", icon = "hash", type = "input", value = 10, props = { type = "number", min = 1, max = 100 } }, + { label = "Enabled", key = "enabled", icon = "toggle-left", type = "checkbox", value = true }, + }, + } +end + +return MyPlugin +``` + +### manifest.lua + +Every plugin that should be auto-discovered by `feather.auto` needs a `manifest.lua` at the root of its directory. Without it the directory is silently skipped. + +```lua +-- plugins/my-plugin/manifest.lua +return { + id = "my-plugin", -- must match the directory name and createPlugin id + name = "My Plugin", -- display name (for tooling / future UI) + description = "What this plugin does", + version = "1.0.0", + capabilities = { "filesystem" }, -- tokens this plugin requires (see Capabilities) + opts = { speed = 1.0 }, -- default options merged with user pluginOptions + optIn = false, -- true = not registered unless in config.include + disabled = true, -- true = registered but inactive by default +} +``` + +| Field | Type | Description | +| -------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | `string` | Unique plugin identifier. Must match the directory name and the `id` passed to `createPlugin`. | +| `name` | `string` | Human-readable display name. | +| `description` | `string` | Short description shown in tooling. | +| `version` | `string` | Semver string. | +| `capabilities` | `string[]` | Capability tokens this plugin needs (e.g. `"filesystem"`, `"network"`). Checked against the user's `config.capabilities` at load time. | +| `opts` | `table` | Default plugin options. Merged with (and overridden by) the user's `config.pluginOptions[id]`. | +| `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`. | + +### 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. + +```lua +function MyPlugin:onDraw() + -- Runs after love.draw(); use love.graphics here +end + +function MyPlugin:onKeypressed(key, scancode, isrepeat) + if key == "f5" then self:doSomething() end +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:onTouchpressed(id, x, y, dx, dy, pressure) end +function MyPlugin:onTouchreleased(id, x, y, dx, dy, pressure) end +function MyPlugin:onJoystickpressed(joystick, button) end +function MyPlugin:onJoystickreleased(joystick, button) 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. + +### Capabilities + +Declare the system access your plugin needs in `manifest.lua`. At load time, `FeatherPluginManager` checks these against the user's `config.capabilities` allowlist and logs a warning for any undeclared capability. Loading is not blocked — warnings are informational. + +Available capability tokens: + +| Token | Meaning | +| -------------- | -------------------------------------------- | +| `"filesystem"` | Reads or writes via `love.filesystem` | +| `"network"` | Uses LuaSocket / HTTP | +| `"audio"` | Accesses `love.audio` | +| `"physics"` | Accesses `love.physics` | +| `"input"` | Hooks input callbacks (`onKeypressed`, etc.) | +| `"draw"` | Hooks `onDraw` to render overlays | + +Users can restrict which capabilities are allowed: + +```lua +require("feather.auto").setup({ + -- Only allow filesystem and draw; plugins requesting anything else get a warning + capabilities = { "filesystem", "draw" }, +}) +``` + +Pass `"all"` (the default) to skip capability checking entirely. + +### API Compatibility + +Feather has a plugin UI/protocol API number. Plugins can declare which desktop API versions they support in `manifest.lua`; the runtime forwards that metadata to the desktop and disables incompatible plugins instead of letting them fail later. + +The current Lua runtime exposes the number as `require("feather").API`. + +```lua +-- plugins/my-plugin/manifest.lua +return { + id = "my-plugin", + name = "My Plugin", + version = "1.0.0", + + -- Exact API + api = 5, + + -- Or a range + -- minApi = 5, + -- maxApi = 6, +} +``` + +For manually registered plugins, pass the same metadata through `createPlugin`: + +```lua +local plugin = FeatherPluginManager.createPlugin(MyPlugin, "my-plugin", {}, false, {}, { + minApi = 5, + maxApi = 6, + name = "My Plugin", + version = "1.0.0", +}) +``` + +> [!IMPORTANT] +> Declare an API range when your plugin depends on specific Feather UI nodes, binary attachments, action semantics, or table/gallery formats. Incompatible plugins are shown in the desktop app with an API mismatch message and cannot be enabled. + +### Plugin lifecycle + +The FeatherPluginManager handles the lifecycle of each plugin. Each plugin's `update()` is wrapped in `pcall` for error isolation — if a plugin crashes, it won't affect the game or other plugins. After 10 consecutive errors, a plugin is automatically disabled. Use `pluginManager:enablePlugin(id)` to re-enable it. + +#### 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. +- `getConfig()`: Returns the plugin configuration (type, icon, tab name, actions). Sent to the desktop app on connect. + +#### Data Push (every cycle) + +- `handleRequest(request, feather)`: Called on each push cycle at the configured `sampleRate`. Return data to be sent to the desktop, or `nil` to skip. The return value should include `type` (content type), `data`, and optionally `loading` and `persist`. + +#### Action Handling (from desktop UI) + +- `handleActionRequest(request, feather)`: Called when a button action is triggered from the desktop. `request.params.action` contains the action key. Returns a `(data, err)` tuple — errors are sent back to the desktop as toast notifications. +- `handleActionCancel(request, feather)`: Called when the user cancels an in-flight action from the desktop (e.g. stopping a GIF recording mid-way). +- `handleParamsUpdate(request, feather)`: Called when the user changes an input or checkbox value in the desktop UI. `request.params` contains the updated key-value pairs. + +#### Update + +- `update(dt, feather)`: Called every frame, error-isolated via `pcall`. Use for ongoing work like batch encoding, timers, or frame capture. + +#### Error Handling + +- `onerror(msg, feather)`: Called when a game-level error occurs (not plugin errors). Use to capture state or clean up. + +#### Finish + +- `finish(feather)`: Called when the game disconnects or shuts down. + +## Registering a plugin + +To register a plugin, you need to create an instance of it and pass it to the FeatherPluginManager. The FeatherPluginManager will handle the lifecycle of the plugin and call the appropriate functions. + +```lua +local MyPlugin = require("my-plugin") + +local plugin = FeatherPluginManager.createPlugin(MyPlugin, "my-plugin", { + -- Plugin options +}) +``` + +## Plugin options + +The plugin options are passed to the plugin's constructor. Here's an example of a plugin with options: + +```lua +local MyPlugin = require("my-plugin") + +local plugin = FeatherPluginManager.createPlugin(MyPlugin, "my-plugin", { + option1 = "value1", + option2 = "value2", +}) +``` + +### Feather Options + +By default, every plugin has the following properties available: + +- `self.logger`: A logger that logs messages to the Feather logger. +- `self.observer`: A logger that logs messages to the Feather observer. + +## Plugin configuration + +Plugins return configuration via `getConfig()` that drives the desktop UI. This uses a **server-driven UI** pattern — the Lua side declares what controls are needed, and the desktop renders them as native components. + +### Config fields + +| Field | Type | Description | +| --------- | -------- | ---------------------------------------------------------------- | +| `type` | `string` | Unique plugin type identifier. | +| `color` | `string` | Hex color for the plugin's sidebar icon. | +| `icon` | `string` | [Lucide](https://lucide.dev/icons) icon name. | +| `tabName` | `string` | Display name shown in the sidebar navigation. | +| `actions` | `table` | Array of action definitions (see [Action types](#action-types)). | + +### Action types + +Actions define the interactive controls rendered in the plugin page. Each action requires `label`, `key`, `icon`, and `type`. Some types support an optional `value` (default/initial value) and `props` (extra configuration). + +| Field | Type | Required | Description | +| ------- | -------- | -------- | ----------------------------------------------------------------------------- | +| `label` | `string` | Yes | Display text for the control. | +| `key` | `string` | Yes | Unique identifier. Sent as the action/param key. | +| `icon` | `string` | Yes | [Lucide](https://lucide.dev/icons) icon name. | +| `type` | `string` | Yes | One of: `button`, `input`, `checkbox`, `select`, `vector`, `file`. | +| `value` | `any` | No | Default value (string, number, or boolean). | +| `props` | `table` | No | Extra configuration — varies by type (see below). | +| `group` | `string` | No | Group name for card layout (see [Grouped card layout](#grouped-card-layout)). | + +#### `type = "button"` + +Renders a button. Clicking sends `cmd:plugin:action` with `params.action` set to the action `key`. The plugin's `handleActionRequest` receives it. + +```lua +{ label = "Capture", key = "screenshot", icon = "camera", type = "button" } +``` + +#### `type = "input"` + +Renders a text input field. Value changes send `cmd:plugin:params`. The plugin's `handleParamsUpdate` receives the key-value pair. + +```lua +{ label = "FPS", key = "fps", icon = "gauge", type = "input", value = 30, + props = { type = "number", min = 5, max = 60, step = 1 } } +``` + +**Supported `props`:** + +| Prop | Type | Description | +| ------------- | --------- | ------------------------------------------------------------------------------------------ | +| `type` | `string` | HTML input type (`"number"`, `"text"`, etc.). Use `"number"` to restrict to numeric entry. | +| `min` | `number` | Minimum value (for `type = "number"`). | +| `max` | `number` | Maximum value (for `type = "number"`). | +| `step` | `number` | Step increment (for `type = "number"`). | +| `placeholder` | `string` | Placeholder text. | +| `disabled` | `boolean` | Disable the input. | +| `readOnly` | `boolean` | Make the input read-only. | + +#### `type = "checkbox"` + +Renders a checkbox. Toggle sends `cmd:plugin:params` with `"true"` or `"false"` as the value. + +```lua +{ label = "Persist", key = "persist", icon = "save", type = "checkbox", value = true } +``` + +#### `type = "select"` + +Renders a dropdown select menu. Value changes send `cmd:plugin:params`. + +```lua +{ label = "Mode", key = "mode", icon = "settings", type = "select", value = "normal", + props = { options = { "normal", "additive", "multiply" } } } +``` + +**Supported `props`:** + +| Prop | Type | Description | +| --------- | ------- | -------------------------------------- | +| `options` | `table` | Array of string values to choose from. | + +#### `type = "vector"` + +Renders a multi-field input for comma-separated values (e.g. coordinates, colors). Each value gets its own labeled input field. Value changes send the joined comma-separated string via `cmd:plugin:params`. + +```lua +-- 3 labeled fields: Start, Mid, End +{ label = "Sizes", key = "sizes", icon = "ruler", type = "vector", + value = "1.0, 0.5, 0.0", + props = { labels = { "Start", "Mid", "End" }, type = "number", min = 0, max = 10, step = 0.01 } } + +-- Repeating labels cycle through the array (e.g. RGBA groups) +{ label = "Colors", key = "colors", icon = "palette", type = "vector", + value = "1, 0.5, 0, 1, 0, 0.2, 0.8, 0", + props = { labels = { "R", "G", "B", "A" }, repeating = true, type = "number", min = 0, max = 1, step = 0.01 } } +``` + +**Supported `props`:** + +| Prop | Type | Description | +| ----------- | --------- | --------------------------------------------------------------------------------------------------------------- | +| `labels` | `table` | Array of labels for each field (e.g. `{"X", "Y"}`, `{"R", "G", "B", "A"}`). | +| `repeating` | `boolean` | When `true`, labels cycle through the array. Groups values into rows of `#labels` size (e.g. RGBA → rows of 4). | +| `type` | `string` | HTML input type for each field (default: `"number"`). | +| `min` | `number` | Minimum value per field. | +| `max` | `number` | Maximum value per field. | +| `step` | `number` | Step increment per field. | + +#### `type = "file"` + +Renders a button that opens a native file picker dialog. The selected file's contents are sent to `handleActionRequest`. + +```lua +{ label = "Import", key = "import", icon = "upload", type = "file", + props = { filters = { { name = "Lua files", extensions = { "lua" } } } } } +``` + +**Supported `props`:** + +| Prop | Type | Description | +| --------- | ------- | -------------------------------------------------------------------- | +| `filters` | `table` | Array of file filters: `{ name = "Label", extensions = { "ext" } }`. | + +### Grouped card layout + +By default, actions render in a horizontal toolbar at the top of the plugin page. For plugins with many controls, you can organize actions into **named groups** using the `group` field. Grouped actions render in a responsive card grid below the toolbar. + +```lua +actions = { + -- These appear in the toolbar (no group) + { label = "Export", key = "export", icon = "download", type = "button" }, + { label = "Reset", key = "reset", icon = "rotate-ccw", type = "button" }, + + -- These appear in a "Speed" card + { label = "Min Speed", key = "speedMin", icon = "gauge", type = "input", value = 100, + props = { type = "number", min = 0, max = 1000 }, group = "Speed" }, + { label = "Max Speed", key = "speedMax", icon = "gauge", type = "input", value = 500, + props = { type = "number", min = 0, max = 1000 }, group = "Speed" }, + + -- These appear in a "Visual" card + { label = "Sizes", key = "sizes", icon = "ruler", type = "vector", value = "1, 0.5, 0", + props = { labels = { "Start", "Mid", "End" }, type = "number" }, group = "Visual" }, + { label = "Colors", key = "colors", icon = "palette", type = "vector", + value = "1, 0, 0, 1", + props = { labels = { "R", "G", "B", "A" }, repeating = true, type = "number" }, group = "Visual" }, +} +``` + +**Layout behavior:** + +- Actions **without** `group` render in the toolbar (top row, horizontal). +- Actions **with** `group` render in a responsive card grid below the toolbar. +- Cards are ordered by the first appearance of each group name. +- The grid is responsive: 1 column on small screens, up to 4 on wide screens. +- All action types (`button`, `input`, `checkbox`, `select`, `vector`, `file`) work inside cards. Buttons and selects expand to full width within a card. + +### Action response types + +When a plugin's `handleActionRequest` returns data, the desktop app handles it based on the response shape: + +#### Error response + +Return an error string as the second value of the `(data, err)` tuple. The error is shown as a toast notification. + +```lua +function MyPlugin:handleActionRequest(request, feather) + return nil, "Something went wrong" +end +``` + +#### Download response + +Return a `download` table to trigger a native save-file dialog on the desktop. + +```lua +function MyPlugin:handleActionRequest(request, feather) + return { + download = { + filename = "export.lua", -- suggested filename + content = "-- exported", -- file content (string) + extension = "lua", -- file extension for the filter + }, + } +end +``` + +#### Clipboard response + +Return a `clipboard` string to copy text to the user's clipboard with a success toast. + +```lua +function MyPlugin:handleActionRequest(request, feather) + return { + clipboard = "local ps = love.graphics.newParticleSystem(image, 100)", + } +end +``` + +### Content types + +The `handleRequest` method returns data that the desktop renders below the actions. The `type` field determines the layout: + +| Type | Description | +| ---------- | ------------------------------------------------------------------------------ | +| `ui` | Declarative Feather UI tree rendered by the desktop. | +| `gallery` | Image grid with download buttons. Supports PNG screenshots and GIF animations. | +| `table` | Data table with sortable columns. | +| `tree` | Collapsible tree view with properties on each node. | +| `timeline` | Chronological event timeline with categories and optional screenshots. | + +#### `ui` + +Lua plugins can describe UI declaratively with `feather.ui` primitives. React is only the renderer; plugins should send stable Feather UI nodes and action keys, not raw React components or JavaScript. + +For the full schema, supported nodes, and complete examples, see [Plugin UI](plugin-ui.md). + +```lua +function MyPlugin:handleRequest(request, feather) + return feather.ui.render( + feather.ui.panel({ + title = "Player Stats", + + feather.ui.row({ + feather.ui.badge({ value = "HP" }), + feather.ui.text({ value = tostring(player.health) }), + }), + + feather.ui.button({ + label = "Kill Player", + action = "kill-player", + }), + }) + ) +end + +function MyPlugin:handleActionRequest(request) + if request.params.action == "kill-player" then + player.health = 0 + end +end +``` + +Supported node types in the first schema pass: `panel`, `row`, `column`, `tabs`, `tab`, `text`, `button`, `input`, `textarea`, `checkbox`, `switch`, `select`, `badge`, `stat`, `progress`, `alert`, `list`, `link`, `separator`, `image`, `code`, `table`, `timeline`, and `inspector`. + +Buttons use `action = "some-key"` (or `onClick = "some-key"`) and are routed through the existing `handleActionRequest()` path. Function callbacks are intentionally not serialized. + +#### `gallery` + +```lua +return { + type = "gallery", + data = { + { type = "image", name = "shot1", downloadable = true, metadata = { type = "png", src = base64data, width = 800, height = 600 } }, + }, + loading = false, + persist = true, -- merge with previous data instead of replacing +} +``` + +For large images or binary payloads, prefer Feather's hybrid protocol instead of embedding base64 in JSON. Call `feather:attachBinary(mime, bytes)` and place the returned `src` and `binary` fields in your normal plugin data. Feather sends the JSON first, then streams the bytes on a binary WebSocket frame; the desktop swaps `feather-binary:` into a local blob URL automatically. + +```lua +function MyPlugin:handleRequest(request, feather) + local pngBytes = self:getLatestPreviewPng() + local preview = feather:attachBinary("image/png", pngBytes) + + return { + type = "gallery", + data = { + { + type = "image", + name = "preview.png", + downloadable = true, + metadata = { + type = "png", + src = preview.src, + binary = preview.binary, + width = 800, + height = 600, + }, + }, + }, + loading = false, + } +end +``` + +For GIF-style frame lists, put the placeholder URLs in `src` and the matching binary refs in `binary` using the same order: + +```lua +metadata = { + type = "gif", + src = { frame1.src, frame2.src }, + binary = { frame1.binary, frame2.binary }, + width = 800, + height = 600, + fps = 30, +} +``` + +The same works in table rows. The desktop resolves `src` to a blob URL and renders it as a download/open control when that column is visible; the `binary` field can stay hidden metadata if you do not add a matching column. + +```lua +function MyPlugin:handleRequest(request, feather) + local file = feather:attachBinary("application/octet-stream", bytes) + + return { + type = "table", + columns = { + { key = "name", label = "Name" }, + { key = "size", label = "Size" }, + { key = "src", label = "File" }, + }, + data = { + { name = "dump.bin", size = tostring(#bytes), src = file.src, binary = file.binary }, + }, + loading = false, + } +end +``` + +#### `table` + +```lua +return { + type = "table", + columns = { + { key = "name", label = "Name" }, + { key = "value", label = "Value" }, + }, + data = { + { name = "FPS", value = "60" }, + { name = "Memory", value = "12.5 MB" }, + }, + loading = false, +} +``` + +#### `tree` + +```lua +return { + type = "tree", + nodes = { + { + name = "Player", + properties = { { key = "health", value = "100" }, { key = "x", value = "320" } }, + children = { + { name = "Inventory", properties = { { key = "items", value = "5" } } }, + }, + }, + }, + sources = { "ECS", "Scene" }, -- data source names for the source selector + selectedSource = 0, -- currently selected source index + searchFilter = "", -- current search filter text + loading = false, +} +``` + +#### `timeline` + +```lua +return { + type = "timeline", + items = { + { id = 1, label = "Game started", category = "lifecycle", time = 0, gameTime = "0:00" }, + { id = 2, label = "Level loaded", category = "lifecycle", color = "#4ade80", time = 1.5, gameTime = "0:01" }, + }, + categories = { "lifecycle", "error" }, + loading = false, +} +``` + +## Adding plugins to `feather.auto` + +`feather.auto` registers all built-in plugins for you. You can extend it with your own plugins through the `plugins` key in `setup()`, without giving up the zero-config defaults. + +### Append a custom plugin + +Pass additional plugins via `config.plugins`. They are appended after all built-in plugins: + +```lua +local FeatherPluginManager = require("feather.plugin_manager") +local MyPlugin = require("my-plugin") + +require("feather.auto").setup({ + plugins = { + FeatherPluginManager.createPlugin(MyPlugin, "my-plugin", { + option1 = "value1", + }), + }, +}) +``` + +`DEBUGGER` is created as usual, and your plugin appears in the sidebar alongside the built-in ones. + +### Start a custom plugin disabled + +Pass `true` as the fourth argument to `createPlugin`. The plugin is registered and visible in the desktop UI, but starts inactive — users can enable it from the desktop without restarting. + +```lua +FeatherPluginManager.createPlugin(MyPlugin, "my-plugin", { option1 = "value1" }, true) +``` + +### Force-enable a built-in plugin + +Built-in plugins that are `optIn` (like `console`) or start `disabled` are not active by default. Add their IDs to `config.include` to force-enable them: + +```lua +require("feather.auto").setup({ + include = { "console", "physics-debug" }, +}) +``` + +`include` both registers `optIn` plugins (which would otherwise be skipped entirely) and activates `disabled` ones. + +### Exclude a built-in plugin + +Remove a built-in plugin entirely with `config.exclude`. It won't be registered at all: + +```lua +require("feather.auto").setup({ + exclude = { "network-inspector", "memory-snapshot" }, +}) +``` + +### Override built-in plugin options + +Pass per-plugin option overrides via `config.pluginOptions`, keyed by plugin ID. Options are merged over the built-in defaults: + +```lua +require("feather.auto").setup({ + pluginOptions = { + screenshots = { fps = 60, gifDuration = 10 }, + ["memory-snapshot"] = { autoInterval = 5 }, + }, +}) +``` + +### Full example + +```lua +local FeatherPluginManager = require("feather.plugin_manager") +local MyPlugin = require("my-plugin") +local AnotherPlugin = require("another-plugin") + +require("feather.auto").setup({ + sessionName = "My Game", + -- Force-enable built-in opt-in plugins + include = { "console" }, + -- Remove plugins you don't need + exclude = { "hump.signal", "lua-state-machine" }, + -- Override options for specific built-in plugins + pluginOptions = { + screenshots = { fps = 60 }, + }, + -- Append your own plugins + plugins = { + FeatherPluginManager.createPlugin(MyPlugin, "my-plugin", { debug = true }), + FeatherPluginManager.createPlugin(AnotherPlugin, "another-plugin", {}, true), -- starts disabled + }, +}) +``` + +## Using Plugin Actions + +Plugins can be triggered from game code at runtime using `debugger:action()`: + +```lua +local debugger = FeatherDebugger({ + debug = true, + plugins = { + FeatherPluginManager.createPlugin(ScreenshotPlugin, "screenshots", { + screenshotDirectory = "screenshots", + fps = 30, + gifDuration = 5, + }), + }, +}) + +function love.keypressed(key) + if key == "f1" then + debugger:action("screenshots", "screenshot", {}) + elseif key == "f2" then + debugger:action("screenshots", "gif", { duration = 3, fps = 30 }) + end +end +``` + +Actions can also be triggered from the desktop UI — button actions in `getConfig()` send `cmd:plugin:action` messages over WebSocket, and the response (success/error) is shown as a toast notification in the desktop app. + +## Plugin examples + +Here are some examples of Feather plugins: + +- [Hump's Signal Plugin](../src-lua/plugins/hump/signal/README.md) +- [Lua State Machine Plugin](../src-lua/plugins/lua-state-machine/README.md) +- [Screenshot Plugin](../src-lua/plugins/screenshots/README.md) +- [Particle Editor Plugin](../src-lua/plugins/particle-editor/README.md) +- [Audio Debug Plugin](../src-lua/plugins/audio-debug/README.md) +- [Coroutine Monitor Plugin](../src-lua/plugins/coroutine-monitor/README.md) +- [Collision Debug Plugin](../src-lua/plugins/collision-debug/README.md) +- [Animation Inspector Plugin](../src-lua/plugins/animation-inspector/README.md) +- [Timer Inspector Plugin](../src-lua/plugins/timer-inspector/README.md) + +## Plugin documentation + +Each plugin should have a README file that explains how to use it and provides examples. diff --git a/src-lua/plugins/hot-reload/README.md b/src-lua/plugins/hot-reload/README.md index ec665753..c3102714 100644 --- a/src-lua/plugins/hot-reload/README.md +++ b/src-lua/plugins/hot-reload/README.md @@ -1,21 +1,56 @@ -# Hot Reload Plugin +# Hot Reload -Development-only Lua module hot reload over Feather's WebSocket connection. +Feather Hot Reload lets the desktop app send Lua source for a specific module over the existing WebSocket connection, then replace that module in the running game. > [!WARNING] -> This plugin is controlled remote code execution. Only enable it in trusted development sessions, keep `allow` narrow, and never ship it in production builds. +> Hot reload is controlled remote code execution. Keep it disabled by default, use a narrow allowlist, and never enable it in production builds. -## Auto Setup +> [!CAUTION] +> Do not copy the example config into a real project without changing the allowlist. Every allowed module can be replaced by whoever can send commands to that Feather session. + +## Before Enabling + +Only enable hot reload when all of this is true: + +- You are running a trusted development build. +- The Feather desktop app and game are on a trusted local machine or trusted LAN. +- `allow` names only the modules you intend to edit. +- `persistToDisk` is off unless you deliberately want patches written into the LÖVE save directory. +- You have explicitly installed and included the opt-in `hot-reload` plugin. +- You have a clear release path where the `hot-reload` plugin is excluded or the Feather integration is removed. + +## Enable It + +Configure it from `feather.config.lua`: ```lua -require("feather.auto").setup({ +return { + debug = true, + + -- Hot reload is an opt-in plugin. Without this include, Feather ignores + -- cmd:hot_reload messages even if debugger.hotReload is configured. include = { "hot-reload" }, + debugger = { enabled = true, hotReload = { + -- Development-only remote code execution. Keep this false unless you + -- understand that Feather can replace allowlisted Lua modules at runtime. enabled = true, - allow = { "game.player" }, - deny = { "main", "conf", "feather.*" }, + + -- Prefer exact modules. Avoid broad patterns unless you really mean it. + allow = { + "game.player", + "game.enemy", + "game.systems.*", + }, + + deny = { + "main", + "conf", + "feather.*", + }, + persistToDisk = false, clearOnBoot = false, requireLocalNetwork = true, @@ -23,13 +58,12 @@ require("feather.auto").setup({ toastDuration = 2.5, }, }, -}) +} ``` -## Manual Setup +For manual plugin registration, require and register the plugin explicitly: ```lua -local FeatherDebugger = require("feather") local FeatherPluginManager = require("feather.plugin_manager") local HotReloadPlugin = require("plugins.hot-reload") @@ -41,9 +75,89 @@ DEBUGGER = FeatherDebugger({ enabled = true, allow = { "game.player" }, deny = { "main", "conf", "feather.*" }, - showOverlay = true, - toastDuration = 2.5, }), }, }) ``` + +> [!IMPORTANT] +> Installing Feather core alone does not enable hot reload. The `hot-reload` plugin must be installed and registered, which keeps the remote-code-loading path out of normal sessions. + +| Field | Default | Description | +| --------------------- | ------- | ----------------------------------------------------------------------------- | +| `enabled` | `false` | Enables hot reload commands for the session. | +| `allow` | `{}` | Module allowlist. Supports exact names and `prefix.*` patterns. | +| `deny` | `{}` | Explicit denylist. `main`, `conf`, and `feather.*` are always denied. | +| `persistToDisk` | `false` | Writes hot patches to `.feather/hot/.lua` in LÖVE's save directory. | +| `clearOnBoot` | `false` | Clears persisted hot patches when Feather starts. | +| `requireLocalNetwork` | `true` | Accepts reload commands only when the configured Feather host is local/LAN. | +| `showOverlay` | `true` | Draws a temporary in-game toast when hot reload succeeds, fails, or restores. | +| `toastDuration` | `2.5` | Seconds the in-game hot reload toast remains visible. | + +> [!IMPORTANT] +> `allow = { "game.*" }` is convenient, but a smaller list is safer. Prefer only the modules you are actively editing. + +> [!WARNING] +> `persistToDisk = true` writes patched Lua source into `.feather/hot` in the save directory. That is useful for mobile development, but it also means a patch can survive an app restart until restored or cleared. + +## Use It + +1. Start the game with Feather enabled. +2. Open **Debugger**. +3. Open the game source folder if Feather cannot auto-detect it. +4. Select a `.lua` file from the file tree. +5. Press **Reload**. + +The **Watch** toggle polls the selected file and reloads it when the source changes. It intentionally watches only the selected module so changes are explicit and easy to reason about. + +> [!TIP] +> A file path like `game/player.lua` maps to the Lua module `game.player`. A folder entry like `game/systems/init.lua` maps to `game.systems`. + +## Example + +From the repository root: + +```bash +love src-lua --hot-reload +``` + +Then open **Debugger**, select `example/hot_reload/gameplay.lua`, edit the file, and press **Reload**. You can also enable **Watch** to reload the selected module whenever the file changes. + +> [!NOTE] +> The example allowlist contains exactly one module: `example.hot_reload.gameplay`. That narrow shape is intentional. + +## Rollback + +Before replacing a module, Feather keeps the original `package.loaded[module]` value. Press **Restore** in the Debugger toolbar to restore all modules replaced by hot reload and clear persisted patches. + +If a new module has a syntax error, runtime error, or failing migration hook, Feather restores the previous module immediately and reports the error in the app. + +## Migration Hook + +Reloaded modules can expose `__feather_reload`: + +```lua +local Player = {} + +function Player.__feather_reload(newModule, oldModule) + newModule.instances = oldModule and oldModule.instances or {} +end + +return Player +``` + +Use this for state migration, metatable updates, cache rebuilds, or system rebinding. + +## Mobile And Remote Devices + +Hot reload works on mobile and handheld devices because Feather sends source code through WebSocket. When `persistToDisk = true`, patched modules are written to LÖVE's save directory using `love.filesystem.write`. + +> [!NOTE] +> This does not modify the application bundle. Persisted patches live under `.feather/hot` in the save directory and are meant for development sessions only. + +> [!IMPORTANT] +> For mobile, Steam Deck, or second-computer workflows, only use hot reload on a trusted private network. Do not expose Feather's WebSocket port to public or shared networks. + +## Limits + +Hot reload does not support native libraries, arbitrary filesystem writes, unrestricted Lua execution, reloading Feather internals, or replacing `main.lua` / `conf.lua`. diff --git a/docs/plugin-ui.md b/src-lua/plugins/plugin-ui.md similarity index 100% rename from docs/plugin-ui.md rename to src-lua/plugins/plugin-ui.md