From b0b89cbbe69eccc6ff2d854ead5766a10d2c64a3 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Tue, 19 May 2026 12:21:51 -0400 Subject: [PATCH 01/28] vscode-extension: create extension --- .github/workflows/lint.yml | 4 + .github/workflows/vscode-extension.yml | 93 + .gitignore | 9 +- .husky/commit-msg | 4 +- .husky/pre-commit | 2 +- .vscode/launch.json | 13 + .vscode/tasks.json | 12 + README.md | 12 +- cli/.gitignore | 1 + cli/package.json | 1 + cli/src/commands/config.ts | 124 + cli/src/commands/run.ts | 40 +- cli/src/index.ts | 1164 ++--- cli/src/lib/paths.ts | 9 +- cli/stubs/react-devtools-core/index.js | 2 + cli/stubs/react-devtools-core/package.json | 1 + cli/test/commands/config.test.mjs | 76 + package-lock.json | 4349 +++++++++++++++++- package.json | 9 +- scripts/set-version.sh | 5 + vscode-extension/.vscodeignore | 9 + vscode-extension/package.json | 177 + vscode-extension/resources/feather-clear.svg | 25 + vscode-extension/resources/feather.svg | 26 + vscode-extension/scripts/prepare.mjs | 40 + vscode-extension/src/catalog.ts | 65 + vscode-extension/src/cli.ts | 49 + vscode-extension/src/command.ts | 15 + vscode-extension/src/extension.ts | 292 ++ vscode-extension/src/featherPanel.ts | 72 + vscode-extension/src/project.ts | 67 + vscode-extension/test/e2e/run.mjs | 20 + vscode-extension/test/e2e/suite/index.cjs | 20 + vscode-extension/test/helpers.test.mjs | 51 + vscode-extension/tsconfig.json | 15 + 35 files changed, 6091 insertions(+), 782 deletions(-) create mode 100644 .github/workflows/vscode-extension.yml create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 cli/src/commands/config.ts create mode 100644 cli/stubs/react-devtools-core/index.js create mode 100644 cli/stubs/react-devtools-core/package.json create mode 100644 cli/test/commands/config.test.mjs create mode 100644 vscode-extension/.vscodeignore create mode 100644 vscode-extension/package.json create mode 100644 vscode-extension/resources/feather-clear.svg create mode 100644 vscode-extension/resources/feather.svg create mode 100644 vscode-extension/scripts/prepare.mjs create mode 100644 vscode-extension/src/catalog.ts create mode 100644 vscode-extension/src/cli.ts create mode 100644 vscode-extension/src/command.ts create mode 100644 vscode-extension/src/extension.ts create mode 100644 vscode-extension/src/featherPanel.ts create mode 100644 vscode-extension/src/project.ts create mode 100644 vscode-extension/test/e2e/run.mjs create mode 100644 vscode-extension/test/e2e/suite/index.cjs create mode 100644 vscode-extension/test/helpers.test.mjs create mode 100644 vscode-extension/tsconfig.json diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e72d5194..1a801d34 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -31,6 +31,10 @@ jobs: - name: Run linting run: npm run lint + + - name: Build and test VS Code extension + run: npm run extension:test + lint-lua: name: Run Lua (Love2D) Linting runs-on: ubuntu-latest diff --git a/.github/workflows/vscode-extension.yml b/.github/workflows/vscode-extension.yml new file mode 100644 index 00000000..25d6dd28 --- /dev/null +++ b/.github/workflows/vscode-extension.yml @@ -0,0 +1,93 @@ +name: VS Code extension release + +on: + push: + tags: + - "*" + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + vsixFile: ${{ steps.package.outputs.vsixFile }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22.21.1 + package-manager-cache: false + + - run: npm ci + + - name: Get version from tag + id: get_version + run: | + VERSION="${GITHUB_REF_NAME#v}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Verify extension package version + run: | + EXT_VERSION="$(node -p "require('./vscode-extension/package.json').version")" + if [ "$EXT_VERSION" != "${{ steps.get_version.outputs.version }}" ]; then + echo "Tag version ${{ steps.get_version.outputs.version }} does not match vscode-extension/package.json version $EXT_VERSION." + exit 1 + fi + + - name: Test extension + run: npm run extension:test + + - name: Package extension + id: package + run: | + npm run extension:package + VSIX_PATH="$(ls vscode-extension/*.vsix | head -n 1)" + VSIX_FILE="$(basename "$VSIX_PATH")" + echo "vsixPath=$VSIX_PATH" >> "$GITHUB_OUTPUT" + echo "vsixFile=$VSIX_FILE" >> "$GITHUB_OUTPUT" + + - name: Upload VSIX + uses: actions/upload-artifact@v4 + with: + name: feather-vscode + path: ${{ steps.package.outputs.vsixPath }} + + publish-vs-marketplace: + runs-on: ubuntu-latest + needs: build + permissions: + contents: read + steps: + - name: Download VSIX + uses: actions/download-artifact@v4 + with: + name: feather-vscode + path: . + + - name: Publish to Visual Studio Marketplace + uses: HaaLeo/publish-vscode-extension@v2 + with: + pat: ${{ secrets.VS_MARKETPLACE_TOKEN }} + registryUrl: https://marketplace.visualstudio.com + extensionFile: ${{ needs.build.outputs.vsixFile }} + + publish-open-vsx: + runs-on: ubuntu-latest + needs: build + permissions: + contents: read + steps: + - name: Download VSIX + uses: actions/download-artifact@v4 + with: + name: feather-vscode + path: . + + - name: Publish to Open VSX + uses: HaaLeo/publish-vscode-extension@v2 + with: + pat: ${{ secrets.OPEN_VSX_TOKEN }} + registryUrl: https://open-vsx.org + extensionFile: ${{ needs.build.outputs.vsixFile }} diff --git a/.gitignore b/.gitignore index f2ef53e3..c655af86 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ dist-ssr # Editor directories and files .vscode/* !.vscode/extensions.json +!.vscode/launch.json +!.vscode/tasks.json .idea .DS_Store *.suo @@ -31,4 +33,9 @@ test-results/ .env builds/ vendor/ -feather.build.json \ No newline at end of file +feather.build.json + +# VS Code extension build outputs +vscode-extension/bundled-*/ +vscode-extension/out/ +vscode-extension/*.vsix diff --git a/.husky/commit-msg b/.husky/commit-msg index 16c5abd4..9356f9cc 100644 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -5,7 +5,7 @@ message_file="$1" subject="$(sed -n '1p' "$message_file")" case "$subject" in - ci:*|cli:*|package:*|plugin:*|app:*|lua:*|tauri:*|feather:*|docs:*) + ci:*|cli:*|package:*|plugin:*|app:*|lua:*|tauri:*|feather:*|docs:*|vscode-extension:*) exit 0 ;; esac @@ -23,6 +23,7 @@ Commit message must start with one of: tauri: feather: docs: + vscode-extension: Examples: ci: update repo workflows @@ -32,6 +33,7 @@ Examples: tauri: update file permissions feather: update repo structure docs: add contribution guidelines + vscode-extension: update extension manifest EOF exit 1 diff --git a/.husky/pre-commit b/.husky/pre-commit index 90fde547..cdfeaa40 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -14,7 +14,7 @@ if ! git diff --exit-code cli/src/generated/plugin-catalog.ts; then fi bash scripts/set-version.sh -if ! git diff --exit-code package.json src-lua/feather/init.lua src-tauri/Cargo.toml src-tauri/tauri.conf.json; then +if ! git diff --exit-code package.json src-lua/feather/init.lua src-tauri/Cargo.toml src-tauri/tauri.conf.json cli/package.json vscode-extension/package.json; then echo "[feather] Version files are out of sync — they have been updated, please stage them and commit again." exit 1 fi diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..c870b95d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Feather VS Code Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}/vscode-extension"], + "outFiles": ["${workspaceFolder}/vscode-extension/out/**/*.js"], + "preLaunchTask": "npm: extension:build" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..93ed1316 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,12 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "npm: extension:build", + "type": "npm", + "script": "extension:build", + "group": "build", + "problemMatcher": "$tsc" + } + ] +} diff --git a/README.md b/README.md index 59fc1f3f..2e31ffa3 100644 --- a/README.md +++ b/README.md @@ -42,13 +42,13 @@ Install the Feather desktop app and CLI: 1. Download the desktop app from [Releases](https://github.com/Kyonru/feather/releases). 2. Install the CLI: -```sh +```bash npm install -g @kyonru/feather ``` Initialize your project, open the Feather app, then run the game: -```sh +```bash feather init path/to/my-game feather run path/to/my-game ``` @@ -57,7 +57,7 @@ Feather is injected by the CLI for dev runs and debug builds, so your game code Optional vendor setup for web, mobile, and packaged desktop workflows: -```sh +```bash feather build vendor add web --dir path/to/my-game feather run path/to/my-game --target web @@ -70,13 +70,13 @@ feather run path/to/my-game --target ios For all build vendors, including desktop packaging runtimes: -```sh +```bash feather build vendor add all --dir path/to/my-game ``` Build release artifacts from the same CLI flow: -```sh +```bash feather build love --dir path/to/my-game feather build android --dir path/to/my-game --release feather build ios --dir path/to/my-game --release @@ -88,7 +88,7 @@ feather build steamos --dir path/to/my-game For more commands and options: -```sh +```bash feather --help feather run --help ``` diff --git a/cli/.gitignore b/cli/.gitignore index 69c6ebc0..31784be8 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -1,3 +1,4 @@ dist/ lua/ node_modules/ +bin/ diff --git a/cli/package.json b/cli/package.json index 49b0a51a..634a740b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -31,6 +31,7 @@ ], "scripts": { "build": "tsc && cp src/generated/registry.json dist/generated/registry.json", + "build:binary": "bun build ./dist/index.js --compile --outfile bin/feather --target bun-darwin-arm64 && bun build ./dist/index.js --compile --outfile bin/feather-darwin-x64 --target bun-darwin-x64 && bun build ./dist/index.js --compile --outfile bin/feather-win-x64.exe --target bun-windows-x64 && bun build ./dist/index.js --compile --outfile bin/feather-linux-x64 --target bun-linux-x64", "dev": "cp src/generated/registry.json dist/generated/registry.json 2>/dev/null; tsc --watch", "bundle:lua": "bash ../scripts/bundle-lua.sh", "prepack": "npm run bundle:lua && npm run build", diff --git a/cli/src/commands/config.ts b/cli/src/commands/config.ts new file mode 100644 index 00000000..1bbafdc4 --- /dev/null +++ b/cli/src/commands/config.ts @@ -0,0 +1,124 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fail } from '../lib/command.js'; +import { loadConfig, luaValue, type FeatherConfig } from '../lib/config.js'; +import { assertSafeProjectTarget } from '../lib/path-safety.js'; +import { pluginCatalog } from '../generated/plugin-catalog.js'; +import { mergeCapabilities } from '../ui/init/config.js'; +import { icon, printLine } from '../lib/output.js'; + +export type ConfigPluginsOptions = { + dir?: string; + include?: string; + exclude?: string; +}; + +const knownPluginIds = new Set(pluginCatalog.map((plugin) => plugin.id)); + +function parseIds(value: string | undefined): string[] { + if (!value) return []; + return [...new Set(value.split(',').map((id) => id.trim()).filter(Boolean))]; +} + +function assertKnownPlugins(ids: string[]): void { + const unknown = ids.filter((id) => !knownPluginIds.has(id)); + if (unknown.length > 0) { + fail(`Unknown plugin: ${unknown[0]}`, { + details: [`Available: ${[...knownPluginIds].sort().join(', ')}`], + }); + } +} + +function setArray(config: Record, key: 'include' | 'exclude', values: Set): void { + if (values.size > 0) { + config[key] = [...values].sort(); + } else { + delete config[key]; + } +} + +function upsertTopLevelValue(source: string, key: string, value: unknown): string { + const rendered = value === undefined ? undefined : ` ${key} = ${luaValue(value, 2)},`; + const assignment = new RegExp(`^\\s*${key}\\s*=\\s*(?:\\{[^\\n]*\\}|\"[^\"]*\"|'[^']*'|true|false|-?\\d+(?:\\.\\d+)?),?\\s*$`, 'm'); + const inlineAssignment = new RegExp(`([,{]\\s*)${key}\\s*=\\s*(?:\\{[^}]*\\}|\"[^\"]*\"|'[^']*'|true|false|-?\\d+(?:\\.\\d+)?)(,?)`); + + if (assignment.test(source)) { + return source.replace(assignment, rendered ?? ''); + } + + if (rendered && inlineAssignment.test(source)) { + return source.replace(inlineAssignment, `$1${key} = ${luaValue(value, 2)}$2`); + } + + if (rendered) { + return source.replace(/return\s*\{\s*\n/, (match) => `${match}${rendered}\n`); + } + + return source; +} + +export async function configPluginsCommand(opts: ConfigPluginsOptions = {}): Promise { + const projectDir = resolve(opts.dir ?? '.'); + const includeIds = parseIds(opts.include); + const excludeIds = parseIds(opts.exclude); + + if (includeIds.length === 0 && excludeIds.length === 0) { + fail('No plugin changes requested.', { + details: ['Pass --include , --exclude , or both.'], + }); + } + + assertKnownPlugins([...includeIds, ...excludeIds]); + + let configPath: string; + try { + configPath = assertSafeProjectTarget(projectDir, 'feather.config.lua', 'Config update target'); + } catch (err) { + fail((err as Error).message); + } + + if (!existsSync(configPath)) { + fail(`No feather.config.lua found in ${projectDir}.`, { + details: ['Run `feather init` first.'], + }); + } + + const loaded = loadConfig(projectDir); + if (!loaded) { + fail(`Failed to load ${join(projectDir, 'feather.config.lua')}.`); + } + + const config = { ...(loaded as FeatherConfig) } as Record; + const originalSource = readFileSync(configPath, 'utf8'); + const include = new Set(Array.isArray(config.include) ? config.include.filter((id): id is string => typeof id === 'string') : []); + const exclude = new Set(Array.isArray(config.exclude) ? config.exclude.filter((id): id is string => typeof id === 'string') : []); + + for (const id of includeIds) { + include.add(id); + exclude.delete(id); + } + + for (const id of excludeIds) { + include.delete(id); + exclude.add(id); + } + + setArray(config, 'include', include); + setArray(config, 'exclude', exclude); + + const mergedCapabilities = mergeCapabilities( + config.capabilities as string[] | 'all' | undefined, + include, + ); + if (mergedCapabilities && mergedCapabilities !== 'all') { + config.capabilities = mergedCapabilities; + } + + let nextSource = originalSource; + nextSource = upsertTopLevelValue(nextSource, 'include', config.include); + nextSource = upsertTopLevelValue(nextSource, 'exclude', config.exclude); + nextSource = upsertTopLevelValue(nextSource, 'capabilities', config.capabilities); + + writeFileSync(configPath, nextSource); + printLine(`${icon.success} Updated feather.config.lua plugin settings`); +} diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index 512c4474..b0bf35d1 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { existsSync, realpathSync } from "node:fs"; import { basename, dirname, join, relative, resolve } from "node:path"; import { findLoveBinary } from "../lib/love.js"; @@ -228,16 +228,7 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) ["Args", opts.gameArgs?.join(" ")], ]); - const result = spawnSync(loveBin, [absGame, ...(opts.gameArgs ?? [])], { - stdio: "inherit", - env: process.env, - }); - - if (result.error) { - fail(`Failed to launch love: ${result.error.message}`, { cause: result.error }); - } - - return result.status ?? 0; + return await runLove(loveBin, [absGame, ...(opts.gameArgs ?? [])], process.env); } const shim = createShim({ @@ -258,18 +249,25 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) const env = shimEnv(absGame, sessionName); - const result = spawnSync(loveBin, [shim.dir, ...(opts.gameArgs ?? [])], { - stdio: "inherit", - env, - }); - - shim.cleanup(); - - if (result.error) { - fail(`Failed to launch love: ${result.error.message}`, { cause: result.error }); + try { + return await runLove(loveBin, [shim.dir, ...(opts.gameArgs ?? [])], env); + } finally { + shim.cleanup(); } +} - return result.status ?? 0; +function runLove(loveBin: string, args: string[], env: NodeJS.ProcessEnv): Promise { + return new Promise((resolveResult, reject) => { + const child = spawn(loveBin, args, { + stdio: ["ignore", "pipe", "pipe"], + env, + }); + + child.stdout?.on("data", (chunk: Buffer) => process.stdout.write(chunk)); + child.stderr?.on("data", (chunk: Buffer) => process.stderr.write(chunk)); + child.on("error", (err) => reject(new Error(`Failed to launch love: ${err.message}`, { cause: err }))); + child.on("close", (code) => resolveResult(code ?? 0)); + }); } export function resolveRunBuildContext(absGame: string, buildConfig: string | undefined): { diff --git a/cli/src/index.ts b/cli/src/index.ts index 05b11d48..e4d0d71f 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { Command } from 'commander'; -import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import packageJson from '../package.json' with { type: 'json' }; import { runCliAction } from './lib/command.js'; import { runCommand } from './commands/run.js'; import { initCommand } from './commands/init.js'; @@ -12,6 +13,7 @@ import { watchCommand } from './commands/watch.js'; import { buildVendorAddCommand, buildVendorListCommand } from './commands/build-vendor.js'; import { buildTargets } from './lib/build/config.js'; import { uploadCommand } from './commands/upload.js'; +import { configPluginsCommand } from './commands/config.js'; import { pluginListCommand, pluginInstallCommand, @@ -31,9 +33,8 @@ import { } from './commands/package.js'; import type { InitMode } from './ui/init/index.js'; -const program = new Command(); const initModes = new Set(['cli', 'auto', 'manual']); -const cliVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version as string; +const cliVersion = packageJson.version; function parseInitMode(value: string): InitMode { if (!initModes.has(value)) { @@ -42,528 +43,657 @@ function parseInitMode(value: string): InitMode { return value as InitMode; } -program - .name('feather') - .description('Run and debug Love2D games with Feather — zero game-side changes required') - .version(cliVersion); - -program - .command('run [game-path] [game-args...]') - .description('Inject Feather into a Love2D game and run it') - .option('--target ', 'Run target: desktop, web, android, ios, or steamos', 'desktop') - .option('--device ', 'Android device serial or iOS simulator UDID') - .option('--build-config ', 'Path to feather.build.json for web/mobile run') - .option('--out-dir ', 'Build output directory for web/mobile run') - .option('--clean', 'Remove the output directory before web/mobile build') - .option('--no-cache', 'Disable Android/iOS dev native build cache') - .option('--verbose', 'Show web/mobile build commands and native tool output') - .option('--web-host ', 'Host for web run static server', '127.0.0.1') - .option('--web-port ', 'Port for web run static server', (value) => Number(value), 8000) - .option('--no-adb-reverse', 'Skip adb reverse setup for Android mobile run') - .option('--port ', 'Feather desktop port for Android adb reverse', (value) => Number(value)) - .option('--love ', 'Path to the love2d binary (overrides auto-detect)') - .option('--session-name ', 'Custom session name shown in the desktop app') - .option('--no-plugins', 'Disable plugin loading (feather core only)') - .option('--no-debugger', 'Run without Feather debugger injection') - .option('--disable-debugger', 'Alias for --no-debugger') - .option('--config ', 'Path to feather.config.lua') - .option('--config-path ', 'Alias for --config') - .option('--configPath ', 'Alias for --config') - .option('--feather-path ', 'Use a local feather install instead of the bundled one') - .option('--plugins-dir ', 'Use a custom plugins directory instead of the bundled one') - .action((gamePath: string | undefined, gameArgs: string[], opts) => runCliAction(() => runCommand(gamePath, { - love: opts.love as string | undefined, - target: opts.target as 'desktop' | 'web' | 'android' | 'ios' | 'steamos' | undefined, - device: opts.device as string | undefined, - buildConfig: opts.buildConfig as string | undefined, - outDir: opts.outDir as string | undefined, - clean: opts.clean as boolean | undefined, - noCache: opts.cache === false, - verbose: opts.verbose as boolean | undefined, - webHost: opts.webHost as string | undefined, - webPort: opts.webPort as number | undefined, - adbReverse: opts.adbReverse as boolean | undefined, - port: opts.port as number | undefined, - sessionName: opts.sessionName as string | undefined, - noPlugins: opts.plugins === false, - debugger: opts.debugger !== false && !opts.disableDebugger, - config: (opts.config ?? opts.configPath) as string | undefined, - featherPath: opts.featherPath as string | undefined, - pluginsDir: opts.pluginsDir as string | undefined, - gameArgs, - }))); - -program - .command('init [dir]') - .description('Initialize Feather in a Love2D project directory (default: current directory)') - .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') - .option('--branch ', 'GitHub branch to download from when using --remote', 'main') - .option('--local-src ', 'Copy Lua runtime from a local src-lua style directory') - .option('--install-dir ', 'Install directory for auto/manual modes', 'feather') - .option('--no-plugins', 'Skip plugin installation') - .option('--plugins ', 'Comma-separated list of plugins to install') - .option('--mode ', 'Setup mode: cli, auto, or manual', parseInitMode) - .option('-y, --yes', 'Skip confirmation prompts') - .option('--allow-insecure-connection', 'Set __DANGEROUS_INSECURE_CONNECTION__ in feather.config.lua (required with --yes if appId is not configured)') - .action((dir: string | undefined, opts) => runCliAction(() => initCommand(dir ?? '.', { - branch: opts.branch as string, - remote: opts.remote as boolean | undefined, - localSrc: opts.localSrc as string | undefined, - installDir: opts.installDir as string, - noPlugins: opts.plugins === false, - plugins: - opts.plugins && opts.plugins !== true - ? (opts.plugins as string).split(',').map((s: string) => s.trim()) - : undefined, - mode: opts.mode as InitMode | undefined, - yes: opts.yes as boolean, - allowInsecureConnection: opts.allowInsecureConnection as boolean | undefined, - }))); - -program - .command('remove [dir]') - .description('Remove Feather files and init markers from a Love2D project') - .option('--install-dir ', 'Feather install directory override') - .option('--dry-run', 'Show what would be removed without changing files') - .option('--keep-config', 'Keep feather.config.lua') - .option('--keep-main', 'Keep main.lua FEATHER-INIT markers') - .option('--keep-manual', 'Keep feather.debugger.lua') - .option('--keep-runtime', 'Keep installed Feather runtime/plugins') - .option('-y, --yes', 'Skip interactive confirmation') - .action((dir: string | undefined, opts) => runCliAction(() => removeCommand(dir ?? '.', { - installDir: opts.installDir as string | undefined, - dryRun: opts.dryRun as boolean | undefined, - keepConfig: opts.keepConfig as boolean | undefined, - keepMain: opts.keepMain as boolean | undefined, - keepManual: opts.keepManual as boolean | undefined, - keepRuntime: opts.keepRuntime as boolean | undefined, - yes: opts.yes as boolean | undefined, - }))); - -program - .command('doctor [dir]') - .description('Check the environment and project setup') - .option('--install-dir ', 'Feather install directory override') - .option('--host ', 'Host to check for the Feather desktop WebSocket') - .option('--port ', 'Port to check for the Feather desktop WebSocket', (value) => Number(value)) - .option('--json', 'Print machine-readable diagnostics') - .option('--production', 'Fail when project settings are unsafe for production builds') - .option('--security', 'Print security-focused diagnostics; use with --json for automation') - .option('--target ', 'Check dependencies for a build target') - .option('--build-target ', 'Alias for --target') - .option('--upload-target ', 'Check dependencies for an upload target') - .option('--release', 'Include mobile release build checks with --target') - .action((dir: string | undefined, opts) => runCliAction(() => doctorCommand(dir, { - installDir: opts.installDir as string | undefined, - host: opts.host as string | undefined, - port: opts.port as number | undefined, - json: opts.json as boolean | undefined, - production: opts.production as boolean | undefined, - security: opts.security as boolean | undefined, - buildTarget: (opts.target ?? opts.buildTarget) as string | undefined, - uploadTarget: opts.uploadTarget as string | undefined, - release: opts.release as boolean | undefined, - }))); - -const build = program - .command('build') - .description('Build a LÖVE game package, web bundle, mobile dev app, or desktop installer'); - -function addBuildTargetCommand(target: string): void { - build - .command(target) - .description(`Build a LÖVE game for ${target}`) +export function createProgram(): Command { + const program = new Command(); + + program + .name('feather') + .description('Run and debug Love2D games with Feather — zero game-side changes required') + .version(cliVersion); + + program + .command('run [game-path] [game-args...]') + .description('Inject Feather into a Love2D game and run it') + .option('--target ', 'Run target: desktop, web, android, ios, or steamos', 'desktop') + .option('--device ', 'Android device serial or iOS simulator UDID') + .option('--build-config ', 'Path to feather.build.json for web/mobile run') + .option('--out-dir ', 'Build output directory for web/mobile run') + .option('--clean', 'Remove the output directory before web/mobile build') + .option('--no-cache', 'Disable Android/iOS dev native build cache') + .option('--verbose', 'Show web/mobile build commands and native tool output') + .option('--web-host ', 'Host for web run static server', '127.0.0.1') + .option('--web-port ', 'Port for web run static server', (value) => Number(value), 8000) + .option('--no-adb-reverse', 'Skip adb reverse setup for Android mobile run') + .option('--port ', 'Feather desktop port for Android adb reverse', (value) => Number(value)) + .option('--love ', 'Path to the love2d binary (overrides auto-detect)') + .option('--session-name ', 'Custom session name shown in the desktop app') + .option('--no-plugins', 'Disable plugin loading (feather core only)') + .option('--no-debugger', 'Run without Feather debugger injection') + .option('--disable-debugger', 'Alias for --no-debugger') + .option('--config ', 'Path to feather.config.lua') + .option('--config-path ', 'Alias for --config') + .option('--configPath ', 'Alias for --config') + .option('--feather-path ', 'Use a local feather install instead of the bundled one') + .option('--plugins-dir ', 'Use a custom plugins directory instead of the bundled one') + .action((gamePath: string | undefined, gameArgs: string[], opts) => + runCliAction(() => + runCommand(gamePath, { + love: opts.love as string | undefined, + target: opts.target as 'desktop' | 'web' | 'android' | 'ios' | 'steamos' | undefined, + device: opts.device as string | undefined, + buildConfig: opts.buildConfig as string | undefined, + outDir: opts.outDir as string | undefined, + clean: opts.clean as boolean | undefined, + noCache: opts.cache === false, + verbose: opts.verbose as boolean | undefined, + webHost: opts.webHost as string | undefined, + webPort: opts.webPort as number | undefined, + adbReverse: opts.adbReverse as boolean | undefined, + port: opts.port as number | undefined, + sessionName: opts.sessionName as string | undefined, + noPlugins: opts.plugins === false, + debugger: opts.debugger !== false && !opts.disableDebugger, + config: (opts.config ?? opts.configPath) as string | undefined, + featherPath: opts.featherPath as string | undefined, + pluginsDir: opts.pluginsDir as string | undefined, + gameArgs, + }), + ), + ); + + program + .command('init [dir]') + .description('Initialize Feather in a Love2D project directory (default: current directory)') + .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') + .option('--branch ', 'GitHub branch to download from when using --remote', 'main') + .option('--local-src ', 'Copy Lua runtime from a local src-lua style directory') + .option('--install-dir ', 'Install directory for auto/manual modes', 'feather') + .option('--no-plugins', 'Skip plugin installation') + .option('--plugins ', 'Comma-separated list of plugins to install') + .option('--mode ', 'Setup mode: cli, auto, or manual', parseInitMode) + .option('-y, --yes', 'Skip confirmation prompts') + .option( + '--allow-insecure-connection', + 'Set __DANGEROUS_INSECURE_CONNECTION__ in feather.config.lua (required with --yes if appId is not configured)', + ) + .action((dir: string | undefined, opts) => + runCliAction(() => + initCommand(dir ?? '.', { + branch: opts.branch as string, + remote: opts.remote as boolean | undefined, + localSrc: opts.localSrc as string | undefined, + installDir: opts.installDir as string, + noPlugins: opts.plugins === false, + plugins: + opts.plugins && opts.plugins !== true + ? (opts.plugins as string).split(',').map((s: string) => s.trim()) + : undefined, + mode: opts.mode as InitMode | undefined, + yes: opts.yes as boolean, + allowInsecureConnection: opts.allowInsecureConnection as boolean | undefined, + }), + ), + ); + + program + .command('remove [dir]') + .description('Remove Feather files and init markers from a Love2D project') + .option('--install-dir ', 'Feather install directory override') + .option('--dry-run', 'Show what would be removed without changing files') + .option('--keep-config', 'Keep feather.config.lua') + .option('--keep-main', 'Keep main.lua FEATHER-INIT markers') + .option('--keep-manual', 'Keep feather.debugger.lua') + .option('--keep-runtime', 'Keep installed Feather runtime/plugins') + .option('-y, --yes', 'Skip interactive confirmation') + .action((dir: string | undefined, opts) => + runCliAction(() => + removeCommand(dir ?? '.', { + installDir: opts.installDir as string | undefined, + dryRun: opts.dryRun as boolean | undefined, + keepConfig: opts.keepConfig as boolean | undefined, + keepMain: opts.keepMain as boolean | undefined, + keepManual: opts.keepManual as boolean | undefined, + keepRuntime: opts.keepRuntime as boolean | undefined, + yes: opts.yes as boolean | undefined, + }), + ), + ); + + program + .command('doctor [dir]') + .description('Check the environment and project setup') + .option('--install-dir ', 'Feather install directory override') + .option('--host ', 'Host to check for the Feather desktop WebSocket') + .option('--port ', 'Port to check for the Feather desktop WebSocket', (value) => Number(value)) + .option('--json', 'Print machine-readable diagnostics') + .option('--production', 'Fail when project settings are unsafe for production builds') + .option('--security', 'Print security-focused diagnostics; use with --json for automation') + .option('--target ', 'Check dependencies for a build target') + .option('--build-target ', 'Alias for --target') + .option('--upload-target ', 'Check dependencies for an upload target') + .option('--release', 'Include mobile release build checks with --target') + .action((dir: string | undefined, opts) => + runCliAction(() => + doctorCommand(dir, { + installDir: opts.installDir as string | undefined, + host: opts.host as string | undefined, + port: opts.port as number | undefined, + json: opts.json as boolean | undefined, + production: opts.production as boolean | undefined, + security: opts.security as boolean | undefined, + buildTarget: (opts.target ?? opts.buildTarget) as string | undefined, + uploadTarget: opts.uploadTarget as string | undefined, + release: opts.release as boolean | undefined, + }), + ), + ); + + const config = program.command('config').description('Update Feather configuration values'); + + config + .command('plugins') + .description('Update feather.config.lua plugin include/exclude settings and capability allowlist') + .option('--dir ', 'Project directory (default: current directory)') + .option('--include ', 'Comma-separated plugin IDs to include and enable') + .option('--exclude ', 'Comma-separated plugin IDs to exclude') + .action((opts) => + runCliAction(() => + configPluginsCommand({ + dir: opts.dir as string | undefined, + include: opts.include as string | undefined, + exclude: opts.exclude as string | undefined, + }), + ), + ); + + const build = program + .command('build') + .description('Build a LÖVE game package, web bundle, mobile dev app, or desktop installer'); + + function addBuildTargetCommand(target: string): void { + build + .command(target) + .description(`Build a LÖVE game for ${target}`) + .option('--dir ', 'Project directory (default: current directory)') + .option('--config ', 'Path to feather.build.json') + .option('--build-config ', 'Alias for --config') + .option('--runtime-config ', 'Path to feather.config.lua for mobile debugger embedding') + .option('--config-path ', 'Alias for --runtime-config') + .option('--configPath ', 'Alias for --runtime-config') + .option('--out-dir ', 'Build output directory') + .option('--name ', 'Build product name') + .option('--version ', 'Build product version') + .option('--clean', 'Remove the output directory before building') + .option('--dry-run', 'Show the build plan without writing artifacts') + .option('--json', 'Output machine-readable JSON') + .option('--allow-unsafe', 'Allow production-unsafe Feather config during build') + .option('--release', 'Build signed/store-oriented mobile release artifacts') + .option('--no-cache', 'Disable Android/iOS dev native build cache') + .option('--no-debugger', 'Build mobile dev artifacts without Feather debugger embedding') + .option('--disable-debugger', 'Alias for --no-debugger') + .option('--verbose', 'Show Android/iOS build commands and native tool output') + .action((opts) => + runCliAction(() => + buildCommand(target, { + dir: opts.dir as string | undefined, + config: buildConfigOption(opts.config as string | undefined, opts.buildConfig as string | undefined), + runtimeConfig: runtimeConfigOption( + opts.config as string | undefined, + opts.runtimeConfig as string | undefined, + opts.configPath as string | undefined, + ), + outDir: opts.outDir as string | undefined, + name: opts.name as string | undefined, + version: opts.version as string | undefined, + clean: opts.clean as boolean | undefined, + dryRun: opts.dryRun as boolean | undefined, + json: opts.json as boolean | undefined, + allowUnsafe: opts.allowUnsafe as boolean | undefined, + release: opts.release as boolean | undefined, + noCache: opts.cache === false, + debugger: opts.debugger !== false && !opts.disableDebugger, + verbose: opts.verbose as boolean | undefined, + }), + ), + ); + } + + function looksLikeRuntimeConfig(path: string | undefined): boolean { + return Boolean(path && (/\.lua$/i.test(path) || path.endsWith('.featherrc'))); + } + + function buildConfigOption(config: string | undefined, buildConfig: string | undefined): string | undefined { + if (buildConfig) return buildConfig; + return looksLikeRuntimeConfig(config) ? undefined : config; + } + + function runtimeConfigOption( + config: string | undefined, + runtimeConfig: string | undefined, + configPath: string | undefined, + ): string | undefined { + return runtimeConfig ?? configPath ?? (looksLikeRuntimeConfig(config) ? config : undefined); + } + + for (const target of buildTargets) { + addBuildTargetCommand(target); + } + + const buildVendor = build.command('vendor').description('Fetch and inspect local build vendor templates'); + + buildVendor + .command('add [targets...]') + .description('Fetch build vendors: web, android, ios, mobile, desktop, or all') + .allowUnknownOption() + .option('--target ', 'Vendor target(s) to add') .option('--dir ', 'Project directory (default: current directory)') .option('--config ', 'Path to feather.build.json') - .option('--build-config ', 'Alias for --config') - .option('--runtime-config ', 'Path to feather.config.lua for mobile debugger embedding') - .option('--config-path ', 'Alias for --runtime-config') - .option('--configPath ', 'Alias for --runtime-config') - .option('--out-dir ', 'Build output directory') - .option('--name ', 'Build product name') - .option('--version ', 'Build product version') - .option('--clean', 'Remove the output directory before building') - .option('--dry-run', 'Show the build plan without writing artifacts') + .option('--vendor-dir ', 'Vendor directory inside the project', 'vendor') + .option('--ref ', 'LÖVE version/tag/ref for all vendors') + .option('--web-ref ', 'love.js vendor branch/tag/ref override') + .option('--android-ref ', 'Android vendor branch/tag/ref override') + .option('--ios-ref ', 'iOS vendor branch/tag/ref override') + .option('--force', 'Replace existing vendor directories or conflicting config paths') + .option('--dry-run', 'Show planned vendor changes without writing files') .option('--json', 'Output machine-readable JSON') - .option('--allow-unsafe', 'Allow production-unsafe Feather config during build') - .option('--release', 'Build signed/store-oriented mobile release artifacts') - .option('--no-cache', 'Disable Android/iOS dev native build cache') - .option('--no-debugger', 'Build mobile dev artifacts without Feather debugger embedding') - .option('--disable-debugger', 'Alias for --no-debugger') - .option('--verbose', 'Show Android/iOS build commands and native tool output') - .action((opts) => runCliAction(() => buildCommand(target, { - dir: opts.dir as string | undefined, - config: buildConfigOption(opts.config as string | undefined, opts.buildConfig as string | undefined), - runtimeConfig: runtimeConfigOption( - opts.config as string | undefined, - opts.runtimeConfig as string | undefined, - opts.configPath as string | undefined, + .addHelpText('after', '\n --no-config Do not update feather.build.json') + .action((targets: string[], opts) => + runCliAction(() => + buildVendorAddCommand( + [...targets.filter((target) => target !== '--no-config'), ...((opts.target as string[] | undefined) ?? [])], + { + dir: opts.dir as string | undefined, + config: opts.config as string | undefined, + vendorDir: opts.vendorDir as string | undefined, + ref: opts.ref as string | undefined, + webRef: opts.webRef as string | undefined, + androidRef: opts.androidRef as string | undefined, + iosRef: opts.iosRef as string | undefined, + force: opts.force as boolean | undefined, + dryRun: opts.dryRun as boolean | undefined, + json: opts.json as boolean | undefined, + configUpdate: !process.argv.includes('--no-config'), + }, + ), ), - outDir: opts.outDir as string | undefined, - name: opts.name as string | undefined, - version: opts.version as string | undefined, - clean: opts.clean as boolean | undefined, - dryRun: opts.dryRun as boolean | undefined, - json: opts.json as boolean | undefined, - allowUnsafe: opts.allowUnsafe as boolean | undefined, - release: opts.release as boolean | undefined, - noCache: opts.cache === false, - debugger: opts.debugger !== false && !opts.disableDebugger, - verbose: opts.verbose as boolean | undefined, - }))); -} + ); -function looksLikeRuntimeConfig(path: string | undefined): boolean { - return Boolean(path && (/\.lua$/i.test(path) || path.endsWith('.featherrc'))); -} + buildVendor + .command('list') + .description('List configured build vendors') + .option('--dir ', 'Project directory (default: current directory)') + .option('--config ', 'Path to feather.build.json') + .option('--vendor-dir ', 'Vendor directory inside the project', 'vendor') + .option('--json', 'Output machine-readable JSON') + .action((opts) => + runCliAction(() => + buildVendorListCommand({ + dir: opts.dir as string | undefined, + config: opts.config as string | undefined, + vendorDir: opts.vendorDir as string | undefined, + json: opts.json as boolean | undefined, + }), + ), + ); -function buildConfigOption(config: string | undefined, buildConfig: string | undefined): string | undefined { - if (buildConfig) return buildConfig; - return looksLikeRuntimeConfig(config) ? undefined : config; -} + program + .command('upload [target] [build-target]') + .description('Upload built artifacts to itch.io or registered store targets') + .option('--dir ', 'Project directory (default: current directory)') + .option('--config ', 'Path to feather.build.json') + .option('--build-dir ', 'Directory containing feather-build-manifest.json') + .option('--project ', 'Upload project override, for example user/game') + .option('--channel ', 'Upload channel override') + .option('--user-version ', 'Store-facing version override') + .option('--dry-run', 'Show the upload plan without running the uploader') + .option('--if-changed', 'Pass --if-changed to supported uploaders') + .option('--hidden', 'Pass --hidden to supported uploaders') + .option('--json', 'Output machine-readable JSON') + .option('-y, --yes', 'Skip upload confirmation in non-interactive mode') + .option('--build', 'Build the selected target before uploading') + .option('--out-dir ', 'Build output directory when used with --build') + .option('--release', 'Build signed/store-oriented mobile release artifacts when used with --build') + .option('--allow-unsafe', 'Allow production-unsafe Feather config during --build') + .option('--clean', 'Remove the output directory before --build') + .option('--no-cache', 'Disable Android/iOS dev native build cache during --build') + .option('--verbose', 'Show build command output when used with --build') + .option('--allow-feather-runtime', 'Allow uploading artifacts that include or may include Feather runtime files') + .action((target: string, buildTarget: string | undefined, opts) => + runCliAction(() => + uploadCommand(target, buildTarget, { + dir: opts.dir as string | undefined, + config: opts.config as string | undefined, + buildDir: opts.buildDir as string | undefined, + project: opts.project as string | undefined, + channel: opts.channel as string | undefined, + userVersion: opts.userVersion as string | undefined, + dryRun: opts.dryRun as boolean | undefined, + ifChanged: opts.ifChanged as boolean | undefined, + hidden: opts.hidden as boolean | undefined, + json: opts.json as boolean | undefined, + yes: opts.yes as boolean | undefined, + build: opts.build as boolean | undefined, + outDir: opts.outDir as string | undefined, + release: opts.release as boolean | undefined, + allowUnsafe: opts.allowUnsafe as boolean | undefined, + clean: opts.clean as boolean | undefined, + noCache: opts.cache === false, + verbose: opts.verbose as boolean | undefined, + allowFeatherRuntime: opts.allowFeatherRuntime as boolean | undefined, + }), + ), + ); + + program + .command('update [dir]') + .description('Update the Feather core library in a project (default: current directory)') + .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') + .option('--branch ', 'GitHub branch to download from when using --remote', 'main') + .option('--local-src ', 'Copy Lua runtime from a local src-lua style directory') + .option('--install-dir ', 'Feather install directory', 'feather') + .option('-y, --yes', 'Skip interactive confirmation and use the selected/default source') + .action((dir: string | undefined, opts) => + runCliAction(() => + updateCommand(dir ?? '.', { + branch: opts.branch as string, + remote: opts.remote as boolean | undefined, + localSrc: opts.localSrc as string | undefined, + installDir: opts.installDir as string, + yes: opts.yes as boolean | undefined, + }), + ), + ); -function runtimeConfigOption( - config: string | undefined, - runtimeConfig: string | undefined, - configPath: string | undefined, -): string | undefined { - return runtimeConfig ?? configPath ?? (looksLikeRuntimeConfig(config) ? config : undefined); + const plugin = program + .command('plugin') + .description('Manage Feather plugins') + .option('--dir ', 'Project directory (default: current directory)') + .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') + .option('--branch ', 'GitHub branch to download from when using --remote', 'main') + .option('--local-src ', 'Copy plugins from a local src-lua style directory') + .option('--install-dir ', 'Feather install directory', 'feather') + .action((opts) => + runCliAction(() => + pluginWorkflowCommand({ + dir: opts.dir as string | undefined, + branch: opts.branch as string, + installDir: opts.installDir as string, + remote: opts.remote as boolean | undefined, + localSrc: opts.localSrc as string | undefined, + }), + ), + ); + + const pluginCommandOptions = (opts: Record) => ({ ...opts, ...plugin.opts() }); + + plugin + .command('list [dir]') + .description('List installed plugins') + .option('--install-dir ', 'Feather install directory', 'feather') + .action((dir: string | undefined, opts) => + runCliAction(() => { + const merged = pluginCommandOptions(opts); + return pluginListCommand(dir ?? (merged.dir as string | undefined), merged.installDir as string); + }), + ); + + plugin + .command('install ') + .description('Install a plugin from the Feather registry') + .option('--dir ', 'Project directory (default: current directory)') + .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') + .option('--branch ', 'GitHub branch to download from when using --remote', 'main') + .option('--local-src ', 'Copy plugins from a local src-lua style directory') + .option('--install-dir ', 'Feather install directory', 'feather') + .action((id: string, opts) => + runCliAction(() => { + const merged = pluginCommandOptions(opts); + return pluginInstallCommand(id, { + dir: merged.dir as string | undefined, + branch: merged.branch as string, + installDir: merged.installDir as string, + remote: merged.remote as boolean | undefined, + localSrc: merged.localSrc as string | undefined, + }); + }), + ); + + plugin + .command('remove ') + .description('Remove an installed plugin') + .option('--dir ', 'Project directory (default: current directory)') + .option('--install-dir ', 'Feather install directory', 'feather') + .option('-y, --yes', 'Skip interactive confirmation') + .action((id: string, opts) => + runCliAction(() => { + const merged = pluginCommandOptions(opts); + return pluginRemoveCommand(id, { + dir: merged.dir as string | undefined, + installDir: merged.installDir as string, + yes: merged.yes as boolean | undefined, + }); + }), + ); + + plugin + .command('update [id]') + .description('Update a plugin (or all installed plugins if no id given)') + .option('--dir ', 'Project directory (default: current directory)') + .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') + .option('--branch ', 'GitHub branch to download from when using --remote', 'main') + .option('--local-src ', 'Copy plugins from a local src-lua style directory') + .option('--install-dir ', 'Feather install directory', 'feather') + .option('-y, --yes', 'Skip interactive selection and update all installed plugins when no id is given') + .action((id: string | undefined, opts) => + runCliAction(() => { + const merged = pluginCommandOptions(opts); + return pluginUpdateCommand(id, { + dir: merged.dir as string | undefined, + branch: merged.branch as string, + installDir: merged.installDir as string, + remote: merged.remote as boolean | undefined, + localSrc: merged.localSrc as string | undefined, + yes: merged.yes as boolean | undefined, + }); + }), + ); + + const pkg = program.command('package').description('Install and manage LÖVE packages from the Feather catalog'); + + pkg + .command('add') + .description('Interactively install a custom dependency from URL(s)') + .option('--dir ', 'Project directory') + .action((opts) => runCliAction(() => packageAddCommand({ dir: opts.dir as string | undefined }))); + + pkg + .command('search [query]') + .description('Search the package catalog') + .option('--offline', 'Use bundled registry snapshot') + .option('--registry ', 'Override registry URL') + .action((query: string | undefined, opts) => + runCliAction(() => + packageSearchCommand(query, { + offline: opts.offline as boolean | undefined, + registryUrl: opts.registry as string | undefined, + }), + ), + ); + + pkg + .command('list') + .description('List all available packages (--installed for installed only)') + .option('--installed', 'Show only installed packages') + .option('--offline', 'Use bundled registry snapshot') + .option('--refresh', 'Force a fresh registry fetch ignoring cache') + .option('--dir ', 'Project directory') + .option('--registry ', 'Override registry URL') + .action((opts) => + runCliAction(() => + packageListCommand({ + installed: opts.installed as boolean | undefined, + offline: opts.offline as boolean | undefined, + refresh: opts.refresh as boolean | undefined, + dir: opts.dir as string | undefined, + registryUrl: opts.registry as string | undefined, + }), + ), + ); + + pkg + .command('info ') + .description('Show package details') + .option('--offline', 'Use bundled registry snapshot') + .option('--dir ', 'Project directory') + .option('--registry ', 'Override registry URL') + .action((name: string, opts) => + runCliAction(() => + packageInfoCommand(name, { + offline: opts.offline as boolean | undefined, + dir: opts.dir as string | undefined, + registryUrl: opts.registry as string | undefined, + }), + ), + ); + + pkg + .command('install [names...]') + .description('Install one or more packages') + .option('--dry-run', 'Show what would be installed without writing files') + .option('--allow-untrusted', 'Allow installing experimental packages') + .option('--target ', 'Override install target directory') + .option('--from-url ', 'Install a single file from an arbitrary URL (requires --allow-untrusted)') + .option('--offline', 'Use bundled registry snapshot') + .option('--dir ', 'Project directory') + .option('-y, --yes', 'Skip confirmation prompts') + .option('--registry ', 'Override registry URL') + .action((names: string[], opts) => + runCliAction(() => + packageInstallCommand(names, { + dryRun: opts.dryRun as boolean | undefined, + allowUntrusted: opts.allowUntrusted as boolean | undefined, + target: opts.target as string | undefined, + fromUrl: opts.fromUrl as string | undefined, + offline: opts.offline as boolean | undefined, + dir: opts.dir as string | undefined, + yes: opts.yes as boolean | undefined, + registryUrl: opts.registry as string | undefined, + }), + ), + ); + + pkg + .command('update [name]') + .description('Update an installed package (or all if no name given)') + .option('--dry-run', 'Show what would change without writing files') + .option('--offline', 'Use bundled registry snapshot') + .option('--dir ', 'Project directory') + .option('--registry ', 'Override registry URL') + .action((name: string | undefined, opts) => + runCliAction(() => + packageUpdateCommand(name, { + dryRun: opts.dryRun as boolean | undefined, + offline: opts.offline as boolean | undefined, + dir: opts.dir as string | undefined, + registryUrl: opts.registry as string | undefined, + }), + ), + ); + + pkg + .command('remove ') + .description('Remove an installed package') + .option('--dir ', 'Project directory') + .option('-y, --yes', 'Skip confirmation prompt') + .action((name: string, opts) => + runCliAction(() => + packageRemoveCommand(name, { + dir: opts.dir as string | undefined, + yes: opts.yes as boolean | undefined, + }), + ), + ); + + pkg + .command('audit') + .description('Verify SHA-256 checksums of all installed packages') + .option('--dir ', 'Project directory') + .option('--json', 'Output machine-readable JSON') + .action((opts) => + runCliAction(() => + packageAuditCommand({ + dir: opts.dir as string | undefined, + json: opts.json as boolean | undefined, + }), + ), + ); + + program + .command('watch [game-path]') + .description( + 'Watch project files and restart desktop LÖVE or push game.love to a connected mobile device on change', + ) + .option('--target ', 'Watch target: desktop, android, ios, or steamos', 'desktop') + .option('--love ', 'Path to love executable for desktop watch') + .option('--device ', 'Android device serial or iOS simulator UDID') + .option('--debounce ', 'Debounce delay in milliseconds', (value) => Number(value), 500) + .option('--no-restart', 'Push game.love without restarting the app') + .option('--build-config ', 'Path to feather.build.json') + .option('--out-dir ', 'Build output directory') + .option('--no-plugins', 'Disable plugin loading (feather core only)') + .option('--no-debugger', 'Run desktop watch without Feather debugger injection') + .option('--disable-debugger', 'Alias for --no-debugger') + .option('--no-adb-reverse', 'Skip adb reverse setup for Android') + .option('--port ', 'Feather port for Android adb reverse', (value) => Number(value)) + .option('--feather-path ', 'Use a local feather install instead of the bundled one') + .option('--plugins-dir ', 'Use a custom plugins directory instead of the bundled one') + .option('--runtime-config ', 'Path to feather.config.lua for debugger embedding') + .option('--verbose', 'Show build commands and native tool output') + .action((gamePath: string | undefined, opts) => + watchCommand(gamePath, { + target: opts.target as 'desktop' | 'android' | 'ios' | 'steamos' | undefined, + love: opts.love as string | undefined, + debugger: opts.debugger !== false && !opts.disableDebugger, + device: opts.device as string | undefined, + debounce: opts.debounce as number | undefined, + restart: opts.restart as boolean | undefined, + buildConfig: opts.buildConfig as string | undefined, + outDir: opts.outDir as string | undefined, + noPlugins: opts.plugins === false, + adbReverse: opts.adbReverse as boolean | undefined, + port: opts.port as number | undefined, + featherPath: opts.featherPath as string | undefined, + pluginsDir: opts.pluginsDir as string | undefined, + runtimeConfig: opts.runtimeConfig as string | undefined, + verbose: opts.verbose as boolean | undefined, + }), + ); + + return program; } -for (const target of buildTargets) { - addBuildTargetCommand(target); +export async function runCli(argv: string[] = process.argv.slice(2)): Promise { + const previousExitCode = process.exitCode; + process.exitCode = undefined; + const program = createProgram(); + program.exitOverride(); + + try { + await program.parseAsync(argv, { from: 'user' }); + } catch (err) { + if (err && typeof err === 'object' && 'exitCode' in err) { + process.exitCode = Number((err as { exitCode: number }).exitCode); + } else { + throw err; + } + } + + const exitCode = typeof process.exitCode === 'number' ? process.exitCode : 0; + process.exitCode = previousExitCode; + return exitCode; } -const buildVendor = build - .command('vendor') - .description('Fetch and inspect local build vendor templates'); - -buildVendor - .command('add [targets...]') - .description('Fetch build vendors: web, android, ios, mobile, desktop, or all') - .allowUnknownOption() - .option('--target ', 'Vendor target(s) to add') - .option('--dir ', 'Project directory (default: current directory)') - .option('--config ', 'Path to feather.build.json') - .option('--vendor-dir ', 'Vendor directory inside the project', 'vendor') - .option('--ref ', 'LÖVE version/tag/ref for all vendors') - .option('--web-ref ', 'love.js vendor branch/tag/ref override') - .option('--android-ref ', 'Android vendor branch/tag/ref override') - .option('--ios-ref ', 'iOS vendor branch/tag/ref override') - .option('--force', 'Replace existing vendor directories or conflicting config paths') - .option('--dry-run', 'Show planned vendor changes without writing files') - .option('--json', 'Output machine-readable JSON') - .addHelpText('after', '\n --no-config Do not update feather.build.json') - .action((targets: string[], opts) => runCliAction(() => buildVendorAddCommand([ - ...targets.filter((target) => target !== '--no-config'), - ...((opts.target as string[] | undefined) ?? []), - ], { - dir: opts.dir as string | undefined, - config: opts.config as string | undefined, - vendorDir: opts.vendorDir as string | undefined, - ref: opts.ref as string | undefined, - webRef: opts.webRef as string | undefined, - androidRef: opts.androidRef as string | undefined, - iosRef: opts.iosRef as string | undefined, - force: opts.force as boolean | undefined, - dryRun: opts.dryRun as boolean | undefined, - json: opts.json as boolean | undefined, - configUpdate: !process.argv.includes('--no-config'), - }))); - -buildVendor - .command('list') - .description('List configured build vendors') - .option('--dir ', 'Project directory (default: current directory)') - .option('--config ', 'Path to feather.build.json') - .option('--vendor-dir ', 'Vendor directory inside the project', 'vendor') - .option('--json', 'Output machine-readable JSON') - .action((opts) => runCliAction(() => buildVendorListCommand({ - dir: opts.dir as string | undefined, - config: opts.config as string | undefined, - vendorDir: opts.vendorDir as string | undefined, - json: opts.json as boolean | undefined, - }))); - -program - .command('upload [target] [build-target]') - .description('Upload built artifacts to itch.io or registered store targets') - .option('--dir ', 'Project directory (default: current directory)') - .option('--config ', 'Path to feather.build.json') - .option('--build-dir ', 'Directory containing feather-build-manifest.json') - .option('--project ', 'Upload project override, for example user/game') - .option('--channel ', 'Upload channel override') - .option('--user-version ', 'Store-facing version override') - .option('--dry-run', 'Show the upload plan without running the uploader') - .option('--if-changed', 'Pass --if-changed to supported uploaders') - .option('--hidden', 'Pass --hidden to supported uploaders') - .option('--json', 'Output machine-readable JSON') - .option('-y, --yes', 'Skip upload confirmation in non-interactive mode') - .option('--build', 'Build the selected target before uploading') - .option('--out-dir ', 'Build output directory when used with --build') - .option('--release', 'Build signed/store-oriented mobile release artifacts when used with --build') - .option('--allow-unsafe', 'Allow production-unsafe Feather config during --build') - .option('--clean', 'Remove the output directory before --build') - .option('--no-cache', 'Disable Android/iOS dev native build cache during --build') - .option('--verbose', 'Show build command output when used with --build') - .option('--allow-feather-runtime', 'Allow uploading artifacts that include or may include Feather runtime files') - .action((target: string, buildTarget: string | undefined, opts) => runCliAction(() => uploadCommand(target, buildTarget, { - dir: opts.dir as string | undefined, - config: opts.config as string | undefined, - buildDir: opts.buildDir as string | undefined, - project: opts.project as string | undefined, - channel: opts.channel as string | undefined, - userVersion: opts.userVersion as string | undefined, - dryRun: opts.dryRun as boolean | undefined, - ifChanged: opts.ifChanged as boolean | undefined, - hidden: opts.hidden as boolean | undefined, - json: opts.json as boolean | undefined, - yes: opts.yes as boolean | undefined, - build: opts.build as boolean | undefined, - outDir: opts.outDir as string | undefined, - release: opts.release as boolean | undefined, - allowUnsafe: opts.allowUnsafe as boolean | undefined, - clean: opts.clean as boolean | undefined, - noCache: opts.cache === false, - verbose: opts.verbose as boolean | undefined, - allowFeatherRuntime: opts.allowFeatherRuntime as boolean | undefined, - }))); - -program - .command('update [dir]') - .description('Update the Feather core library in a project (default: current directory)') - .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') - .option('--branch ', 'GitHub branch to download from when using --remote', 'main') - .option('--local-src ', 'Copy Lua runtime from a local src-lua style directory') - .option('--install-dir ', 'Feather install directory', 'feather') - .option('-y, --yes', 'Skip interactive confirmation and use the selected/default source') - .action((dir: string | undefined, opts) => runCliAction(() => updateCommand(dir ?? '.', { - branch: opts.branch as string, - remote: opts.remote as boolean | undefined, - localSrc: opts.localSrc as string | undefined, - installDir: opts.installDir as string, - yes: opts.yes as boolean | undefined, - }))); - -const plugin = program - .command('plugin') - .description('Manage Feather plugins') - .option('--dir ', 'Project directory (default: current directory)') - .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') - .option('--branch ', 'GitHub branch to download from when using --remote', 'main') - .option('--local-src ', 'Copy plugins from a local src-lua style directory') - .option('--install-dir ', 'Feather install directory', 'feather') - .action((opts) => runCliAction(() => pluginWorkflowCommand({ - dir: opts.dir as string | undefined, - branch: opts.branch as string, - installDir: opts.installDir as string, - remote: opts.remote as boolean | undefined, - localSrc: opts.localSrc as string | undefined, - }))); - -const pluginCommandOptions = (opts: Record) => ({ ...opts, ...plugin.opts() }); - -plugin - .command('list [dir]') - .description('List installed plugins') - .option('--install-dir ', 'Feather install directory', 'feather') - .action((dir: string | undefined, opts) => runCliAction(() => { - const merged = pluginCommandOptions(opts); - return pluginListCommand(dir ?? (merged.dir as string | undefined), merged.installDir as string); - })); - -plugin - .command('install ') - .description('Install a plugin from the Feather registry') - .option('--dir ', 'Project directory (default: current directory)') - .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') - .option('--branch ', 'GitHub branch to download from when using --remote', 'main') - .option('--local-src ', 'Copy plugins from a local src-lua style directory') - .option('--install-dir ', 'Feather install directory', 'feather') - .action((id: string, opts) => runCliAction(() => { - const merged = pluginCommandOptions(opts); - return pluginInstallCommand(id, { - dir: merged.dir as string | undefined, - branch: merged.branch as string, - installDir: merged.installDir as string, - remote: merged.remote as boolean | undefined, - localSrc: merged.localSrc as string | undefined, - }); - })); - -plugin - .command('remove ') - .description('Remove an installed plugin') - .option('--dir ', 'Project directory (default: current directory)') - .option('--install-dir ', 'Feather install directory', 'feather') - .option('-y, --yes', 'Skip interactive confirmation') - .action((id: string, opts) => runCliAction(() => { - const merged = pluginCommandOptions(opts); - return pluginRemoveCommand(id, { - dir: merged.dir as string | undefined, - installDir: merged.installDir as string, - yes: merged.yes as boolean | undefined, - }); - })); - -plugin - .command('update [id]') - .description('Update a plugin (or all installed plugins if no id given)') - .option('--dir ', 'Project directory (default: current directory)') - .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') - .option('--branch ', 'GitHub branch to download from when using --remote', 'main') - .option('--local-src ', 'Copy plugins from a local src-lua style directory') - .option('--install-dir ', 'Feather install directory', 'feather') - .option('-y, --yes', 'Skip interactive selection and update all installed plugins when no id is given') - .action((id: string | undefined, opts) => runCliAction(() => { - const merged = pluginCommandOptions(opts); - return pluginUpdateCommand(id, { - dir: merged.dir as string | undefined, - branch: merged.branch as string, - installDir: merged.installDir as string, - remote: merged.remote as boolean | undefined, - localSrc: merged.localSrc as string | undefined, - yes: merged.yes as boolean | undefined, - }); - })); - -const pkg = program.command('package').description('Install and manage LÖVE packages from the Feather catalog'); - -pkg - .command('add') - .description('Interactively install a custom dependency from URL(s)') - .option('--dir ', 'Project directory') - .action((opts) => runCliAction(() => packageAddCommand({ dir: opts.dir as string | undefined }))); - -pkg - .command('search [query]') - .description('Search the package catalog') - .option('--offline', 'Use bundled registry snapshot') - .option('--registry ', 'Override registry URL') - .action((query: string | undefined, opts) => runCliAction(() => packageSearchCommand(query, { - offline: opts.offline as boolean | undefined, - registryUrl: opts.registry as string | undefined, - }))); - -pkg - .command('list') - .description('List all available packages (--installed for installed only)') - .option('--installed', 'Show only installed packages') - .option('--offline', 'Use bundled registry snapshot') - .option('--refresh', 'Force a fresh registry fetch ignoring cache') - .option('--dir ', 'Project directory') - .option('--registry ', 'Override registry URL') - .action((opts) => runCliAction(() => packageListCommand({ - installed: opts.installed as boolean | undefined, - offline: opts.offline as boolean | undefined, - refresh: opts.refresh as boolean | undefined, - dir: opts.dir as string | undefined, - registryUrl: opts.registry as string | undefined, - }))); - -pkg - .command('info ') - .description('Show package details') - .option('--offline', 'Use bundled registry snapshot') - .option('--dir ', 'Project directory') - .option('--registry ', 'Override registry URL') - .action((name: string, opts) => runCliAction(() => packageInfoCommand(name, { - offline: opts.offline as boolean | undefined, - dir: opts.dir as string | undefined, - registryUrl: opts.registry as string | undefined, - }))); - -pkg - .command('install [names...]') - .description('Install one or more packages') - .option('--dry-run', 'Show what would be installed without writing files') - .option('--allow-untrusted', 'Allow installing experimental packages') - .option('--target ', 'Override install target directory') - .option('--from-url ', 'Install a single file from an arbitrary URL (requires --allow-untrusted)') - .option('--offline', 'Use bundled registry snapshot') - .option('--dir ', 'Project directory') - .option('-y, --yes', 'Skip confirmation prompts') - .option('--registry ', 'Override registry URL') - .action((names: string[], opts) => runCliAction(() => packageInstallCommand(names, { - dryRun: opts.dryRun as boolean | undefined, - allowUntrusted: opts.allowUntrusted as boolean | undefined, - target: opts.target as string | undefined, - fromUrl: opts.fromUrl as string | undefined, - offline: opts.offline as boolean | undefined, - dir: opts.dir as string | undefined, - yes: opts.yes as boolean | undefined, - registryUrl: opts.registry as string | undefined, - }))); - -pkg - .command('update [name]') - .description('Update an installed package (or all if no name given)') - .option('--dry-run', 'Show what would change without writing files') - .option('--offline', 'Use bundled registry snapshot') - .option('--dir ', 'Project directory') - .option('--registry ', 'Override registry URL') - .action((name: string | undefined, opts) => runCliAction(() => packageUpdateCommand(name, { - dryRun: opts.dryRun as boolean | undefined, - offline: opts.offline as boolean | undefined, - dir: opts.dir as string | undefined, - registryUrl: opts.registry as string | undefined, - }))); - -pkg - .command('remove ') - .description('Remove an installed package') - .option('--dir ', 'Project directory') - .option('-y, --yes', 'Skip confirmation prompt') - .action((name: string, opts) => runCliAction(() => packageRemoveCommand(name, { - dir: opts.dir as string | undefined, - yes: opts.yes as boolean | undefined, - }))); - -pkg - .command('audit') - .description('Verify SHA-256 checksums of all installed packages') - .option('--dir ', 'Project directory') - .option('--json', 'Output machine-readable JSON') - .action((opts) => runCliAction(() => packageAuditCommand({ - dir: opts.dir as string | undefined, - json: opts.json as boolean | undefined, - }))); - -program - .command('watch [game-path]') - .description('Watch project files and restart desktop LÖVE or push game.love to a connected mobile device on change') - .option('--target ', 'Watch target: desktop, android, ios, or steamos', 'desktop') - .option('--love ', 'Path to love executable for desktop watch') - .option('--device ', 'Android device serial or iOS simulator UDID') - .option('--debounce ', 'Debounce delay in milliseconds', (value) => Number(value), 500) - .option('--no-restart', 'Push game.love without restarting the app') - .option('--build-config ', 'Path to feather.build.json') - .option('--out-dir ', 'Build output directory') - .option('--no-plugins', 'Disable plugin loading (feather core only)') - .option('--no-debugger', 'Run desktop watch without Feather debugger injection') - .option('--disable-debugger', 'Alias for --no-debugger') - .option('--no-adb-reverse', 'Skip adb reverse setup for Android') - .option('--port ', 'Feather port for Android adb reverse', (value) => Number(value)) - .option('--feather-path ', 'Use a local feather install instead of the bundled one') - .option('--plugins-dir ', 'Use a custom plugins directory instead of the bundled one') - .option('--runtime-config ', 'Path to feather.config.lua for debugger embedding') - .option('--verbose', 'Show build commands and native tool output') - .action((gamePath: string | undefined, opts) => watchCommand(gamePath, { - target: opts.target as 'desktop' | 'android' | 'ios' | 'steamos' | undefined, - love: opts.love as string | undefined, - debugger: opts.debugger !== false && !opts.disableDebugger, - device: opts.device as string | undefined, - debounce: opts.debounce as number | undefined, - restart: opts.restart as boolean | undefined, - buildConfig: opts.buildConfig as string | undefined, - outDir: opts.outDir as string | undefined, - noPlugins: opts.plugins === false, - adbReverse: opts.adbReverse as boolean | undefined, - port: opts.port as number | undefined, - featherPath: opts.featherPath as string | undefined, - pluginsDir: opts.pluginsDir as string | undefined, - runtimeConfig: opts.runtimeConfig as string | undefined, - verbose: opts.verbose as boolean | undefined, - })); - -program.parse(); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const exitCode = await runCli(); + process.exitCode = exitCode; +} diff --git a/cli/src/lib/paths.ts b/cli/src/lib/paths.ts index fe0a8a19..7361c2d7 100644 --- a/cli/src/lib/paths.ts +++ b/cli/src/lib/paths.ts @@ -2,10 +2,13 @@ import { existsSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -const __dirname = dirname(fileURLToPath(import.meta.url)); - export function bundledLuaRoot(): string { - return resolve(__dirname, '../../lua'); + // When running as a compiled binary, lua/ is shipped next to the executable. + const execDir = dirname(process.execPath); + const sibling = join(execDir, 'lua'); + if (existsSync(join(sibling, 'feather', 'init.lua'))) return sibling; + // Fallback for npm/node: dist/lib/paths.js → ../../lua + return resolve(dirname(fileURLToPath(import.meta.url)), '../../lua'); } export function repoLuaRoot(): string | null { diff --git a/cli/stubs/react-devtools-core/index.js b/cli/stubs/react-devtools-core/index.js new file mode 100644 index 00000000..ff76698d --- /dev/null +++ b/cli/stubs/react-devtools-core/index.js @@ -0,0 +1,2 @@ +// Stub — devtools are not supported in standalone binary builds. +export default { connectToDevTools: () => {} }; diff --git a/cli/stubs/react-devtools-core/package.json b/cli/stubs/react-devtools-core/package.json new file mode 100644 index 00000000..3561cd36 --- /dev/null +++ b/cli/stubs/react-devtools-core/package.json @@ -0,0 +1 @@ +{ "name": "react-devtools-core", "version": "0.0.0", "type": "module", "main": "index.js" } diff --git a/cli/test/commands/config.test.mjs b/cli/test/commands/config.test.mjs new file mode 100644 index 00000000..94e466db --- /dev/null +++ b/cli/test/commands/config.test.mjs @@ -0,0 +1,76 @@ +/* eslint-disable no-undef */ +import { + assert, + join, + makeTmp, + outputOf, + readFileSync, + run, + test, + writeFileSync, + writeGame, +} from './helpers.mjs'; + +test('config plugins: adds included plugins and merges required capabilities', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + sessionName = "Config Game", + pluginOptions = { + console = { evalEnabled = true }, + }, + capabilities = { "logs" }, + include = { "runtime-snapshot" }, +} +`, + ); + + const result = run(['config', 'plugins', '--dir', dir, '--include', 'console,input-replay']); + assert.equal(result.exitCode, 0, outputOf(result)); + + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.match(config, /sessionName\s*=\s*"Config Game"/); + assert.match(config, /pluginOptions\s*=\s*\{/); + assert.match(config, /evalEnabled\s*=\s*true/); + assert.match(config, /include\s*=\s*\{\s*"console",\s*"input-replay",\s*"runtime-snapshot"\s*\}/); + assert.match(config, /capabilities\s*=\s*\{\s*"filesystem",\s*"input",\s*"logs"\s*\}/); +}); + +test('config plugins: exclude removes included plugin and writes exclude list', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + 'return { include = { "console", "input-replay" }, exclude = { "bookmark" }, capabilities = { "filesystem", "input" } }\n', + ); + + const result = run(['config', 'plugins', '--dir', dir, '--exclude', 'console,hot-reload']); + assert.equal(result.exitCode, 0, outputOf(result)); + + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.match(config, /include\s*=\s*\{\s*"input-replay"\s*\}/); + assert.match(config, /exclude\s*=\s*\{\s*"bookmark",\s*"console",\s*"hot-reload"\s*\}/); + assert.match(config, /capabilities\s*=\s*\{\s*"filesystem",\s*"input"\s*\}/); +}); + +test('config plugins: missing config fails with init guidance', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['config', 'plugins', '--dir', dir, '--include', 'console']); + assert.equal(result.exitCode, 1, outputOf(result)); + assert.match(outputOf(result), /No feather\.config\.lua found/); + assert.match(outputOf(result), /feather init/); +}); + +test('config plugins: unknown plugin is rejected', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { sessionName = "Config Game" }\n'); + + const result = run(['config', 'plugins', '--dir', dir, '--include', 'not-a-plugin']); + assert.equal(result.exitCode, 1, outputOf(result)); + assert.match(outputOf(result), /Unknown plugin: not-a-plugin/); +}); diff --git a/package-lock.json b/package-lock.json index 134770e0..924a49f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "name": "feather", "version": "1.2.0", "workspaces": [ - "cli" + "cli", + "vscode-extension" ], "dependencies": { "@base-ui/react": "1.4.1", @@ -412,6 +413,216 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@azu/format-text": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", + "integrity": "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@azu/style-format": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@azu/style-format/-/style-format-1.0.1.tgz", + "integrity": "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "@azu/format-text": "^1.0.1" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz", + "integrity": "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.10.1.tgz", + "integrity": "sha512-hTbvOi9Ko2Jvn+G/fSmjzHf9WbNcf/o3epMtbeGx/pMwMrVAbi6OgCJVeCfsAb8IybSRpaCSc4EDRlYAhgngUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.6.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.6.1.tgz", + "integrity": "sha512-VxKdEtUwDuLD0F1hOQP7kye0YadZxFJfv37Em440geEf/w9uggKnHpRrqwZJOdxmPUOdhZ9kyRtKuAJW8wUcRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.1.tgz", + "integrity": "sha512-tmQiQ2HvtzaeLqYGy3BemiPOSGPY4wCy1IW5zDWITKSs/s35WEd7Zij/hCxvUdAOzj6U3qnyaGbYXY91ortFEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.6.1", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", @@ -1204,6 +1415,16 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1271,6 +1492,44 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@oxc-project/types": { "version": "0.130.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", @@ -4602,106 +4861,346 @@ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" - }, - "node_modules/@tailwindcss/node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", - "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "node_modules/@secretlint/config-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", + "integrity": "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==", + "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.21.0", - "jiti": "^2.6.1", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.3.0" + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", - "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "node_modules/@secretlint/config-loader": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.2.tgz", + "integrity": "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==", + "dev": true, "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "ajv": "^8.17.1", + "debug": "^4.4.1", + "rc-config-loader": "^4.1.3" + }, "engines": { - "node": ">= 20" + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/config-loader/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-x64": "4.3.0", - "@tailwindcss/oxide-freebsd-x64": "4.3.0", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-x64-musl": "4.3.0", - "@tailwindcss/oxide-wasm32-wasi": "4.3.0", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", - "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", - "cpu": [ - "arm64" - ], + "node_modules/@secretlint/config-loader/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/core": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", + "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "structured-source": "^4.0.0" + }, "engines": { - "node": ">= 20" + "node": ">=20.0.0" } }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", - "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", - "cpu": [ - "arm64" - ], + "node_modules/@secretlint/formatter": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", + "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "@textlint/linter-formatter": "^15.2.0", + "@textlint/module-interop": "^15.2.0", + "@textlint/types": "^15.2.0", + "chalk": "^5.4.1", + "debug": "^4.4.1", + "pluralize": "^8.0.0", + "strip-ansi": "^7.1.0", + "table": "^6.9.0", + "terminal-link": "^4.0.0" + }, "engines": { - "node": ">= 20" + "node": ">=20.0.0" } }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", - "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", - "cpu": [ - "x64" - ], + "node_modules/@secretlint/formatter/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">= 20" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.0", + "node_modules/@secretlint/formatter/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@secretlint/formatter/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@secretlint/node": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.2.tgz", + "integrity": "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-loader": "^10.2.2", + "@secretlint/core": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "@secretlint/source-creator": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "p-map": "^7.0.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/profiler": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz", + "integrity": "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/resolver": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", + "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/secretlint-formatter-sarif": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz", + "integrity": "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-sarif-builder": "^3.2.0" + } + }, + "node_modules/@secretlint/secretlint-rule-no-dotenv": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", + "integrity": "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/secretlint-rule-preset-recommend": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.2.tgz", + "integrity": "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/source-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-10.2.2.tgz", + "integrity": "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2", + "istextorbinary": "^9.5.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/types": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.2.tgz", + "integrity": "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", "cpu": [ @@ -5249,6 +5748,67 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@textlint/ast-node-types": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.7.1.tgz", + "integrity": "sha512-Wii5UgUKFEh9Uv6wbq1zr4/Kf+dtjiUuzPrrXzKp8H+ifkvKNzi23V4Nz+6wVyHQn5T28AFuc8VH8OtzvGYecA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.7.1.tgz", + "integrity": "sha512-TdwZ/debWYFD05K3CcoHtwvnCrza29wZxD+BjDTk/V5N7iRqkK1dTTHSD4A8AIgROLiDkHJmIKQbasbmsg8AvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azu/format-text": "^1.0.2", + "@azu/style-format": "^1.0.1", + "@textlint/module-interop": "15.7.1", + "@textlint/resolver": "15.7.1", + "@textlint/types": "15.7.1", + "chalk": "^4.1.2", + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "lodash": "^4.18.1", + "pluralize": "^2.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "table": "^6.9.0", + "text-table": "^0.2.0" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/pluralize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", + "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/module-interop": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.7.1.tgz", + "integrity": "sha512-Jg+sQW2L/cRJypk59wtcMUVVpt8vmit5ZMT3gUnFwevP3A6Qp1HfOtUy9ObT4hBX3lOSGT/ekcCDxR1pL7uH1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/resolver": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.7.1.tgz", + "integrity": "sha512-8XnO0pgF6mXnm41VvWmBbEIdGPhiCUt31uLZkOis1ECeg/1SoUcIT6Mx/F0e1rukq8l0UlOSeY9a31CsvRMK0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/types": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.7.1.tgz", + "integrity": "sha512-Vye/GmFNBTgVzZFtIFJTmLB+s2A7oIADxNG6r9UhfPuY+Czv0z5G3xeyFZZudPlfxURsKUyPIU5XsjOFqVp33A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "15.7.1" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -5402,6 +5962,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/prismjs": { "version": "1.26.5", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", @@ -5436,6 +6003,13 @@ "@types/react": "*" } }, + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -5448,6 +6022,13 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/vscode": { + "version": "1.120.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.120.0.tgz", + "integrity": "sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", @@ -5691,6 +6272,21 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.5.tgz", + "integrity": "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", @@ -5717,38 +6313,278 @@ } } }, - "node_modules/@xyflow/react": { - "version": "12.10.2", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", - "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "node_modules/@vscode/test-electron": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", + "integrity": "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==", + "dev": true, "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.76", - "classcat": "^5.0.3", - "zustand": "^4.4.0" + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "jszip": "^3.10.1", + "ora": "^8.1.0", + "semver": "^7.6.2" }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" + "engines": { + "node": ">=16" } }, - "node_modules/@xyflow/react/node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "node_modules/@vscode/vsce": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.1.tgz", + "integrity": "sha512-MPn5p+DoudI+3GfJSpAZZraE1lgLv0LcwbH3+xy7RgEhty3UIkmUMUA+5jPTDaxXae00AnX5u77FxGM8FhfKKA==", + "dev": true, "license": "MIT", "dependencies": { - "use-sync-external-store": "^1.2.2" + "@azure/identity": "^4.1.0", + "@secretlint/node": "^10.1.2", + "@secretlint/secretlint-formatter-sarif": "^10.1.2", + "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", + "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", + "chalk": "^4.1.2", + "cheerio": "^1.0.0-rc.9", + "cockatiel": "^3.1.2", + "commander": "^12.1.0", + "form-data": "^4.0.0", + "glob": "^11.0.0", + "hosted-git-info": "^4.0.2", + "jsonc-parser": "^3.2.0", + "leven": "^3.1.0", + "markdown-it": "^14.1.0", + "mime": "^1.3.4", + "minimatch": "^3.0.3", + "parse-semver": "^1.1.1", + "read": "^1.0.7", + "secretlint": "^10.1.2", + "semver": "^7.5.2", + "tmp": "^0.2.3", + "typed-rest-client": "^1.8.4", + "url-join": "^4.0.1", + "xml2js": "^0.5.0", + "yauzl": "^3.2.1", + "yazl": "^2.2.2" }, - "engines": { - "node": ">=12.7.0" + "bin": { + "vsce": "vsce" }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" + "engines": { + "node": ">= 20" }, - "peerDependenciesMeta": { + "optionalDependencies": { + "keytar": "^7.7.0" + } + }, + "node_modules/@vscode/vsce-sign": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.9.tgz", + "integrity": "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g==", + "dev": true, + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optionalDependencies": { + "@vscode/vsce-sign-alpine-arm64": "2.0.6", + "@vscode/vsce-sign-alpine-x64": "2.0.6", + "@vscode/vsce-sign-darwin-arm64": "2.0.6", + "@vscode/vsce-sign-darwin-x64": "2.0.6", + "@vscode/vsce-sign-linux-arm": "2.0.6", + "@vscode/vsce-sign-linux-arm64": "2.0.6", + "@vscode/vsce-sign-linux-x64": "2.0.6", + "@vscode/vsce-sign-win32-arm64": "2.0.6", + "@vscode/vsce-sign-win32-x64": "2.0.6" + } + }, + "node_modules/@vscode/vsce-sign-alpine-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz", + "integrity": "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-alpine-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz", + "integrity": "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz", + "integrity": "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz", + "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz", + "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz", + "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz", + "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-win32-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz", + "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce-sign-win32-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz", + "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vscode/vsce/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@vscode/vsce/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { "@types/react": { "optional": true }, @@ -5800,6 +6636,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -5856,6 +6702,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -5867,6 +6720,23 @@ "node": ">=10" } }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/auto-bind": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", @@ -5879,6 +6749,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/azure-devops-node-api": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", + "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "^1.8.4" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -5889,6 +6770,87 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/binaryextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", + "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/boundary": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", + "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -5902,49 +6864,152 @@ "node": "18 || 20 || >=22" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "fill-range": "^7.1.1" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=8" } }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" } }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "engines": { + "node": "*" } }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "license": "MIT", + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -5960,6 +7025,58 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -5989,6 +7106,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-spinners": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", @@ -6082,6 +7215,16 @@ "node": ">=6" } }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/code-excerpt": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", @@ -6112,6 +7255,19 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -6131,6 +7287,13 @@ "node": ">=18" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/concurrently": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", @@ -6173,6 +7336,13 @@ "node": ">=18" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6187,6 +7357,36 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -6418,49 +7618,62 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, - "node_modules/enhanced-resolve": { - "version": "5.21.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", - "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" }, "engines": { - "node": ">=10.13.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -6469,45 +7682,301 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/es-toolkit": { - "version": "1.46.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", - "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">=6" + "node": ">=0.4.0" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/eslint": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", - "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/editions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", + "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "version-range": "^4.15.0" + }, + "engines": { + "ecmascript": ">= es5", + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", + "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", @@ -6683,6 +8152,17 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6690,6 +8170,36 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -6703,6 +8213,33 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fault": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", @@ -6733,6 +8270,10 @@ } } }, + "node_modules/feather-vscode": { + "resolved": "vscode-extension", + "link": true + }, "node_modules/fflate": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", @@ -6751,6 +8292,19 @@ "node": ">=16.0.0" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -6787,6 +8341,40 @@ "dev": true, "license": "ISC" }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", @@ -6795,6 +8383,29 @@ "node": ">=0.4.x" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -6810,12 +8421,22 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, - "engines": { + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { "node": "6.* || 8.* || >= 10.*" } }, @@ -6831,6 +8452,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -6839,6 +8485,20 @@ "node": ">=6" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.14.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", @@ -6862,6 +8522,39 @@ "resolved": "https://registry.npmjs.org/gif.js.optimized/-/gif.js.optimized-1.0.1.tgz", "integrity": "sha512-IS0F42Xken6lp/iR4irgG4r52tvxRkEKsXGZmlUHUOb00SWNMezJOJwkVaJk2MLW53rqzMbPnnBtEhs9hcMJ9w==" }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -6874,6 +8567,50 @@ "node": ">=10.13.0" } }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -6889,6 +8626,48 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hast-util-parse-selector": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", @@ -6934,6 +8713,80 @@ "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", "license": "CC0-1.0" }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -6949,6 +8802,41 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6959,6 +8847,13 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, "node_modules/immer": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", @@ -6990,6 +8885,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -7033,6 +8956,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -7088,6 +9027,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-interactive": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", @@ -7100,6 +9058,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-unicode-supported": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", @@ -7112,26 +9080,103 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" + "node_modules/istextorbinary": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", + "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "binaryextensions": "^6.11.0", + "editions": "^6.21.0", + "textextensions": "^6.11.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true }, "node_modules/json-schema-traverse": { "version": "0.4.1", @@ -7146,6 +9191,111 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7155,6 +9305,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -7168,6 +9328,16 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -7417,6 +9587,16 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -7432,6 +9612,69 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", @@ -7486,6 +9729,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/lucide-react": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", @@ -7504,6 +9760,114 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -7513,6 +9877,33 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -7529,12 +9920,48 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -7553,6 +9980,14 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -7569,6 +10004,149 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-sarif-builder": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.4.0.tgz", + "integrity": "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sarif": "^2.1.7", + "fs-extra": "^11.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7583,7 +10161,98 @@ "word-wrap": "^1.2.5" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ora/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/p-limit": { @@ -7616,6 +10285,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -7641,6 +10337,110 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-semver": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", + "integrity": "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.1.0" + } + }, + "node_modules/parse-semver/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/patch-console": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", @@ -7668,6 +10468,53 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7718,6 +10565,16 @@ "node": ">=18" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -7746,6 +10603,35 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7776,6 +10662,13 @@ "node": ">=6" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -7786,6 +10679,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7796,6 +10701,53 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/radix-ui": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", @@ -7987,6 +10939,36 @@ } } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc-config-loader": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", + "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "json5": "^2.2.3", + "require-from-string": "^2.0.2" + } + }, "node_modules/react": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", @@ -8166,6 +11148,88 @@ "react-dom": ">=16 || >=17 || >= 18 || >=19" } }, + "node_modules/read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "mute-stream": "~0.0.4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/recharts": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", @@ -8236,6 +11300,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -8248,8 +11322,36 @@ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "devOptional": true, "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" } }, "node_modules/rolldown": { @@ -8285,6 +11387,43 @@ "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -8294,12 +11433,72 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/secretlint": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", + "integrity": "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-creator": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/node": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "debug": "^4.4.1", + "globby": "^14.1.0", + "read-pkg": "^9.0.1" + }, + "bin": { + "secretlint": "bin/secretlint.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/semver": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", @@ -8318,6 +11517,13 @@ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -8351,6 +11557,157 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/slice-ansi": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", @@ -8423,6 +11780,42 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -8450,51 +11843,178 @@ "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/structured-source": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", + "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boundary": "^2.0.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "has-flag": "^4.0.0" }, "engines": { "node": ">=8" } }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "ansi-regex": "^5.0.1" + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=8" + "node": ">=10.0.0" } }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/table/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, + "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/table/node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" }, "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, "node_modules/tagged-tag": { @@ -8538,6 +12058,71 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/terminal-link": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", + "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "supports-hyperlinks": "^3.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/terminal-size": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", @@ -8550,6 +12135,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/textextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", + "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -8571,6 +12179,29 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -8674,6 +12305,30 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -8711,6 +12366,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-rest-client": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", + "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "qs": "^6.9.1", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -8749,6 +12416,30 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", @@ -8756,6 +12447,29 @@ "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -8766,6 +12480,13 @@ "punycode": "^2.1.0" } }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -8816,6 +12537,24 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, "node_modules/vaul": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", @@ -8828,6 +12567,19 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/version-range": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", + "integrity": "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==", + "dev": true, + "license": "Artistic-2.0", + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -8941,6 +12693,30 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9040,6 +12816,14 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", @@ -9061,6 +12845,46 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -9070,6 +12894,13 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -9097,6 +12928,30 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.3.0.tgz", + "integrity": "sha512-PtGEvEP30p7sbIBJKUBjUnqgTVOyMURc4dLo9iNyAJnNIEz9pm88cCXF21w94Kg3k6RXkeZh5DHOGS0qEONvNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yazl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", + "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -9152,6 +13007,38 @@ "optional": true } } + }, + "vscode-extension": { + "name": "feather-vscode", + "version": "1.2.0", + "license": "EPL-2.0", + "devDependencies": { + "@types/node": "^22.15.0", + "@types/vscode": "^1.101.0", + "@vscode/test-electron": "^2.5.2", + "@vscode/vsce": "^3.6.3", + "typescript": "^6.0.3" + }, + "engines": { + "vscode": "^1.101.0" + } + }, + "vscode-extension/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "vscode-extension/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index bb5e0258..c15ff632 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "version": "1.2.0", "type": "module", "workspaces": [ - "cli" + "cli", + "vscode-extension" ], "scripts": { "cli:build": "npm run build --workspace=cli", @@ -14,6 +15,12 @@ "test:lua:e2e": "node scripts/lua-e2e.mjs", "test:app:e2e": "playwright test", "test:tauri:e2e": "cargo test --manifest-path src-tauri/Cargo.toml ws_server", + "extension:prepare": "node vscode-extension/scripts/prepare.mjs", + "extension:build": "npm run extension:prepare && tsc -p vscode-extension/tsconfig.json", + "extension:binary": "npm run cli:build && npm run build:binary --workspace=cli", + "extension:test": "npm run extension:build && npm run test --workspace=vscode-extension", + "extension:test:integration": "npm run extension:build && npm run test:integration --workspace=vscode-extension", + "extension:package": "npm run extension:binary && npm run extension:build && cd vscode-extension && npx @vscode/vsce package", "feather": "node cli/dist/index.js", "dev": "vite", "dev:lua": "concurrently \"vite\" \"sh ./scripts/watch-lua.sh\"", diff --git a/scripts/set-version.sh b/scripts/set-version.sh index 279ce5a9..738f9915 100755 --- a/scripts/set-version.sh +++ b/scripts/set-version.sh @@ -37,9 +37,14 @@ sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" \ sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" \ "${ROOT}/cli/package.json" +# vscode-extension/package.json +sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" \ + "${ROOT}/vscode-extension/package.json" + echo "Done. Updated:" echo " package.json" echo " src-lua/feather/init.lua" echo " src-tauri/Cargo.toml" echo " src-tauri/tauri.conf.json" echo " cli/package.json" +echo " vscode-extension/package.json" diff --git a/vscode-extension/.vscodeignore b/vscode-extension/.vscodeignore new file mode 100644 index 00000000..093ea9f9 --- /dev/null +++ b/vscode-extension/.vscodeignore @@ -0,0 +1,9 @@ +src/ +test/ +scripts/ +tsconfig.json +*.map +node_modules/** +../** +../../** +*.vsix diff --git a/vscode-extension/package.json b/vscode-extension/package.json new file mode 100644 index 00000000..1a865038 --- /dev/null +++ b/vscode-extension/package.json @@ -0,0 +1,177 @@ +{ + "name": "feather-vscode", + "displayName": "Feather for LÖVE", + "description": "Run, debug, and manage Feather for Love2D games from VS Code.", + "version": "1.2.0", + "publisher": "kyonru", + "license": "EPL-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/Kyonru/feather.git", + "directory": "vscode-extension" + }, + "engines": { + "vscode": "^1.101.0" + }, + "categories": [ + "Other" + ], + "keywords": [ + "feather", + "love2d", + "lua", + "debugger" + ], + "activationEvents": [], + "main": "./out/extension.js", + "contributes": { + "commands": [ + { + "command": "feather.run", + "title": "Run Project", + "category": "Feather", + "icon": "$(play)" + }, + { + "command": "feather.init", + "title": "Initialize Project", + "category": "Feather", + "icon": "$(add)" + }, + { + "command": "feather.doctor", + "title": "Run Doctor", + "category": "Feather", + "icon": "$(pulse)" + }, + { + "command": "feather.plugins", + "title": "Manage Plugins", + "category": "Feather", + "icon": "$(extensions)" + }, + { + "command": "feather.packages", + "title": "Manage Packages", + "category": "Feather", + "icon": "$(package)" + }, + { + "command": "feather.upload", + "title": "Upload Build", + "category": "Feather", + "icon": "$(cloud-upload)" + }, + { + "command": "feather.remove", + "title": "Remove Feather", + "category": "Feather", + "icon": "$(trash)" + }, + { + "command": "feather.update", + "title": "Update Runtime", + "category": "Feather", + "icon": "$(sync)" + }, + { + "command": "feather.refreshProjectView", + "title": "Refresh", + "category": "Feather", + "icon": "$(refresh)" + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "feather", + "title": "Feather", + "icon": "resources/feather-clear.svg" + } + ] + }, + "views": { + "feather": [ + { + "id": "feather.projectView", + "name": "Project" + } + ] + }, + "menus": { + "view/title": [ + { + "command": "feather.run", + "when": "view == feather.projectView", + "group": "navigation" + }, + { + "command": "feather.doctor", + "when": "view == feather.projectView", + "group": "navigation" + }, + { + "command": "feather.refreshProjectView", + "when": "view == feather.projectView", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "feather.run", + "when": "view == feather.projectView && viewItem == actionRun", + "group": "inline" + }, + { + "command": "feather.init", + "when": "view == feather.projectView && viewItem == actionInit", + "group": "inline" + }, + { + "command": "feather.doctor", + "when": "view == feather.projectView && viewItem == actionDoctor", + "group": "inline" + } + ] + }, + "configuration": { + "title": "Feather", + "properties": { + "feather.projectDir": { + "type": "string", + "default": "", + "description": "Path to the LÖVE project. Defaults to the first workspace folder." + }, + "feather.loveExecutable": { + "type": "string", + "default": "", + "description": "Path to the LÖVE executable. Leave empty for Feather auto-detection." + }, + "feather.defaultUploadTarget": { + "type": "string", + "default": "itch", + "description": "Default upload target used by Feather: Upload Build." + }, + "feather.defaultUploadChannel": { + "type": "string", + "default": "", + "description": "Default upload channel passed to Feather uploads." + } + } + } + }, + "scripts": { + "prepare:bundle": "node scripts/prepare.mjs", + "build": "tsc -p .", + "test": "node --test test/*.test.mjs", + "test:integration": "node test/e2e/run.mjs", + "package": "npm run prepare:bundle && npm run build && vsce package" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "@types/vscode": "^1.101.0", + "@vscode/test-electron": "^2.5.2", + "@vscode/vsce": "^3.6.3", + "typescript": "^6.0.3" + } +} diff --git a/vscode-extension/resources/feather-clear.svg b/vscode-extension/resources/feather-clear.svg new file mode 100644 index 00000000..b97e0c90 --- /dev/null +++ b/vscode-extension/resources/feather-clear.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vscode-extension/resources/feather.svg b/vscode-extension/resources/feather.svg new file mode 100644 index 00000000..0995f64b --- /dev/null +++ b/vscode-extension/resources/feather.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vscode-extension/scripts/prepare.mjs b/vscode-extension/scripts/prepare.mjs new file mode 100644 index 00000000..5b3a1629 --- /dev/null +++ b/vscode-extension/scripts/prepare.mjs @@ -0,0 +1,40 @@ +import { cpSync, existsSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const extensionRoot = join(__dirname, '..'); +const repoRoot = join(extensionRoot, '..'); + +const cliBin = join(repoRoot, 'cli', 'bin'); +const cliLua = join(repoRoot, 'cli', 'lua'); +const cliGenerated = join(repoRoot, 'cli', 'dist', 'generated'); +const bundledBin = join(extensionRoot, 'bundled-bin'); + +if (!existsSync(cliBin)) { + throw new Error('Missing cli/bin/. Run `npm run build:binary --workspace=cli` first.'); +} + +if (!existsSync(join(cliLua, 'feather', 'init.lua'))) { + throw new Error('Missing cli/lua/feather/init.lua. Run `npm run bundle:lua --workspace=cli` first.'); +} + +rmSync(bundledBin, { recursive: true, force: true }); + +// Copy platform binaries +cpSync(cliBin, bundledBin, { recursive: true }); + +// Copy Lua runtime next to the binaries (bundledLuaRoot() checks process.execPath + '/lua') +cpSync(cliLua, join(bundledBin, 'lua'), { recursive: true }); + +// Copy registry.json for the packages catalog +cpSync(join(cliGenerated, 'registry.json'), join(bundledBin, 'registry.json')); + +// Generate plugin-catalog.json from the ESM plugin-catalog.js +const { pluginCatalog } = await import(join(cliGenerated, 'plugin-catalog.js')); +writeFileSync( + join(bundledBin, 'plugin-catalog.json'), + JSON.stringify(pluginCatalog, null, 2) + '\n', +); + +console.log('Prepared bundled-bin/ with platform binaries, lua/, registry.json, and plugin-catalog.json'); diff --git a/vscode-extension/src/catalog.ts b/vscode-extension/src/catalog.ts new file mode 100644 index 00000000..5f3233cb --- /dev/null +++ b/vscode-extension/src/catalog.ts @@ -0,0 +1,65 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type * as vscode from 'vscode'; + +export type PluginEntry = { + id: string; + name: string; + description: string; + capabilities: string[]; + optIn: boolean; +}; + +export type PackageEntry = { + id: string; + description: string; + version: string; + trust: string; + tags: string[]; + installed?: boolean; +}; + +type RegistryFile = { + packages: Record; +}; + +export async function loadPluginCatalog(context: vscode.ExtensionContext): Promise { + const file = join(context.extensionPath, 'bundled-bin', 'plugin-catalog.json'); + if (!existsSync(file)) return []; + const data = JSON.parse(readFileSync(file, 'utf8')) as PluginEntry[]; + return [...data].sort((a, b) => a.name.localeCompare(b.name)); +} + +export function loadPackageCatalog(context: vscode.ExtensionContext, installedIds = new Set()): PackageEntry[] { + const file = join(context.extensionPath, 'bundled-bin', 'registry.json'); + if (!existsSync(file)) return []; + const registry = JSON.parse(readFileSync(file, 'utf8')) as RegistryFile; + return Object.entries(registry.packages) + .filter(([, entry]) => !entry.parent) + .map(([id, entry]) => ({ + id, + description: entry.description, + version: entry.source?.tag ?? 'unknown', + trust: entry.trust, + tags: entry.tags ?? [], + installed: installedIds.has(id), + })) + .sort((a, b) => a.id.localeCompare(b.id)); +} + +export function readInstalledPackageIds(root: string): Set { + const file = join(root, 'feather.lock.json'); + if (!existsSync(file)) return new Set(); + try { + const parsed = JSON.parse(readFileSync(file, 'utf8')) as { packages?: Record }; + return new Set(Object.keys(parsed.packages ?? {})); + } catch { + return new Set(); + } +} diff --git a/vscode-extension/src/cli.ts b/vscode-extension/src/cli.ts new file mode 100644 index 00000000..5ad69167 --- /dev/null +++ b/vscode-extension/src/cli.ts @@ -0,0 +1,49 @@ +import * as vscode from 'vscode'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { join } from 'node:path'; +import * as os from 'node:os'; +import { shellQuote } from './command'; + +function getBinaryPath(context: vscode.ExtensionContext): string { + const p = os.platform(); + const a = os.arch(); + const name = + p === 'darwin' && a === 'arm64' ? 'feather' : + p === 'darwin' ? 'feather-darwin-x64' : + p === 'win32' ? 'feather-win-x64.exe' : 'feather-linux-x64'; + return join(context.extensionPath, 'bundled-bin', name); +} + +export function runInTerminal( + context: vscode.ExtensionContext, + name: string, + args: string[], + cwd: string, +): vscode.Terminal { + return runCommandsInTerminal(context, name, [args], cwd); +} + +export function runCommandsInTerminal( + context: vscode.ExtensionContext, + name: string, + commands: string[][], + cwd: string, +): vscode.Terminal { + const bin = getBinaryPath(context); + const existing = vscode.window.terminals.find((t) => t.name === name); + existing?.dispose(); + const terminal = vscode.window.createTerminal({ name, cwd }); + for (const args of commands) { + terminal.sendText([bin, ...args].map(shellQuote).join(' ')); + } + terminal.show(); + return terminal; +} + +export function spawnFeather( + context: vscode.ExtensionContext, + args: string[], + cwd: string, +): ChildProcess { + return spawn(getBinaryPath(context), args, { cwd, shell: false }); +} diff --git a/vscode-extension/src/command.ts b/vscode-extension/src/command.ts new file mode 100644 index 00000000..b38278a6 --- /dev/null +++ b/vscode-extension/src/command.ts @@ -0,0 +1,15 @@ +export function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:=@%+-]+$/.test(value)) return value; + return `"${value.replace(/(["\\$`])/g, '\\$1')}"`; +} + +export function buildCommand(executable: string, script: string, args: string[]): string { + return [executable, script, ...args].map(shellQuote).join(' '); +} + +export function buildEnvCommand(env: Record, executable: string, script: string, args: string[]): string { + const assignments = Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`); + return [...assignments, executable, script, ...args].map((value, index) => ( + index < assignments.length ? value : shellQuote(value) + )).join(' '); +} diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts new file mode 100644 index 00000000..842f9474 --- /dev/null +++ b/vscode-extension/src/extension.ts @@ -0,0 +1,292 @@ +import * as vscode from 'vscode'; +import { loadPackageCatalog, loadPluginCatalog, readInstalledPackageIds, type PackageEntry, type PluginEntry } from './catalog'; +import { runCommandsInTerminal, runInTerminal } from './cli'; +import { FeatherProjectProvider } from './featherPanel'; +import { getProjectStatus, resolveProjectDir } from './project'; + +type Pick = vscode.QuickPickItem & { value: T }; +type PluginPick = vscode.QuickPickItem & { plugin: PluginEntry }; +type PackagePick = vscode.QuickPickItem & { pkg: PackageEntry }; + +function workspaceRoots(): string[] { + return vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath) ?? []; +} + +function featherConfig(): vscode.WorkspaceConfiguration { + return vscode.workspace.getConfiguration('feather'); +} + +function activeProjectDir(): string | undefined { + return resolveProjectDir(featherConfig().get('projectDir'), workspaceRoots()); +} + +function requireProjectDir(): string | undefined { + const root = activeProjectDir(); + if (!root) { + vscode.window.showErrorMessage('No workspace open.'); + return undefined; + } + return root; +} + +function pluginPick(plugin: PluginEntry): PluginPick { + const caps = plugin.capabilities.length > 0 ? ` · ${plugin.capabilities.join(', ')}` : ''; + return { + label: plugin.name, + description: plugin.id, + detail: `${plugin.description}${caps}`, + plugin, + }; +} + +function packagePick(pkg: PackageEntry): PackagePick { + return { + label: pkg.id, + description: `${pkg.version} · ${pkg.trust}${pkg.installed ? ' · installed' : ''}`, + detail: pkg.description, + pkg, + }; +} + +async function pickPlugins(context: vscode.ExtensionContext, placeHolder: string): Promise { + const catalog = await loadPluginCatalog(context); + const picked = await vscode.window.showQuickPick(catalog.map(pluginPick), { + canPickMany: true, + matchOnDescription: true, + matchOnDetail: true, + placeHolder, + }); + return picked?.map((item) => item.plugin); +} + +async function registerCommands( + context: vscode.ExtensionContext, + provider: FeatherProjectProvider, +): Promise { + context.subscriptions.push( + vscode.commands.registerCommand('feather.run', () => { + const root = requireProjectDir(); + if (!root) return; + const args = ['run', root]; + const love = featherConfig().get('loveExecutable')?.trim(); + if (love) args.push('--love', love); + runInTerminal(context, 'Feather: Run', args, root); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('feather.init', async () => { + const root = requireProjectDir(); + if (!root) return; + + const mode = await vscode.window.showQuickPick>( + [ + { label: 'CLI mode', description: 'No game-code changes. Use Feather: Run Project.', value: 'cli' }, + { label: 'Auto mode', description: 'Patch main.lua with guarded feather.auto loader.', value: 'auto' }, + { label: 'Manual mode', description: 'Create feather.debugger.lua and guarded loader.', value: 'manual' }, + ], + { placeHolder: 'Select init mode' }, + ); + if (!mode) return; + + const plugins = await pickPlugins(context, 'Select plugins to install now'); + if (!plugins) return; + + const args = ['init', root, '--mode', mode.value, '--yes', '--allow-insecure-connection']; + if (plugins.length > 0) args.push('--plugins', plugins.map((plugin) => plugin.id).join(',')); + runInTerminal(context, 'Feather: Init', args, root); + provider.refresh(); + setTimeout(() => provider.refresh(), 1500); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('feather.doctor', () => { + const root = requireProjectDir(); + if (!root) return; + runInTerminal(context, 'Feather: Doctor', ['doctor', root], root); + provider.refresh(); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('feather.plugins', async () => { + const root = requireProjectDir(); + if (!root) return; + const action = await vscode.window.showQuickPick>( + [ + { label: 'Install plugins', description: 'Copy plugins into the project and include them in config.', value: 'install' }, + { label: 'Update plugins', description: 'Refresh installed plugin files from the bundled runtime.', value: 'update' }, + { label: 'Remove plugins', description: 'Delete plugin folders and exclude them in config.', value: 'remove' }, + ], + { placeHolder: 'Plugin action' }, + ); + if (!action) return; + + const plugins = await pickPlugins(context, `Select plugins to ${action.value}`); + if (!plugins || plugins.length === 0) return; + const ids = plugins.map((plugin) => plugin.id); + const commands = + action.value === 'install' + ? [ + ...ids.map((id) => ['plugin', 'install', id, '--dir', root]), + ['config', 'plugins', '--dir', root, '--include', ids.join(',')], + ] + : action.value === 'remove' + ? [ + ...ids.map((id) => ['plugin', 'remove', id, '--dir', root, '--yes']), + ['config', 'plugins', '--dir', root, '--exclude', ids.join(',')], + ] + : ids.map((id) => ['plugin', 'update', id, '--dir', root, '--yes']); + + runCommandsInTerminal(context, `Feather: Plugins ${action.value}`, commands, root); + provider.refresh(); + setTimeout(() => provider.refresh(), 1500); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('feather.packages', async () => { + const root = requireProjectDir(); + if (!root) return; + const installedIds = readInstalledPackageIds(root); + const packages = loadPackageCatalog(context, installedIds); + const selected = await vscode.window.showQuickPick(packages.map(packagePick), { + matchOnDescription: true, + matchOnDetail: true, + placeHolder: 'Select a package', + }); + if (!selected) return; + const action = await vscode.window.showQuickPick>( + selected.pkg.installed + ? [ + { label: 'Update', description: selected.pkg.version, value: 'update' }, + { label: 'Remove', description: 'Delete package files and lockfile entry.', value: 'remove' }, + ] + : [ + { label: 'Install', description: selected.pkg.version, value: 'install' }, + ], + { placeHolder: `${selected.pkg.id}: choose action` }, + ); + if (!action) return; + + const command = + action.value === 'remove' + ? ['package', 'remove', selected.pkg.id, '--dir', root, '--yes'] + : action.value === 'update' + ? ['package', 'update', selected.pkg.id, '--dir', root] + : ['package', 'install', selected.pkg.id, '--dir', root, '--yes']; + runInTerminal(context, `Feather: Package ${action.value}`, command, root); + provider.refresh(); + setTimeout(() => provider.refresh(), 1500); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('feather.upload', async () => { + const root = requireProjectDir(); + if (!root) return; + const dryRun = await vscode.window.showQuickPick>( + [ + { label: 'Dry run', description: 'Show the upload plan without running the uploader.', value: 'dry' }, + { label: 'Upload', description: 'Run the uploader after confirmation.', value: 'upload' }, + ], + { placeHolder: 'Upload mode' }, + ); + if (!dryRun) return; + + if (dryRun.value === 'upload') { + const confirmed = await vscode.window.showWarningMessage('Upload this build?', { modal: true }, 'Upload'); + if (confirmed !== 'Upload') return; + } + + const build = await vscode.window.showQuickPick>( + [ + { label: 'Use existing build', description: 'Upload from feather-build-manifest.json.', value: 'none' }, + { label: 'Build web first', value: 'web' }, + { label: 'Build Windows first', value: 'windows' }, + { label: 'Build macOS first', value: 'macos' }, + { label: 'Build Linux first', value: 'linux' }, + { label: 'Build Android first', value: 'android' }, + { label: 'Build iOS first', value: 'ios' }, + { label: 'Build SteamOS first', value: 'steamos' }, + ], + { placeHolder: 'Build before upload?' }, + ); + if (!build) return; + + const target = featherConfig().get('defaultUploadTarget')?.trim() || 'itch'; + const channel = featherConfig().get('defaultUploadChannel')?.trim(); + const args = ['upload', target]; + if (build.value !== 'none') args.push(build.value, '--build'); + args.push('--dir', root, '--yes'); + if (dryRun.value === 'dry') args.push('--dry-run'); + if (channel) args.push('--channel', channel); + runInTerminal(context, 'Feather: Upload', args, root); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('feather.remove', async () => { + const root = requireProjectDir(); + if (!root) return; + const confirmed = await vscode.window.showWarningMessage('Remove Feather from this project?', { modal: true }, 'Remove'); + if (confirmed !== 'Remove') return; + runInTerminal(context, 'Feather: Remove', ['remove', root, '--yes'], root); + provider.refresh(); + setTimeout(() => provider.refresh(), 1500); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('feather.update', () => { + const root = requireProjectDir(); + if (!root) return; + runInTerminal(context, 'Feather: Update', ['update', root, '--yes'], root); + provider.refresh(); + setTimeout(() => provider.refresh(), 1500); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('feather.refreshProjectView', () => provider.refresh()), + ); +} + +export async function activate(context: vscode.ExtensionContext): Promise { + try { + const provider = new FeatherProjectProvider(() => getProjectStatus(activeProjectDir())); + vscode.window.registerTreeDataProvider('feather.projectView', provider); + + const statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); + const updateStatus = () => { + const status = getProjectStatus(activeProjectDir()); + statusItem.text = status.hasConfig ? '$(zap) Feather' : '$(zap) Feather: Init'; + statusItem.tooltip = status.hasConfig ? 'Run Feather project' : 'Initialize Feather project'; + statusItem.command = status.hasConfig ? 'feather.run' : 'feather.init'; + }; + updateStatus(); + statusItem.show(); + + context.subscriptions.push( + statusItem, + vscode.workspace.onDidChangeWorkspaceFolders(() => { + updateStatus(); + provider.refresh(); + }), + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration('feather.projectDir')) { + updateStatus(); + provider.refresh(); + } + }), + ); + + await registerCommands(context, provider); + } catch (err) { + vscode.window.showErrorMessage(`Feather failed to activate: ${(err as Error).message ?? err}`); + throw err; + } +} + +export function deactivate(): void {} diff --git a/vscode-extension/src/featherPanel.ts b/vscode-extension/src/featherPanel.ts new file mode 100644 index 00000000..a6c94f8e --- /dev/null +++ b/vscode-extension/src/featherPanel.ts @@ -0,0 +1,72 @@ +import * as vscode from 'vscode'; +import type { ProjectStatus } from './project'; + +type FeatherItemKind = 'status' | 'actionRun' | 'actionInit' | 'actionDoctor' | 'actionPlugins' | 'actionPackages' | 'actionUpload'; + +export class FeatherProjectProvider implements vscode.TreeDataProvider { + private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; + + constructor(private getStatus: () => ProjectStatus) {} + + refresh(): void { + this.onDidChangeTreeDataEmitter.fire(); + } + + getTreeItem(item: FeatherTreeItem): vscode.TreeItem { + return item; + } + + getChildren(): FeatherTreeItem[] { + const status = this.getStatus(); + if (!status.hasWorkspace) { + return [statusItem('No workspace open', 'info')]; + } + if (!status.hasMain) { + return [ + statusItem('No main.lua found', 'warning'), + statusItem('Open a LÖVE project folder', 'info'), + ]; + } + if (!status.hasConfig) { + return [ + statusItem('feather.config.lua not found', 'warning'), + actionItem('Initialize Project', 'feather.init', 'add', 'actionInit'), + actionItem('Run Doctor', 'feather.doctor', 'pulse', 'actionDoctor'), + ]; + } + + return [ + statusItem('feather.config.lua found', 'pass'), + statusItem(status.hasRuntime ? 'Embedded runtime found' : 'CLI mode project', 'info'), + statusItem(`${status.pluginCount} plugin${status.pluginCount === 1 ? '' : 's'} installed`, 'info'), + statusItem(`${status.packageCount} package${status.packageCount === 1 ? '' : 's'} installed`, 'info'), + actionItem('Run Project', 'feather.run', 'play', 'actionRun'), + actionItem('Run Doctor', 'feather.doctor', 'pulse', 'actionDoctor'), + actionItem('Manage Plugins', 'feather.plugins', 'extensions', 'actionPlugins'), + actionItem('Manage Packages', 'feather.packages', 'package', 'actionPackages'), + actionItem('Upload Build', 'feather.upload', 'cloud-upload', 'actionUpload'), + ]; + } +} + +export class FeatherTreeItem extends vscode.TreeItem { + constructor(label: string, public readonly kind: FeatherItemKind) { + super(label); + this.contextValue = kind; + } +} + +function statusItem(label: string, icon: 'pass' | 'warning' | 'info'): FeatherTreeItem { + const item = new FeatherTreeItem(label, 'status'); + const icons = { pass: 'check', warning: 'warning', info: 'info' }; + item.iconPath = new vscode.ThemeIcon(icons[icon]); + return item; +} + +function actionItem(label: string, command: string, icon: string, kind: FeatherItemKind): FeatherTreeItem { + const item = new FeatherTreeItem(label, kind); + item.iconPath = new vscode.ThemeIcon(icon); + item.command = { command, title: label }; + return item; +} diff --git a/vscode-extension/src/project.ts b/vscode-extension/src/project.ts new file mode 100644 index 00000000..ad857a1c --- /dev/null +++ b/vscode-extension/src/project.ts @@ -0,0 +1,67 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +export type ProjectStatus = { + root: string | undefined; + hasWorkspace: boolean; + hasMain: boolean; + hasConfig: boolean; + hasRuntime: boolean; + pluginCount: number; + packageCount: number; +}; + +export function resolveProjectDir(configuredProjectDir: string | undefined, workspaceRoots: string[]): string | undefined { + const configured = configuredProjectDir?.trim(); + if (configured) return resolve(configured); + return workspaceRoots[0]; +} + +function countManifestFiles(dir: string): number { + if (!existsSync(dir)) return 0; + let count = 0; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + count += countManifestFiles(path); + } else if (entry.isFile() && entry.name === 'manifest.lua') { + count += 1; + } + } + return count; +} + +function packageCount(root: string): number { + const lockPath = join(root, 'feather.lock.json'); + if (!existsSync(lockPath)) return 0; + try { + const parsed = JSON.parse(readFileSync(lockPath, 'utf8')) as { packages?: Record }; + return Object.keys(parsed.packages ?? {}).length; + } catch { + return 0; + } +} + +export function getProjectStatus(root: string | undefined): ProjectStatus { + if (!root) { + return { + root, + hasWorkspace: false, + hasMain: false, + hasConfig: false, + hasRuntime: false, + pluginCount: 0, + packageCount: 0, + }; + } + + return { + root, + hasWorkspace: true, + hasMain: existsSync(join(root, 'main.lua')), + hasConfig: existsSync(join(root, 'feather.config.lua')), + hasRuntime: existsSync(join(root, 'feather', 'init.lua')), + pluginCount: countManifestFiles(join(root, 'feather', 'plugins')), + packageCount: packageCount(root), + }; +} diff --git a/vscode-extension/test/e2e/run.mjs b/vscode-extension/test/e2e/run.mjs new file mode 100644 index 00000000..de249638 --- /dev/null +++ b/vscode-extension/test/e2e/run.mjs @@ -0,0 +1,20 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runTests } from '@vscode/test-electron'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const extensionDevelopmentPath = resolve(__dirname, '../..'); +const extensionTestsPath = resolve(__dirname, 'suite/index.cjs'); +const workspacePath = join(tmpdir(), 'feather-vscode-e2e-workspace'); + +mkdirSync(workspacePath, { recursive: true }); +writeFileSync(join(workspacePath, 'main.lua'), 'function love.draw() end\n'); +writeFileSync(join(workspacePath, 'feather.config.lua'), 'return { __DANGEROUS_INSECURE_CONNECTION__ = true }\n'); + +await runTests({ + extensionDevelopmentPath, + extensionTestsPath, + launchArgs: [workspacePath, '--disable-extensions'], +}); diff --git a/vscode-extension/test/e2e/suite/index.cjs b/vscode-extension/test/e2e/suite/index.cjs new file mode 100644 index 00000000..6957866c --- /dev/null +++ b/vscode-extension/test/e2e/suite/index.cjs @@ -0,0 +1,20 @@ +const assert = require('node:assert/strict'); +const vscode = require('vscode'); + +suite('Feather VS Code extension', () => { + test('registers core commands', async () => { + const commands = await vscode.commands.getCommands(true); + for (const command of [ + 'feather.run', + 'feather.init', + 'feather.doctor', + 'feather.plugins', + 'feather.packages', + 'feather.upload', + 'feather.remove', + 'feather.update', + ]) { + assert.ok(commands.includes(command), `${command} should be registered`); + } + }); +}); diff --git a/vscode-extension/test/helpers.test.mjs b/vscode-extension/test/helpers.test.mjs new file mode 100644 index 00000000..c564eb91 --- /dev/null +++ b/vscode-extension/test/helpers.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { buildCommand, buildEnvCommand, shellQuote } = require('../out/command.js'); +const { getProjectStatus, resolveProjectDir } = require('../out/project.js'); + +test('command helpers quote paths with spaces', () => { + assert.equal(shellQuote('/tmp/Feather App/node'), '"/tmp/Feather App/node"'); + assert.equal( + buildCommand('/tmp/VS Code/node', '/tmp/ext/bundled-cli/index.js', ['run', '/tmp/My Game']), + '"/tmp/VS Code/node" /tmp/ext/bundled-cli/index.js run "/tmp/My Game"', + ); + assert.equal( + buildEnvCommand({ ELECTRON_RUN_AS_NODE: '1' }, '/tmp/VS Code/helper', '/tmp/ext/bundled-cli/launcher.js', ['run', '/tmp/My Game']), + 'ELECTRON_RUN_AS_NODE=1 "/tmp/VS Code/helper" /tmp/ext/bundled-cli/launcher.js run "/tmp/My Game"', + ); +}); + +test('project helpers resolve configured project before workspace root', () => { + assert.equal(resolveProjectDir('/tmp/game', ['/tmp/workspace']), '/tmp/game'); + assert.equal(resolveProjectDir('', ['/tmp/workspace']), '/tmp/workspace'); + assert.equal(resolveProjectDir(undefined, []), undefined); +}); + +test('project status reports config, runtime, plugins, and packages', () => { + const root = mkdtempSync(join(tmpdir(), 'feather-vscode-test-')); + try { + writeFileSync(join(root, 'main.lua'), ''); + writeFileSync(join(root, 'feather.config.lua'), 'return {}\n'); + mkdirSync(join(root, 'feather', 'plugins', 'console'), { recursive: true }); + writeFileSync(join(root, 'feather', 'init.lua'), 'return {}\n'); + writeFileSync(join(root, 'feather', 'plugins', 'console', 'manifest.lua'), 'return {}\n'); + writeFileSync(join(root, 'feather.lock.json'), JSON.stringify({ packages: { anim8: {}, baton: {} } })); + + const status = getProjectStatus(root); + assert.equal(status.hasWorkspace, true); + assert.equal(status.hasMain, true); + assert.equal(status.hasConfig, true); + assert.equal(status.hasRuntime, true); + assert.equal(status.pluginCount, 1); + assert.equal(status.packageCount, 2); + assert.equal(existsSync(root), true); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/vscode-extension/tsconfig.json b/vscode-extension/tsconfig.json new file mode 100644 index 00000000..3b0f504e --- /dev/null +++ b/vscode-extension/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "CommonJS", + "target": "ES2022", + "lib": ["ES2022"], + "outDir": "out", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "moduleResolution": "node", + "ignoreDeprecations": "6.0", + "types": ["node", "vscode"] + }, + "include": ["src"] +} From 7a2816ea718c8058e4598b28f7b092f913b4bfd7 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Tue, 19 May 2026 13:33:43 -0400 Subject: [PATCH 02/28] vscode-extension: add watch and vendor --- cli/src/lib/shim.ts | 10 +- src-lua/feather/plugin_manager.lua | 24 +- vscode-extension/package.json | 66 +++ vscode-extension/src/buildConfigPanel.ts | 598 +++++++++++++++++++++++ vscode-extension/src/extension.ts | 304 +++++++++++- vscode-extension/src/featherPanel.ts | 48 +- vscode-extension/src/vendor.ts | 54 ++ 7 files changed, 1061 insertions(+), 43 deletions(-) create mode 100644 vscode-extension/src/buildConfigPanel.ts create mode 100644 vscode-extension/src/vendor.ts diff --git a/cli/src/lib/shim.ts b/cli/src/lib/shim.ts index 1e7dfd8a..5b96010d 100644 --- a/cli/src/lib/shim.ts +++ b/cli/src/lib/shim.ts @@ -3,15 +3,17 @@ import { tmpdir } from 'node:os'; import { join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -const __dirname = fileURLToPath(new URL('.', import.meta.url)); - // Path to the bundled Lua library shipped with this CLI package. // In a source checkout `npm run build` does not run the publish-time Lua bundle, // so fall back to the repository's src-lua directory for local development. -const PACKAGED_LUA = resolve(__dirname, '../../lua'); -const SOURCE_LUA = resolve(__dirname, '../../../src-lua'); +const PACKAGED_LUA = resolve(dirname(fileURLToPath(new URL('.', import.meta.url))), '../../lua'); +const SOURCE_LUA = resolve(dirname(fileURLToPath(new URL('.', import.meta.url))), '../../../src-lua'); export function bundledLuaRoot(): string { + // When running as a compiled binary, lua/ ships next to the executable. + const siblingLua = join(dirname(process.execPath), 'lua'); + if (existsSync(join(siblingLua, 'feather', 'auto.lua'))) return siblingLua; + // Fallback for npm/node installs. if (existsSync(join(PACKAGED_LUA, 'feather', 'auto.lua'))) return PACKAGED_LUA; if (existsSync(join(SOURCE_LUA, 'feather', 'auto.lua'))) return SOURCE_LUA; return PACKAGED_LUA; diff --git a/src-lua/feather/plugin_manager.lua b/src-lua/feather/plugin_manager.lua index 9d0c0e19..e8f6b24f 100644 --- a/src-lua/feather/plugin_manager.lua +++ b/src-lua/feather/plugin_manager.lua @@ -96,28 +96,6 @@ local function describeApiCompatibility(compatibility, currentApi) return "Requires a different Feather plugin API. Desktop API is " .. tostring(currentApi) .. "." end -local function callbackReferences(callback, target) - if type(callback) ~= "function" or type(target) ~= "function" then - return false - end - if not debug or not debug.getupvalue then - return false - end - - local index = 1 - while true do - local name, value = debug.getupvalue(callback, index) - if not name then - break - end - if value == target then - return true - end - index = index + 1 - end - - return false -end ---@param feather Feather ---@param logger FeatherLogger @@ -410,7 +388,7 @@ function FeatherPluginManager:hookLoveCallbacks() end local current = love[name] - if current ~= wrapper and not callbackReferences(current, wrapper) then + if current ~= wrapper and not self._loveCallbackOriginals[name] then self._loveCallbackOriginals[name] = current love[name] = wrapper end diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 1a865038..0722adfb 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -79,6 +79,36 @@ "title": "Refresh", "category": "Feather", "icon": "$(refresh)" + }, + { + "command": "feather.toggleWatch", + "title": "Toggle Watch Mode", + "category": "Feather", + "icon": "$(eye)" + }, + { + "command": "feather.configure", + "title": "Configure", + "category": "Feather", + "icon": "$(settings-gear)" + }, + { + "command": "feather.vendor", + "title": "Manage Vendors", + "category": "Feather", + "icon": "$(archive)" + }, + { + "command": "feather.selectTargets", + "title": "Select Run Targets", + "category": "Feather", + "icon": "$(target)" + }, + { + "command": "feather.buildConfig", + "title": "Edit Build Config", + "category": "Feather", + "icon": "$(file-code)" } ], "viewsContainers": { @@ -131,6 +161,31 @@ "command": "feather.doctor", "when": "view == feather.projectView && viewItem == actionDoctor", "group": "inline" + }, + { + "command": "feather.toggleWatch", + "when": "view == feather.projectView && viewItem == actionToggleWatch", + "group": "inline" + }, + { + "command": "feather.configure", + "when": "view == feather.projectView && viewItem == actionConfigure", + "group": "inline" + }, + { + "command": "feather.vendor", + "when": "view == feather.projectView && viewItem == actionVendor", + "group": "inline" + }, + { + "command": "feather.selectTargets", + "when": "view == feather.projectView && viewItem == actionSelectTargets", + "group": "inline" + }, + { + "command": "feather.buildConfig", + "when": "view == feather.projectView && viewItem == actionBuildConfig", + "group": "inline" } ] }, @@ -156,6 +211,17 @@ "type": "string", "default": "", "description": "Default upload channel passed to Feather uploads." + }, + "feather.watchMode": { + "type": "boolean", + "default": false, + "description": "When enabled, Feather: Run Project uses watch instead of run (hot-reloads on file changes)." + }, + "feather.runTargets": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Run targets selected for Feather: Run Project (e.g. desktop, web, android, ios, steamos)." } } } diff --git a/vscode-extension/src/buildConfigPanel.ts b/vscode-extension/src/buildConfigPanel.ts new file mode 100644 index 00000000..840d6863 --- /dev/null +++ b/vscode-extension/src/buildConfigPanel.ts @@ -0,0 +1,598 @@ +import * as vscode from 'vscode'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +type BuildConfig = { + name?: string; + version?: string; + loveVersion?: string; + productId?: string; + description?: string; + company?: string; + website?: string; + copyright?: string; + sourceDir?: string; + outDir?: string; + include?: string[]; + exclude?: string[]; + icon?: string; + includeRuntime?: boolean; + targets?: { + web?: { loveJsDir?: string; title?: string; outputName?: string }; + windows?: { loveRuntimeDir?: string }; + macos?: { loveRuntimeDir?: string }; + linux?: { loveRuntimeDir?: string }; + steamos?: { loveRuntimeDir?: string }; + android?: { + loveAndroidDir?: string; + productId?: string; + displayName?: string; + orientation?: string; + versionCode?: number; + versionName?: string; + }; + ios?: { + loveIosDir?: string; + bundleIdentifier?: string; + displayName?: string; + scheme?: string; + configuration?: string; + deploymentTarget?: string; + }; + }; + upload?: { + itch?: { project?: string; channels?: Record }; + }; +}; + +let panel: vscode.WebviewPanel | undefined; + +export function openBuildConfigPanel(context: vscode.ExtensionContext, root: string): void { + if (panel) { + panel.reveal(vscode.ViewColumn.One); + return; + } + + panel = vscode.window.createWebviewPanel( + 'featherBuildConfig', + 'feather.build.json', + vscode.ViewColumn.One, + { enableScripts: true, retainContextWhenHidden: true }, + ); + + panel.onDidDispose(() => { panel = undefined; }, null, context.subscriptions); + + const configPath = join(root, 'feather.build.json'); + let config: BuildConfig = {}; + if (existsSync(configPath)) { + try { + config = JSON.parse(readFileSync(configPath, 'utf8')) as BuildConfig; + } catch { + vscode.window.showWarningMessage('feather.build.json contains invalid JSON — starting with an empty config.'); + } + } + + panel.webview.html = getWebviewContent(config); + + panel.webview.onDidReceiveMessage( + async (msg: { type: string; config?: BuildConfig; key?: string; kind?: 'file' | 'folder' }) => { + switch (msg.type) { + case 'save': + if (msg.config) { + try { + writeFileSync(configPath, JSON.stringify(msg.config, null, 2) + '\n', 'utf8'); + vscode.window.showInformationMessage('feather.build.json saved.'); + } catch (err) { + vscode.window.showErrorMessage(`Failed to save feather.build.json: ${(err as Error).message}`); + } + } + break; + + case 'browse': { + const isFolder = msg.kind === 'folder'; + const uris = await vscode.window.showOpenDialog({ + canSelectFiles: !isFolder, + canSelectFolders: isFolder, + canSelectMany: false, + openLabel: isFolder ? 'Select folder' : 'Select file', + defaultUri: vscode.Uri.file(root), + }); + if (uris?.[0]) { + panel?.webview.postMessage({ type: 'browse-result', key: msg.key, value: uris[0].fsPath }); + } + break; + } + } + }, + undefined, + context.subscriptions, + ); +} + +function field(id: string, label: string, value: string | undefined, placeholder = ''): string { + const v = value ? escapeHtml(value) : ''; + const ph = placeholder ? ` placeholder="${escapeHtml(placeholder)}"` : ''; + return ` +
+ + +
`; +} + +function browseField(id: string, label: string, value: string | undefined, kind: 'file' | 'folder', placeholder = ''): string { + const v = value ? escapeHtml(value) : ''; + const ph = placeholder ? ` placeholder="${escapeHtml(placeholder)}"` : ''; + return ` +
+ +
+ + +
+
`; +} + +function checkboxField(id: string, label: string, checked: boolean | undefined): string { + const c = checked ? ' checked' : ''; + return ` +
+ + +
`; +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); +} + +function getWebviewContent(config: BuildConfig): string { + const t = config.targets ?? {}; + const itchChannels = config.upload?.itch?.channels + ? Object.entries(config.upload.itch.channels).map(([k, v]) => `${k}=${v}`).join('\n') + : ''; + + return ` + + + + +feather.build.json + + + +

feather.build.json

+
+ +
+

General

+
+ ${field('name', 'Game name', config.name, 'My Game')} + ${field('version', 'Version', config.version, '1.0.0')} + ${field('loveVersion', 'LÖVE version', config.loveVersion, '11.5')} + ${field('productId', 'Product ID', config.productId, 'com.company.mygame')} +
+ ${field('description', 'Description', config.description)} +
+ ${field('company', 'Company', config.company)} + ${field('website', 'Website', config.website)} +
+ ${field('copyright', 'Copyright', config.copyright, '© 2025 My Company')} +
+ +
+

Files

+
+ ${browseField('sourceDir', 'Source directory', config.sourceDir, 'folder', '(project root)')} + ${browseField('outDir', 'Output directory', config.outDir, 'folder', 'dist')} +
+ ${browseField('icon', 'Icon file', config.icon, 'file', 'icon.png')} +
+ + +
+
+ + +
+ ${checkboxField('includeRuntime', 'Bundle Feather runtime in build output', config.includeRuntime)} +
+ +
+

Platform Targets

+ +
+ 🌐 Web +
+ ${browseField('targets.web.loveJsDir', 'love.js vendor directory', t.web?.loveJsDir, 'folder', 'vendor/love.js')} + ${field('targets.web.title', 'HTML title', t.web?.title)} + ${field('targets.web.outputName', 'Output name', t.web?.outputName, 'game')} +
+
+ +
+ 🪟 Windows +
+ ${browseField('targets.windows.loveRuntimeDir', 'LÖVE Windows runtime directory', t.windows?.loveRuntimeDir, 'folder', 'vendor/love-windows')} +
+
+ +
+ 🍎 macOS +
+ ${browseField('targets.macos.loveRuntimeDir', 'LÖVE macOS runtime directory', t.macos?.loveRuntimeDir, 'folder', 'vendor/love-macos')} +
+
+ +
+ 🐧 Linux +
+ ${browseField('targets.linux.loveRuntimeDir', 'LÖVE Linux runtime directory', t.linux?.loveRuntimeDir, 'folder', 'vendor/love-linux')} +
+
+ +
+ 🎮 SteamOS +
+ ${browseField('targets.steamos.loveRuntimeDir', 'LÖVE SteamOS runtime directory', t.steamos?.loveRuntimeDir, 'folder', 'vendor/love-linux')} +

Leave empty to fall back to the Linux runtime directory.

+
+
+ +
+ 🤖 Android +
+ ${browseField('targets.android.loveAndroidDir', 'love-android vendor directory', t.android?.loveAndroidDir, 'folder', 'vendor/love-android')} +
+ ${field('targets.android.productId', 'Package name', t.android?.productId, 'com.company.mygame')} + ${field('targets.android.displayName', 'Display name', t.android?.displayName)} + ${field('targets.android.versionCode', 'Version code', t.android?.versionCode?.toString(), '1')} + ${field('targets.android.versionName', 'Version name', t.android?.versionName, '1.0')} +
+
+ + +
+
+
+ +
+ 📱 iOS +
+ ${browseField('targets.ios.loveIosDir', 'love-ios vendor directory', t.ios?.loveIosDir, 'folder', 'vendor/love-ios')} +
+ ${field('targets.ios.bundleIdentifier', 'Bundle identifier', t.ios?.bundleIdentifier, 'com.company.mygame')} + ${field('targets.ios.displayName', 'Display name', t.ios?.displayName)} + ${field('targets.ios.scheme', 'Xcode scheme', t.ios?.scheme, 'love')} + ${field('targets.ios.configuration', 'Build configuration', t.ios?.configuration, 'Release')} + ${field('targets.ios.deploymentTarget', 'Deployment target', t.ios?.deploymentTarget, '16.0')} +
+
+
+
+ +
+

Upload

+ ${field('upload.itch.project', 'itch.io project', config.upload?.itch?.project, 'username/game-name')} +
+ + +
+
+ +
+ + + + + +`; +} diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 842f9474..8efae9c2 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -3,6 +3,8 @@ import { loadPackageCatalog, loadPluginCatalog, readInstalledPackageIds, type Pa import { runCommandsInTerminal, runInTerminal } from './cli'; import { FeatherProjectProvider } from './featherPanel'; import { getProjectStatus, resolveProjectDir } from './project'; +import { openBuildConfigPanel } from './buildConfigPanel'; +import { ALL_RUN_TARGETS, vendorPresent, vendorLabel, vendorArg, targetIcon, type RunTarget } from './vendor'; type Pick = vscode.QuickPickItem & { value: T }; type PluginPick = vscode.QuickPickItem & { plugin: PluginEntry }; @@ -29,6 +31,14 @@ function requireProjectDir(): string | undefined { return root; } +function isWatchMode(): boolean { + return featherConfig().get('watchMode') ?? false; +} + +function savedTargets(): RunTarget[] { + return (featherConfig().get('runTargets') ?? []) as RunTarget[]; +} + function pluginPick(plugin: PluginEntry): PluginPick { const caps = plugin.capabilities.length > 0 ? ` · ${plugin.capabilities.join(', ')}` : ''; return { @@ -59,21 +69,231 @@ async function pickPlugins(context: vscode.ExtensionContext, placeHolder: string return picked?.map((item) => item.plugin); } +async function pickTargets(currentTargets: RunTarget[], watchMode: boolean): Promise { + const available = watchMode + ? ALL_RUN_TARGETS.filter((t) => t !== 'web') + : ALL_RUN_TARGETS; + + type TargetPick = vscode.QuickPickItem & { target: RunTarget }; + const items: TargetPick[] = available.map((t) => ({ + label: `$(${targetIcon(t)}) ${t}`, + target: t, + picked: currentTargets.includes(t), + description: t === 'web' && watchMode ? 'not available in watch mode' : undefined, + })); + + const picked = await vscode.window.showQuickPick(items, { + canPickMany: true, + placeHolder: `Select run targets${watchMode ? ' (watch mode — web excluded)' : ''}`, + title: 'Feather: Select Run Targets', + }); + return picked?.map((i) => i.target); +} + async function registerCommands( context: vscode.ExtensionContext, provider: FeatherProjectProvider, + updateStatus: () => void, ): Promise { + // ── Run / Watch ─────────────────────────────────────────────────────────── context.subscriptions.push( - vscode.commands.registerCommand('feather.run', () => { + vscode.commands.registerCommand('feather.run', async () => { const root = requireProjectDir(); if (!root) return; - const args = ['run', root]; - const love = featherConfig().get('loveExecutable')?.trim(); - if (love) args.push('--love', love); - runInTerminal(context, 'Feather: Run', args, root); + + const cfg = featherConfig(); + let targets = savedTargets(); + + if (targets.length === 0) { + const picked = await pickTargets([], isWatchMode()); + if (!picked || picked.length === 0) return; + targets = picked; + await cfg.update('runTargets', targets, vscode.ConfigurationTarget.Workspace); + updateStatus(); + provider.refresh(); + } + + // Vendor check + const missingVendors = targets.filter((t) => !vendorPresent(root, t)); + if (missingVendors.length > 0) { + const names = missingVendors.map(vendorLabel).join(', '); + const action = await vscode.window.showWarningMessage( + `Missing vendor files for: ${names}.`, + { modal: false }, + 'Fetch Vendors', + 'Run Anyway', + 'Change Targets', + ); + if (!action) return; + if (action === 'Change Targets') { + await vscode.commands.executeCommand('feather.selectTargets'); + return; + } + if (action === 'Fetch Vendors') { + for (const t of missingVendors) { + const arg = vendorArg(t)!; + runInTerminal(context, `Feather: Vendor (${t})`, ['build', 'vendor', 'add', arg, '--dir', root], root); + } + return; + } + // 'Run Anyway' falls through + } + + const watchMode = isWatchMode(); + const love = cfg.get('loveExecutable')?.trim(); + for (const target of targets) { + const label = targets.length > 1 ? ` (${target})` : ''; + if (watchMode) { + if (target === 'web') { + vscode.window.showInformationMessage('Watch mode does not support web — skipping web target.'); + continue; + } + const args = ['watch', '--target', target, root]; + if (love && target === 'desktop') args.push('--love', love); + runInTerminal(context, `Feather: Watch${label}`, args, root); + } else { + const args = ['run', '--target', target, root]; + if (love && target === 'desktop') args.push('--love', love); + runInTerminal(context, `Feather: Run${label}`, args, root); + } + } + }), + ); + + // ── Select targets ──────────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand('feather.selectTargets', async () => { + const picked = await pickTargets(savedTargets(), isWatchMode()); + if (!picked) return; + await featherConfig().update('runTargets', picked, vscode.ConfigurationTarget.Workspace); + updateStatus(); + provider.refresh(); }), ); + // ── Toggle watch ────────────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand('feather.toggleWatch', async () => { + const next = !isWatchMode(); + await featherConfig().update('watchMode', next, vscode.ConfigurationTarget.Workspace); + updateStatus(); + provider.refresh(); + }), + ); + + // ── Configure ───────────────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand('feather.configure', async () => { + const cfg = featherConfig(); + const projectDir = cfg.get('projectDir') || ''; + const loveExec = cfg.get('loveExecutable') || ''; + const uploadTarget = cfg.get('defaultUploadTarget') || 'itch'; + const uploadChannel = cfg.get('defaultUploadChannel') || ''; + const watchMode = cfg.get('watchMode') ?? false; + + type SettingPick = vscode.QuickPickItem & { key: string }; + const setting = await vscode.window.showQuickPick( + [ + { + label: '$(folder) Project directory', + description: projectDir || '(workspace root)', + detail: 'Folder containing main.lua and feather.config.lua', + key: 'projectDir', + }, + { + label: '$(terminal) LÖVE executable', + description: loveExec || '(auto-detect)', + detail: 'Path to the love2d binary', + key: 'loveExecutable', + }, + { + label: '$(cloud-upload) Upload target', + description: uploadTarget, + detail: 'Default target for Feather: Upload Build (e.g. itch, steam)', + key: 'defaultUploadTarget', + }, + { + label: '$(tag) Upload channel', + description: uploadChannel || '(none)', + detail: 'Default channel passed to uploads', + key: 'defaultUploadChannel', + }, + { + label: watchMode ? '$(eye) Watch mode: ON' : '$(eye-closed) Watch mode: OFF', + description: 'Toggle between run and watch when clicking Run Project', + key: 'watchMode', + }, + ], + { placeHolder: 'Select a setting to configure', matchOnDescription: true, matchOnDetail: true }, + ); + if (!setting) return; + + if (setting.key === 'projectDir') { + const uris = await vscode.window.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + openLabel: 'Select project directory', + title: 'Feather: Select LÖVE project directory', + }); + if (!uris || uris.length === 0) return; + await cfg.update('projectDir', uris[0].fsPath, vscode.ConfigurationTarget.Workspace); + } else if (setting.key === 'loveExecutable') { + const choice = await vscode.window.showQuickPick>( + [ + { label: '$(folder-opened) Browse…', value: 'browse' }, + { label: '$(trash) Clear (use auto-detect)', value: 'clear' }, + ], + { placeHolder: loveExec || 'Select action' }, + ); + if (!choice) return; + if (choice.value === 'clear') { + await cfg.update('loveExecutable', '', vscode.ConfigurationTarget.Workspace); + } else { + const uris = await vscode.window.showOpenDialog({ + canSelectFolders: false, + canSelectFiles: true, + canSelectMany: false, + openLabel: 'Select LÖVE executable', + title: 'Feather: Select LÖVE executable', + }); + if (!uris || uris.length === 0) return; + await cfg.update('loveExecutable', uris[0].fsPath, vscode.ConfigurationTarget.Workspace); + } + } else if (setting.key === 'defaultUploadTarget') { + const target = await vscode.window.showInputBox({ + prompt: 'Upload target (e.g. itch, steam)', + value: uploadTarget, + validateInput: (v) => v.trim() ? undefined : 'Target cannot be empty', + }); + if (target === undefined) return; + await cfg.update('defaultUploadTarget', target.trim(), vscode.ConfigurationTarget.Workspace); + } else if (setting.key === 'defaultUploadChannel') { + const channel = await vscode.window.showInputBox({ + prompt: 'Upload channel (leave empty to unset)', + value: uploadChannel, + }); + if (channel === undefined) return; + await cfg.update('defaultUploadChannel', channel || '', vscode.ConfigurationTarget.Workspace); + } else if (setting.key === 'watchMode') { + await cfg.update('watchMode', !watchMode, vscode.ConfigurationTarget.Workspace); + } + + updateStatus(); + provider.refresh(); + }), + ); + + // ── Build config panel ──────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand('feather.buildConfig', () => { + const root = requireProjectDir(); + if (!root) return; + openBuildConfigPanel(context, root); + }), + ); + + // ── Init ────────────────────────────────────────────────────────────────── context.subscriptions.push( vscode.commands.registerCommand('feather.init', async () => { const root = requireProjectDir(); @@ -100,6 +320,7 @@ async function registerCommands( }), ); + // ── Doctor ──────────────────────────────────────────────────────────────── context.subscriptions.push( vscode.commands.registerCommand('feather.doctor', () => { const root = requireProjectDir(); @@ -109,6 +330,7 @@ async function registerCommands( }), ); + // ── Plugins ─────────────────────────────────────────────────────────────── context.subscriptions.push( vscode.commands.registerCommand('feather.plugins', async () => { const root = requireProjectDir(); @@ -145,6 +367,7 @@ async function registerCommands( }), ); + // ── Packages ────────────────────────────────────────────────────────────── context.subscriptions.push( vscode.commands.registerCommand('feather.packages', async () => { const root = requireProjectDir(); @@ -182,6 +405,42 @@ async function registerCommands( }), ); + // ── Vendor ──────────────────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand('feather.vendor', async () => { + const root = requireProjectDir(); + if (!root) return; + const action = await vscode.window.showQuickPick>( + [ + { label: 'Add vendor templates', description: 'Fetch build vendor templates into the project.', value: 'add' }, + { label: 'List vendors', description: 'Show configured build vendors.', value: 'list' }, + ], + { placeHolder: 'Vendor action' }, + ); + if (!action) return; + + if (action.value === 'list') { + runInTerminal(context, 'Feather: Vendor list', ['build', 'vendor', 'list', root], root); + return; + } + + const vendor = await vscode.window.showQuickPick>( + [ + { label: 'All vendors', value: 'all' }, + { label: 'Web', value: 'web' }, + { label: 'Android', value: 'android' }, + { label: 'iOS', value: 'ios' }, + { label: 'Desktop (Windows, macOS, Linux)', value: 'desktop' }, + ], + { placeHolder: 'Select vendor targets to fetch' }, + ); + if (!vendor) return; + + runInTerminal(context, 'Feather: Vendor add', ['build', 'vendor', 'add', vendor.value, '--dir', root], root); + }), + ); + + // ── Upload ──────────────────────────────────────────────────────────────── context.subscriptions.push( vscode.commands.registerCommand('feather.upload', async () => { const root = requireProjectDir(); @@ -226,6 +485,7 @@ async function registerCommands( }), ); + // ── Remove ──────────────────────────────────────────────────────────────── context.subscriptions.push( vscode.commands.registerCommand('feather.remove', async () => { const root = requireProjectDir(); @@ -238,6 +498,7 @@ async function registerCommands( }), ); + // ── Update ──────────────────────────────────────────────────────────────── context.subscriptions.push( vscode.commands.registerCommand('feather.update', () => { const root = requireProjectDir(); @@ -255,15 +516,32 @@ async function registerCommands( export async function activate(context: vscode.ExtensionContext): Promise { try { - const provider = new FeatherProjectProvider(() => getProjectStatus(activeProjectDir())); + const provider = new FeatherProjectProvider(() => ({ + status: getProjectStatus(activeProjectDir()), + watchMode: isWatchMode(), + targets: savedTargets(), + })); vscode.window.registerTreeDataProvider('feather.projectView', provider); const statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); const updateStatus = () => { const status = getProjectStatus(activeProjectDir()); - statusItem.text = status.hasConfig ? '$(zap) Feather' : '$(zap) Feather: Init'; - statusItem.tooltip = status.hasConfig ? 'Run Feather project' : 'Initialize Feather project'; - statusItem.command = status.hasConfig ? 'feather.run' : 'feather.init'; + const watchMode = isWatchMode(); + const targets = savedTargets(); + const targetStr = targets.length > 0 ? ` · ${targets.join(', ')}` : ''; + if (!status.hasConfig) { + statusItem.text = '$(zap) Feather: Init'; + statusItem.tooltip = 'Initialize Feather project'; + statusItem.command = 'feather.init'; + } else if (watchMode) { + statusItem.text = `$(eye) Feather Watch${targetStr}`; + statusItem.tooltip = `Watching (${targets.join(', ') || 'no targets'}) — click to run`; + statusItem.command = 'feather.run'; + } else { + statusItem.text = `$(zap) Feather${targetStr}`; + statusItem.tooltip = `Run Feather (${targets.join(', ') || 'no target selected'})`; + statusItem.command = 'feather.run'; + } }; updateStatus(); statusItem.show(); @@ -275,14 +553,18 @@ export async function activate(context: vscode.ExtensionContext): Promise provider.refresh(); }), vscode.workspace.onDidChangeConfiguration((event) => { - if (event.affectsConfiguration('feather.projectDir')) { + if ( + event.affectsConfiguration('feather.projectDir') || + event.affectsConfiguration('feather.watchMode') || + event.affectsConfiguration('feather.runTargets') + ) { updateStatus(); provider.refresh(); } }), ); - await registerCommands(context, provider); + await registerCommands(context, provider, updateStatus); } catch (err) { vscode.window.showErrorMessage(`Feather failed to activate: ${(err as Error).message ?? err}`); throw err; diff --git a/vscode-extension/src/featherPanel.ts b/vscode-extension/src/featherPanel.ts index a6c94f8e..2d0b5438 100644 --- a/vscode-extension/src/featherPanel.ts +++ b/vscode-extension/src/featherPanel.ts @@ -1,13 +1,29 @@ import * as vscode from 'vscode'; import type { ProjectStatus } from './project'; +import type { RunTarget } from './vendor'; +import { targetIcon } from './vendor'; -type FeatherItemKind = 'status' | 'actionRun' | 'actionInit' | 'actionDoctor' | 'actionPlugins' | 'actionPackages' | 'actionUpload'; +type FeatherViewState = { status: ProjectStatus; watchMode: boolean; targets: RunTarget[] }; + +type FeatherItemKind = + | 'status' + | 'actionRun' + | 'actionInit' + | 'actionDoctor' + | 'actionPlugins' + | 'actionPackages' + | 'actionUpload' + | 'actionVendor' + | 'actionToggleWatch' + | 'actionConfigure' + | 'actionSelectTargets' + | 'actionBuildConfig'; export class FeatherProjectProvider implements vscode.TreeDataProvider { private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); readonly onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; - constructor(private getStatus: () => ProjectStatus) {} + constructor(private getState: () => FeatherViewState) {} refresh(): void { this.onDidChangeTreeDataEmitter.fire(); @@ -18,14 +34,19 @@ export class FeatherProjectProvider implements vscode.TreeDataProvider 0 + ? targets.map((t) => `$(${targetIcon(t)}) ${t}`).join(' ') + : 'No targets selected'; + return [ statusItem('feather.config.lua found', 'pass'), statusItem(status.hasRuntime ? 'Embedded runtime found' : 'CLI mode project', 'info'), statusItem(`${status.pluginCount} plugin${status.pluginCount === 1 ? '' : 's'} installed`, 'info'), statusItem(`${status.packageCount} package${status.packageCount === 1 ? '' : 's'} installed`, 'info'), - actionItem('Run Project', 'feather.run', 'play', 'actionRun'), + watchMode + ? actionItem('Watch Project', 'feather.run', 'eye', 'actionRun') + : actionItem('Run Project', 'feather.run', 'play', 'actionRun'), + actionItem( + watchMode ? 'Watch mode: ON — click to disable' : 'Watch mode: OFF — click to enable', + 'feather.toggleWatch', + watchMode ? 'eye' : 'eye-closed', + 'actionToggleWatch', + ), + actionItem(`Targets: ${targetLabel}`, 'feather.selectTargets', 'target', 'actionSelectTargets'), actionItem('Run Doctor', 'feather.doctor', 'pulse', 'actionDoctor'), + actionItem('Build Config', 'feather.buildConfig', 'file-code', 'actionBuildConfig'), actionItem('Manage Plugins', 'feather.plugins', 'extensions', 'actionPlugins'), actionItem('Manage Packages', 'feather.packages', 'package', 'actionPackages'), + actionItem('Manage Vendors', 'feather.vendor', 'archive', 'actionVendor'), actionItem('Upload Build', 'feather.upload', 'cloud-upload', 'actionUpload'), + actionItem('Configure Feather', 'feather.configure', 'settings-gear', 'actionConfigure'), ]; } } diff --git a/vscode-extension/src/vendor.ts b/vscode-extension/src/vendor.ts new file mode 100644 index 00000000..a4949973 --- /dev/null +++ b/vscode-extension/src/vendor.ts @@ -0,0 +1,54 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +export type RunTarget = 'desktop' | 'web' | 'android' | 'ios' | 'steamos'; + +export const ALL_RUN_TARGETS: RunTarget[] = ['desktop', 'web', 'android', 'ios', 'steamos']; + +type VendorRequirement = { + label: string; + checkPath: string; + vendorArg: string; +}; + +const VENDOR_REQUIREMENTS: Partial> = { + web: { + label: 'love.js (web)', + checkPath: join('vendor', 'love.js', 'index.html'), + vendorArg: 'web', + }, + android: { + label: 'love-android', + checkPath: join('vendor', 'love-android', 'gradlew'), + vendorArg: 'android', + }, + ios: { + label: 'love-ios', + checkPath: join('vendor', 'love-ios', 'platform', 'xcode', 'love.xcodeproj'), + vendorArg: 'ios', + }, +}; + +export function vendorPresent(root: string, target: RunTarget): boolean { + const req = VENDOR_REQUIREMENTS[target]; + if (!req) return true; + return existsSync(join(root, req.checkPath)); +} + +export function vendorLabel(target: RunTarget): string { + return VENDOR_REQUIREMENTS[target]?.label ?? target; +} + +export function vendorArg(target: RunTarget): string | undefined { + return VENDOR_REQUIREMENTS[target]?.vendorArg; +} + +export function targetIcon(target: RunTarget): string { + switch (target) { + case 'desktop': return 'desktop-download'; + case 'web': return 'globe'; + case 'android': return 'device-mobile'; + case 'ios': return 'device-mobile'; + case 'steamos': return 'game'; + } +} From 812eb207d0a41ea87593c5b20c49cbc41df40554 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Tue, 19 May 2026 14:13:37 -0400 Subject: [PATCH 03/28] lua: fix stackoverflow on love hooks --- src-lua/feather/plugin_manager.lua | 35 +++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src-lua/feather/plugin_manager.lua b/src-lua/feather/plugin_manager.lua index e8f6b24f..e30815b2 100644 --- a/src-lua/feather/plugin_manager.lua +++ b/src-lua/feather/plugin_manager.lua @@ -85,18 +85,11 @@ local function describeApiCompatibility(compatibility, currentApi) if compatibility.minApi ~= nil or compatibility.maxApi ~= nil then local min = compatibility.minApi ~= nil and tostring(compatibility.minApi) or "any" local max = compatibility.maxApi ~= nil and tostring(compatibility.maxApi) or "any" - return "Requires Feather plugin API " - .. min - .. "-" - .. max - .. "; desktop API is " - .. tostring(currentApi) - .. "." + return "Requires Feather plugin API " .. min .. "-" .. max .. "; desktop API is " .. tostring(currentApi) .. "." end return "Requires a different Feather plugin API. Desktop API is " .. tostring(currentApi) .. "." end - ---@param feather Feather ---@param logger FeatherLogger ---@param observer FeatherObserver @@ -178,7 +171,10 @@ function FeatherPluginManager:init(feather, logger, observer) if not supported then self.logger:log({ type = "error", - str = "Plugin <" .. plugin.identifier .. "> is not compatible: " .. describeApiCompatibility(compatibility, feather.version), + str = "Plugin <" .. plugin.identifier .. "> is not compatible: " .. describeApiCompatibility( + compatibility, + feather.version + ), }) end @@ -367,12 +363,29 @@ function FeatherPluginManager:hookLoveCallbacks() local wrapper = self._loveCallbackWrappers[name] if not wrapper then + self._dispatchingLoveCallbacks = self._dispatchingLoveCallbacks or {} + wrapper = function(...) + if mgr._dispatchingLoveCallbacks[name] then + return + end + + mgr._dispatchingLoveCallbacks[name] = true + local original = mgr._loveCallbackOriginals and mgr._loveCallbackOriginals[name] + if original and original ~= wrapper then - original(...) + local ok, err = pcall(original, ...) + if not ok and mgr.logger then + mgr.logger:log({ + type = "error", + str = "[FeatherPluginManager] love." .. name .. " original callback error: " .. tostring(err), + }) + end end + dispatch(method, ...) + local overlay = mgr.feather and mgr.feather.debugOverlay if overlay then if method == "onDraw" and overlay.onDraw then @@ -383,6 +396,8 @@ function FeatherPluginManager:hookLoveCallbacks() overlay:onTouchpressed(...) end end + + mgr._dispatchingLoveCallbacks[name] = false end self._loveCallbackWrappers[name] = wrapper end From baa5fc55ff345507437f3b8d58c9e18bffb03644 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Tue, 19 May 2026 15:22:30 -0400 Subject: [PATCH 04/28] lua: introduce callback bus with priority override --- cli/src/lib/paths.ts | 6 +- cli/src/lib/shim.ts | 31 +-- src-lua/example/e2e/main.lua | 274 ++++++++++++++++++++++++++ src-lua/feather/callback_bus.lua | 124 ++++++++++++ src-lua/feather/core/assets.lua | 4 + src-lua/feather/core/base.lua | 1 + src-lua/feather/init.lua | 14 ++ src-lua/feather/plugin_manager.lua | 150 ++++++++++---- src-lua/manifest.txt | 1 + src-lua/plugins/README.md | 45 ++++- src-lua/plugins/input-replay/init.lua | 234 +++++++++++++++++----- 11 files changed, 778 insertions(+), 106 deletions(-) create mode 100644 src-lua/feather/callback_bus.lua diff --git a/cli/src/lib/paths.ts b/cli/src/lib/paths.ts index 7361c2d7..19bdfdb8 100644 --- a/cli/src/lib/paths.ts +++ b/cli/src/lib/paths.ts @@ -2,17 +2,19 @@ import { existsSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url)); + export function bundledLuaRoot(): string { // When running as a compiled binary, lua/ is shipped next to the executable. const execDir = dirname(process.execPath); const sibling = join(execDir, 'lua'); if (existsSync(join(sibling, 'feather', 'init.lua'))) return sibling; // Fallback for npm/node: dist/lib/paths.js → ../../lua - return resolve(dirname(fileURLToPath(import.meta.url)), '../../lua'); + return resolve(MODULE_DIR, '../../lua'); } export function repoLuaRoot(): string | null { - const candidate = resolve(__dirname, '../../../src-lua'); + const candidate = resolve(MODULE_DIR, '../../../src-lua'); return existsSync(join(candidate, 'feather', 'init.lua')) ? candidate : null; } diff --git a/cli/src/lib/shim.ts b/cli/src/lib/shim.ts index 5b96010d..94e7190f 100644 --- a/cli/src/lib/shim.ts +++ b/cli/src/lib/shim.ts @@ -6,8 +6,9 @@ import { fileURLToPath } from 'node:url'; // Path to the bundled Lua library shipped with this CLI package. // In a source checkout `npm run build` does not run the publish-time Lua bundle, // so fall back to the repository's src-lua directory for local development. -const PACKAGED_LUA = resolve(dirname(fileURLToPath(new URL('.', import.meta.url))), '../../lua'); -const SOURCE_LUA = resolve(dirname(fileURLToPath(new URL('.', import.meta.url))), '../../../src-lua'); +const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url)); +const PACKAGED_LUA = resolve(MODULE_DIR, '../../lua'); +const SOURCE_LUA = resolve(MODULE_DIR, '../../../src-lua'); export function bundledLuaRoot(): string { // When running as a compiled binary, lua/ ships next to the executable. @@ -70,7 +71,12 @@ function serializeLuaConfig(cfg: Record): string { return lines.join('\n'); } -function buildMainLua(opts: ShimOptions, pluginsDir?: string): string { +function luaPackagePath(rootDir: string): string { + const normalized = rootDir.replace(/\\/g, '/'); + return `${normalized}/?.lua;${normalized}/?/init.lua`; +} + +function buildMainLua(opts: ShimOptions, featherDir: string, pluginsDir?: string): string { const sessionName = opts.sessionName ?? ''; const configLines = opts.userConfig ? serializeLuaConfig(opts.userConfig) : ''; @@ -78,11 +84,13 @@ function buildMainLua(opts: ShimOptions, pluginsDir?: string): string { const pluginListLine = pluginIds.length > 0 ? `FEATHER_PLUGIN_LIST = { ${pluginIds.map((id) => JSON.stringify(id)).join(', ')} }` : ''; - // Add the plugins parent dir to package.path so require("plugins.X") resolves - // via the OS filesystem (no PhysFS / symlink dependency). - const packagePathLine = pluginsDir && pluginIds.length > 0 - ? `package.path = package.path .. ";${dirname(pluginsDir).replace(/\\/g, '/')}/?.lua;${dirname(pluginsDir).replace(/\\/g, '/')}/?/init.lua"` - : ''; + // Add runtime/plugin parent dirs to package.path so require() resolves via the + // OS filesystem instead of depending on PhysFS symlink behavior. + const packagePaths = new Set([luaPackagePath(dirname(featherDir))]); + if (pluginsDir && pluginIds.length > 0) { + packagePaths.add(luaPackagePath(dirname(pluginsDir))); + } + const packagePathLine = `package.path = package.path .. ";${[...packagePaths].join(';')}"`; return `-- Feather CLI injector — generated, do not edit -- Game files are symlinked into this directory so love.filesystem works as normal. @@ -152,6 +160,7 @@ const SHIM_OWNED = new Set(['main.lua', 'conf.lua', 'feather', 'plugins']); export function createShim(opts: ShimOptions): Shim { const absGame = resolve(opts.gamePath); const dir = mkdtempSync(join(tmpdir(), 'feather-')); + const resolvedFeatherDir = featherRoot(opts.featherOverride); // Resolve plugins directory early so buildMainLua can embed the ID list and // package.path addition (bypasses PhysFS symlink dependency in auto.lua). @@ -162,12 +171,12 @@ export function createShim(opts: ShimOptions): Shim { : (() => { const p = opts.pluginsOverride ? resolve(opts.pluginsOverride) - : pluginsRoot(featherRoot(opts.featherOverride), opts.featherOverride); + : pluginsRoot(resolvedFeatherDir, opts.featherOverride); return existsSync(p) ? p : undefined; })(); // 1. Write shim entry points - writeFileSync(join(dir, 'main.lua'), buildMainLua(opts, resolvedPluginsDir)); + writeFileSync(join(dir, 'main.lua'), buildMainLua(opts, resolvedFeatherDir, resolvedPluginsDir)); writeFileSync(join(dir, 'conf.lua'), buildConfLua()); // 2. Symlink every file/dir from the game into the shim root, except shim-owned names. @@ -179,7 +188,7 @@ export function createShim(opts: ShimOptions): Shim { // 3. Add feather library — prefer game-local install (already symlinked above if present) if (!existsSync(join(dir, 'feather'))) { - symlinkSync(featherRoot(opts.featherOverride), join(dir, 'feather'), 'dir'); + symlinkSync(resolvedFeatherDir, join(dir, 'feather'), 'dir'); } // 4. Add plugins directory symlink — still created for love.filesystem access to diff --git a/src-lua/example/e2e/main.lua b/src-lua/example/e2e/main.lua index ddc771bd..19a74d46 100644 --- a/src-lua/example/e2e/main.lua +++ b/src-lua/example/e2e/main.lua @@ -40,6 +40,87 @@ local function assertTruthy(value, name) record(name) end +local function assertArrayEqual(actual, expected, name) + if #actual ~= #expected then + error(string.format("%s: expected %d values, got %d", name, #expected, #actual), 2) + end + + for index = 1, #expected do + if actual[index] ~= expected[index] then + error( + string.format("%s: expected [%s], got [%s]", name, table.concat(expected, ", "), table.concat(actual, ", ")), + 2 + ) + end + end + + record(name) +end + +local callbackOrder = {} +local stressHookCount = 0 +local stressHookSum = 0 +local stressKeyCount = 0 +local stressKeySum = 0 + +local OrderedPlugin = Class({ + __includes = FeatherPluginBase, +}) + +function OrderedPlugin:init(config) + FeatherPluginBase.init(self, config) + + if self.options.mode == "bus" then + config.callbacks.register("draw", function() + callbackOrder[#callbackOrder + 1] = self.options.label + end, self.options.priority ~= nil and { priority = self.options.priority } or nil) + end +end + +function OrderedPlugin:onDraw() + if self.options.mode == "legacy" then + callbackOrder[#callbackOrder + 1] = self.options.label + end +end + +local StressPlugin = Class({ + __includes = FeatherPluginBase, +}) + +function StressPlugin:init(config) + FeatherPluginBase.init(self, config) + + config.callbacks.register("draw", function() + stressHookCount = stressHookCount + 1 + stressHookSum = stressHookSum + self.options.index + end) + + config.callbacks.register("keypressed", function() + stressKeyCount = stressKeyCount + 1 + stressKeySum = stressKeySum + self.options.index + end) +end + +local function resetCallbackOrder() + for index = #callbackOrder, 1, -1 do + callbackOrder[index] = nil + end +end + +local function resetStressHooks() + stressHookCount = 0 + stressHookSum = 0 + stressKeyCount = 0 + stressKeySum = 0 +end + +local function assertDrawOrder(feather, expected, name) + resetCallbackOrder() + love.draw() + assertArrayEqual(callbackOrder, expected, name) + feather:finish() +end + local function run() local feather = FeatherDebugger({ debug = true, @@ -148,6 +229,199 @@ return M feather:finish() + local fifoFeather = FeatherDebugger({ + debug = true, + mode = "disk", + sessionName = "Callback FIFO E2E", + deviceId = "callback-fifo-e2e", + assetPreview = false, + plugins = { + FeatherPluginManager.createPlugin(OrderedPlugin, "fifo-a", { mode = "bus", label = "A" }), + FeatherPluginManager.createPlugin(OrderedPlugin, "fifo-b", { mode = "bus", label = "B" }), + FeatherPluginManager.createPlugin(OrderedPlugin, "fifo-c", { mode = "bus", label = "C" }), + }, + debugger = { + enabled = false, + }, + }) + + assertDrawOrder(fifoFeather, { "A", "B", "C" }, "callback bus preserves FIFO order by default") + + local mixedPriorityFeather = FeatherDebugger({ + debug = true, + mode = "disk", + sessionName = "Callback Priority E2E", + deviceId = "callback-priority-e2e", + assetPreview = false, + plugins = { + FeatherPluginManager.createPlugin(OrderedPlugin, "mixed-a", { mode = "legacy", label = "A" }), + FeatherPluginManager.createPlugin(OrderedPlugin, "mixed-b", { mode = "bus", label = "B" }), + FeatherPluginManager.createPlugin(OrderedPlugin, "mixed-c", { + mode = "bus", + label = "C", + priority = 1000, + }), + FeatherPluginManager.createPlugin(OrderedPlugin, "mixed-d", { mode = "legacy", label = "D" }), + }, + debugger = { + enabled = false, + }, + }) + + resetCallbackOrder() + love.draw() + assertArrayEqual(callbackOrder, { "A", "B", "D", "C" }, "priority runs after default FIFO callbacks when larger") + + mixedPriorityFeather.pluginManager:disablePlugin("mixed-b") + resetCallbackOrder() + love.draw() + assertArrayEqual(callbackOrder, { "A", "D", "C" }, "disabled plugins stop scoped callback bus handlers") + mixedPriorityFeather:finish() + + local equalPriorityFeather = FeatherDebugger({ + debug = true, + mode = "disk", + sessionName = "Callback Stable E2E", + deviceId = "callback-stable-e2e", + assetPreview = false, + plugins = { + FeatherPluginManager.createPlugin(OrderedPlugin, "stable-a", { mode = "bus", label = "A", priority = 25 }), + FeatherPluginManager.createPlugin(OrderedPlugin, "stable-b", { mode = "bus", label = "B", priority = 25 }), + FeatherPluginManager.createPlugin(OrderedPlugin, "stable-c", { mode = "bus", label = "C", priority = 25 }), + }, + debugger = { + enabled = false, + }, + }) + + assertDrawOrder(equalPriorityFeather, { "A", "B", "C" }, "equal priorities preserve FIFO order") + + local pluginCount = 1000 + local expectedHookSum = (pluginCount * (pluginCount + 1)) / 2 + local stressPlugins = {} + local originalDraw = love.draw + local originalKeypressed = love.keypressed + local beforeOverrideCount = 0 + local afterOverrideCount = 0 + local repeatedOverrideCount = 0 + local beforeKeyOverrideCount = 0 + local afterKeyOverrideCount = 0 + local repeatedKeyOverrideCount = 0 + + love.draw = function() + beforeOverrideCount = beforeOverrideCount + 1 + end + + love.keypressed = function() + beforeKeyOverrideCount = beforeKeyOverrideCount + 1 + end + + for index = 1, pluginCount do + stressPlugins[index] = FeatherPluginManager.createPlugin(StressPlugin, "stress-" .. tostring(index), { + index = index, + }) + end + + local stressFeather = FeatherDebugger({ + debug = true, + mode = "disk", + sessionName = "Callback Stress E2E", + deviceId = "callback-stress-e2e", + assetPreview = false, + plugins = stressPlugins, + debugger = { + enabled = false, + }, + }) + + resetStressHooks() + love.draw() + assertEqual(beforeOverrideCount, 1, "external draw override registered before Feather still runs") + assertEqual( + stressHookCount, + pluginCount, + "all 1000 plugins receive draw when external override existed before Feather" + ) + assertEqual(stressHookSum, expectedHookSum, "all 1000 plugin draw hooks run exactly once with pre-Feather override") + love.keypressed("space", "space", false) + assertEqual(beforeKeyOverrideCount, 1, "external keypressed override registered before Feather still runs") + assertEqual( + stressKeyCount, + pluginCount, + "all 1000 plugins receive keypressed when external override existed before Feather" + ) + assertEqual( + stressKeySum, + expectedHookSum, + "all 1000 plugin keypressed hooks run exactly once with pre-Feather override" + ) + + love.draw = function() + afterOverrideCount = afterOverrideCount + 1 + end + + love.keypressed = function() + afterKeyOverrideCount = afterKeyOverrideCount + 1 + end + + resetStressHooks() + stressFeather:update(0) + love.draw() + assertEqual(afterOverrideCount, 1, "external draw override registered after Feather still runs after rehook") + assertEqual( + stressHookCount, + pluginCount, + "all 1000 plugins receive draw after external override replaces Feather wrapper" + ) + assertEqual(stressHookSum, expectedHookSum, "all 1000 plugin draw hooks run exactly once after rehook") + love.keypressed("space", "space", false) + assertEqual(afterKeyOverrideCount, 1, "external keypressed override registered after Feather still runs after rehook") + assertEqual( + stressKeyCount, + pluginCount, + "all 1000 plugins receive keypressed after external override replaces Feather wrapper" + ) + assertEqual(stressKeySum, expectedHookSum, "all 1000 plugin keypressed hooks run exactly once after rehook") + + love.draw = function() + repeatedOverrideCount = repeatedOverrideCount + 1 + end + + love.keypressed = function() + repeatedKeyOverrideCount = repeatedKeyOverrideCount + 1 + end + + resetStressHooks() + stressFeather:update(0) + love.draw() + love.keypressed("space", "space", false) + assertEqual(repeatedOverrideCount, 1, "second external draw override still runs after another rehook") + assertEqual( + stressHookCount, + pluginCount, + "all 1000 plugins still receive draw after multiple external override replacements" + ) + assertEqual( + stressHookSum, + expectedHookSum, + "all 1000 plugin draw hooks still run exactly once after multiple rehooks" + ) + assertEqual(repeatedKeyOverrideCount, 1, "second external keypressed override still runs after another rehook") + assertEqual( + stressKeyCount, + pluginCount, + "all 1000 plugins still receive keypressed after multiple external override replacements" + ) + assertEqual( + stressKeySum, + expectedHookSum, + "all 1000 plugin keypressed hooks still run exactly once after multiple rehooks" + ) + + stressFeather:finish() + love.draw = originalDraw + love.keypressed = originalKeypressed + -- ── Auth handshake state machine ─────────────────────────────────────────── -- Test the challenge-response logic in isolation. We use a disk-mode instance -- so no real WS server is needed; __sendWs is a no-op when wsConnected=false. diff --git a/src-lua/feather/callback_bus.lua b/src-lua/feather/callback_bus.lua new file mode 100644 index 00000000..1ceb60b5 --- /dev/null +++ b/src-lua/feather/callback_bus.lua @@ -0,0 +1,124 @@ +local Class = require(FEATHER_PATH .. ".lib.class") + +---@class FeatherCallbackBusEntry +---@field fn function +---@field order number +---@field priority number|nil +---@field active boolean + +---@class FeatherCallbackBus +---@field registrations table +---@field allowedCallbacks table +---@field nextOrder number +local FeatherCallbackBus = Class({}) + +local function normalizedPriority(priority) + if priority == nil then + return 0 + end + return priority +end + +local function sortRegistrations(registrations) + table.sort(registrations, function(a, b) + local aPriority = normalizedPriority(a.priority) + local bPriority = normalizedPriority(b.priority) + + if aPriority == bPriority then + return a.order < b.order + end + + return aPriority < bPriority + end) +end + +function FeatherCallbackBus:init(callbackNames) + self.registrations = {} + self.allowedCallbacks = {} + self.nextOrder = 0 + + for _, name in ipairs(callbackNames or {}) do + self.allowedCallbacks[name] = true + self.registrations[name] = {} + end +end + +function FeatherCallbackBus:isSupported(name) + return self.allowedCallbacks[name] == true +end + +function FeatherCallbackBus:assertSupported(name) + if not self:isSupported(name) then + error("[FeatherCallbackBus] Unsupported callback: " .. tostring(name), 3) + end +end + +---@param name string +---@param fn function +---@param opts? { priority?: number } +---@return function unregister +function FeatherCallbackBus:register(name, fn, opts) + self:assertSupported(name) + + if type(fn) ~= "function" then + error("[FeatherCallbackBus] Callback handler must be a function", 2) + end + + if opts ~= nil and type(opts) ~= "table" then + error("[FeatherCallbackBus] Callback options must be a table", 2) + end + + local priority = opts and opts.priority or nil + if priority ~= nil and type(priority) ~= "number" then + error("[FeatherCallbackBus] Callback priority must be a number", 2) + end + + self.nextOrder = self.nextOrder + 1 + + local entry = { + fn = fn, + order = self.nextOrder, + priority = priority, + active = true, + } + + local registrations = self.registrations[name] + registrations[#registrations + 1] = entry + sortRegistrations(registrations) + + return function() + if not entry.active then + return false + end + + entry.active = false + + for index, candidate in ipairs(registrations) do + if candidate == entry then + table.remove(registrations, index) + return true + end + end + + return false + end +end + +function FeatherCallbackBus:dispatch(name, ...) + self:assertSupported(name) + + local registrations = self.registrations[name] + local snapshot = {} + + for index = 1, #registrations do + snapshot[index] = registrations[index] + end + + for _, entry in ipairs(snapshot) do + if entry.active then + entry.fn(...) + end + end +end + +return FeatherCallbackBus diff --git a/src-lua/feather/core/assets.lua b/src-lua/feather/core/assets.lua index 4b7b12f7..f451ba46 100644 --- a/src-lua/feather/core/assets.lua +++ b/src-lua/feather/core/assets.lua @@ -152,6 +152,10 @@ function FeatherAssets:update() self:_hookDraw() end +function FeatherAssets:isDrawWrapper(fn) + return fn ~= nil and fn == self._drawWrapper +end + function FeatherAssets:hasPreview() return self._previewReady ~= nil end diff --git a/src-lua/feather/core/base.lua b/src-lua/feather/core/base.lua index 9e2554dd..4d02aa5a 100644 --- a/src-lua/feather/core/base.lua +++ b/src-lua/feather/core/base.lua @@ -29,6 +29,7 @@ function FeatherPlugin:init(config) self.options = config.options or {} self.logger = config.logger or {} self.observer = config.observer or {} + self.callbacks = config.callbacks or {} self.api = config.api self.minApi = config.minApi self.maxApi = config.maxApi diff --git a/src-lua/feather/init.lua b/src-lua/feather/init.lua index d057e26d..0632d6c6 100644 --- a/src-lua/feather/init.lua +++ b/src-lua/feather/init.lua @@ -9,6 +9,7 @@ FEATHER_PATH = FEATHER_PATH or PATH local Class = require(FEATHER_PATH .. ".lib.class") local json = require(FEATHER_PATH .. ".lib.json") local errorhandler = require(FEATHER_PATH .. ".error_handler") +local FeatherCallbackBus = require(FEATHER_PATH .. ".callback_bus") local FeatherPluginManager = require(FEATHER_PATH .. ".plugin_manager") local FeatherLogger = require(FEATHER_PATH .. ".core.logger") local FeatherObserver = require(FEATHER_PATH .. ".core.observer") @@ -28,6 +29,18 @@ local FEATHER_VERSION = { api = FEATHER_API, } +local CALLBACK_NAMES = { + "draw", + "keypressed", + "keyreleased", + "mousepressed", + "mousereleased", + "touchpressed", + "touchreleased", + "joystickpressed", + "joystickreleased", +} + ---@class Feather: FeatherConfig ---@field lastError number ---@field debug boolean @@ -199,6 +212,7 @@ function Feather:init(config) self.assets = FeatherAssets(self.featherLogger) end + self.callbackBus = FeatherCallbackBus(CALLBACK_NAMES) self.pluginManager = FeatherPluginManager(self, self.featherLogger, self.featherObserver) self.pluginManager:hookLoveCallbacks() self.debugOverlay = FeatherDebugOverlay(self, conf.debugOverlay) diff --git a/src-lua/feather/plugin_manager.lua b/src-lua/feather/plugin_manager.lua index e30815b2..7a3da4a3 100644 --- a/src-lua/feather/plugin_manager.lua +++ b/src-lua/feather/plugin_manager.lua @@ -6,11 +6,24 @@ local Class = require(FEATHER_PATH .. ".lib.class") --- @field disabled boolean --- @field errorCount number --- @field capabilities string[] +--- @field callbackDisposers function[]|nil ---@class FeatherPluginManager ---@field plugins FeatherPluginInstance[] local FeatherPluginManager = Class({}) +local LOVE_CALLBACKS = { + { name = "draw", method = "onDraw" }, + { name = "keypressed", method = "onKeypressed" }, + { name = "keyreleased", method = "onKeyreleased" }, + { name = "mousepressed", method = "onMousepressed" }, + { name = "mousereleased", method = "onMousereleased" }, + { name = "touchpressed", method = "onTouchpressed" }, + { name = "touchreleased", method = "onTouchreleased" }, + { name = "joystickpressed", method = "onJoystickpressed" }, + { name = "joystickreleased", method = "onJoystickreleased" }, +} + local function normalizeApiCompatibility(api) if api == nil then return {} @@ -98,6 +111,7 @@ function FeatherPluginManager:init(feather, logger, observer) self.logger = logger self.observer = observer self.feather = feather + self.callbackBus = feather.callbackBus self._hookedCallbacks = nil if not feather.plugins then @@ -120,20 +134,25 @@ function FeatherPluginManager:init(feather, logger, observer) compatibility.minApi = compatibility.minApi or plugin.minApi compatibility.maxApi = compatibility.maxApi or plugin.maxApi compatibility.currentApi = feather.version + local pluginRecord = { + instance = nil, + identifier = plugin.identifier, + disabled = plugin.disabled or false, + incompatible = false, + incompatibilityReason = nil, + capabilities = plugin.capabilities or {}, + compatibility = compatibility, + name = plugin.name, + version = plugin.version, + callbackDisposers = {}, + } if not isApiCompatible(compatibility, feather.version) then local message = describeApiCompatibility(compatibility, feather.version) - table.insert(self.plugins, { - instance = nil, - identifier = plugin.identifier, - disabled = true, - incompatible = true, - incompatibilityReason = message, - capabilities = plugin.capabilities or {}, - compatibility = compatibility, - name = plugin.name, - version = plugin.version, - }) + pluginRecord.disabled = true + pluginRecord.incompatible = true + pluginRecord.incompatibilityReason = message + table.insert(self.plugins, pluginRecord) self.logger:log({ type = "error", str = "Plugin <" .. plugin.identifier .. "> is not compatible: " .. message, @@ -146,6 +165,7 @@ function FeatherPluginManager:init(feather, logger, observer) feather = feather, logger = logger, observer = observer, + callbacks = self:createCallbackRegistrar(pluginRecord), api = compatibility.api, minApi = compatibility.minApi, maxApi = compatibility.maxApi, @@ -156,17 +176,18 @@ function FeatherPluginManager:init(feather, logger, observer) if pluginInstance and pluginInstance.isSupported then supported = pluginInstance:isSupported(feather.version) end - table.insert(self.plugins, { - instance = pluginInstance, - identifier = plugin.identifier, - disabled = plugin.disabled or not supported or false, - incompatible = not supported, - incompatibilityReason = (not supported) and describeApiCompatibility(compatibility, feather.version) or nil, - capabilities = plugin.capabilities or {}, - compatibility = compatibility, - name = plugin.name, - version = plugin.version, - }) + pluginRecord.instance = pluginInstance + pluginRecord.disabled = plugin.disabled or not supported or false + pluginRecord.incompatible = not supported + pluginRecord.incompatibilityReason = not supported and describeApiCompatibility(compatibility, feather.version) + or nil + table.insert(self.plugins, pluginRecord) + + if supported then + self:registerPluginCallbacks(pluginRecord) + else + self:disposePluginCallbacks(pluginRecord) + end if not supported then self.logger:log({ @@ -194,6 +215,7 @@ function FeatherPluginManager:init(feather, logger, observer) end end else + self:disposePluginCallbacks(pluginRecord) -- pluginInstance is the formatted error+traceback string from the xpcall handler self.logger:log({ type = "error", str = tostring(pluginInstance) }) end @@ -201,6 +223,58 @@ function FeatherPluginManager:init(feather, logger, observer) end end +function FeatherPluginManager:createCallbackRegistrar(plugin) + return { + register = function(name, fn, opts) + local disposer = self.callbackBus:register(name, function(...) + if plugin.disabled then + return + end + + return fn(...) + end, opts) + + plugin.callbackDisposers[#plugin.callbackDisposers + 1] = disposer + return disposer + end, + } +end + +function FeatherPluginManager:registerPluginCallbacks(plugin) + if not plugin.instance then + return + end + + for _, callback in ipairs(LOVE_CALLBACKS) do + local disposer = self.callbackBus:register(callback.name, function(...) + if plugin.disabled or not plugin.instance then + return + end + + local method = plugin.instance[callback.method] + if type(method) ~= "function" then + return + end + + pcall(method, plugin.instance, ...) + end) + + plugin.callbackDisposers[#plugin.callbackDisposers + 1] = disposer + end +end + +function FeatherPluginManager:disposePluginCallbacks(plugin) + if not plugin.callbackDisposers then + return + end + + for _, dispose in ipairs(plugin.callbackDisposers) do + dispose() + end + + plugin.callbackDisposers = {} +end + function FeatherPluginManager:update(dt, feather) for _, plugin in ipairs(self.plugins) do if plugin.instance and not plugin.disabled then @@ -334,30 +408,14 @@ function FeatherPluginManager:hookLoveCallbacks() local mgr = self - local function dispatch(method, ...) - for _, p in ipairs(mgr.plugins) do - if p.instance and not p.disabled then - pcall(p.instance[method], p.instance, ...) - end - end + local function dispatch(name, ...) + mgr.callbackBus:dispatch(name, ...) end self._loveCallbackOriginals = self._loveCallbackOriginals or {} self._loveCallbackWrappers = self._loveCallbackWrappers or {} - local callbacks = { - { name = "draw", method = "onDraw" }, - { name = "keypressed", method = "onKeypressed" }, - { name = "keyreleased", method = "onKeyreleased" }, - { name = "mousepressed", method = "onMousepressed" }, - { name = "mousereleased", method = "onMousereleased" }, - { name = "touchpressed", method = "onTouchpressed" }, - { name = "touchreleased", method = "onTouchreleased" }, - { name = "joystickpressed", method = "onJoystickpressed" }, - { name = "joystickreleased", method = "onJoystickreleased" }, - } - - for _, callback in ipairs(callbacks) do + for _, callback in ipairs(LOVE_CALLBACKS) do local name = callback.name local method = callback.method local wrapper = self._loveCallbackWrappers[name] @@ -384,7 +442,7 @@ function FeatherPluginManager:hookLoveCallbacks() end end - dispatch(method, ...) + dispatch(name, ...) local overlay = mgr.feather and mgr.feather.debugOverlay if overlay then @@ -403,7 +461,12 @@ function FeatherPluginManager:hookLoveCallbacks() end local current = love[name] - if current ~= wrapper and not self._loveCallbackOriginals[name] then + local isFeatherOwnedWrapper = false + if name == "draw" and self.feather and self.feather.assets and self.feather.assets.isDrawWrapper then + isFeatherOwnedWrapper = self.feather.assets:isDrawWrapper(current) + end + + if current ~= wrapper and (not isFeatherOwnedWrapper or self._loveCallbackOriginals[name] == nil) then self._loveCallbackOriginals[name] = current love[name] = wrapper end @@ -436,6 +499,7 @@ function FeatherPluginManager:finish(feather) if plugin.instance then pcall(plugin.instance.finish, plugin.instance, feather) end + self:disposePluginCallbacks(plugin) end end diff --git a/src-lua/manifest.txt b/src-lua/manifest.txt index 771c303f..bdcd6e08 100644 --- a/src-lua/manifest.txt +++ b/src-lua/manifest.txt @@ -1,4 +1,5 @@ core:feather/auto.lua +core:feather/callback_bus.lua core:feather/core/assets.lua core:feather/core/base.lua core:feather/core/debug_overlay.lua diff --git a/src-lua/plugins/README.md b/src-lua/plugins/README.md index 4fa8701b..571cc40b 100644 --- a/src-lua/plugins/README.md +++ b/src-lua/plugins/README.md @@ -118,7 +118,48 @@ return { ### Love-event hooks -Instead of patching `love.*` callbacks inside `init()`, override the corresponding `on*` method. `FeatherPluginManager` patches each love callback once and dispatches to all enabled plugins — this prevents conflicts when multiple plugins hook the same callback. +Instead of patching `love.*` callbacks inside `init()`, use Feather's callback bus or override the corresponding `on*` method. `FeatherPluginManager` patches each love callback once and dispatches through a shared bus — this prevents conflicts when multiple plugins hook the same callback. + +### Callback bus + +`config.callbacks.register(name, fn, opts)` registers a handler on the shared runtime callback bus. + +```lua +function MyPlugin:init(config) + self.disposeOverlay = config.callbacks.register("draw", function() + -- Runs after love.draw(); use love.graphics here + end) + + config.callbacks.register("draw", function() + -- Rare override: run later than default callbacks + end, { + priority = 1000, + }) +end +``` + +Supported callback names are: + +- `draw` +- `keypressed` +- `keyreleased` +- `mousepressed` +- `mousereleased` +- `touchpressed` +- `touchreleased` +- `joystickpressed` +- `joystickreleased` + +Ordering rules: + +- No priority: FIFO, based on registration order. +- Lower numeric priorities run earlier; higher numeric priorities run later. +- Undefined priorities preserve FIFO order. +- Equal priorities preserve FIFO order. + +Use priority as a rare escape hatch. Normal plugins should usually omit it. + +Legacy `on*` methods still work and are routed through the same callback bus. If your plugin only needs the standard love hooks, overriding `onDraw`, `onKeypressed`, and friends remains valid. ```lua function MyPlugin:onDraw() @@ -211,7 +252,7 @@ The FeatherPluginManager handles the lifecycle of each plugin. Each plugin's `up #### Initialization -- `init(config)`: Called when the plugin is initialized. `config.options` contains the options passed to `createPlugin`, and `config.logger` / `config.observer` are always available. +- `init(config)`: Called when the plugin is initialized. `config.options` contains the options passed to `createPlugin`, `config.logger` / `config.observer` are always available, and `config.callbacks.register(...)` lets plugins join the shared love-event callback bus. - `getConfig()`: Returns the plugin configuration (type, icon, tab name, actions). Sent to the desktop app on connect. #### Data Push (every cycle) diff --git a/src-lua/plugins/input-replay/init.lua b/src-lua/plugins/input-replay/init.lua index 1c76a5d0..8bcf01d9 100644 --- a/src-lua/plugins/input-replay/init.lua +++ b/src-lua/plugins/input-replay/init.lua @@ -35,11 +35,14 @@ end ---@field captureJoystick boolean ---@field captureJoystickAxis boolean ---@field _originals table Original love callbacks saved for restoration +---@field _wrappers table Stable love callback wrappers owned by the plugin +---@field _callbackDisposers function[] ---@field _hooked boolean local InputReplayPlugin = Class({ __includes = Base, init = function(self, config) self.options = config.options or {} + self.feather = config.feather self.logger = config.logger self.observer = config.observer self.events = {} @@ -57,42 +60,173 @@ local InputReplayPlugin = Class({ self.captureJoystick = self.options.captureJoystick ~= false self.captureJoystickAxis = self.options.captureJoystickAxis == true -- off by default (noisy) self._originals = {} + self._wrappers = {} + self._callbackDisposers = {} self._hooked = false + + if config.callbacks then + self:_installCallbackBusHooks(config.callbacks) + end end, }) +local CALLBACK_BUS_EVENTS = { + keypressed = true, + keyreleased = true, + mousepressed = true, + mousereleased = true, + touchpressed = true, + touchreleased = true, + joystickpressed = true, + joystickreleased = true, +} + +local DIRECT_HOOK_EVENTS = { + mousemoved = true, + touchmoved = true, + joystickhat = true, + joystickaxis = true, + gamepadpressed = true, + gamepadreleased = true, + gamepadaxis = true, +} + +function InputReplayPlugin:_shouldCapture(name) + if name == "keypressed" or name == "keyreleased" then + return self.captureKeys + elseif name == "mousepressed" or name == "mousereleased" then + return self.captureMouse + elseif name == "mousemoved" then + return self.captureMouseMove + elseif name == "touchpressed" or name == "touchreleased" then + return self.captureTouch + elseif name == "touchmoved" then + return self.captureTouchMove + elseif + name == "joystickpressed" + or name == "joystickreleased" + or name == "joystickhat" + or name == "gamepadpressed" + or name == "gamepadreleased" + then + return self.captureJoystick + elseif name == "joystickaxis" or name == "gamepadaxis" then + return self.captureJoystickAxis + end + return false +end + +function InputReplayPlugin:_recordEvent(name, args) + if not self.recording or not self:_shouldCapture(name) then + return + end + if #self.events >= self.maxEvents then + return + end + self.events[#self.events + 1] = { + time = gettime() - self.recordStart, + type = name, + args = args, + } +end + +function InputReplayPlugin:_installCallbackBusHooks(callbacks) + local function register(name, argsTransform) + local ok, disposer = pcall(callbacks.register, name, function(...) + self:_recordEvent(name, argsTransform and argsTransform(...) or { ... }) + end) + if ok and disposer then + self._callbackDisposers[#self._callbackDisposers + 1] = disposer + end + end + + -- Touch id is light userdata — store as string so events are JSON-serializable + -- and can be replayed consistently across save/load sessions. + local function touchArgs(id, x, y, dx, dy, pressure) + return { tostring(id), x, y, dx, dy, pressure } + end + + -- Joystick userdata is stored as its numeric ID so events are JSON-serializable. + -- The ID is resolved back to the live object at replay time via REPLAY_RESOLVERS. + local function joystickArgs(joystick, ...) + local id = joystick:getID() -- returns id, instanceid; we keep only id + return { id, ... } + end + + register("keypressed") + register("keyreleased") + register("mousepressed") + register("mousereleased") + register("touchpressed", touchArgs) + register("touchreleased", touchArgs) + register("joystickpressed", joystickArgs) + register("joystickreleased", joystickArgs) +end + --- Install hooks on love callbacks to intercept input events. --- Safe to call multiple times — only hooks once. function InputReplayPlugin:_installHooks() if self._hooked then + local function recapture(name) + local wrapper = self._wrappers[name] + local featherWrapper = self.feather + and self.feather.pluginManager + and self.feather.pluginManager._loveCallbackWrappers + and self.feather.pluginManager._loveCallbackWrappers[name] + + if wrapper and DIRECT_HOOK_EVENTS[name] and love[name] ~= wrapper then + if love[name] ~= featherWrapper then + self._originals[name] = love[name] + end + love[name] = wrapper + end + end + + if self.captureMouseMove then + recapture("mousemoved") + end + + if self.captureTouchMove then + recapture("touchmoved") + end + + if self.captureJoystick then + recapture("joystickhat") + recapture("gamepadpressed") + recapture("gamepadreleased") + end + + if self.captureJoystickAxis then + recapture("joystickaxis") + recapture("gamepadaxis") + end + return end self._hooked = true local selfRef = self - -- Helper: wrap a love callback, recording events when active + -- Helper: keep a stable love callback wrapper and re-capture later overrides. local function hookCallback(name, argsTransform) - local original = love[name] - selfRef._originals[name] = original - - love[name] = function(...) - -- Record if active - if selfRef.recording then - local elapsed = gettime() - selfRef.recordStart - if #selfRef.events < selfRef.maxEvents then - local args = argsTransform and argsTransform(...) or { ... } - selfRef.events[#selfRef.events + 1] = { - time = elapsed, - type = name, - args = args, - } + local wrapper = selfRef._wrappers[name] + if not wrapper then + wrapper = function(...) + selfRef:_recordEvent(name, argsTransform and argsTransform(...) or { ... }) + + local original = selfRef._originals[name] + if original and original ~= wrapper then + return original(...) end end - -- Always call the original - if original then - return original(...) - end + + selfRef._wrappers[name] = wrapper + end + + local current = love[name] + if current ~= wrapper then + selfRef._originals[name] = current + love[name] = wrapper end end @@ -109,32 +243,15 @@ function InputReplayPlugin:_installHooks() return { id, ... } end - if self.captureKeys then - hookCallback("keypressed") - hookCallback("keyreleased") - end - - if self.captureMouse then - hookCallback("mousepressed") - hookCallback("mousereleased") - end - if self.captureMouseMove then hookCallback("mousemoved") end - if self.captureTouch then - hookCallback("touchpressed", touchArgs) - hookCallback("touchreleased", touchArgs) - end - if self.captureTouchMove then hookCallback("touchmoved", touchArgs) end if self.captureJoystick then - hookCallback("joystickpressed", joystickArgs) - hookCallback("joystickreleased", joystickArgs) hookCallback("joystickhat", joystickArgs) hookCallback("gamepadpressed", joystickArgs) hookCallback("gamepadreleased", joystickArgs) @@ -152,9 +269,12 @@ function InputReplayPlugin:_removeHooks() return end for name, original in pairs(self._originals) do - love[name] = original + if love[name] == self._wrappers[name] then + love[name] = original + end end self._originals = {} + self._wrappers = {} self._hooked = false end @@ -253,8 +373,32 @@ local REPLAY_RESOLVERS = { end, } +function InputReplayPlugin:_dispatchReplayEvent(event) + local resolver = REPLAY_RESOLVERS[event.type] + local replayArgs = resolver and resolver(event.args) or event.args + if not replayArgs then + return + end + + if CALLBACK_BUS_EVENTS[event.type] and love[event.type] then + pcall(love[event.type], unpack(replayArgs)) + return + end + + local original = self._originals[event.type] + if original then + pcall(original, unpack(replayArgs)) + elseif love[event.type] and love[event.type] ~= self._wrappers[event.type] then + pcall(love[event.type], unpack(replayArgs)) + end +end + --- Called every frame by the plugin manager. function InputReplayPlugin:update() + if self._hooked then + self:_installHooks() + end + if not self.replaying then return end @@ -268,17 +412,7 @@ function InputReplayPlugin:update() break -- not yet end - -- Fire the original callback (not our hook, to avoid re-recording). - -- For joystick events the stored args use a numeric ID; resolve back to the - -- live Joystick object before firing. - local original = self._originals[event.type] - if original then - local resolver = REPLAY_RESOLVERS[event.type] - local replayArgs = resolver and resolver(event.args) or event.args - if replayArgs then - pcall(original, unpack(replayArgs)) - end - end + self:_dispatchReplayEvent(event) self.replayIndex = self.replayIndex + 1 end @@ -536,6 +670,10 @@ end function InputReplayPlugin:finish(_feather) self:_removeHooks() + for _, dispose in ipairs(self._callbackDisposers) do + pcall(dispose) + end + self._callbackDisposers = {} self.recording = false self.replaying = false end From 0c4622ea460c1ee60f97dd567887a5d87a14150b Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Tue, 19 May 2026 16:13:52 -0400 Subject: [PATCH 05/28] lua: add plugins unit test --- scripts/lua-e2e.mjs | 5 + src-lua/e2e/plugins/animation_inspector.lua | 3 + src-lua/e2e/plugins/audio_debug.lua | 3 + src-lua/e2e/plugins/bookmark.lua | 3 + src-lua/e2e/plugins/collision_debug.lua | 3 + src-lua/e2e/plugins/config_tweaker.lua | 3 + src-lua/e2e/plugins/console.lua | 3 + src-lua/e2e/plugins/coroutine_monitor.lua | 3 + src-lua/e2e/plugins/entity_inspector.lua | 3 + src-lua/e2e/plugins/filesystem.lua | 3 + src-lua/e2e/plugins/helper.lua | 280 ++++++++++++++++++ src-lua/e2e/plugins/hot_reload.lua | 3 + src-lua/e2e/plugins/hump_signal.lua | 3 + src-lua/e2e/plugins/ingame_overlay.lua | 3 + src-lua/e2e/plugins/init.lua | 47 +++ src-lua/e2e/plugins/input_replay.lua | 87 ++++++ src-lua/e2e/plugins/lua_state_machine.lua | 3 + src-lua/e2e/plugins/memory_snapshot.lua | 3 + src-lua/e2e/plugins/network_inspector.lua | 3 + src-lua/e2e/plugins/particle_editor.lua | 3 + .../plugins/particle_system_playground.lua | 3 + src-lua/e2e/plugins/physics_debug.lua | 3 + src-lua/e2e/plugins/profiler.lua | 3 + src-lua/e2e/plugins/runtime_snapshot.lua | 3 + src-lua/e2e/plugins/screenshots.lua | 3 + src-lua/e2e/plugins/shader_graph.lua | 3 + src-lua/e2e/plugins/smoke.lua | 1 + src-lua/e2e/plugins/time_travel.lua | 3 + src-lua/e2e/plugins/timer_inspector.lua | 3 + src-lua/example/e2e/main.lua | 43 +++ 30 files changed, 535 insertions(+) create mode 100644 src-lua/e2e/plugins/animation_inspector.lua create mode 100644 src-lua/e2e/plugins/audio_debug.lua create mode 100644 src-lua/e2e/plugins/bookmark.lua create mode 100644 src-lua/e2e/plugins/collision_debug.lua create mode 100644 src-lua/e2e/plugins/config_tweaker.lua create mode 100644 src-lua/e2e/plugins/console.lua create mode 100644 src-lua/e2e/plugins/coroutine_monitor.lua create mode 100644 src-lua/e2e/plugins/entity_inspector.lua create mode 100644 src-lua/e2e/plugins/filesystem.lua create mode 100644 src-lua/e2e/plugins/helper.lua create mode 100644 src-lua/e2e/plugins/hot_reload.lua create mode 100644 src-lua/e2e/plugins/hump_signal.lua create mode 100644 src-lua/e2e/plugins/ingame_overlay.lua create mode 100644 src-lua/e2e/plugins/init.lua create mode 100644 src-lua/e2e/plugins/input_replay.lua create mode 100644 src-lua/e2e/plugins/lua_state_machine.lua create mode 100644 src-lua/e2e/plugins/memory_snapshot.lua create mode 100644 src-lua/e2e/plugins/network_inspector.lua create mode 100644 src-lua/e2e/plugins/particle_editor.lua create mode 100644 src-lua/e2e/plugins/particle_system_playground.lua create mode 100644 src-lua/e2e/plugins/physics_debug.lua create mode 100644 src-lua/e2e/plugins/profiler.lua create mode 100644 src-lua/e2e/plugins/runtime_snapshot.lua create mode 100644 src-lua/e2e/plugins/screenshots.lua create mode 100644 src-lua/e2e/plugins/shader_graph.lua create mode 100644 src-lua/e2e/plugins/smoke.lua create mode 100644 src-lua/e2e/plugins/time_travel.lua create mode 100644 src-lua/e2e/plugins/timer_inspector.lua diff --git a/scripts/lua-e2e.mjs b/scripts/lua-e2e.mjs index 4f9c1668..56b42881 100644 --- a/scripts/lua-e2e.mjs +++ b/scripts/lua-e2e.mjs @@ -27,7 +27,12 @@ if (!love) { process.exit(1); } +const pluginSelector = process.argv[2]; const args = [gamePath, '--e2e']; + +if (pluginSelector) { + args.push(`--plugin-e2e=${pluginSelector}`); +} let command = love; let commandArgs = args; diff --git a/src-lua/e2e/plugins/animation_inspector.lua b/src-lua/e2e/plugins/animation_inspector.lua new file mode 100644 index 00000000..5dfcc673 --- /dev/null +++ b/src-lua/e2e/plugins/animation_inspector.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("animation-inspector") diff --git a/src-lua/e2e/plugins/audio_debug.lua b/src-lua/e2e/plugins/audio_debug.lua new file mode 100644 index 00000000..e9c0ee5d --- /dev/null +++ b/src-lua/e2e/plugins/audio_debug.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("audio-debug") diff --git a/src-lua/e2e/plugins/bookmark.lua b/src-lua/e2e/plugins/bookmark.lua new file mode 100644 index 00000000..c9ad02a7 --- /dev/null +++ b/src-lua/e2e/plugins/bookmark.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("bookmark") diff --git a/src-lua/e2e/plugins/collision_debug.lua b/src-lua/e2e/plugins/collision_debug.lua new file mode 100644 index 00000000..418eb0ad --- /dev/null +++ b/src-lua/e2e/plugins/collision_debug.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("collision-debug") diff --git a/src-lua/e2e/plugins/config_tweaker.lua b/src-lua/e2e/plugins/config_tweaker.lua new file mode 100644 index 00000000..d926b11b --- /dev/null +++ b/src-lua/e2e/plugins/config_tweaker.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("config-tweaker") diff --git a/src-lua/e2e/plugins/console.lua b/src-lua/e2e/plugins/console.lua new file mode 100644 index 00000000..b46ccb49 --- /dev/null +++ b/src-lua/e2e/plugins/console.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("console") diff --git a/src-lua/e2e/plugins/coroutine_monitor.lua b/src-lua/e2e/plugins/coroutine_monitor.lua new file mode 100644 index 00000000..b30384e8 --- /dev/null +++ b/src-lua/e2e/plugins/coroutine_monitor.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("coroutine-monitor") diff --git a/src-lua/e2e/plugins/entity_inspector.lua b/src-lua/e2e/plugins/entity_inspector.lua new file mode 100644 index 00000000..a124c04a --- /dev/null +++ b/src-lua/e2e/plugins/entity_inspector.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("entity-inspector") diff --git a/src-lua/e2e/plugins/filesystem.lua b/src-lua/e2e/plugins/filesystem.lua new file mode 100644 index 00000000..b11f1229 --- /dev/null +++ b/src-lua/e2e/plugins/filesystem.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("filesystem") diff --git a/src-lua/e2e/plugins/helper.lua b/src-lua/e2e/plugins/helper.lua new file mode 100644 index 00000000..958ddf29 --- /dev/null +++ b/src-lua/e2e/plugins/helper.lua @@ -0,0 +1,280 @@ +local FeatherDebugger = require("feather") +local FeatherPluginManager = require("feather.plugin_manager") + +local PluginE2EHelper = {} + +local pluginSpecs = { + { + id = "animation-inspector", + modulePath = "plugins.animation-inspector", + suiteModulePath = "e2e.plugins.animation_inspector", + }, + { + id = "audio-debug", + modulePath = "plugins.audio-debug", + suiteModulePath = "e2e.plugins.audio_debug", + }, + { + id = "bookmark", + modulePath = "plugins.bookmark", + suiteModulePath = "e2e.plugins.bookmark", + }, + { + id = "collision-debug", + modulePath = "plugins.collision-debug", + suiteModulePath = "e2e.plugins.collision_debug", + }, + { + id = "config-tweaker", + modulePath = "plugins.config-tweaker", + suiteModulePath = "e2e.plugins.config_tweaker", + }, + { + id = "console", + modulePath = "plugins.console", + suiteModulePath = "e2e.plugins.console", + }, + { + id = "coroutine-monitor", + modulePath = "plugins.coroutine-monitor", + suiteModulePath = "e2e.plugins.coroutine_monitor", + }, + { + id = "entity-inspector", + modulePath = "plugins.entity-inspector", + suiteModulePath = "e2e.plugins.entity_inspector", + }, + { + id = "filesystem", + modulePath = "plugins.filesystem", + suiteModulePath = "e2e.plugins.filesystem", + }, + { + id = "hot-reload", + modulePath = "plugins.hot-reload", + suiteModulePath = "e2e.plugins.hot_reload", + }, + { + id = "hump.signal", + modulePath = "plugins.hump.signal", + suiteModulePath = "e2e.plugins.hump_signal", + }, + { + id = "ingame-overlay", + modulePath = "plugins.ingame-overlay", + suiteModulePath = "e2e.plugins.ingame_overlay", + }, + { + id = "input-replay", + modulePath = "plugins.input-replay", + suiteModulePath = "e2e.plugins.input_replay", + }, + { + id = "lua-state-machine", + modulePath = "plugins.lua-state-machine", + suiteModulePath = "e2e.plugins.lua_state_machine", + }, + { + id = "memory-snapshot", + modulePath = "plugins.memory-snapshot", + suiteModulePath = "e2e.plugins.memory_snapshot", + }, + { + id = "network-inspector", + modulePath = "plugins.network-inspector", + suiteModulePath = "e2e.plugins.network_inspector", + }, + { + id = "particle-editor", + modulePath = "plugins.particle-editor", + suiteModulePath = "e2e.plugins.particle_editor", + }, + { + id = "particle-system-playground", + modulePath = "plugins.particle-system-playground", + suiteModulePath = "e2e.plugins.particle_system_playground", + }, + { + id = "physics-debug", + modulePath = "plugins.physics-debug", + suiteModulePath = "e2e.plugins.physics_debug", + }, + { + id = "profiler", + modulePath = "plugins.profiler", + suiteModulePath = "e2e.plugins.profiler", + }, + { + id = "runtime-snapshot", + modulePath = "plugins.runtime-snapshot", + suiteModulePath = "e2e.plugins.runtime_snapshot", + }, + { + id = "screenshots", + modulePath = "plugins.screenshots", + suiteModulePath = "e2e.plugins.screenshots", + }, + { + id = "shader-graph", + modulePath = "plugins.shader-graph", + suiteModulePath = "e2e.plugins.shader_graph", + }, + { + id = "time-travel", + modulePath = "plugins.time-travel", + suiteModulePath = "e2e.plugins.time_travel", + }, + { + id = "timer-inspector", + modulePath = "plugins.timer-inspector", + suiteModulePath = "e2e.plugins.timer_inspector", + }, +} + +local specsById = {} +for _, spec in ipairs(pluginSpecs) do + specsById[spec.id] = spec +end + +local function copyTable(source) + local target = {} + for key, value in pairs(source or {}) do + target[key] = value + end + return target +end + +function PluginE2EHelper.getPluginSpecs() + return pluginSpecs +end + +function PluginE2EHelper.getPluginSpec(pluginId) + local spec = specsById[pluginId] + if not spec then + error("Unknown plugin E2E suite: " .. tostring(pluginId), 2) + end + return spec +end + +function PluginE2EHelper.getSuiteModulePaths() + local modulePaths = {} + + for _, spec in ipairs(pluginSpecs) do + modulePaths[#modulePaths + 1] = spec.suiteModulePath + end + + return modulePaths +end + +function PluginE2EHelper.loadPluginDefinition(pluginId) + local spec = PluginE2EHelper.getPluginSpec(pluginId) + local manifest = require(spec.modulePath .. ".manifest") + local plugin = require(spec.modulePath) + + return { + spec = spec, + manifest = manifest, + plugin = plugin, + } +end + +function PluginE2EHelper.createPluginRecord(definition, optionOverrides, disabledOverride) + local manifest = definition.manifest + local pluginOptions = copyTable(manifest.opts or {}) + + for key, value in pairs(optionOverrides or {}) do + pluginOptions[key] = value + end + + return FeatherPluginManager.createPlugin( + definition.plugin, + manifest.id, + pluginOptions, + disabledOverride ~= nil and disabledOverride or manifest.disabled, + manifest.capabilities or {}, + { + api = manifest.api, + minApi = manifest.minApi, + maxApi = manifest.maxApi, + name = manifest.name, + version = manifest.version, + } + ) +end + +function PluginE2EHelper.createFeather(config) + return FeatherDebugger({ + debug = true, + mode = "disk", + sessionName = config.sessionName, + deviceId = config.deviceId, + assetPreview = config.assetPreview == true, + plugins = config.plugins, + debugger = { + enabled = false, + }, + }) +end + +function PluginE2EHelper.assertSmoke(context) + local feather = context.feather + local definition = context.definition + local assertEqual = context.assertEqual + local assertTruthy = context.assertTruthy + local pluginId = definition.manifest.id + local pluginRecord = feather.pluginManager:getPlugin(pluginId) + local config = feather:__getConfig() + + assertTruthy(pluginRecord, "plugin smoke suite registers " .. pluginId) + assertTruthy(pluginRecord.instance, "plugin smoke suite instantiates " .. pluginId) + assertTruthy(config.plugins[pluginId], "plugin smoke suite exposes " .. pluginId .. " in config") + assertEqual(pluginRecord.identifier, pluginId, "plugin smoke suite keeps identifier for " .. pluginId) + + return pluginRecord +end + +function PluginE2EHelper.createSmokeSuite(pluginId, options) + options = options or {} + + return { + id = pluginId, + run = function(assertEqual, assertTruthy) + local definition = PluginE2EHelper.loadPluginDefinition(pluginId) + local feather = PluginE2EHelper.createFeather({ + sessionName = options.sessionName or ("Plugin " .. pluginId .. " E2E"), + deviceId = options.deviceId or (("plugin-" .. pluginId .. "-e2e"):gsub("[^%w%-]", "-")), + assetPreview = options.assetPreview, + plugins = { + PluginE2EHelper.createPluginRecord(definition, options.pluginOptions, options.disabled), + }, + }) + + local ok, result = xpcall(function() + local pluginRecord = PluginE2EHelper.assertSmoke({ + feather = feather, + definition = definition, + assertEqual = assertEqual, + assertTruthy = assertTruthy, + }) + + if options.run then + options.run({ + feather = feather, + definition = definition, + pluginRecord = pluginRecord, + assertEqual = assertEqual, + assertTruthy = assertTruthy, + }) + end + end, debug.traceback) + + feather:finish() + + if not ok then + error(result, 0) + end + end, + } +end + +return PluginE2EHelper diff --git a/src-lua/e2e/plugins/hot_reload.lua b/src-lua/e2e/plugins/hot_reload.lua new file mode 100644 index 00000000..a0176448 --- /dev/null +++ b/src-lua/e2e/plugins/hot_reload.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("hot-reload") diff --git a/src-lua/e2e/plugins/hump_signal.lua b/src-lua/e2e/plugins/hump_signal.lua new file mode 100644 index 00000000..2bec5ce1 --- /dev/null +++ b/src-lua/e2e/plugins/hump_signal.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("hump.signal") diff --git a/src-lua/e2e/plugins/ingame_overlay.lua b/src-lua/e2e/plugins/ingame_overlay.lua new file mode 100644 index 00000000..f5ee47d3 --- /dev/null +++ b/src-lua/e2e/plugins/ingame_overlay.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("ingame-overlay") diff --git a/src-lua/e2e/plugins/init.lua b/src-lua/e2e/plugins/init.lua new file mode 100644 index 00000000..bab6e9d7 --- /dev/null +++ b/src-lua/e2e/plugins/init.lua @@ -0,0 +1,47 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +local PluginE2ESuite = {} + +local function getSelectedPluginId() + local selected = os.getenv("FEATHER_E2E_PLUGIN") + if type(selected) == "string" and selected ~= "" then + return selected + end + + for _, value in ipairs(rawget(_G, "arg") or {}) do + local pluginId = value:match("^%-%-plugin%-e2e=(.+)$") + if pluginId and pluginId ~= "" then + return pluginId + end + end + + return nil +end + +function PluginE2ESuite.run(assertEqual, assertTruthy) + local selectedPluginId = getSelectedPluginId() + if selectedPluginId then + local ok, spec = pcall(PluginE2EHelper.getPluginSpec, selectedPluginId) + if not ok then + local orderedSuiteIds = {} + for _, pluginSpec in ipairs(PluginE2EHelper.getPluginSpecs()) do + orderedSuiteIds[#orderedSuiteIds + 1] = pluginSpec.id + end + error( + "Unknown plugin E2E selector '" .. selectedPluginId .. "'. Available: " .. table.concat(orderedSuiteIds, ", "), + 2 + ) + end + + local suite = require(spec.suiteModulePath) + suite.run(assertEqual, assertTruthy) + return + end + + for _, modulePath in ipairs(PluginE2EHelper.getSuiteModulePaths()) do + local suite = require(modulePath) + suite.run(assertEqual, assertTruthy) + end +end + +return PluginE2ESuite diff --git a/src-lua/e2e/plugins/input_replay.lua b/src-lua/e2e/plugins/input_replay.lua new file mode 100644 index 00000000..e283a6d6 --- /dev/null +++ b/src-lua/e2e/plugins/input_replay.lua @@ -0,0 +1,87 @@ +local FeatherDebugger = require("feather") +local FeatherPluginManager = require("feather.plugin_manager") +local InputReplayPlugin = require("plugins.input-replay") + +local InputReplaySuite = { + id = "input-replay", +} + +function InputReplaySuite.run(assertEqual, assertTruthy) + local originalReplayKeypressed = love.keypressed + local preReplayRecordCount = 0 + local lateRecordOverrideCount = 0 + local lateReplayOverrideCount = 0 + + love.keypressed = function() + preReplayRecordCount = preReplayRecordCount + 1 + end + + local inputReplayFeather = FeatherDebugger({ + debug = true, + mode = "disk", + sessionName = "Input Replay E2E", + deviceId = "input-replay-e2e", + assetPreview = false, + plugins = { + FeatherPluginManager.createPlugin(InputReplayPlugin, "input-replay", { + captureKeys = true, + captureMouse = false, + captureTouch = false, + captureJoystick = false, + }), + }, + debugger = { + enabled = false, + }, + }) + + local ok, result = xpcall(function() + local inputReplayPlugin = inputReplayFeather.pluginManager:getPlugin("input-replay") + local config = inputReplayFeather:__getConfig() + local replay = inputReplayPlugin and inputReplayPlugin.instance + + assertTruthy(inputReplayPlugin, "plugin smoke suite registers input-replay") + assertTruthy(replay, "plugin smoke suite instantiates input-replay") + assertTruthy(config.plugins["input-replay"], "plugin smoke suite exposes input-replay in config") + assertEqual(inputReplayPlugin.identifier, "input-replay", "plugin smoke suite keeps identifier for input-replay") + assertTruthy(replay, "input replay plugin instance is available") + + replay:startRecording() + love.keypressed("a", "a", false) + assertEqual(preReplayRecordCount, 1, "input replay recording preserves pre-hook keypressed override") + assertEqual(#replay.events, 1, "input replay records initial keypressed event") + + love.keypressed = function() + lateRecordOverrideCount = lateRecordOverrideCount + 1 + end + + inputReplayFeather:update(0) + love.keypressed("b", "b", false) + assertEqual(lateRecordOverrideCount, 1, "input replay recording survives late keypressed override") + assertEqual(#replay.events, 2, "input replay keeps recording after late keypressed override") + + replay:stopRecording() + replay.events = { + { time = 0, type = "keypressed", args = { "x", "x", false } }, + { time = 0, type = "keypressed", args = { "y", "y", false } }, + } + + love.keypressed = function() + lateReplayOverrideCount = lateReplayOverrideCount + 1 + end + + replay:startReplay() + inputReplayFeather:update(0) + assertEqual(lateReplayOverrideCount, 2, "input replay replays through late keypressed override") + assertEqual(replay.replaying, false, "input replay stops after queued replay events finish") + end, debug.traceback) + + love.keypressed = originalReplayKeypressed + inputReplayFeather:finish() + + if not ok then + error(result, 0) + end +end + +return InputReplaySuite diff --git a/src-lua/e2e/plugins/lua_state_machine.lua b/src-lua/e2e/plugins/lua_state_machine.lua new file mode 100644 index 00000000..e712ce3e --- /dev/null +++ b/src-lua/e2e/plugins/lua_state_machine.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("lua-state-machine") diff --git a/src-lua/e2e/plugins/memory_snapshot.lua b/src-lua/e2e/plugins/memory_snapshot.lua new file mode 100644 index 00000000..295932f0 --- /dev/null +++ b/src-lua/e2e/plugins/memory_snapshot.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("memory-snapshot") diff --git a/src-lua/e2e/plugins/network_inspector.lua b/src-lua/e2e/plugins/network_inspector.lua new file mode 100644 index 00000000..38c1f0d1 --- /dev/null +++ b/src-lua/e2e/plugins/network_inspector.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("network-inspector") diff --git a/src-lua/e2e/plugins/particle_editor.lua b/src-lua/e2e/plugins/particle_editor.lua new file mode 100644 index 00000000..a3aed15c --- /dev/null +++ b/src-lua/e2e/plugins/particle_editor.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("particle-editor") diff --git a/src-lua/e2e/plugins/particle_system_playground.lua b/src-lua/e2e/plugins/particle_system_playground.lua new file mode 100644 index 00000000..729b179b --- /dev/null +++ b/src-lua/e2e/plugins/particle_system_playground.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("particle-system-playground") diff --git a/src-lua/e2e/plugins/physics_debug.lua b/src-lua/e2e/plugins/physics_debug.lua new file mode 100644 index 00000000..17ba6a69 --- /dev/null +++ b/src-lua/e2e/plugins/physics_debug.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("physics-debug") diff --git a/src-lua/e2e/plugins/profiler.lua b/src-lua/e2e/plugins/profiler.lua new file mode 100644 index 00000000..4f0365c1 --- /dev/null +++ b/src-lua/e2e/plugins/profiler.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("profiler") diff --git a/src-lua/e2e/plugins/runtime_snapshot.lua b/src-lua/e2e/plugins/runtime_snapshot.lua new file mode 100644 index 00000000..c73a0fc7 --- /dev/null +++ b/src-lua/e2e/plugins/runtime_snapshot.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("runtime-snapshot") diff --git a/src-lua/e2e/plugins/screenshots.lua b/src-lua/e2e/plugins/screenshots.lua new file mode 100644 index 00000000..b84b40e6 --- /dev/null +++ b/src-lua/e2e/plugins/screenshots.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("screenshots") diff --git a/src-lua/e2e/plugins/shader_graph.lua b/src-lua/e2e/plugins/shader_graph.lua new file mode 100644 index 00000000..24130eee --- /dev/null +++ b/src-lua/e2e/plugins/shader_graph.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("shader-graph") diff --git a/src-lua/e2e/plugins/smoke.lua b/src-lua/e2e/plugins/smoke.lua new file mode 100644 index 00000000..28181143 --- /dev/null +++ b/src-lua/e2e/plugins/smoke.lua @@ -0,0 +1 @@ +return require("e2e.plugins") diff --git a/src-lua/e2e/plugins/time_travel.lua b/src-lua/e2e/plugins/time_travel.lua new file mode 100644 index 00000000..bc35b580 --- /dev/null +++ b/src-lua/e2e/plugins/time_travel.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("time-travel") diff --git a/src-lua/e2e/plugins/timer_inspector.lua b/src-lua/e2e/plugins/timer_inspector.lua new file mode 100644 index 00000000..16c69697 --- /dev/null +++ b/src-lua/e2e/plugins/timer_inspector.lua @@ -0,0 +1,3 @@ +local PluginE2EHelper = require("e2e.plugins.helper") + +return PluginE2EHelper.createSmokeSuite("timer-inspector") diff --git a/src-lua/example/e2e/main.lua b/src-lua/example/e2e/main.lua index 19a74d46..819e041d 100644 --- a/src-lua/example/e2e/main.lua +++ b/src-lua/example/e2e/main.lua @@ -3,6 +3,7 @@ local Class = require("feather.lib.class") local FeatherPluginBase = require("feather.core.base") local FeatherPluginManager = require("feather.plugin_manager") local HotReloadPlugin = require("plugins.hot-reload") +local PluginE2ESuite = require("e2e.plugins") local E2EPlugin = Class({ __includes = FeatherPluginBase, @@ -422,6 +423,48 @@ return M love.draw = originalDraw love.keypressed = originalKeypressed + local assetDrawCount = 0 + local assetHookCount = 0 + local AssetHookPlugin = Class({ + __includes = FeatherPluginBase, + init = function(self, config) + FeatherPluginBase.init(self, config) + config.callbacks.register("draw", function() + assetHookCount = assetHookCount + 1 + end) + end, + }) + + love.draw = function() + assetDrawCount = assetDrawCount + 1 + end + + local assetFeather = FeatherDebugger({ + debug = true, + mode = "disk", + sessionName = "Callback Asset Draw E2E", + deviceId = "callback-asset-draw-e2e", + assetPreview = true, + plugins = { + FeatherPluginManager.createPlugin(AssetHookPlugin, "asset-draw", {}), + }, + debugger = { + enabled = false, + }, + }) + + love.draw() + assetFeather:update(0) + love.draw() + assetFeather:update(0) + love.draw() + assertEqual(assetDrawCount, 3, "asset preview draw wrapper preserves game draw across rehooks") + assertEqual(assetHookCount, 3, "asset preview draw wrapper preserves callback bus draw across rehooks") + assetFeather:finish() + love.draw = originalDraw + + PluginE2ESuite.run(assertEqual, assertTruthy) + -- ── Auth handshake state machine ─────────────────────────────────────────── -- Test the challenge-response logic in isolation. We use a disk-mode instance -- so no real WS server is needed; __sendWs is a no-op when wsConnected=false. From a8dab64d39125a440f041c0a17b464ffa37206af Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Tue, 19 May 2026 16:54:29 -0400 Subject: [PATCH 06/28] cli: improve linux vendoring --- .github/workflows/cli-e2e.yml | 20 +++-- cli/src/commands/config.ts | 3 +- cli/src/commands/init.ts | 13 +++ cli/src/commands/package/shared.ts | 4 +- cli/src/commands/plugin/install.ts | 61 ++++++++------ cli/src/commands/plugin/shared.ts | 4 +- cli/src/index.ts | 8 +- cli/src/lib/build/vendor.ts | 53 ++++++++++++- cli/src/lib/config.ts | 10 ++- cli/src/lib/paths.ts | 41 +++++++++- cli/test/commands/build-vendor.test.mjs | 101 ++++++++++++++++++++++++ cli/test/commands/config.test.mjs | 12 +++ cli/test/commands/helpers.mjs | 62 +++++++++++++++ cli/test/commands/init.test.mjs | 28 +++++++ cli/test/commands/package.test.mjs | 12 +++ cli/test/commands/plugins.test.mjs | 14 ++++ cli/test/commands/run.test.mjs | 5 ++ vscode-extension/src/extension.ts | 58 +++++++++++--- 18 files changed, 454 insertions(+), 55 deletions(-) diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml index d9384ab5..62ff6db1 100644 --- a/.github/workflows/cli-e2e.yml +++ b/.github/workflows/cli-e2e.yml @@ -26,9 +26,14 @@ permissions: jobs: cli-e2e: - name: CLI end-to-end - runs-on: ubuntu-latest - timeout-minutes: 10 + name: CLI end-to-end (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] steps: - name: Checkout @@ -40,10 +45,15 @@ jobs: node-version: 22.21.1 cache: "npm" - - name: Install LÖVE + - name: Install LÖVE (Linux) + if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y love + sudo apt-get install -y love squashfs-tools + + - name: Install squashfs-tools (macOS) + if: runner.os == 'macOS' + run: brew install squashfs - name: Install dependencies run: npm ci diff --git a/cli/src/commands/config.ts b/cli/src/commands/config.ts index 1bbafdc4..c1657f40 100644 --- a/cli/src/commands/config.ts +++ b/cli/src/commands/config.ts @@ -6,6 +6,7 @@ import { assertSafeProjectTarget } from '../lib/path-safety.js'; import { pluginCatalog } from '../generated/plugin-catalog.js'; import { mergeCapabilities } from '../ui/init/config.js'; import { icon, printLine } from '../lib/output.js'; +import { findConfigDir } from '../lib/paths.js'; export type ConfigPluginsOptions = { dir?: string; @@ -58,7 +59,7 @@ function upsertTopLevelValue(source: string, key: string, value: unknown): strin } export async function configPluginsCommand(opts: ConfigPluginsOptions = {}): Promise { - const projectDir = resolve(opts.dir ?? '.'); + const projectDir = findConfigDir(opts.dir ? resolve(opts.dir) : process.cwd()); const includeIds = parseIds(opts.include); const excludeIds = parseIds(opts.exclude); diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 1a694a5e..a85439f8 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -41,6 +41,16 @@ function addPluginCapabilities(config: Record, pluginIds: Itera } } +function addIncludedPlugins(config: Record, pluginIds: Iterable): void { + const include = new Set(Array.isArray(config.include) ? config.include.filter((id): id is string => typeof id === 'string') : []); + for (const id of pluginIds) { + include.add(id); + } + if (include.size > 0) { + config.include = [...include].sort(); + } +} + const toLocalName = (id: string) => id .split(/[-.]/) @@ -124,6 +134,9 @@ export async function initCommand(dir: string, opts: InitOptions): Promise const useRemote = opts.remote === true || setup.source === 'remote'; if (mode === 'cli') { + const pluginIds = pluginsDisabled ? [] : (opts.plugins ?? []).filter((id) => !setup.exclude.includes(id)); + addIncludedPlugins(setup.config, pluginIds); + addPluginCapabilities(setup.config, pluginIds); writeConfig(target, setup.config, { mode, installDir, diff --git a/cli/src/commands/package/shared.ts b/cli/src/commands/package/shared.ts index 1b29cd20..082c1ae2 100644 --- a/cli/src/commands/package/shared.ts +++ b/cli/src/commands/package/shared.ts @@ -1,11 +1,11 @@ import { resolve } from 'node:path'; -import { findProjectDir } from '../../lib/paths.js'; +import { findPackageDir } from '../../lib/paths.js'; import { loadRegistry, type Registry, type RegistryLoadOptions } from '../../lib/package/registry.js'; import { fail } from '../../lib/command.js'; import { createSpinner, printMuted, printStatus } from '../../lib/output.js'; export function resolvePackageProjectDir(dir?: string): string { - return dir ? resolve(dir) : findProjectDir(); + return findPackageDir(dir ? resolve(dir) : process.cwd()); } export async function loadRegistryOrExit( diff --git a/cli/src/commands/plugin/install.ts b/cli/src/commands/plugin/install.ts index 00dcd1f3..a9666b21 100644 --- a/cli/src/commands/plugin/install.ts +++ b/cli/src/commands/plugin/install.ts @@ -13,29 +13,42 @@ import { resolveLocalLuaRoot } from '../../lib/paths.js'; import { assertValidPluginId, pluginIdToSourceDir } from '../../lib/plugin-utils.js'; import { type PluginSourceOptions, resolvePluginProjectDir, warnDangerousPlugin } from './shared.js'; -export async function pluginInstallCommand(pluginId: string, opts: PluginSourceOptions): Promise { +export async function pluginInstallCommand(pluginIds: string | string[], opts: PluginSourceOptions): Promise { const projectDir = resolvePluginProjectDir(opts.dir); const branch = opts.branch ?? 'main'; const installDir = opts.installDir ?? 'feather'; - try { - assertValidPluginId(pluginId); - } catch (err) { - fail((err as Error).message); + const ids = (Array.isArray(pluginIds) ? pluginIds : [pluginIds]).flatMap((value) => + value.split(',').map((id) => id.trim()).filter(Boolean), + ); + const uniqueIds = [...new Set(ids)]; + if (uniqueIds.length === 0) { + fail('Plugin id is required.'); + } + for (const pluginId of uniqueIds) { + try { + assertValidPluginId(pluginId); + } catch (err) { + fail((err as Error).message); + } } if (!opts.remote) { const sourceRoot = resolveLocalLuaRoot(opts); const available = getLocalPluginIds(sourceRoot); - const sourceExists = existsSync(join(sourceRoot, 'plugins', pluginIdToSourceDir(pluginId))); - if (!available.includes(pluginId) && !sourceExists) { - fail(`Unknown plugin: ${pluginId}`, { details: ['Available: ' + available.join(', ')] }); + for (const pluginId of uniqueIds) { + const sourceExists = existsSync(join(sourceRoot, 'plugins', pluginIdToSourceDir(pluginId))); + if (!available.includes(pluginId) && !sourceExists) { + fail(`Unknown plugin: ${pluginId}`, { details: ['Available: ' + available.join(', ')] }); + } } - const spinner = createSpinner(`Copying ${pluginId}…`).start(); + const spinner = createSpinner(`Copying ${uniqueIds.join(', ')}…`).start(); try { - installPluginsFromLocal([pluginId], sourceRoot, projectDir, installDir); - spinner.succeed(`Installed ${pluginId}`); - warnDangerousPlugin(pluginId); + installPluginsFromLocal(uniqueIds, sourceRoot, projectDir, installDir); + spinner.succeed(`Installed ${uniqueIds.join(', ')}`); + for (const pluginId of uniqueIds) { + warnDangerousPlugin(pluginId); + } } catch (err) { spinner.fail((err as Error).message); fail((err as Error).message, { cause: err, silent: true }); @@ -54,17 +67,21 @@ export async function pluginInstallCommand(pluginId: string, opts: PluginSourceO } const available = getPluginIds(entries); - if (!available.includes(pluginId)) { - fail(`Unknown plugin: ${pluginId}`, { details: ['Available: ' + available.join(', ')] }); + for (const pluginId of uniqueIds) { + if (!available.includes(pluginId)) { + fail(`Unknown plugin: ${pluginId}`, { details: ['Available: ' + available.join(', ')] }); + } } - const installSpinner = createSpinner(`Installing ${pluginId}…`).start(); - try { - await installPlugin(pluginId, entries, projectDir, branch, undefined, installDir); - installSpinner.succeed(`Installed ${pluginId}`); - warnDangerousPlugin(pluginId); - } catch (err) { - installSpinner.fail((err as Error).message); - fail((err as Error).message, { cause: err, silent: true }); + for (const pluginId of uniqueIds) { + const installSpinner = createSpinner(`Installing ${pluginId}…`).start(); + try { + await installPlugin(pluginId, entries, projectDir, branch, undefined, installDir); + installSpinner.succeed(`Installed ${pluginId}`); + warnDangerousPlugin(pluginId); + } catch (err) { + installSpinner.fail((err as Error).message); + fail((err as Error).message, { cause: err, silent: true }); + } } } diff --git a/cli/src/commands/plugin/shared.ts b/cli/src/commands/plugin/shared.ts index cda30f9c..19ee2959 100644 --- a/cli/src/commands/plugin/shared.ts +++ b/cli/src/commands/plugin/shared.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { normalizeInstallDir } from '../../lib/install.js'; -import { findProjectDir } from '../../lib/paths.js'; +import { findPackageDir } from '../../lib/paths.js'; import { dangerousPluginIds, findInstalledPluginDirs, readPluginManifest } from '../../lib/plugin-utils.js'; import { printWarning } from '../../lib/output.js'; @@ -14,7 +14,7 @@ export type PluginSourceOptions = { }; export function resolvePluginProjectDir(dir?: string): string { - return dir ? resolve(dir) : findProjectDir(); + return findPackageDir(dir ? resolve(dir) : process.cwd()); } export function pluginsDir(projectDir: string, installDir = 'feather'): string { diff --git a/cli/src/index.ts b/cli/src/index.ts index e4d0d71f..717baa90 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -438,17 +438,17 @@ export function createProgram(): Command { ); plugin - .command('install ') - .description('Install a plugin from the Feather registry') + .command('install ') + .description('Install one or more plugins from the Feather registry') .option('--dir ', 'Project directory (default: current directory)') .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') .option('--branch ', 'GitHub branch to download from when using --remote', 'main') .option('--local-src ', 'Copy plugins from a local src-lua style directory') .option('--install-dir ', 'Feather install directory', 'feather') - .action((id: string, opts) => + .action((ids: string[], opts) => runCliAction(() => { const merged = pluginCommandOptions(opts); - return pluginInstallCommand(id, { + return pluginInstallCommand(ids, { dir: merged.dir as string | undefined, branch: merged.branch as string, installDir: merged.installDir as string, diff --git a/cli/src/lib/build/vendor.ts b/cli/src/lib/build/vendor.ts index e746f8ba..4def0f1f 100644 --- a/cli/src/lib/build/vendor.ts +++ b/cli/src/lib/build/vendor.ts @@ -2,9 +2,13 @@ import { spawnSync } from 'node:child_process'; import { chmodSync, cpSync, + closeSync, existsSync, mkdirSync, + openSync, readFileSync, + readSync, + rmSync, writeFileSync, } from 'node:fs'; import { inflateRawSync } from 'node:zlib'; @@ -375,6 +379,24 @@ function cloneVendor(repo: string, ref: string, targetPath: string, recurseSubmo } } +/** + * Find the byte offset of the embedded SquashFS archive inside an AppImage. + * AppImages align the SquashFS to 1-byte boundaries; we scan the first 4 MB + * in 4-byte steps for the SquashFS v4 magic (little-endian 0x73717368 = 'sqsh'). + */ +function findSquashFsOffset(filePath: string): number { + const SQFS_MAGIC_LE = 0x73717368; + const CHUNK = 4 * 1024 * 1024; // scan first 4 MB + const buf = Buffer.alloc(CHUNK); + const fd = openSync(filePath, 'r'); + const bytesRead = readSync(fd, buf, 0, CHUNK, 0); + closeSync(fd); + for (let i = 0; i < bytesRead - 4; i += 4) { + if (buf.readUInt32LE(i) === SQFS_MAGIC_LE) return i; + } + return -1; +} + function isDownloadedRuntimeTarget(target: ConcreteBuildVendorTarget): target is 'windows' | 'macos' | 'linux' | 'steamos' { return target === 'windows' || target === 'macos' || target === 'linux' || target === 'steamos'; } @@ -401,13 +423,36 @@ async function installDesktopRuntime(target: 'windows' | 'macos' | 'linux' | 'st await downloadRuntimeFile(APPIMAGETOOL_URL, appImageTool, 'FEATHER_TEST_APPIMAGETOOL'); chmodSync(loveAppImage, 0o755); chmodSync(appImageTool, 0o755); + + const squashfsRoot = join(targetPath, 'squashfs-root'); const result = spawnSync(loveAppImage, ['--appimage-extract'], { cwd: targetPath, encoding: 'utf8' }); - if (result.error) { - throw new Error(`Failed to extract LÖVE AppImage: ${result.error.message}`); - } - if (result.status !== 0 || !existsSync(join(targetPath, 'squashfs-root', 'bin', 'love'))) { + if (!result.error) { + if (result.status === 0 && existsSync(join(squashfsRoot, 'bin', 'love'))) return; throw new Error((result.stderr || result.stdout || 'Failed to extract LÖVE AppImage.').trim()); } + + const code = (result.error as NodeJS.ErrnoException).code; + if (code !== 'ENOEXEC' && code !== 'ENOENT' && code !== 'EACCES') { + throw new Error(`Failed to extract LÖVE AppImage: ${result.error.message}`); + } + + // Not on Linux — try unsquashfs with explicit SquashFS offset (squashfs-tools >= 4.4). + // The macOS port doesn't auto-detect the offset, so we scan for the magic bytes ourselves. + if (existsSync(squashfsRoot)) rmSync(squashfsRoot, { recursive: true }); + const offset = findSquashFsOffset(loveAppImage); + const unsquashArgs = offset >= 0 + ? ['-offset', String(offset), '-d', squashfsRoot, loveAppImage] + : ['-d', squashfsRoot, loveAppImage]; + const us = spawnSync('unsquashfs', unsquashArgs, { cwd: targetPath, encoding: 'utf8' }); + if (!us.error && us.status === 0 && existsSync(join(squashfsRoot, 'bin', 'love'))) return; + + const detail = us.error ? us.error.message : (us.stderr || us.stdout || '').trim(); + throw new Error( + `Cannot extract the Linux AppImage on this platform (${process.platform}).\n` + + (detail ? `unsquashfs: ${detail}\n` : '') + + `Install squashfs-tools and retry: brew install squashfs\n` + + `Or run \`feather build vendor add linux\` on a Linux host.`, + ); } async function downloadRuntimeArchive(target: 'windows' | 'macos', loveVersion: string): Promise { diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index 2c36af5f..a9fd8f48 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -1,5 +1,6 @@ import { readFileSync, existsSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; +import { findConfigDir } from "./paths.js"; export interface FeatherConfig { sessionName?: string; @@ -101,9 +102,16 @@ export function loadConfig(gamePath: string, override?: string): FeatherConfig | : [ join(gamePath, "feather.config.lua"), join(gamePath, ".featherrc.lua"), + join(dirname(resolve(gamePath)), "feather.config.lua"), + join(dirname(resolve(gamePath)), ".featherrc.lua"), + join(findConfigDir(gamePath), "feather.config.lua"), + join(findConfigDir(gamePath), ".featherrc.lua"), ]; + const seen = new Set(); for (const path of candidates) { + if (seen.has(path)) continue; + seen.add(path); if (!existsSync(path)) continue; try { const src = readFileSync(path, "utf8"); diff --git a/cli/src/lib/paths.ts b/cli/src/lib/paths.ts index 19bdfdb8..9ec85253 100644 --- a/cli/src/lib/paths.ts +++ b/cli/src/lib/paths.ts @@ -23,8 +23,43 @@ export function resolveLocalLuaRoot(opts: { localSrc?: string }): string { return repoLuaRoot() ?? bundledLuaRoot(); } +function hasProjectConfig(dir: string): boolean { + return existsSync(join(dir, 'feather.config.lua')) || existsSync(join(dir, '.featherrc.lua')); +} + +function hasPackageLock(dir: string): boolean { + return existsSync(join(dir, 'feather.lock.json')); +} + +function hasRuntime(dir: string): boolean { + return existsSync(join(dir, 'feather', 'init.lua')); +} + +function hasMain(dir: string): boolean { + return existsSync(join(dir, 'main.lua')); +} + +function walkUp(start: string, predicate: (dir: string) => boolean): string | null { + let current = resolve(start); + while (true) { + if (predicate(current)) return current; + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } +} + +export function findConfigDir(cwd = process.cwd()): string { + const start = resolve(cwd); + return walkUp(start, hasProjectConfig) ?? findProjectDir(start); +} + +export function findPackageDir(cwd = process.cwd()): string { + const start = resolve(cwd); + return walkUp(start, (dir) => hasPackageLock(dir) || hasProjectConfig(dir)) ?? findProjectDir(start); +} + export function findProjectDir(cwd = process.cwd()): string { - if (existsSync(join(cwd, 'feather', 'init.lua'))) return cwd; - if (existsSync(join(cwd, 'main.lua'))) return cwd; - return cwd; + const start = resolve(cwd); + return walkUp(start, (dir) => hasProjectConfig(dir) || hasPackageLock(dir) || hasRuntime(dir) || hasMain(dir)) ?? start; } diff --git a/cli/test/commands/build-vendor.test.mjs b/cli/test/commands/build-vendor.test.mjs index 3d78555f..2f27b8f3 100644 --- a/cli/test/commands/build-vendor.test.mjs +++ b/cli/test/commands/build-vendor.test.mjs @@ -2,6 +2,7 @@ import { ANSI_RE, assert, + chmodSync, envWithPath, existsSync, join, @@ -11,6 +12,7 @@ import { readFileSync, run, test, + writeFileSync, writeBuildConfig, writeFakeAppleLibrariesZip, writeFakeAppImageTool, @@ -20,6 +22,8 @@ import { writeFakeLoveWindowsZip, writeFakeLoveAndroid, writeFakeLoveJs, + writeFakeNonNativeElfAppImage, + writeFakeUnsquashfs, writeFakeVendorGit, writeGame, } from './helpers.mjs'; @@ -348,3 +352,100 @@ test('build vendor add: missing git produces compact actionable error', () => { assert.equal(result.exitCode, 1); assert.ok(outputOf(result).includes('git is required')); }); + +// ── AppImage extraction fallback (non-native ELF → unsquashfs) ─────────────── + +test('build vendor add linux: falls back to unsquashfs with explicit offset when AppImage is non-native', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor Linux', version: '1.0.0', loveVersion: '11.5' }); + + // A foreign-architecture ELF with SquashFS magic at a known offset. + // Running it returns ENOEXEC on x86_64 Linux and all macOS variants. + const { appImage } = writeFakeNonNativeElfAppImage(dir); + const appImageTool = writeFakeAppImageTool(dir); + // Fake unsquashfs that creates the expected squashfs-root structure. + const unsquashfsBin = writeFakeUnsquashfs(dir); + + const result = run(['build', 'vendor', 'add', 'linux', '--dir', dir, '--json'], { + env: envWithPath(unsquashfsBin, { + FEATHER_TEST_LOVE_LINUX_APPIMAGE: appImage, + FEATHER_TEST_APPIMAGETOOL: appImageTool, + }), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].target, 'linux'); + assert.equal(existsSync(join(dir, 'vendor', 'love-linux', 'squashfs-root', 'bin', 'love')), true); + assert.equal(existsSync(join(dir, 'vendor', 'love-linux', 'appimagetool.AppImage')), true); +}); + +test('build vendor add linux: unsquashfs receives explicit -offset matching SquashFS magic position', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor Linux', version: '1.0.0', loveVersion: '11.5' }); + + const { appImage, sqfsOffset } = writeFakeNonNativeElfAppImage(dir); + const appImageTool = writeFakeAppImageTool(dir); + + // Recording unsquashfs: writes received args to a JSON file, then creates squashfs-root. + const recordPath = join(dir, 'unsquashfs-args.json'); + const binDir = join(dir, 'rec-bin'); + mkdirSync(binDir, { recursive: true }); + const script = join(binDir, 'unsquashfs'); + writeFileSync( + script, + `#!/usr/bin/env node +const fs = require('node:fs'), path = require('node:path'); +const args = process.argv.slice(2); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(args)); +let dest = null; +for (let i = 0; i < args.length - 1; i++) { + if (args[i] === '-d') { dest = args[i + 1]; break; } +} +if (!dest) process.exit(1); +fs.mkdirSync(path.join(dest, 'bin'), { recursive: true }); +fs.writeFileSync(path.join(dest, 'bin', 'love'), '#!/bin/sh\\n'); +fs.chmodSync(path.join(dest, 'bin', 'love'), 0o755); +process.exit(0); +`, + ); + chmodSync(script, 0o755); + + const result = run(['build', 'vendor', 'add', 'linux', '--dir', dir, '--json'], { + env: envWithPath(binDir, { + FEATHER_TEST_LOVE_LINUX_APPIMAGE: appImage, + FEATHER_TEST_APPIMAGETOOL: appImageTool, + }), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + + const args = JSON.parse(readFileSync(recordPath, 'utf8')); + const offsetIdx = args.indexOf('-offset'); + assert.ok(offsetIdx >= 0, 'unsquashfs was called with -offset'); + assert.equal(Number(args[offsetIdx + 1]), sqfsOffset, '-offset value matches SquashFS magic position'); +}); + +test('build vendor add linux: reports actionable error when AppImage cannot be extracted', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor Linux', version: '1.0.0', loveVersion: '11.5' }); + + const { appImage } = writeFakeNonNativeElfAppImage(dir); + const appImageTool = writeFakeAppImageTool(dir); + + // Run without any unsquashfs in PATH. + const result = run(['build', 'vendor', 'add', 'linux', '--dir', dir, '--json'], { + env: { + ...process.env, + NO_COLOR: '1', + FORCE_COLOR: '0', + PATH: '', + FEATHER_TEST_LOVE_LINUX_APPIMAGE: appImage, + FEATHER_TEST_APPIMAGETOOL: appImageTool, + }, + }); + assert.equal(result.exitCode, 1); + const out = outputOf(result); + assert.ok(out.includes('brew install squashfs') || out.includes('Linux host'), out); +}); diff --git a/cli/test/commands/config.test.mjs b/cli/test/commands/config.test.mjs index 94e466db..7abbfa2e 100644 --- a/cli/test/commands/config.test.mjs +++ b/cli/test/commands/config.test.mjs @@ -38,6 +38,18 @@ test('config plugins: adds included plugins and merges required capabilities', ( assert.match(config, /capabilities\s*=\s*\{\s*"filesystem",\s*"input",\s*"logs"\s*\}/); }); +test('config plugins: resolves parent config when dir points at nested game main.lua', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { include = { "runtime-snapshot" } }\n'); + + const result = run(['config', 'plugins', '--dir', gameDir, '--include', 'console']); + assert.equal(result.exitCode, 0, outputOf(result)); + + assert.match(readFileSync(join(dir, 'feather.config.lua'), 'utf8'), /include\s*=\s*\{\s*"console",\s*"runtime-snapshot"\s*\}/); +}); + test('config plugins: exclude removes included plugin and writes exclude list', () => { const dir = makeTmp(); writeGame(dir); diff --git a/cli/test/commands/helpers.mjs b/cli/test/commands/helpers.mjs index 4a55df28..840732f1 100644 --- a/cli/test/commands/helpers.mjs +++ b/cli/test/commands/helpers.mjs @@ -679,6 +679,66 @@ process.exit(0); return appImage; } +/** + * Creates a fake AppImage containing ELF magic (so it triggers ENOEXEC on any + * non-native platform) with real SquashFS v4 magic bytes at a known offset. + * Used to test the unsquashfs fallback path. + */ +function writeFakeNonNativeElfAppImage(dir) { + const appImage = join(dir, 'love-foreign.AppImage'); + const SQFS_OFFSET = 8192; // 8 KB, 4-byte aligned (AppImages use 1024-byte alignment) + + // Minimal AArch64 ELF header — triggers ENOEXEC on x86_64 Linux and all macOS. + // 64 bytes for the ELF ident + e_type/e_machine/e_version, rest zeroed. + const header = Buffer.alloc(64, 0); + header[0] = 0x7f; header[1] = 0x45; header[2] = 0x4c; header[3] = 0x46; // \x7fELF + header[4] = 0x02; // EI_CLASS: 64-bit + header[5] = 0x01; // EI_DATA: little-endian + header[6] = 0x01; // EI_VERSION: 1 + header.writeUInt16LE(0x00b7, 18); // e_machine: EM_AARCH64 = 183 + + const data = Buffer.alloc(SQFS_OFFSET + 8, 0); + header.copy(data, 0); + // SquashFS v4 magic (little-endian 0x73717368 = 'sqsh') + data.writeUInt32LE(0x73717368, SQFS_OFFSET); + + writeFileSync(appImage, data); + chmodSync(appImage, 0o755); + return { appImage, sqfsOffset: SQFS_OFFSET }; +} + +/** + * Creates a fake `unsquashfs` binary that parses -offset/-d args and writes + * a minimal squashfs-root structure expected by the CLI. + * Returns the bin directory to prepend to PATH. + */ +function writeFakeUnsquashfs(tmpDir) { + const binDir = join(tmpDir, 'fake-unsquashfs-bin'); + mkdirSync(binDir, { recursive: true }); + const script = join(binDir, 'unsquashfs'); + writeFileSync( + script, + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +// Parse -d from args (ignore -offset, -f, etc.) +let dest = null; +for (let i = 0; i < args.length - 1; i++) { + if (args[i] === '-d') { dest = args[i + 1]; break; } +} +if (!dest) { console.error('unsquashfs: missing -d'); process.exit(1); } +fs.mkdirSync(path.join(dest, 'bin'), { recursive: true }); +fs.writeFileSync(path.join(dest, 'bin', 'love'), '#!/bin/sh\\n'); +fs.chmodSync(path.join(dest, 'bin', 'love'), 0o755); +fs.writeFileSync(path.join(dest, 'AppRun'), '#!/bin/sh\\nexec "$APPDIR/bin/love" "$@"\\n'); +process.exit(0); +`, + ); + chmodSync(script, 0o755); + return binDir; +} + function writeFakeAppImageTool(dir) { const appImageTool = join(dir, 'appimagetool.AppImage'); writeFileSync( @@ -762,6 +822,8 @@ export { writeFakeLoveAndroid, writeFakeLoveIos, writeFakeLoveLinuxAppImage, + writeFakeNonNativeElfAppImage, + writeFakeUnsquashfs, writeFakeLoveMacosZip, writeFakeLoveWindowsZip, writeFakeLoveJs, diff --git a/cli/test/commands/init.test.mjs b/cli/test/commands/init.test.mjs index ddb5d255..325e0d34 100644 --- a/cli/test/commands/init.test.mjs +++ b/cli/test/commands/init.test.mjs @@ -119,6 +119,34 @@ test('init e2e: defaults to cli mode and creates config without embedding runtim } }); +test('init e2e: cli mode records selected plugins in generated config', () => { + const workspace = makeTmp(); + const project = join(workspace, 'cli-plugins-game'); + + try { + writeE2eGame(project); + + runOk([ + 'init', + project, + '--mode', + 'cli', + '--plugins', + 'console,input-replay', + '--yes', + '--allow-insecure-connection', + ]); + + const config = readFileSync(join(project, 'feather.config.lua'), 'utf8'); + assert.match(config, /include\s*=\s*\{\s*"console",\s*"input-replay"\s*\}/); + assert.match(config, /capabilities\s*=\s*\{\s*"filesystem",\s*"input"\s*\}/); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + test('init --yes without --allow-insecure-connection: config omits __DANGEROUS_INSECURE_CONNECTION__ and doctor fails on missing appId', () => { const workspace = makeTmp(); const project = join(workspace, 'secure-game'); diff --git a/cli/test/commands/package.test.mjs b/cli/test/commands/package.test.mjs index ce77ff5b..4f828b0a 100644 --- a/cli/test/commands/package.test.mjs +++ b/cli/test/commands/package.test.mjs @@ -62,6 +62,7 @@ function writeLock(dir, packages) { } function writeGame(dir) { + mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, 'main.lua'), `function love.update(dt) @@ -73,6 +74,17 @@ end ); } +test('package project resolver: nested game dir resolves parent project metadata', async () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + writeFileSync(join(dir, 'feather.config.lua'), 'return {}\n'); + writeLock(dir, {}); + + const { resolvePackageProjectDir } = await import('../../dist/commands/package/shared.js'); + assert.equal(resolvePackageProjectDir(gameDir), dir); +}); + function sourceFiles(dir) { return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const path = join(dir, entry.name); diff --git a/cli/test/commands/plugins.test.mjs b/cli/test/commands/plugins.test.mjs index 665fc586..e934225d 100644 --- a/cli/test/commands/plugins.test.mjs +++ b/cli/test/commands/plugins.test.mjs @@ -31,6 +31,20 @@ test('plugin install: local source copies console manifest', () => { assert.ok(outputOf(result).includes('Installed console')); }); +test('plugin install: accepts multiple ids and resolves parent project from nested game dir', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + mkdirSync(gameDir, { recursive: true }); + writeFileSync(join(gameDir, 'main.lua'), 'function love.draw() end\n'); + writeFileSync(join(dir, 'feather.config.lua'), 'return {}\n'); + + const result = run(['plugin', 'install', 'console', 'input-replay', '--local-src', LOCAL_SRC, '--dir', gameDir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), true); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'input-replay', 'manifest.lua')), true); + assert.equal(existsSync(join(gameDir, 'feather')), false); +}); + test('plugin update: explicit local update refreshes damaged files', () => { const dir = makeTmp(); run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); diff --git a/cli/test/commands/run.test.mjs b/cli/test/commands/run.test.mjs index 30e7e668..74d25452 100644 --- a/cli/test/commands/run.test.mjs +++ b/cli/test/commands/run.test.mjs @@ -99,6 +99,11 @@ test('run: source checkout build exposes feather.auto without a bundled cli/lua assert.equal(result.exitCode, 0, outputOf(result)); const record = JSON.parse(readFileSync(recordPath, 'utf8')); assert.equal(record.featherAutoExists, true); + assert.ok( + [join(dirname(LOCAL_SRC), 'cli', 'lua'), LOCAL_SRC].some((root) => + record.shimMain.includes(`${root.replace(/\\/g, '/')}/?.lua`), + ), + ); }); test('run: accepts configPath aliases and recovers npm-stripped config path argument', () => { diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 8efae9c2..ad92b500 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -1,4 +1,6 @@ import * as vscode from 'vscode'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { loadPackageCatalog, loadPluginCatalog, readInstalledPackageIds, type PackageEntry, type PluginEntry } from './catalog'; import { runCommandsInTerminal, runInTerminal } from './cli'; import { FeatherProjectProvider } from './featherPanel'; @@ -31,6 +33,17 @@ function requireProjectDir(): string | undefined { return root; } +function ensureGitignoreEntry(dir: string, entry: string): void { + const gitignorePath = join(dir, '.gitignore'); + const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : ''; + const lines = existing.split('\n').map((l) => l.trimEnd()); + if (lines.some((l) => l === entry)) return; + const updated = existing.endsWith('\n') || existing === '' + ? existing + entry + '\n' + : existing + '\n' + entry + '\n'; + writeFileSync(gitignorePath, updated, 'utf8'); +} + function isWatchMode(): boolean { return featherConfig().get('watchMode') ?? false; } @@ -351,7 +364,7 @@ async function registerCommands( const commands = action.value === 'install' ? [ - ...ids.map((id) => ['plugin', 'install', id, '--dir', root]), + ['plugin', 'install', ...ids, '--dir', root], ['config', 'plugins', '--dir', root, '--include', ids.join(',')], ] : action.value === 'remove' @@ -375,30 +388,41 @@ async function registerCommands( const installedIds = readInstalledPackageIds(root); const packages = loadPackageCatalog(context, installedIds); const selected = await vscode.window.showQuickPick(packages.map(packagePick), { + canPickMany: true, matchOnDescription: true, matchOnDetail: true, - placeHolder: 'Select a package', + placeHolder: 'Select package(s)', }); - if (!selected) return; + if (!selected || selected.length === 0) return; + + if (selected.length > 1) { + const ids = selected.map((item) => item.pkg.id); + runInTerminal(context, 'Feather: Package install', ['package', 'install', ...ids, '--dir', root, '--yes'], root); + provider.refresh(); + setTimeout(() => provider.refresh(), 1500); + return; + } + + const pkg = selected[0].pkg; const action = await vscode.window.showQuickPick>( - selected.pkg.installed + pkg.installed ? [ - { label: 'Update', description: selected.pkg.version, value: 'update' }, + { label: 'Update', description: pkg.version, value: 'update' }, { label: 'Remove', description: 'Delete package files and lockfile entry.', value: 'remove' }, ] : [ - { label: 'Install', description: selected.pkg.version, value: 'install' }, + { label: 'Install', description: pkg.version, value: 'install' }, ], - { placeHolder: `${selected.pkg.id}: choose action` }, + { placeHolder: `${pkg.id}: choose action` }, ); if (!action) return; const command = action.value === 'remove' - ? ['package', 'remove', selected.pkg.id, '--dir', root, '--yes'] + ? ['package', 'remove', pkg.id, '--dir', root, '--yes'] : action.value === 'update' - ? ['package', 'update', selected.pkg.id, '--dir', root] - : ['package', 'install', selected.pkg.id, '--dir', root, '--yes']; + ? ['package', 'update', pkg.id, '--dir', root] + : ['package', 'install', pkg.id, '--dir', root, '--yes']; runInTerminal(context, `Feather: Package ${action.value}`, command, root); provider.refresh(); setTimeout(() => provider.refresh(), 1500); @@ -436,7 +460,19 @@ async function registerCommands( ); if (!vendor) return; - runInTerminal(context, 'Feather: Vendor add', ['build', 'vendor', 'add', vendor.value, '--dir', root], root); + const defaultUri = vscode.Uri.file(root); + const dirUris = await vscode.window.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + defaultUri, + openLabel: 'Install vendors here', + title: 'Select vendor installation directory', + }); + const vendorDir = dirUris?.[0]?.fsPath ?? root; + + ensureGitignoreEntry(vendorDir, '/vendor'); + runInTerminal(context, 'Feather: Vendor add', ['build', 'vendor', 'add', vendor.value, '--dir', vendorDir], vendorDir); }), ); From a7ce4c6d9b3203b5b46526ded100cfdd04d63ee4 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Tue, 19 May 2026 18:24:32 -0400 Subject: [PATCH 07/28] cli: add managed key to identify flow type --- cli/src/commands/config.ts | 35 +- cli/src/commands/init.ts | 4 + cli/src/commands/plugin/install.ts | 13 +- cli/src/commands/plugin/list.ts | 32 +- cli/src/commands/plugin/remove.ts | 30 +- cli/src/commands/plugin/shared.ts | 19 + cli/src/commands/plugin/update.ts | 9 +- cli/src/commands/update.ts | 7 + cli/src/index.ts | 10 +- cli/src/lib/config.ts | 1 + cli/test/commands/init.test.mjs | 205 +++++++- cli/test/commands/plugins-managed.test.mjs | 532 +++++++++++++++++++++ cli/test/commands/plugins.test.mjs | 40 ++ 13 files changed, 917 insertions(+), 20 deletions(-) create mode 100644 cli/test/commands/plugins-managed.test.mjs diff --git a/cli/src/commands/config.ts b/cli/src/commands/config.ts index c1657f40..424ee073 100644 --- a/cli/src/commands/config.ts +++ b/cli/src/commands/config.ts @@ -18,7 +18,14 @@ const knownPluginIds = new Set(pluginCatalog.map((plugin) => plugin.id)); function parseIds(value: string | undefined): string[] { if (!value) return []; - return [...new Set(value.split(',').map((id) => id.trim()).filter(Boolean))]; + return [ + ...new Set( + value + .split(',') + .map((id) => id.trim()) + .filter(Boolean), + ), + ]; } function assertKnownPlugins(ids: string[]): void { @@ -40,8 +47,13 @@ function setArray(config: Record, key: 'include' | 'exclude', v function upsertTopLevelValue(source: string, key: string, value: unknown): string { const rendered = value === undefined ? undefined : ` ${key} = ${luaValue(value, 2)},`; - const assignment = new RegExp(`^\\s*${key}\\s*=\\s*(?:\\{[^\\n]*\\}|\"[^\"]*\"|'[^']*'|true|false|-?\\d+(?:\\.\\d+)?),?\\s*$`, 'm'); - const inlineAssignment = new RegExp(`([,{]\\s*)${key}\\s*=\\s*(?:\\{[^}]*\\}|\"[^\"]*\"|'[^']*'|true|false|-?\\d+(?:\\.\\d+)?)(,?)`); + const assignment = new RegExp( + `^\\s*${key}\\s*=\\s*(?:\\{[^\\n]*\\}|"[^"]*"|'[^']*'|true|false|-?\\d+(?:\\.\\d+)?),?\\s*$`, + 'm', + ); + const inlineAssignment = new RegExp( + `([,{]\\s*)${key}\\s*=\\s*(?:\\{[^}]*\\}|"[^"]*"|'[^']*'|true|false|-?\\d+(?:\\.\\d+)?)(,?)`, + ); if (assignment.test(source)) { return source.replace(assignment, rendered ?? ''); @@ -52,7 +64,9 @@ function upsertTopLevelValue(source: string, key: string, value: unknown): strin } if (rendered) { - return source.replace(/return\s*\{\s*\n/, (match) => `${match}${rendered}\n`); + const multiLine = source.replace(/return\s*\{\s*\n/, (match) => `${match}${rendered}\n`); + if (multiLine !== source) return multiLine; + return source.replace(/return\s*\{\s*\}/, `return {\n${rendered}\n}`); } return source; @@ -91,8 +105,12 @@ export async function configPluginsCommand(opts: ConfigPluginsOptions = {}): Pro const config = { ...(loaded as FeatherConfig) } as Record; const originalSource = readFileSync(configPath, 'utf8'); - const include = new Set(Array.isArray(config.include) ? config.include.filter((id): id is string => typeof id === 'string') : []); - const exclude = new Set(Array.isArray(config.exclude) ? config.exclude.filter((id): id is string => typeof id === 'string') : []); + const include = new Set( + Array.isArray(config.include) ? config.include.filter((id): id is string => typeof id === 'string') : [], + ); + const exclude = new Set( + Array.isArray(config.exclude) ? config.exclude.filter((id): id is string => typeof id === 'string') : [], + ); for (const id of includeIds) { include.add(id); @@ -107,10 +125,7 @@ export async function configPluginsCommand(opts: ConfigPluginsOptions = {}): Pro setArray(config, 'include', include); setArray(config, 'exclude', exclude); - const mergedCapabilities = mergeCapabilities( - config.capabilities as string[] | 'all' | undefined, - include, - ); + const mergedCapabilities = mergeCapabilities(config.capabilities as string[] | 'all' | undefined, include); if (mergedCapabilities && mergedCapabilities !== 'all') { config.capabilities = mergedCapabilities; } diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index a85439f8..27690302 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -133,6 +133,10 @@ export async function initCommand(dir: string, opts: InitOptions): Promise const alreadyInstalled = existsSync(installInitPath); const useRemote = opts.remote === true || setup.source === 'remote'; + // Record the init mode so plugin commands can detect CLI vs embedded without + // relying on the presence/absence of feather/init.lua in the project tree. + setup.config.managed = mode; + if (mode === 'cli') { const pluginIds = pluginsDisabled ? [] : (opts.plugins ?? []).filter((id) => !setup.exclude.includes(id)); addIncludedPlugins(setup.config, pluginIds); diff --git a/cli/src/commands/plugin/install.ts b/cli/src/commands/plugin/install.ts index a9666b21..21b1ff52 100644 --- a/cli/src/commands/plugin/install.ts +++ b/cli/src/commands/plugin/install.ts @@ -11,7 +11,8 @@ import { fail } from '../../lib/command.js'; import { createSpinner } from '../../lib/output.js'; import { resolveLocalLuaRoot } from '../../lib/paths.js'; import { assertValidPluginId, pluginIdToSourceDir } from '../../lib/plugin-utils.js'; -import { type PluginSourceOptions, resolvePluginProjectDir, warnDangerousPlugin } from './shared.js'; +import { configPluginsCommand } from '../config.js'; +import { type PluginSourceOptions, resolveManaged, resolvePluginProjectDir, warnDangerousPlugin } from './shared.js'; export async function pluginInstallCommand(pluginIds: string | string[], opts: PluginSourceOptions): Promise { const projectDir = resolvePluginProjectDir(opts.dir); @@ -32,6 +33,16 @@ export async function pluginInstallCommand(pluginIds: string | string[], opts: P } } + // CLI mode: plugin Lua files live in the bundled CLI runtime; only feather.config.lua + // `include` list needs updating. `--managed cli` overrides auto-detection. + if (resolveManaged(projectDir, installDir, opts.managed) === 'cli') { + await configPluginsCommand({ dir: projectDir, include: uniqueIds.join(',') }); + for (const pluginId of uniqueIds) { + warnDangerousPlugin(pluginId); + } + return; + } + if (!opts.remote) { const sourceRoot = resolveLocalLuaRoot(opts); const available = getLocalPluginIds(sourceRoot); diff --git a/cli/src/commands/plugin/list.ts b/cli/src/commands/plugin/list.ts index 73f4568c..a13c601a 100644 --- a/cli/src/commands/plugin/list.ts +++ b/cli/src/commands/plugin/list.ts @@ -1,10 +1,38 @@ import { existsSync } from 'node:fs'; +import { loadConfig } from '../../lib/config.js'; +import { pluginCatalog } from '../../generated/plugin-catalog.js'; import { findInstalledPluginDirs, readPluginManifest } from '../../lib/plugin-utils.js'; import { printBlank, printHeading, printMuted, printTable, style } from '../../lib/output.js'; -import { pluginsDir, resolvePluginProjectDir } from './shared.js'; +import { pluginsDir, resolveManaged, resolvePluginProjectDir } from './shared.js'; -export async function pluginListCommand(dir?: string, installDir = 'feather'): Promise { +export async function pluginListCommand(dir?: string, installDir = 'feather', managed?: string): Promise { const projectDir = resolvePluginProjectDir(dir); + + if (resolveManaged(projectDir, installDir, managed) === 'cli') { + const config = loadConfig(projectDir); + const included = Array.isArray(config?.include) ? (config.include as string[]) : []; + if (included.length === 0) { + printMuted('No plugins included. Run `feather plugin install ` to add one.'); + return; + } + const catalogMap = new Map(pluginCatalog.map((p) => [p.id, p])); + printHeading(`\nIncluded plugins (${included.length})\n`); + const rows = included.map((id) => { + const meta = catalogMap.get(id); + return { id, version: meta?.version ?? '', name: meta?.name ?? id }; + }); + printTable({ + columns: [ + { key: 'id', label: 'ID', color: (value) => style.info(value) }, + { key: 'version', label: 'VERSION', color: (value) => style.muted(value) }, + { key: 'name', label: 'NAME' }, + ], + rows, + }); + printBlank(); + return; + } + const dirPath = pluginsDir(projectDir, installDir); if (!existsSync(dirPath)) { diff --git a/cli/src/commands/plugin/remove.ts b/cli/src/commands/plugin/remove.ts index 712ed4e1..f90fc0de 100644 --- a/cli/src/commands/plugin/remove.ts +++ b/cli/src/commands/plugin/remove.ts @@ -6,18 +6,42 @@ import { icon, printLine, printMuted } from '../../lib/output.js'; import { assertSafeProjectTarget } from '../../lib/path-safety.js'; import { pluginIdToSourceDir } from '../../lib/plugin-utils.js'; import { confirmAction } from '../../ui/confirm.js'; -import { resolvePluginProjectDir } from './shared.js'; +import { configPluginsCommand } from '../config.js'; +import { resolveManaged, resolvePluginProjectDir } from './shared.js'; export async function pluginRemoveCommand( pluginId: string, - opts: { dir?: string; installDir?: string; yes?: boolean }, + opts: { dir?: string; installDir?: string; yes?: boolean; managed?: string }, ): Promise { const projectDir = resolvePluginProjectDir(opts.dir); + const installDir = normalizeInstallDir(opts.installDir); + + if (resolveManaged(projectDir, installDir, opts.managed) === 'cli') { + if (!opts.yes && (!process.stdin.isTTY || !process.stdout.isTTY)) { + fail(`Refusing to remove "${pluginId}" without --yes in non-interactive mode.`); + } + if (!opts.yes) { + const confirmed = await confirmAction({ + title: 'feather plugin remove', + label: `Remove plugin "${pluginId}" from include list?`, + hint: 'This removes the plugin from feather.config.lua (CLI-managed project).', + danger: false, + rows: [pluginId], + }); + if (!confirmed) { + printMuted('Plugin remove cancelled.'); + return; + } + } + await configPluginsCommand({ dir: projectDir, exclude: pluginId }); + return; + } + let pluginDir: string; try { pluginDir = assertSafeProjectTarget( projectDir, - join(normalizeInstallDir(opts.installDir), 'plugins', pluginIdToSourceDir(pluginId)), + join(installDir, 'plugins', pluginIdToSourceDir(pluginId)), 'Plugin remove target', ); } catch (err) { diff --git a/cli/src/commands/plugin/shared.ts b/cli/src/commands/plugin/shared.ts index 19ee2959..c6fc9be0 100644 --- a/cli/src/commands/plugin/shared.ts +++ b/cli/src/commands/plugin/shared.ts @@ -1,6 +1,7 @@ import { existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { normalizeInstallDir } from '../../lib/install.js'; +import { loadConfig } from '../../lib/config.js'; import { findPackageDir } from '../../lib/paths.js'; import { dangerousPluginIds, findInstalledPluginDirs, readPluginManifest } from '../../lib/plugin-utils.js'; import { printWarning } from '../../lib/output.js'; @@ -11,8 +12,26 @@ export type PluginSourceOptions = { installDir?: string; remote?: boolean; localSrc?: string; + managed?: string; }; +/** + * Returns the effective managed mode string for a project. + * Priority: explicit override > feather.config.lua `managed` field > filesystem fallback. + */ +export function resolveManaged(projectDir: string, installDir: string, override?: string): string | undefined { + if (override) return override; + const config = loadConfig(projectDir); + const fromConfig = config?.managed as string | undefined; + if (fromConfig) return fromConfig; + // Backward compat: no managed field but also no embedded runtime → infer CLI mode. + const hasConfig = existsSync(join(projectDir, 'feather.config.lua')) || existsSync(join(projectDir, '.featherrc.lua')); + if (hasConfig && !existsSync(join(projectDir, normalizeInstallDir(installDir), 'init.lua'))) { + return 'cli'; + } + return undefined; +} + export function resolvePluginProjectDir(dir?: string): string { return findPackageDir(dir ? resolve(dir) : process.cwd()); } diff --git a/cli/src/commands/plugin/update.ts b/cli/src/commands/plugin/update.ts index 8de8cc37..1a75f688 100644 --- a/cli/src/commands/plugin/update.ts +++ b/cli/src/commands/plugin/update.ts @@ -12,15 +12,20 @@ import { createSpinner, printMuted } from '../../lib/output.js'; import { resolveLocalLuaRoot } from '../../lib/paths.js'; import { assertValidPluginId, pluginIdToSourceDir } from '../../lib/plugin-utils.js'; import { choosePluginUpdateWorkflow } from '../../ui/plugin-workflow.js'; -import { getInstalledPluginIds, pluginsDir, resolvePluginProjectDir, warnDangerousPlugin } from './shared.js'; +import { getInstalledPluginIds, pluginsDir, resolveManaged, resolvePluginProjectDir, warnDangerousPlugin } from './shared.js'; export async function pluginUpdateCommand( pluginId: string | undefined, - opts: { dir?: string; branch?: string; installDir?: string; remote?: boolean; localSrc?: string; yes?: boolean }, + opts: { dir?: string; branch?: string; installDir?: string; remote?: boolean; localSrc?: string; yes?: boolean; managed?: string }, ): Promise { const projectDir = resolvePluginProjectDir(opts.dir); const branch = opts.branch ?? 'main'; const installDir = opts.installDir ?? 'feather'; + + if (resolveManaged(projectDir, installDir, opts.managed) === 'cli') { + printMuted('CLI-managed project: plugins are bundled in the Feather CLI binary. Update the CLI to get the latest plugins.'); + return; + } const dirPath = pluginsDir(projectDir, installDir); if (pluginId) { try { diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index c408f246..46160c48 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -6,6 +6,7 @@ import { resolveLocalLuaRoot } from "../lib/paths.js"; import { fail } from "../lib/command.js"; import { createSpinner, printLine, printMuted, style } from "../lib/output.js"; import { assertSafeProjectTarget } from "../lib/path-safety.js"; +import { resolveManaged } from "./plugin/shared.js"; export async function updateCommand( dir: string, @@ -13,6 +14,12 @@ export async function updateCommand( ): Promise { const target = resolve(dir); const installDir = normalizeInstallDir(opts.installDir); + + if (resolveManaged(target, installDir) === 'cli') { + printMuted('CLI-managed project: the Feather runtime is bundled in the CLI binary. Update the CLI to get the latest runtime.'); + return; + } + let installedInit: string; try { installedInit = assertSafeProjectTarget(target, join(installDir, "init.lua"), "Core update target"); diff --git a/cli/src/index.ts b/cli/src/index.ts index 717baa90..05be8bac 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -412,6 +412,7 @@ export function createProgram(): Command { .option('--branch ', 'GitHub branch to download from when using --remote', 'main') .option('--local-src ', 'Copy plugins from a local src-lua style directory') .option('--install-dir ', 'Feather install directory', 'feather') + .option('--managed ', 'Override managed mode detection (cli, auto, manual)') .action((opts) => runCliAction(() => pluginWorkflowCommand({ @@ -430,10 +431,11 @@ export function createProgram(): Command { .command('list [dir]') .description('List installed plugins') .option('--install-dir ', 'Feather install directory', 'feather') + .option('--managed ', 'Override managed mode detection (cli, auto, manual)') .action((dir: string | undefined, opts) => runCliAction(() => { const merged = pluginCommandOptions(opts); - return pluginListCommand(dir ?? (merged.dir as string | undefined), merged.installDir as string); + return pluginListCommand(dir ?? (merged.dir as string | undefined), merged.installDir as string, merged.managed as string | undefined); }), ); @@ -445,6 +447,7 @@ export function createProgram(): Command { .option('--branch ', 'GitHub branch to download from when using --remote', 'main') .option('--local-src ', 'Copy plugins from a local src-lua style directory') .option('--install-dir ', 'Feather install directory', 'feather') + .option('--managed ', 'Override managed mode detection (cli, auto, manual)') .action((ids: string[], opts) => runCliAction(() => { const merged = pluginCommandOptions(opts); @@ -454,6 +457,7 @@ export function createProgram(): Command { installDir: merged.installDir as string, remote: merged.remote as boolean | undefined, localSrc: merged.localSrc as string | undefined, + managed: merged.managed as string | undefined, }); }), ); @@ -464,6 +468,7 @@ export function createProgram(): Command { .option('--dir ', 'Project directory (default: current directory)') .option('--install-dir ', 'Feather install directory', 'feather') .option('-y, --yes', 'Skip interactive confirmation') + .option('--managed ', 'Override managed mode detection (cli, auto, manual)') .action((id: string, opts) => runCliAction(() => { const merged = pluginCommandOptions(opts); @@ -471,6 +476,7 @@ export function createProgram(): Command { dir: merged.dir as string | undefined, installDir: merged.installDir as string, yes: merged.yes as boolean | undefined, + managed: merged.managed as string | undefined, }); }), ); @@ -484,6 +490,7 @@ export function createProgram(): Command { .option('--local-src ', 'Copy plugins from a local src-lua style directory') .option('--install-dir ', 'Feather install directory', 'feather') .option('-y, --yes', 'Skip interactive selection and update all installed plugins when no id is given') + .option('--managed ', 'Override managed mode detection (cli, auto, manual)') .action((id: string | undefined, opts) => runCliAction(() => { const merged = pluginCommandOptions(opts); @@ -494,6 +501,7 @@ export function createProgram(): Command { remote: merged.remote as boolean | undefined, localSrc: merged.localSrc as string | undefined, yes: merged.yes as boolean | undefined, + managed: merged.managed as string | undefined, }); }), ); diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index a9fd8f48..32624960 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -4,6 +4,7 @@ import { findConfigDir } from "./paths.js"; export interface FeatherConfig { sessionName?: string; + managed?: string; include?: string[]; exclude?: string[]; pluginOptions?: Record>; diff --git a/cli/test/commands/init.test.mjs b/cli/test/commands/init.test.mjs index 325e0d34..5c7a9792 100644 --- a/cli/test/commands/init.test.mjs +++ b/cli/test/commands/init.test.mjs @@ -106,7 +106,9 @@ test('init e2e: defaults to cli mode and creates config without embedding runtim assert.equal(existsSync(join(project, 'feather.config.lua')), true); assert.equal(existsSync(join(project, 'feather')), false); - assert.match(readFileSync(join(project, 'feather.config.lua'), 'utf8'), /-- mode: cli/); + const config = readFileSync(join(project, 'feather.config.lua'), 'utf8'); + assert.match(config, /-- mode: cli/); + assert.match(config, /managed\s*=\s*"cli"/); assert.equal(readFileSync(join(project, 'main.lua'), 'utf8').includes('FEATHER-INIT'), false); const report = doctorJson(project); @@ -119,6 +121,207 @@ test('init e2e: defaults to cli mode and creates config without embedding runtim } }); +test('init e2e: auto mode writes managed = "auto" as parseable Lua field', () => { + const workspace = makeTmp(); + const project = join(workspace, 'auto-managed-game'); + + try { + writeE2eGame(project); + + runOk([ + 'init', + project, + '--mode', + 'auto', + '--local-src', + LOCAL_SRC, + '--install-dir', + 'feather', + '--no-plugins', + '--yes', + '--allow-insecure-connection', + ]); + + const config = readFileSync(join(project, 'feather.config.lua'), 'utf8'); + assert.match(config, /managed\s*=\s*"auto"/); + assert.match(config, /-- mode: auto/); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: cli mode removes only config, leaves main.lua untouched', () => { + const workspace = makeTmp(); + const project = join(workspace, 'cli-remove-game'); + + try { + writeE2eGame(project); + runOk(['init', project, '--no-plugins', '--yes', '--allow-insecure-connection']); + + assert.equal(existsSync(join(project, 'feather.config.lua')), true); + assert.equal(existsSync(join(project, 'feather')), false); + assert.equal(readFileSync(join(project, 'main.lua'), 'utf8').includes('FEATHER-INIT'), false); + + runOk(['remove', project, '--yes']); + + assert.equal(existsSync(join(project, 'feather.config.lua')), false); + // main.lua must still exist and be unmodified (CLI mode never patches it) + assert.ok(existsSync(join(project, 'main.lua'))); + assert.equal(readFileSync(join(project, 'main.lua'), 'utf8').includes('FEATHER-INIT'), false); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: remove --dry-run prints targets without deleting files', () => { + const workspace = makeTmp(); + const project = join(workspace, 'dry-run-game'); + + try { + writeE2eGame(project); + runOk(['init', project, '--no-plugins', '--yes', '--allow-insecure-connection']); + + const result = run(['remove', project, '--dry-run']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('feather.config.lua')); + // file must still exist + assert.equal(existsSync(join(project, 'feather.config.lua')), true); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: remove without --yes in non-interactive mode fails', () => { + const workspace = makeTmp(); + const project = join(workspace, 'non-interactive-game'); + + try { + writeE2eGame(project); + runOk(['init', project, '--no-plugins', '--yes', '--allow-insecure-connection']); + + const result = run(['remove', project]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Refusing to remove Feather files without --yes')); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: auto mode remove keeps config with --keep-config', () => { + const workspace = makeTmp(); + const project = join(workspace, 'keep-config-game'); + + try { + writeE2eGame(project); + runOk([ + 'init', + project, + '--mode', + 'auto', + '--local-src', + LOCAL_SRC, + '--install-dir', + 'feather', + '--no-plugins', + '--yes', + '--allow-insecure-connection', + ]); + + runOk(['remove', project, '--yes', '--keep-config']); + + assert.equal(existsSync(join(project, 'feather')), false); + assert.equal(existsSync(join(project, 'feather.config.lua')), true); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: auto mode remove keeps runtime with --keep-runtime', () => { + const workspace = makeTmp(); + const project = join(workspace, 'keep-runtime-game'); + + try { + writeE2eGame(project); + runOk([ + 'init', + project, + '--mode', + 'auto', + '--local-src', + LOCAL_SRC, + '--install-dir', + 'feather', + '--no-plugins', + '--yes', + '--allow-insecure-connection', + ]); + + runOk(['remove', project, '--yes', '--keep-runtime']); + + assert.equal(existsSync(join(project, 'feather')), true); + assert.equal(existsSync(join(project, 'feather.config.lua')), false); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: cli mode round-trip — init, plugin install, plugin list, plugin remove, feather update', () => { + const workspace = makeTmp(); + const project = join(workspace, 'cli-roundtrip'); + + try { + writeE2eGame(project); + + // init in CLI mode + runOk(['init', project, '--no-plugins', '--yes', '--allow-insecure-connection']); + assert.match(readFileSync(join(project, 'feather.config.lua'), 'utf8'), /managed\s*=\s*"cli"/); + + // plugin install via CLI mode + runOk(['plugin', 'install', 'console', '--dir', project]); + const afterInstall = readFileSync(join(project, 'feather.config.lua'), 'utf8'); + assert.match(afterInstall, /include\s*=\s*\{[^}]*"console"/); + // no plugin files copied to filesystem + assert.equal(existsSync(join(project, 'feather', 'plugins', 'console')), false); + + // plugin list shows the installed plugin + const listResult = run(['plugin', 'list', '--dir', project]); + assert.equal(listResult.exitCode, 0, outputOf(listResult)); + assert.ok(outputOf(listResult).includes('console')); + + // plugin remove via CLI mode + runOk(['plugin', 'remove', 'console', '--dir', project, '--yes']); + const afterRemove = readFileSync(join(project, 'feather.config.lua'), 'utf8'); + // only check uncommented include lines (comment block also contains "include = ...") + const includeMatch = afterRemove.match(/^\s*include\s*=\s*\{([^}]*)\}/m); + assert.ok(!includeMatch || !includeMatch[1].includes('"console"')); + + // feather update is a no-op for CLI mode + const updateResult = run(['update', project]); + assert.equal(updateResult.exitCode, 0, outputOf(updateResult)); + assert.ok(outputOf(updateResult).includes('CLI')); + + // remove the project + runOk(['remove', project, '--yes']); + assert.equal(existsSync(join(project, 'feather.config.lua')), false); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + test('init e2e: cli mode records selected plugins in generated config', () => { const workspace = makeTmp(); const project = join(workspace, 'cli-plugins-game'); diff --git a/cli/test/commands/plugins-managed.test.mjs b/cli/test/commands/plugins-managed.test.mjs new file mode 100644 index 00000000..678a39e6 --- /dev/null +++ b/cli/test/commands/plugins-managed.test.mjs @@ -0,0 +1,532 @@ +/* eslint-disable no-undef */ +/** + * Tests for `--managed ` override and automatic managed-mode detection + * across all plugin subcommands (install, remove, update, list). + * + * Mode semantics: + * cli – plugins are bundled in the CLI binary; only feather.config.lua + * `include`/`exclude` lists are updated, no files copied. + * auto – embedded runtime; plugin Lua files are copied into the project. + * manual – same file-copying behaviour as auto. + */ +import { + LOCAL_SRC, + assert, + existsSync, + join, + makeTmp, + outputOf, + readFileSync, + run, + test, + writeFileSync, + writeMinimalRuntime, +} from './helpers.mjs'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Write a feather.config.lua with a `managed` field and an optional + * `include` list. Returns the path to the config file. + */ +function writeConfig(dir, managed, { include = [] } = {}) { + const lines = [` managed = "${managed}",`]; + if (include.length > 0) { + lines.push(` include = { ${include.map((id) => `"${id}"`).join(', ')} },`); + } + const path = join(dir, 'feather.config.lua'); + writeFileSync(path, `return {\n${lines.join('\n')}\n}\n`); + return path; +} + +/** + * Parse the string values from a named Lua array field, e.g. + * include = { "console", "hot-reload" } → ["console", "hot-reload"] + */ +function parseLuaArray(source, key) { + const re = new RegExp(`${key}\\s*=\\s*\\{([^}]*)\\}`); + const m = source.match(re); + if (!m) return []; + return [...m[1].matchAll(/"([^"]*)"/g)].map((match) => match[1]); +} + +/** + * Install a plugin in embedded (file-copy) mode, bypassing managed-mode + * detection in the config. Used to set up filesystem state for update/remove tests. + */ +function installEmbedded(dir, id = 'console') { + const result = run(['plugin', 'install', id, '--managed', 'auto', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); +} + +// --------------------------------------------------------------------------- +// plugin install +// --------------------------------------------------------------------------- + +test('plugin install --managed cli: updates include list, does not copy files (overrides embedded project)', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); // has feather/init.lua + writeConfig(dir, 'auto'); // config says auto … + // … but --managed cli wins + + const result = run(['plugin', 'install', 'console', '--managed', 'cli', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), false); + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.ok(config.includes('"console"'), `include list missing "console":\n${config}`); +}); + +test('plugin install --managed auto: copies files even when config says cli', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'cli'); // config says cli … + // … but --managed auto wins + + const result = run(['plugin', 'install', 'console', '--managed', 'auto', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), true); +}); + +test('plugin install --managed manual: copies files even when config says cli', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'cli'); + + const result = run(['plugin', 'install', 'console', '--managed', 'manual', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), true); +}); + +test('plugin install --managed cli: overrides filesystem fallback (feather/init.lua present, no managed field)', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); // would normally trigger embedded mode + writeFileSync(join(dir, 'feather.config.lua'), 'return {\n}\n'); + + const result = run(['plugin', 'install', 'console', '--managed', 'cli', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), false); + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.ok(config.includes('"console"'), `include list missing:\n${config}`); +}); + +test('plugin install --managed auto: overrides filesystem fallback (no feather/init.lua)', () => { + // Without override, no feather/init.lua + no managed field → CLI mode. + // --managed auto forces file-copy path. + const dir = makeTmp(); + writeMinimalRuntime(dir); // creates feather/init.lua so copy will work + writeFileSync(join(dir, 'feather.config.lua'), 'return {\n managed = "cli",\n}\n'); + + const result = run(['plugin', 'install', 'console', '--managed', 'auto', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), true); +}); + +test('plugin install: managed = cli detected from config', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli'); + + const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), false); + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.ok(config.includes('"console"'), config); +}); + +test('plugin install: managed = auto detected from config copies files', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + + const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), true); +}); + +test('plugin install: managed = manual detected from config copies files', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'manual'); + + const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), true); +}); + +test('plugin install --managed cli: multiple ids all added to include list', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli'); + + const result = run(['plugin', 'install', 'console', 'input-replay', '--managed', 'cli', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.ok(config.includes('"console"'), config); + assert.ok(config.includes('"input-replay"'), config); + assert.equal(existsSync(join(dir, 'feather', 'plugins')), false); +}); + +test('plugin install --managed cli: idempotent — re-install does not duplicate include list entry', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli'); + + run(['plugin', 'install', 'console', '--managed', 'cli', '--local-src', LOCAL_SRC, '--dir', dir]); + run(['plugin', 'install', 'console', '--managed', 'cli', '--local-src', LOCAL_SRC, '--dir', dir]); + + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + const matches = [...config.matchAll(/"console"/g)]; + assert.equal(matches.length, 1, `"console" should appear exactly once:\n${config}`); +}); + +test('plugin install --managed cli: unknown plugin id is rejected by catalog', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli'); + + const result = run(['plugin', 'install', 'zzz-missing', '--managed', 'cli', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Unknown plugin: zzz-missing'), outputOf(result)); +}); + +test('plugin install --managed auto: unknown plugin id rejected from local source', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + + const result = run(['plugin', 'install', 'zzz-missing', '--managed', 'auto', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Unknown plugin: zzz-missing'), outputOf(result)); +}); + +// --------------------------------------------------------------------------- +// plugin remove +// --------------------------------------------------------------------------- + +test('plugin remove --managed cli: removes plugin from include list', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli', { include: ['console'] }); + + const result = run(['plugin', 'remove', 'console', '--managed', 'cli', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.ok(!parseLuaArray(config, 'include').includes('console'), `"console" should not be in include:\n${config}`); +}); + +test('plugin remove --managed auto: deletes plugin files from filesystem', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + installEmbedded(dir); + + const pluginDir = join(dir, 'feather', 'plugins', 'console'); + assert.equal(existsSync(pluginDir), true); + + const result = run(['plugin', 'remove', 'console', '--managed', 'auto', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(pluginDir), false); +}); + +test('plugin remove --managed manual: deletes plugin files from filesystem', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'manual'); + installEmbedded(dir); + + const result = run(['plugin', 'remove', 'console', '--managed', 'manual', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console')), false); +}); + +test('plugin remove: managed = cli from config removes from include list', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli', { include: ['console', 'hot-reload'] }); + + const result = run(['plugin', 'remove', 'console', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + const included = parseLuaArray(config, 'include'); + assert.ok(!included.includes('console'), `"console" still in include:\n${config}`); + assert.ok(included.includes('hot-reload'), `"hot-reload" was unexpectedly removed:\n${config}`); +}); + +test('plugin remove: managed = auto from config removes filesystem files', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + installEmbedded(dir); + + const result = run(['plugin', 'remove', 'console', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console')), false); +}); + +test('plugin remove: managed = manual from config removes filesystem files', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'manual'); + installEmbedded(dir); + + const result = run(['plugin', 'remove', 'console', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console')), false); +}); + +test('plugin remove --managed cli overrides embedded project: removes from include list, files stay on disk', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto', { include: ['console'] }); + installEmbedded(dir); // files exist on disk + + const result = run(['plugin', 'remove', 'console', '--managed', 'cli', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.ok(!parseLuaArray(config, 'include').includes('console'), `"console" still in include:\n${config}`); + // Files stay on disk because we were in CLI mode (config update only) + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console')), true); +}); + +test('plugin remove --managed auto: fails when plugin file not found', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + + const result = run(['plugin', 'remove', 'console', '--managed', 'auto', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Plugin not found'), outputOf(result)); +}); + +// --------------------------------------------------------------------------- +// plugin update +// --------------------------------------------------------------------------- + +test('plugin update --managed cli: prints info message and exits 0', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli', { include: ['console'] }); + + const result = run(['plugin', 'update', '--managed', 'cli', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('CLI-managed'), outputOf(result)); +}); + +test('plugin update: managed = cli from config prints info and exits 0', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli'); + + const result = run(['plugin', 'update', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('CLI-managed'), outputOf(result)); +}); + +test('plugin update --managed cli: specific plugin id also prints info and exits 0', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli', { include: ['console'] }); + + const result = run(['plugin', 'update', 'console', '--managed', 'cli', '--local-src', LOCAL_SRC, '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('CLI-managed'), outputOf(result)); +}); + +test('plugin update --managed auto: updates plugin files', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + installEmbedded(dir); + + const installedInit = join(dir, 'feather', 'plugins', 'console', 'init.lua'); + writeFileSync(installedInit, 'damaged'); + + const result = run(['plugin', 'update', 'console', '--managed', 'auto', '--local-src', LOCAL_SRC, '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.notEqual(readFileSync(installedInit, 'utf8'), 'damaged'); + assert.ok(outputOf(result).includes('Updated console'), outputOf(result)); +}); + +test('plugin update --managed manual: updates plugin files', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'manual'); + installEmbedded(dir); + + const installedInit = join(dir, 'feather', 'plugins', 'console', 'init.lua'); + writeFileSync(installedInit, 'damaged'); + + const result = run(['plugin', 'update', 'console', '--managed', 'manual', '--local-src', LOCAL_SRC, '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.notEqual(readFileSync(installedInit, 'utf8'), 'damaged'); +}); + +test('plugin update: managed = auto from config updates plugin files', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + installEmbedded(dir); + + const installedInit = join(dir, 'feather', 'plugins', 'console', 'init.lua'); + writeFileSync(installedInit, 'damaged'); + + const result = run(['plugin', 'update', 'console', '--local-src', LOCAL_SRC, '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.notEqual(readFileSync(installedInit, 'utf8'), 'damaged'); +}); + +test('plugin update --managed auto overrides CLI config: updates files instead of printing info', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'cli'); // config says cli + installEmbedded(dir); // install via --managed auto so files exist + + const installedInit = join(dir, 'feather', 'plugins', 'console', 'init.lua'); + writeFileSync(installedInit, 'damaged'); + + // --managed auto overrides the cli config + const result = run(['plugin', 'update', 'console', '--managed', 'auto', '--local-src', LOCAL_SRC, '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.notEqual(readFileSync(installedInit, 'utf8'), 'damaged'); + assert.ok(!outputOf(result).includes('CLI-managed'), outputOf(result)); +}); + +// --------------------------------------------------------------------------- +// plugin list +// --------------------------------------------------------------------------- + +test('plugin list --managed cli: shows include list from config', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli', { include: ['console', 'hot-reload'] }); + + const result = run(['plugin', 'list', dir, '--managed', 'cli']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('console'), result.stdout); + assert.ok(result.stdout.includes('hot-reload'), result.stdout); +}); + +test('plugin list: managed = cli from config shows include list', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli', { include: ['console'] }); + + const result = run(['plugin', 'list', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('console'), result.stdout); +}); + +test('plugin list: managed = cli with empty include list shows no-plugins message', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli'); + + const result = run(['plugin', 'list', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('No plugins included'), outputOf(result)); +}); + +test('plugin list --managed cli: shows catalog name for known plugin ids', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli', { include: ['console'] }); + + const result = run(['plugin', 'list', dir, '--managed', 'cli']); + assert.equal(result.exitCode, 0, outputOf(result)); + // The catalog entry for 'console' has a name; it should appear in the table + assert.ok(result.stdout.includes('console'), result.stdout); +}); + +test('plugin list --managed auto: shows installed plugins from filesystem', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + installEmbedded(dir); + + const result = run(['plugin', 'list', dir, '--managed', 'auto']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('console'), result.stdout); +}); + +test('plugin list --managed manual: shows installed plugins from filesystem', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'manual'); + installEmbedded(dir); + + const result = run(['plugin', 'list', dir, '--managed', 'manual']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('console'), result.stdout); +}); + +test('plugin list: managed = auto from config shows filesystem plugins', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + installEmbedded(dir); + + const result = run(['plugin', 'list', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('console'), result.stdout); +}); + +test('plugin list: managed = manual from config shows filesystem plugins', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'manual'); + installEmbedded(dir); + + const result = run(['plugin', 'list', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('console'), result.stdout); +}); + +test('plugin list --managed auto overrides cli config: shows filesystem plugins', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'cli', { include: ['hot-reload'] }); + // Install console in embedded mode so it's on the filesystem + run(['plugin', 'install', 'console', '--managed', 'auto', '--local-src', LOCAL_SRC, '--dir', dir]); + + const result = run(['plugin', 'list', dir, '--managed', 'auto']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('console'), result.stdout); + // hot-reload is in include list but NOT on filesystem; auto mode should not show it + assert.ok(!result.stdout.includes('hot-reload'), result.stdout); +}); + +test('plugin list --managed cli overrides embedded config: shows include list not filesystem', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto', { include: ['hot-reload'] }); + installEmbedded(dir); // console on filesystem + + const result = run(['plugin', 'list', dir, '--managed', 'cli']); + assert.equal(result.exitCode, 0, outputOf(result)); + // Should show hot-reload (from include list), not console (filesystem only) + assert.ok(result.stdout.includes('hot-reload'), result.stdout); + assert.ok(!result.stdout.includes('console'), result.stdout); +}); + +// --------------------------------------------------------------------------- +// feather update (core runtime update) +// --------------------------------------------------------------------------- + +test('feather update: managed = cli from config prints info and exits 0', () => { + const dir = makeTmp(); + writeConfig(dir, 'cli'); + + const result = run(['update', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('CLI-managed'), outputOf(result)); +}); + +test('feather update: managed = auto from config proceeds (attempts update)', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'auto'); + + // With a minimal runtime, a local-src update should succeed + const result = run(['update', dir, '--yes', '--local-src', LOCAL_SRC]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(!outputOf(result).includes('CLI-managed'), outputOf(result)); +}); + +test('feather update: managed = manual from config proceeds (attempts update)', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + writeConfig(dir, 'manual'); + + const result = run(['update', dir, '--yes', '--local-src', LOCAL_SRC]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(!outputOf(result).includes('CLI-managed'), outputOf(result)); +}); diff --git a/cli/test/commands/plugins.test.mjs b/cli/test/commands/plugins.test.mjs index e934225d..24372053 100644 --- a/cli/test/commands/plugins.test.mjs +++ b/cli/test/commands/plugins.test.mjs @@ -13,6 +13,7 @@ import { test, writeFileSync, writeLocalPluginSource, + writeMinimalRuntime, } from './helpers.mjs'; test('plugin list: missing plugin directory is a clean empty state', () => { @@ -24,6 +25,7 @@ test('plugin list: missing plugin directory is a clean empty state', () => { test('plugin install: local source copies console manifest', () => { const dir = makeTmp(); + writeMinimalRuntime(dir); const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); assert.equal(result.exitCode, 0, outputOf(result)); const manifest = readFileSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua'), 'utf8'); @@ -37,6 +39,7 @@ test('plugin install: accepts multiple ids and resolves parent project from nest mkdirSync(gameDir, { recursive: true }); writeFileSync(join(gameDir, 'main.lua'), 'function love.draw() end\n'); writeFileSync(join(dir, 'feather.config.lua'), 'return {}\n'); + writeMinimalRuntime(dir); const result = run(['plugin', 'install', 'console', 'input-replay', '--local-src', LOCAL_SRC, '--dir', gameDir]); assert.equal(result.exitCode, 0, outputOf(result)); @@ -47,6 +50,7 @@ test('plugin install: accepts multiple ids and resolves parent project from nest test('plugin update: explicit local update refreshes damaged files', () => { const dir = makeTmp(); + writeMinimalRuntime(dir); run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); const installedInit = join(dir, 'feather', 'plugins', 'console', 'init.lua'); writeFileSync(installedInit, 'damaged'); @@ -62,6 +66,7 @@ test('plugin update: explicit local update refreshes damaged files', () => { test('plugin update: local --yes updates all installed plugins without selection', () => { const dir = makeTmp(); + writeMinimalRuntime(dir); run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); run(['plugin', 'install', 'hot-reload', '--local-src', LOCAL_SRC, '--dir', dir]); const consoleInit = join(dir, 'feather', 'plugins', 'console', 'init.lua'); @@ -90,6 +95,7 @@ test('plugin install: unknown local plugin exits 1', () => { test('plugin install: local manifest is validated before copying', () => { const dir = makeTmp(); + writeMinimalRuntime(dir); const source = join(makeTmp(), 'src-lua'); writeLocalPluginSource(source, 'bad-plugin', { version: null }); @@ -101,6 +107,7 @@ test('plugin install: local manifest is validated before copying', () => { test('plugin install: local manifest id must match plugin path', () => { const dir = makeTmp(); + writeMinimalRuntime(dir); const source = join(makeTmp(), 'src-lua'); writeLocalPluginSource(source, 'console', { manifestId: 'other-plugin' }); @@ -121,6 +128,7 @@ test('plugin install: refuses install directory symlink escaping project', () => const dir = makeTmp(); const outside = join(makeTmp(), 'outside-runtime'); mkdirSync(outside, { recursive: true }); + writeFileSync(join(outside, 'init.lua'), 'return {}\n'); symlinkSync(outside, join(dir, 'feather'), 'dir'); const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); @@ -152,6 +160,38 @@ test('plugin remove: refuses plugin directory symlink escaping project', () => { assert.equal(existsSync(join(outside, 'plugins', 'console', 'manifest.lua')), true); }); +test('plugin install: CLI mode project only updates feather.config.lua include list, does not copy files', () => { + // A CLI-mode project has feather.config.lua but NO feather/init.lua. + // Plugin code lives in the bundled runtime; only the include list needs updating. + const dir = makeTmp(); + writeFileSync(join(dir, 'main.lua'), 'function love.draw() end\n'); + writeFileSync(join(dir, 'feather.config.lua'), 'return {}\n'); + + const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + + // Plugin files must NOT be copied into the project + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua')), false); + + // feather.config.lua must have console in the include list + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.ok(config.includes('"console"'), `include list should contain "console"\n${config}`); +}); + +test('plugin install: CLI mode accepts multiple ids and adds all to include list', () => { + const dir = makeTmp(); + writeFileSync(join(dir, 'main.lua'), 'function love.draw() end\n'); + writeFileSync(join(dir, 'feather.config.lua'), 'return {}\n'); + + const result = run(['plugin', 'install', 'console', 'input-replay', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + + assert.equal(existsSync(join(dir, 'feather', 'plugins')), false); + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.ok(config.includes('"console"'), config); + assert.ok(config.includes('"input-replay"'), config); +}); + test('plugin list: malformed manifests do not crash and use directory fallback id', () => { const dir = makeTmp(); const pluginDir = join(dir, 'feather', 'plugins', 'bad-plugin'); From 158d6dd6441c29d108455c5cfe3c415061a19f1e Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Tue, 19 May 2026 19:08:53 -0400 Subject: [PATCH 08/28] cli: improve configuration --- CONTRIBUTING.md | 72 ++++++++++++++- cli/README.md | 106 ++++++++++++++++++++-- cli/src/commands/build-vendor.ts | 115 ++++++++++++++++++------ cli/src/commands/config.ts | 33 +++++++ cli/src/commands/init.ts | 2 +- cli/src/commands/plugin/install.ts | 86 +++++++++++++++--- cli/src/commands/plugin/shared.ts | 1 + cli/src/commands/plugin/update.ts | 4 +- cli/src/index.ts | 16 +++- cli/src/lib/build/vendor.ts | 21 ++++- cli/src/lib/install.ts | 23 ++++- cli/test/commands/build-vendor.test.mjs | 55 +++++++++++- cli/test/commands/config.test.mjs | 47 ++++++++++ cli/test/commands/plugins.test.mjs | 55 ++++++++++++ vscode-extension/package.json | 5 ++ vscode-extension/src/extension.ts | 32 ++++--- 16 files changed, 601 insertions(+), 72 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 017c539c..2a2d674c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to Feather -Thanks for helping make Feather better. This project spans a React/Tauri desktop app, a TypeScript CLI, and an embedded Lua runtime for LÖVE games, so the best contributions are small, well-tested, and clear about which part of the stack they touch. +Thanks for helping make Feather better. This project spans a React/Tauri desktop app, a TypeScript CLI, a VS Code extension, and an embedded Lua runtime for LÖVE games, so the best contributions are small, well-tested, and clear about which part of the stack they touch. Feather uses [Ink](https://github.com/vadimdemedes/ink) for interactive CLI workflows. Ink's own contributing guide is a good reference for the kind of contribution style we want too: focused changes, reproducible tests, and human-reviewed work. @@ -8,8 +8,8 @@ Feather uses [Ink](https://github.com/vadimdemedes/ink) for interactive CLI work - Open an issue or draft PR for large features, protocol changes, or plugin API changes. - Keep PRs focused. A bug fix, documentation update, CLI workflow, or Lua runtime change should usually stand on its own. -- Include docs when behavior changes. User-facing CLI, plugin, debugger, hot reload, or security changes should update `docs/` and relevant README files. -- Treat Feather as development tooling that can run inside someone’s game. Security-sensitive features such as Console, hot reload, and app ID validation should stay opt-in and clearly documented. +- Include docs when behavior changes. User-facing CLI, plugin, debugger, hot reload, or security changes should update `docs/` and `cli/README.md` (the source for `docs/cli.md`). +- Treat Feather as development tooling that can run inside someone's game. Security-sensitive features such as Console, hot reload, and app ID validation should stay opt-in and clearly documented. ## Development Setup @@ -58,6 +58,14 @@ npm run feather -- doctor src-lua/example/test_cli npm run feather -- run src-lua/example/test_cli ``` +### Run a single CLI test file + +The full CLI test suite runs all `*.test.mjs` files. To iterate on a single file: + +```sh +npm run cli:build && node --test cli/test/commands/config.test.mjs +``` + ### Run Game Examples Run the `test_cli` example directly with verbose game arguments and an explicit config: @@ -97,27 +105,65 @@ npm run feather -- build android --dir src-lua/example/test_cli --no-cache --ver npm run feather -- build android --dir src-lua/example/test_cli --clean --verbose ``` +### Develop the VS Code Extension + +Build the extension (this runs `prepare.mjs` which copies the bundled CLI, then compiles TypeScript): + +```sh +npm run extension:build +``` + +Launch an Extension Development Host with F5 in VS Code, or run the unit tests headlessly: + +```sh +npm run extension:test +``` + +For integration tests that open a real VS Code window: + +```sh +npm run extension:test:integration +``` + +To package a `.vsix` for local installation: + +```sh +npm run extension:package +``` + ## Project Layout - `src/` contains the React desktop app. - `src-tauri/` contains the Tauri shell and WebSocket server. - `cli/` contains the Feather CLI and Ink workflows. +- `cli/lua/` contains the bundled Lua runtime shipped inside the CLI package. +- `cli/test/commands/` contains the CLI end-to-end test suite (Node.js built-in test runner). - `src-lua/feather/` contains the embedded Lua runtime. - `src-lua/plugins/` contains built-in Lua plugins. - `src-lua/example/` contains runnable LÖVE examples. +- `vscode-extension/` contains the VS Code extension. +- `vscode-extension/src/` contains the extension TypeScript source. +- `vscode-extension/bundled-cli/` is generated by `extension:prepare` — do not commit it. - `docs/` contains the MkDocs documentation site. +> [!NOTE] +> `docs/cli.md` is a symlink to `cli/README.md`. Edit `cli/README.md` directly, same applies for most documents in docs — tools that refuse to write through symlinks will otherwise silently fail. + ## Commit Messages Commit subjects must start with one of these prefixes: ```txt +ci: cli: +package: +plugin: app: lua: tauri: feather: docs: +vscode-extension: ``` Examples: @@ -127,6 +173,9 @@ cli: add interactive remove workflow app: improve session empty state lua: harden hot reload allowlist checks tauri: validate websocket payloads +vscode-extension: added vendor view +package: changed luarock/npm/extension name +plugin: new plugin docs: document app id pairing ``` @@ -136,6 +185,7 @@ Some files are generated and must stay in sync: - `src-lua/manifest.txt` - `cli/src/generated/plugin-catalog.ts` +- `cli/src/generated/registry.json` - version fields in `package.json`, `src-lua/feather/init.lua`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json` The pre-commit hook checks these automatically. You can refresh them manually: @@ -146,6 +196,8 @@ npm run generate:plugin-catalog bash scripts/set-version.sh ``` +`vscode-extension/bundled-cli/` is generated by `npm run extension:prepare`. It is gitignored and should never be committed. + ## Checks Run the checks that match your change. For broad changes, run all of them. @@ -159,6 +211,8 @@ npm run test:cli:e2e npm run test:lua:e2e npm run test:app:e2e npm run test:tauri:e2e +npm run extension:build +npm run extension:test ``` Use the focused lanes when possible: @@ -167,6 +221,7 @@ Use the focused lanes when possible: - CLI changes: `npm run cli:build` and `npm run test:cli:e2e`. - Lua runtime/plugin changes: `npm run typecheck:lua` and `npm run test:lua:e2e`. - Tauri/WebSocket changes: `npm run test:tauri:e2e`. +- VS Code extension changes: `npm run extension:build` and `npm run extension:test`. - Docs-only changes: `npm run docs` and skim the rendered page. ## Lua Runtime Guidelines @@ -183,7 +238,16 @@ Use the focused lanes when possible: - Prefer interactive Ink workflows for commands that need several choices. - Keep non-interactive flags available for scripts and CI. - Make generated Lua easy to remove later. Preserve `FEATHER-INIT` style comments and metadata when touching init/remove flows. -- When adding setup options, update `docs/cli.md`, `docs/configuration.md`, and generated config templates. +- When adding a command that installs files, follow the skip-on-exists pattern: skip items that already exist, install the rest, then offer an interactive override prompt. Use `--force` to bypass without prompting. +- When adding setup options, update `cli/README.md` (which is also `docs/cli.md`), `docs/configuration.md`, and generated config templates. + +## VS Code Extension Guidelines + +- The extension is a thin UI controller — it should `spawn` the CLI binary for all Feather logic and never re-implement CLI behavior itself. +- Keep extension commands mapped 1-to-1 to CLI commands. If a behavior doesn't exist in the CLI, add it there first. +- Workspace-scoped settings (such as remembered vendor directories) should use `ConfigurationTarget.Workspace`, not `Global`. +- When adding a new extension command, register it in both `vscode-extension/src/extension.ts` and `vscode-extension/package.json` (`contributes.commands`). +- `bundled-cli/` is populated by `extension:prepare` and gitignored. Never commit it or reference it from source. ## Package Catalog Contributions diff --git a/cli/README.md b/cli/README.md index 1fb0989d..426f769d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -88,10 +88,10 @@ feather init --install-dir lib/feather # configure like FEATHER_DIR=l By default, `feather init` opens an interactive terminal picker powered by Ink with CLI mode selected: -| Mode | Behavior | -| -------- | ------------------------------------------------------------------------------------------------------------------------ | -| `cli` | Recommended. Creates `feather.config.lua` for `feather run` without changing game code. | -| `auto` | Advanced embedded mode. Copies core/plugins and patches `main.lua` with a guarded `require("feather.auto")`. | +| Mode | Behavior | +| -------- | ----------------------------------------------------------------------------------------------------------------------- | +| `cli` | Recommended. Creates `feather.config.lua` for `feather run` without changing game code. | +| `auto` | Advanced embedded mode. Copies core/plugins and patches `main.lua` with a guarded `require("feather.auto")`. | | `manual` | Advanced embedded mode. Copies core/plugins, creates `feather.debugger.lua`, and loads it from `main.lua` when enabled. | Install source priority: @@ -451,10 +451,44 @@ feather build vendor add desktop feather build vendor add all --json feather build vendor add android --ref 11.5 feather build vendor add ios --ref 11.5 --json +feather build vendor add web --force # overwrite existing vendor directory feather build vendor list ``` -`build vendor add` installs local build vendors into `vendor/` and updates `feather.build.json` by default. Web fetches `2dengine/love.js` into `vendor/love.js`. Android fetches `love2d/love-android` with submodules. iOS fetches `love2d/love` and installs the matching `love--apple-libraries.zip` into the Xcode tree. Desktop vendors download official LÖVE runtimes for Windows, macOS, and Linux; SteamOS reuses the Linux runtime unless configured separately. Mobile and desktop versions come from `loveVersion` or `--ref`, falling back to `11.5`; web defaults to the love.js `main` branch unless `--web-ref` or `--ref` is passed. +## `build vendor add` + +Installs local build vendors into `vendor/` and updates `feather.build.json` by default. + +If a vendor directory already exists, it is skipped and installation continues for the remaining vendors. In interactive terminals, Feather prompts whether the existing vendor should be overwritten. Use `--force` to overwrite existing vendors without prompting. + +### Vendor Sources + +- **Web** — Fetches `2dengine/love.js` into `vendor/love.js` +- **Android** — Fetches `love2d/love-android` with submodules +- **iOS** — Fetches `love2d/love` and installs the matching `love--apple-libraries.zip` into the Xcode project tree +- **Desktop** — Downloads official LÖVE runtimes for: + - Windows + - macOS + - Linux +- **SteamOS** — Reuses the Linux runtime unless configured separately + +### Version Resolution + +> [!NOTE] +> Only 11.5 has been tested, future Love2D releases will be officially supported short after launch. + +Mobile and desktop vendors resolve versions using: + +1. `loveVersion` +2. `--ref` + +If neither is provided, Feather defaults to `11.5`. + +Web vendors behave slightly differently: + +- Defaults to the `main` branch of `love.js` +- Can be pinned using `--web-ref` +- Falls back to `--ref` if provided ```json { @@ -627,6 +661,9 @@ In an interactive terminal, `feather update` opens an Ink workflow to choose loc This updates all `core:` files listed in `manifest.txt`. Plugin files are not touched — use `feather plugin update` for those. +> [!NOTE] +> CLI-managed projects (initialized with `feather init` in `cli` mode) do not embed the runtime in the project. For those projects `feather update` prints an informational message and exits — update the CLI package itself to get the latest runtime. + --- ### `feather plugin` @@ -674,8 +711,23 @@ feather plugin install console feather plugin install time-travel --remote --branch main feather plugin install console --local-src ../feather/src-lua feather plugin install console --install-dir lib/feather +feather plugin install console input-replay # install multiple at once +feather plugin install console --force # overwrite if already installed ``` +If a plugin is already installed, `feather plugin install` skips it and continues installing the others. In an interactive terminal it then offers to overwrite the skipped plugins. Pass `--force` to overwrite without prompting. + +**Options:** + +| Option | Description | +| ---------------------- | ------------------------------------------------------------- | +| `--force` | Overwrite already-installed plugins without prompting. | +| `--remote` | Download from GitHub instead of the local/bundled runtime. | +| `--branch ` | GitHub branch or tag when using `--remote` (default: `main`). | +| `--local-src ` | Copy from a local `src-lua` style directory. | +| `--install-dir ` | Install directory (default: `feather`). | +| `--dir ` | Project directory (default: current directory). | + #### `feather plugin remove ` Remove an installed plugin. @@ -699,6 +751,50 @@ When no plugin ID or source flag is provided in an interactive terminal, `feathe Use `--install-dir ` with plugin commands when the project was initialized outside the default `feather/` directory. +Use `--managed ` to override the managed-mode detection read from `feather.config.lua`. Accepted values are `cli`, `auto`, and `manual`. This is rarely needed; the config file is the source of truth. + +--- + +### `feather config` + +Update values in `feather.config.lua` without opening the file. + +#### `feather config plugins` + +Add or remove plugins from the `include`/`exclude` lists and keep the `capabilities` allowlist in sync. + +```bash +feather config plugins --include console,input-replay +feather config plugins --exclude hump.signal +feather config plugins --include profiler --exclude runtime-snapshot --dir path/to/my-game +``` + +**Options:** + +| Option | Description | +| ----------------- | ------------------------------------------------------- | +| `--include ` | Comma-separated plugin IDs to add to `include`. | +| `--exclude ` | Comma-separated plugin IDs to add to `exclude`. | +| `--dir ` | Project directory (default: current directory). | + +#### `feather config managed ` + +Change the `managed` field that controls how Feather detects the integration mode. Useful when you want to override what `feather init` wrote without re-initialising the project. + +```bash +feather config managed cli +feather config managed auto +feather config managed manual --dir path/to/my-game +``` + +Valid modes are `cli`, `auto`, and `manual`. This updates the config field only — it does not patch `main.lua`, install the runtime, or generate `feather.debugger.lua`. Use `feather init --mode ` for a full mode transition. + +**Options:** + +| Option | Description | +| -------------- | ----------------------------------------------- | +| `--dir ` | Project directory (default: current directory). | + --- ## feather.config.lua diff --git a/cli/src/commands/build-vendor.ts b/cli/src/commands/build-vendor.ts index 334992b5..38f53d26 100644 --- a/cli/src/commands/build-vendor.ts +++ b/cli/src/commands/build-vendor.ts @@ -7,6 +7,7 @@ import { printMuted, printStatus, printTable, + printWarning, style, } from '../lib/output.js'; import { @@ -15,7 +16,9 @@ import { isBuildVendorTarget, listBuildVendors, type BuildVendorTargetInput, + type ConcreteBuildVendorTarget, } from '../lib/build/vendor.js'; +import { confirmAction } from '../ui/confirm.js'; export type BuildVendorCommandOptions = { dir?: string; @@ -64,37 +67,48 @@ export async function buildVendorAddCommand(targetValues: string[], opts: BuildV return; } - if (opts.dryRun) { - printStatus('info', 'Build vendor plan'); + const installed = result.vendors.filter((v) => v.installed || v.skipped); + const skipped = result.skippedTargets; + + if (installed.length > 0 || opts.dryRun) { + if (opts.dryRun) { + printStatus('info', 'Build vendor plan'); + } else { + spinner?.succeed('Build vendors ready'); + } + printBlank(); + printKeyValues([ + ['Project', result.projectDir], + ['Config', result.configPath], + ['LÖVE', result.loveVersion], + ]); + printBlank(); + printTable({ + columns: [ + { key: 'target', label: 'Target' }, + { key: 'ref', label: 'Ref' }, + { key: 'path', label: 'Path' }, + { key: 'config', label: 'Config' }, + ], + rows: installed.map((vendor) => ({ + target: vendor.target, + ref: vendor.ref, + path: vendor.relativePath, + config: vendor.configUpdated ? 'updated' : opts.dryRun && opts.configUpdate !== false ? 'planned' : 'unchanged', + })), + }); + if (result.vendors.some((vendor) => vendor.target === 'steamos' && (vendor.installed || vendor.skipped))) { + printBlank(); + printMuted('SteamOS Devkit setup is manual before using `feather watch --target steamos`:'); + printMuted('Official Steam Deck loading docs: https://partner.steamgames.com/doc/steamdeck/loadgames'); + printMuted('Cross-platform Devkit client, especially useful on macOS: https://gitlab.steamos.cloud/devkit/steamos-devkit'); + } } else { - spinner?.succeed('Build vendors ready'); + spinner?.stop(); } - printBlank(); - printKeyValues([ - ['Project', result.projectDir], - ['Config', result.configPath], - ['LÖVE', result.loveVersion], - ]); - printBlank(); - printTable({ - columns: [ - { key: 'target', label: 'Target' }, - { key: 'ref', label: 'Ref' }, - { key: 'path', label: 'Path' }, - { key: 'config', label: 'Config' }, - ], - rows: result.vendors.map((vendor) => ({ - target: vendor.target, - ref: vendor.ref, - path: vendor.relativePath, - config: vendor.configUpdated ? 'updated' : opts.dryRun && opts.configUpdate !== false ? 'planned' : 'unchanged', - })), - }); - if (result.vendors.some((vendor) => vendor.target === 'steamos')) { - printBlank(); - printMuted('SteamOS Devkit setup is manual before using `feather watch --target steamos`:'); - printMuted('Official Steam Deck loading docs: https://partner.steamgames.com/doc/steamdeck/loadgames'); - printMuted('Cross-platform Devkit client, especially useful on macOS: https://gitlab.steamos.cloud/devkit/steamos-devkit'); + + if (skipped.length > 0 && !opts.dryRun) { + await handleSkippedVendors(skipped, opts, targets); } } catch (err) { spinner?.fail((err as Error).message); @@ -102,6 +116,49 @@ export async function buildVendorAddCommand(targetValues: string[], opts: BuildV } } +async function handleSkippedVendors( + skipped: ConcreteBuildVendorTarget[], + opts: BuildVendorCommandOptions, + originalTargets: BuildVendorTargetInput[], +): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + printWarning(`${skipped.length} vendor(s) already exist: ${skipped.join(', ')}. Use --force to overwrite.`); + return; + } + + const shouldOverride = await confirmAction({ + title: 'feather build vendor add', + label: `${skipped.length} vendor(s) already exist. Overwrite?`, + hint: 'Pass --force to skip this prompt.', + rows: skipped, + defaultYes: false, + }); + + if (!shouldOverride) { + printMuted(`Skipped: ${skipped.join(', ')}`); + return; + } + + const overwriteSpinner = createSpinner(`Overwriting ${skipped.join(', ')}…`).start(); + try { + await addBuildVendors(skipped as BuildVendorTargetInput[], { + projectDir: opts.dir, + configPath: opts.config, + vendorDir: opts.vendorDir, + ref: opts.ref, + webRef: opts.webRef, + androidRef: opts.androidRef, + iosRef: opts.iosRef, + force: true, + updateConfig: opts.configUpdate, + }); + overwriteSpinner.succeed(`Overwritten: ${skipped.join(', ')}`); + } catch (err) { + overwriteSpinner.fail((err as Error).message); + fail((err as Error).message, { silent: true }); + } +} + export function buildVendorListCommand(opts: BuildVendorListCommandOptions = {}): void { const result = listBuildVendors({ projectDir: opts.dir, diff --git a/cli/src/commands/config.ts b/cli/src/commands/config.ts index 424ee073..42e8ea06 100644 --- a/cli/src/commands/config.ts +++ b/cli/src/commands/config.ts @@ -14,6 +14,12 @@ export type ConfigPluginsOptions = { exclude?: string; }; +export type ConfigManagedOptions = { + dir?: string; +}; + +const VALID_MANAGED_MODES = ['cli', 'auto', 'manual'] as const; + const knownPluginIds = new Set(pluginCatalog.map((plugin) => plugin.id)); function parseIds(value: string | undefined): string[] { @@ -138,3 +144,30 @@ export async function configPluginsCommand(opts: ConfigPluginsOptions = {}): Pro writeFileSync(configPath, nextSource); printLine(`${icon.success} Updated feather.config.lua plugin settings`); } + +export async function configManagedCommand(mode: string, opts: ConfigManagedOptions = {}): Promise { + if (!(VALID_MANAGED_MODES as readonly string[]).includes(mode)) { + fail(`Invalid mode: ${mode}`, { + details: [`Valid modes: ${VALID_MANAGED_MODES.join(', ')}`], + }); + } + + const projectDir = findConfigDir(opts.dir ? resolve(opts.dir) : process.cwd()); + let configPath: string; + try { + configPath = assertSafeProjectTarget(projectDir, 'feather.config.lua', 'Config update target'); + } catch (err) { + fail((err as Error).message); + } + + if (!existsSync(configPath)) { + fail(`No feather.config.lua found in ${projectDir}.`, { + details: ['Run `feather init` first.'], + }); + } + + const source = readFileSync(configPath, 'utf8'); + const next = upsertTopLevelValue(source, 'managed', mode); + writeFileSync(configPath, next); + printLine(`${icon.success} Updated managed mode to "${mode}"`); +} diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 27690302..e0c75c2e 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -221,7 +221,7 @@ export async function initCommand(dir: string, opts: InitOptions): Promise let failed = 0; for (const id of pluginIds) { try { - installPluginsFromLocal([id], sourceRoot, target, installDir); + installPluginsFromLocal([id], sourceRoot, target, installDir, undefined, true); pluginSpinner.text = `Copied plugin: ${id}`; } catch { failed++; diff --git a/cli/src/commands/plugin/install.ts b/cli/src/commands/plugin/install.ts index 21b1ff52..5ff4032c 100644 --- a/cli/src/commands/plugin/install.ts +++ b/cli/src/commands/plugin/install.ts @@ -6,18 +6,35 @@ import { getPluginIds, installPlugin, installPluginsFromLocal, + normalizeInstallDir, } from '../../lib/install.js'; import { fail } from '../../lib/command.js'; -import { createSpinner } from '../../lib/output.js'; +import { createSpinner, printMuted, printWarning } from '../../lib/output.js'; import { resolveLocalLuaRoot } from '../../lib/paths.js'; import { assertValidPluginId, pluginIdToSourceDir } from '../../lib/plugin-utils.js'; import { configPluginsCommand } from '../config.js'; +import { confirmAction } from '../../ui/confirm.js'; import { type PluginSourceOptions, resolveManaged, resolvePluginProjectDir, warnDangerousPlugin } from './shared.js'; +async function offerOverride(skipped: string[], label: string): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + printWarning(`${skipped.length} plugin(s) already installed: ${skipped.join(', ')}. Use --force to overwrite.`); + return false; + } + return confirmAction({ + title: 'feather plugin install', + label: `${skipped.length} plugin(s) already installed. Overwrite?`, + hint: `Pass --force to skip this prompt. ${label}`, + rows: skipped, + defaultYes: false, + }); +} + export async function pluginInstallCommand(pluginIds: string | string[], opts: PluginSourceOptions): Promise { const projectDir = resolvePluginProjectDir(opts.dir); const branch = opts.branch ?? 'main'; - const installDir = opts.installDir ?? 'feather'; + const installDir = normalizeInstallDir(opts.installDir); + const force = opts.force === true; const ids = (Array.isArray(pluginIds) ? pluginIds : [pluginIds]).flatMap((value) => value.split(',').map((id) => id.trim()).filter(Boolean), ); @@ -54,16 +71,40 @@ export async function pluginInstallCommand(pluginIds: string | string[], opts: P } const spinner = createSpinner(`Copying ${uniqueIds.join(', ')}…`).start(); + let result: { installed: string[]; skipped: string[] }; try { - installPluginsFromLocal(uniqueIds, sourceRoot, projectDir, installDir); - spinner.succeed(`Installed ${uniqueIds.join(', ')}`); - for (const pluginId of uniqueIds) { - warnDangerousPlugin(pluginId); - } + result = installPluginsFromLocal(uniqueIds, sourceRoot, projectDir, installDir, undefined, force); } catch (err) { spinner.fail((err as Error).message); fail((err as Error).message, { cause: err, silent: true }); } + const { installed, skipped } = result!; + if (installed.length > 0) { + spinner.succeed(`Installed ${installed.join(', ')}`); + for (const pluginId of installed) { + warnDangerousPlugin(pluginId); + } + } else { + spinner.stop(); + } + if (skipped.length > 0) { + const shouldOverride = await offerOverride(skipped, 'Local source.'); + if (shouldOverride) { + const overwriteSpinner = createSpinner(`Overwriting ${skipped.join(', ')}…`).start(); + try { + const overwriteResult = installPluginsFromLocal(skipped, sourceRoot, projectDir, installDir, undefined, true); + overwriteSpinner.succeed(`Overwritten: ${overwriteResult.installed.join(', ')}`); + for (const pluginId of overwriteResult.installed) { + warnDangerousPlugin(pluginId); + } + } catch (err) { + overwriteSpinner.fail((err as Error).message); + fail((err as Error).message, { cause: err, silent: true }); + } + } else if (process.stdin.isTTY) { + printMuted(`Skipped: ${skipped.join(', ')}`); + } + } return; } @@ -84,15 +125,40 @@ export async function pluginInstallCommand(pluginIds: string | string[], opts: P } } + const skippedRemote: string[] = []; for (const pluginId of uniqueIds) { const installSpinner = createSpinner(`Installing ${pluginId}…`).start(); try { - await installPlugin(pluginId, entries, projectDir, branch, undefined, installDir); - installSpinner.succeed(`Installed ${pluginId}`); - warnDangerousPlugin(pluginId); + const result = await installPlugin(pluginId, entries, projectDir, branch, undefined, installDir, force); + if (result.skipped) { + installSpinner.stop(); + skippedRemote.push(pluginId); + } else { + installSpinner.succeed(`Installed ${pluginId}`); + warnDangerousPlugin(pluginId); + } } catch (err) { installSpinner.fail((err as Error).message); fail((err as Error).message, { cause: err, silent: true }); } } + + if (skippedRemote.length > 0) { + const shouldOverride = await offerOverride(skippedRemote, 'Remote source.'); + if (shouldOverride) { + for (const pluginId of skippedRemote) { + const overwriteSpinner = createSpinner(`Overwriting ${pluginId}…`).start(); + try { + await installPlugin(pluginId, entries, projectDir, branch, undefined, installDir, true); + overwriteSpinner.succeed(`Overwritten: ${pluginId}`); + warnDangerousPlugin(pluginId); + } catch (err) { + overwriteSpinner.fail((err as Error).message); + fail((err as Error).message, { cause: err, silent: true }); + } + } + } else if (process.stdin.isTTY) { + printMuted(`Skipped: ${skippedRemote.join(', ')}`); + } + } } diff --git a/cli/src/commands/plugin/shared.ts b/cli/src/commands/plugin/shared.ts index c6fc9be0..9804e54f 100644 --- a/cli/src/commands/plugin/shared.ts +++ b/cli/src/commands/plugin/shared.ts @@ -13,6 +13,7 @@ export type PluginSourceOptions = { remote?: boolean; localSrc?: string; managed?: string; + force?: boolean; }; /** diff --git a/cli/src/commands/plugin/update.ts b/cli/src/commands/plugin/update.ts index 1a75f688..785cdffb 100644 --- a/cli/src/commands/plugin/update.ts +++ b/cli/src/commands/plugin/update.ts @@ -78,7 +78,7 @@ export async function pluginUpdateCommand( for (const id of ids) { const s = createSpinner(`Updating ${id}…`).start(); try { - installPluginsFromLocal([id], sourceRoot, projectDir, installDir); + installPluginsFromLocal([id], sourceRoot, projectDir, installDir, undefined, true); s.succeed(`Updated ${id}`); warnDangerousPlugin(id); } catch (err) { @@ -106,7 +106,7 @@ export async function pluginUpdateCommand( for (const id of ids) { const s = createSpinner(`Updating ${id}…`).start(); try { - await installPlugin(id, entries, projectDir, branch, undefined, installDir); + await installPlugin(id, entries, projectDir, branch, undefined, installDir, true); s.succeed(`Updated ${id}`); warnDangerousPlugin(id); } catch (err) { diff --git a/cli/src/index.ts b/cli/src/index.ts index 05be8bac..92a7f429 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -13,7 +13,7 @@ import { watchCommand } from './commands/watch.js'; import { buildVendorAddCommand, buildVendorListCommand } from './commands/build-vendor.js'; import { buildTargets } from './lib/build/config.js'; import { uploadCommand } from './commands/upload.js'; -import { configPluginsCommand } from './commands/config.js'; +import { configManagedCommand, configPluginsCommand } from './commands/config.js'; import { pluginListCommand, pluginInstallCommand, @@ -206,6 +206,18 @@ export function createProgram(): Command { ), ); + config + .command('managed ') + .description('Set the managed mode in feather.config.lua (cli, auto, manual)') + .option('--dir ', 'Project directory (default: current directory)') + .action((mode, opts) => + runCliAction(() => + configManagedCommand(mode as string, { + dir: opts.dir as string | undefined, + }), + ), + ); + const build = program .command('build') .description('Build a LÖVE game package, web bundle, mobile dev app, or desktop installer'); @@ -448,6 +460,7 @@ export function createProgram(): Command { .option('--local-src ', 'Copy plugins from a local src-lua style directory') .option('--install-dir ', 'Feather install directory', 'feather') .option('--managed ', 'Override managed mode detection (cli, auto, manual)') + .option('--force', 'Overwrite already-installed plugins without prompting') .action((ids: string[], opts) => runCliAction(() => { const merged = pluginCommandOptions(opts); @@ -458,6 +471,7 @@ export function createProgram(): Command { remote: merged.remote as boolean | undefined, localSrc: merged.localSrc as string | undefined, managed: merged.managed as string | undefined, + force: merged.force as boolean | undefined, }); }), ); diff --git a/cli/src/lib/build/vendor.ts b/cli/src/lib/build/vendor.ts index 4def0f1f..ab22c31a 100644 --- a/cli/src/lib/build/vendor.ts +++ b/cli/src/lib/build/vendor.ts @@ -53,6 +53,8 @@ export type BuildVendorResult = { repo: string; installed: boolean; skipped: boolean; + /** True when skipped because the vendor directory already exists (not --force, not SteamOS reuse). */ + alreadyExists: boolean; configUpdated: boolean; actions: string[]; }; @@ -63,6 +65,7 @@ export type BuildVendorAddResult = { configPath: string; loveVersion: string; vendors: BuildVendorResult[]; + skippedTargets: ConcreteBuildVendorTarget[]; }; export type BuildVendorListEntry = { @@ -130,6 +133,7 @@ export async function addBuildVendors(targets: BuildVendorTargetInput[], options configPath, loveVersion, vendors: results, + skippedTargets: results.filter((r) => r.alreadyExists).map((r) => r.target), }; } @@ -195,8 +199,20 @@ async function addSingleVendor(input: AddSingleVendorInput): Promise void -): void { + onProgress?: (file: string) => void, + force = false, +): { installed: string[]; skipped: string[] } { const pluginsRoot = join(sourceRoot, "plugins"); if (!existsSync(pluginsRoot)) throw new Error(`No plugins directory found at ${pluginsRoot}`); @@ -130,12 +131,20 @@ export function installPluginsFromLocal( return { pluginId, sourceDir, source }; }); + const installed: string[] = []; + const skipped: string[] = []; for (const { pluginId, sourceDir, source } of plans) { const dest = assertSafeProjectTarget(targetDir, join(root, "plugins", sourceDir), "Plugin install target"); + if (!force && existsSync(dest)) { + skipped.push(pluginId); + continue; + } mkdirSync(dirname(dest), { recursive: true }); cpSync(source, dest, { recursive: true, force: true }); + installed.push(pluginId); onProgress?.(pluginId); } + return { installed, skipped }; } export async function installPlugin( @@ -144,12 +153,17 @@ export async function installPlugin( targetDir: string, branch = "main", onProgress?: (file: string) => void, - installDir = "feather" -): Promise { + installDir = "feather", + force = false, +): Promise<{ skipped: boolean }> { assertValidPluginId(pluginId); const pluginEntries = entries.filter((e) => e.type === "plugin" && e.plugin === pluginId); if (pluginEntries.length === 0) throw new Error(`Unknown plugin: ${pluginId}`); const root = normalizeInstallDir(installDir); + const pluginDir = assertSafeProjectTarget(targetDir, join(root, "plugins", pluginIdToSourceDir(pluginId)), "Plugin install target"); + if (!force && existsSync(pluginDir)) { + return { skipped: true }; + } const plans = pluginEntries.map((entry) => { const sourceDir = entry.sourceDir ?? pluginId.replace(/\./g, "/"); const file = entry.file ?? entry.path.replace(new RegExp(`^plugins/${sourceDir}/`), ""); @@ -176,6 +190,7 @@ export async function installPlugin( await downloadFile(entry.path, dest, branch); onProgress?.(entry.path); } + return { skipped: false }; } export function getPluginIds(entries: ManifestEntry[]): string[] { diff --git a/cli/test/commands/build-vendor.test.mjs b/cli/test/commands/build-vendor.test.mjs index 2f27b8f3..19fa3c1d 100644 --- a/cli/test/commands/build-vendor.test.mjs +++ b/cli/test/commands/build-vendor.test.mjs @@ -287,15 +287,19 @@ test('build vendor add --no-config: fetches vendor without writing build config' assert.equal(parsed.vendors[0].configUpdated, false); }); -test('build vendor add: existing directories and conflicting config require --force', () => { +test('build vendor add: existing directory is skipped (not a fatal error); conflicting configured path still requires --force', () => { const dir = makeTmp(); writeGame(dir); mkdirSync(join(dir, 'vendor', 'love-android'), { recursive: true }); + // Existing directory: no longer fails — returns skippedTargets instead. const existing = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json']); - assert.equal(existing.exitCode, 1); - assert.ok(outputOf(existing).includes('--force')); + assert.equal(existing.exitCode, 0, outputOf(existing)); + const parsed = JSON.parse(existing.stdout); + assert.equal(parsed.vendors[0].alreadyExists, true); + assert.ok(parsed.skippedTargets.includes('android')); + // Conflicting configured path still fails without --force. writeBuildConfig(dir, { targets: { android: { loveAndroidDir: 'native/love-android' } } }); const conflict = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--dry-run', '--json']); assert.equal(conflict.exitCode, 1); @@ -449,3 +453,48 @@ test('build vendor add linux: reports actionable error when AppImage cannot be e const out = outputOf(result); assert.ok(out.includes('brew install squashfs') || out.includes('Linux host'), out); }); + +test('build vendor add: skips existing vendor and warns without --force', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor Skip', version: '1.0.0', loveVersion: '11.5' }); + const { binDir } = writeFakeVendorGit(dir); + + // Install android vendor first. + run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json'], { env: envWithPath(binDir) }); + assert.equal(existsSync(join(dir, 'vendor', 'love-android', 'gradlew')), true); + + // Run again — should skip, not fail. + const result = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json'], { + env: envWithPath(binDir), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].target, 'android'); + assert.equal(parsed.vendors[0].alreadyExists, true); + assert.equal(parsed.skippedTargets[0], 'android'); +}); + +test('build vendor add --force: overwrites existing vendor directory', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor Force', version: '1.0.0', loveVersion: '11.5' }); + const { binDir } = writeFakeVendorGit(dir); + + // Install android vendor first. + run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json'], { env: envWithPath(binDir) }); + // Write a sentinel file to detect overwrite. + writeFileSync(join(dir, 'vendor', 'love-android', 'sentinel.txt'), 'original'); + + // Run again with --force — should overwrite. + const result = run(['build', 'vendor', 'add', 'android', '--force', '--dir', dir, '--json'], { + env: envWithPath(binDir), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].target, 'android'); + assert.equal(parsed.vendors[0].alreadyExists, false); + assert.equal(parsed.skippedTargets.length, 0); + // Sentinel file gone — directory was replaced. + assert.equal(existsSync(join(dir, 'vendor', 'love-android', 'sentinel.txt')), false); +}); diff --git a/cli/test/commands/config.test.mjs b/cli/test/commands/config.test.mjs index 7abbfa2e..0500e137 100644 --- a/cli/test/commands/config.test.mjs +++ b/cli/test/commands/config.test.mjs @@ -86,3 +86,50 @@ test('config plugins: unknown plugin is rejected', () => { assert.equal(result.exitCode, 1, outputOf(result)); assert.match(outputOf(result), /Unknown plugin: not-a-plugin/); }); + +test('config managed: sets managed field when not present', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return {\n sessionName = "My Game",\n}\n'); + + const result = run(['config', 'managed', 'auto', '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.match(config, /managed\s*=\s*"auto"/); + assert.match(config, /sessionName\s*=\s*"My Game"/); +}); + +test('config managed: updates an existing managed field', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return {\n managed = "auto",\n sessionName = "My Game",\n}\n'); + + const result = run(['config', 'managed', 'manual', '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.match(config, /managed\s*=\s*"manual"/); + assert.doesNotMatch(config, /managed\s*=\s*"auto"/); +}); + +test('config managed: rejects invalid mode', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { managed = "cli" }\n'); + + const result = run(['config', 'managed', 'embedded', '--dir', dir]); + assert.equal(result.exitCode, 1, outputOf(result)); + assert.match(outputOf(result), /Invalid mode: embedded/); + assert.match(outputOf(result), /cli, auto, manual/); +}); + +test('config managed: missing config fails with init guidance', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['config', 'managed', 'cli', '--dir', dir]); + assert.equal(result.exitCode, 1, outputOf(result)); + assert.match(outputOf(result), /No feather\.config\.lua found/); + assert.match(outputOf(result), /feather init/); +}); diff --git a/cli/test/commands/plugins.test.mjs b/cli/test/commands/plugins.test.mjs index 24372053..5af3591d 100644 --- a/cli/test/commands/plugins.test.mjs +++ b/cli/test/commands/plugins.test.mjs @@ -203,3 +203,58 @@ test('plugin list: malformed manifests do not crash and use directory fallback i assert.ok(result.stdout.includes('bad-plugin')); assert.ok(result.stdout.includes('Bad Plugin')); }); + +test('plugin install: skips already-installed plugin and warns in non-TTY mode', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + + // First install succeeds. + const first = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(first.exitCode, 0, outputOf(first)); + const manifestPath = join(dir, 'feather', 'plugins', 'console', 'manifest.lua'); + assert.equal(existsSync(manifestPath), true); + + // Overwrite the manifest to detect whether it's been replaced. + writeFileSync(manifestPath, '-- sentinel\n'); + + // Second install without --force: should skip, file must remain unchanged. + const second = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(second.exitCode, 0, outputOf(second)); + assert.ok(outputOf(second).includes('already installed'), outputOf(second)); + assert.equal(readFileSync(manifestPath, 'utf8'), '-- sentinel\n'); +}); + +test('plugin install --force: overwrites already-installed plugin', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + + // First install. + run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + const manifestPath = join(dir, 'feather', 'plugins', 'console', 'manifest.lua'); + writeFileSync(manifestPath, '-- sentinel\n'); + + // Second install with --force: should overwrite. + const result = run(['plugin', 'install', 'console', '--force', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('Installed'), outputOf(result)); + assert.notEqual(readFileSync(manifestPath, 'utf8'), '-- sentinel\n'); +}); + +test('plugin install: installs new plugins and skips already-installed ones in the same batch', () => { + const dir = makeTmp(); + writeMinimalRuntime(dir); + + // Pre-install console. + run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + const consoleSentinel = join(dir, 'feather', 'plugins', 'console', 'manifest.lua'); + writeFileSync(consoleSentinel, '-- sentinel\n'); + + // Install both console (already exists) and input-replay (new). + const result = run(['plugin', 'install', 'console', 'input-replay', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + // input-replay should be installed. + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'input-replay', 'manifest.lua')), true); + // console should be skipped — sentinel still intact. + assert.equal(readFileSync(consoleSentinel, 'utf8'), '-- sentinel\n'); + assert.ok(outputOf(result).includes('already installed'), outputOf(result)); +}); diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 0722adfb..cf250ac1 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -222,6 +222,11 @@ "items": { "type": "string" }, "default": [], "description": "Run targets selected for Feather: Run Project (e.g. desktop, web, android, ios, steamos)." + }, + "feather.vendorDir": { + "type": "string", + "default": "", + "description": "Directory where build vendor files are installed. Remembered per workspace after the first Feather: Vendor add." } } } diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index ad92b500..bd37c5de 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -143,9 +143,10 @@ async function registerCommands( return; } if (action === 'Fetch Vendors') { + const savedVendorDir = featherConfig().get('vendorDir')?.trim() ?? root; for (const t of missingVendors) { const arg = vendorArg(t)!; - runInTerminal(context, `Feather: Vendor (${t})`, ['build', 'vendor', 'add', arg, '--dir', root], root); + runInTerminal(context, `Feather: Vendor (${t})`, ['build', 'vendor', 'add', arg, '--dir', savedVendorDir], savedVendorDir); } return; } @@ -460,16 +461,25 @@ async function registerCommands( ); if (!vendor) return; - const defaultUri = vscode.Uri.file(root); - const dirUris = await vscode.window.showOpenDialog({ - canSelectFolders: true, - canSelectFiles: false, - canSelectMany: false, - defaultUri, - openLabel: 'Install vendors here', - title: 'Select vendor installation directory', - }); - const vendorDir = dirUris?.[0]?.fsPath ?? root; + const cfg = featherConfig(); + const savedVendorDir = cfg.get('vendorDir')?.trim(); + + let vendorDir: string; + if (savedVendorDir) { + vendorDir = savedVendorDir; + } else { + const defaultUri = vscode.Uri.file(root); + const dirUris = await vscode.window.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + defaultUri, + openLabel: 'Install vendors here', + title: 'Select vendor installation directory', + }); + vendorDir = dirUris?.[0]?.fsPath ?? root; + await cfg.update('vendorDir', vendorDir, vscode.ConfigurationTarget.Workspace); + } ensureGitignoreEntry(vendorDir, '/vendor'); runInTerminal(context, 'Feather: Vendor add', ['build', 'vendor', 'add', vendor.value, '--dir', vendorDir], vendorDir); From 33c14a666ac4d2dd404165f634fdf082663237ed Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Tue, 19 May 2026 20:01:53 -0400 Subject: [PATCH 09/28] lua: shim hooks on load --- cli/src/lib/shim.ts | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/cli/src/lib/shim.ts b/cli/src/lib/shim.ts index 94e7190f..0dbd8536 100644 --- a/cli/src/lib/shim.ts +++ b/cli/src/lib/shim.ts @@ -120,7 +120,8 @@ if gamePath then end -- CLI mode should not require game code to call DEBUGGER:update(dt). --- Wrap after loading the game so we preserve the user's love.update callback. +-- Defer wrapping until after love.load completes so games that define +-- love.update inside love.load() are handled correctly. -- If the game already calls DEBUGGER:update(dt), avoid a second update in the same frame. if DEBUGGER and type(DEBUGGER.update) == "function" and not DEBUGGER.__cliAutoUpdateInstalled then DEBUGGER.__cliAutoUpdateInstalled = true @@ -130,16 +131,26 @@ if DEBUGGER and type(DEBUGGER.update) == "function" and not DEBUGGER.__cliAutoUp return featherUpdate(self, dt) end - local gameUpdate = love.update - love.update = function(dt) - DEBUGGER.__cliAutoUpdatedThisFrame = false - if gameUpdate then - gameUpdate(dt) - end - if not DEBUGGER.__cliAutoUpdatedThisFrame then - featherUpdate(DEBUGGER, dt) + local function installUpdateHook() + if DEBUGGER.__cliUpdateHookInstalled then return end + DEBUGGER.__cliUpdateHookInstalled = true + local gameUpdate = love.update + love.update = function(dt) + DEBUGGER.__cliAutoUpdatedThisFrame = false + if gameUpdate then + gameUpdate(dt) + end + if not DEBUGGER.__cliAutoUpdatedThisFrame then + featherUpdate(DEBUGGER, dt) + end end end + + local gameLoad = love.load + love.load = function(arg) + if gameLoad then gameLoad(arg) end + installUpdateHook() + end end `; } From 4a728642d2e7dcbd520d7bd2d45ab37a02595796 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 20 May 2026 10:07:31 -0400 Subject: [PATCH 10/28] feather: improve testing --- .github/workflows/release.yml | 5 +- cli/src/index.ts | 36 +-- package-lock.json | 430 ---------------------------------- 3 files changed, 23 insertions(+), 448 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4626b7bc..a0a944ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: - name: Install LÖVE run: | sudo apt-get update - sudo apt-get install -y love + sudo apt-get install -y love xvfb - name: Get version from tag id: get_version @@ -58,6 +58,9 @@ jobs: - name: Run CLI e2e run: npm run test:cli:e2e + - name: Run Lua e2e + run: npm run test:lua:e2e + - name: Verify npm package contents run: npm pack --workspace=cli --dry-run diff --git a/cli/src/index.ts b/cli/src/index.ts index 92a7f429..8c8e22e1 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -672,23 +672,25 @@ export function createProgram(): Command { .option('--runtime-config ', 'Path to feather.config.lua for debugger embedding') .option('--verbose', 'Show build commands and native tool output') .action((gamePath: string | undefined, opts) => - watchCommand(gamePath, { - target: opts.target as 'desktop' | 'android' | 'ios' | 'steamos' | undefined, - love: opts.love as string | undefined, - debugger: opts.debugger !== false && !opts.disableDebugger, - device: opts.device as string | undefined, - debounce: opts.debounce as number | undefined, - restart: opts.restart as boolean | undefined, - buildConfig: opts.buildConfig as string | undefined, - outDir: opts.outDir as string | undefined, - noPlugins: opts.plugins === false, - adbReverse: opts.adbReverse as boolean | undefined, - port: opts.port as number | undefined, - featherPath: opts.featherPath as string | undefined, - pluginsDir: opts.pluginsDir as string | undefined, - runtimeConfig: opts.runtimeConfig as string | undefined, - verbose: opts.verbose as boolean | undefined, - }), + runCliAction(() => + watchCommand(gamePath, { + target: opts.target as 'desktop' | 'android' | 'ios' | 'steamos' | undefined, + love: opts.love as string | undefined, + debugger: opts.debugger !== false && !opts.disableDebugger, + device: opts.device as string | undefined, + debounce: opts.debounce as number | undefined, + restart: opts.restart as boolean | undefined, + buildConfig: opts.buildConfig as string | undefined, + outDir: opts.outDir as string | undefined, + noPlugins: opts.plugins === false, + adbReverse: opts.adbReverse as boolean | undefined, + port: opts.port as number | undefined, + featherPath: opts.featherPath as string | undefined, + pluginsDir: opts.pluginsDir as string | undefined, + runtimeConfig: opts.runtimeConfig as string | undefined, + verbose: opts.verbose as boolean | undefined, + }), + ), ); return program; diff --git a/package-lock.json b/package-lock.json index 924a49f3..69672195 100644 --- a/package-lock.json +++ b/package-lock.json @@ -785,422 +785,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -12291,20 +11875,6 @@ "@esbuild/win32-x64": "0.27.7" } }, - "node_modules/tsx/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", From 6f2881820b480939d638e989348ea3c4b11f31d4 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 20 May 2026 10:07:37 -0400 Subject: [PATCH 11/28] feather: improve init process --- .github/workflows/vscode-extension.yml | 21 +- cli/README.md | 47 +++-- cli/src/commands/config.ts | 141 ++++++++++++++ cli/src/commands/init.ts | 47 ++++- cli/src/index.ts | 35 +++- cli/src/ui/init/model.ts | 3 + cli/src/ui/init/workflow.tsx | 3 +- cli/test/commands/config.test.mjs | 27 +++ cli/test/commands/init.test.mjs | 98 ++++++++++ docs/configuration.md | 32 +++ docs/index.md | 7 + docs/installation.md | 14 ++ docs/usage.md | 7 + package.json | 4 +- src-lua/feather/core/logger.lua | 2 +- src-lua/feather/plugin_manager.lua | 21 +- src-lua/plugins/README.md | 17 ++ src-lua/plugins/hot-reload/README.md | 16 +- vscode-extension/scripts/prepare.mjs | 45 +++-- vscode-extension/src/cli.ts | 22 ++- vscode-extension/src/extension.ts | 257 ++++++++++++++++++++++--- vscode-extension/src/project.ts | 37 +++- vscode-extension/test/helpers.test.mjs | 25 +++ 23 files changed, 849 insertions(+), 79 deletions(-) diff --git a/.github/workflows/vscode-extension.yml b/.github/workflows/vscode-extension.yml index 25d6dd28..270dd164 100644 --- a/.github/workflows/vscode-extension.yml +++ b/.github/workflows/vscode-extension.yml @@ -9,7 +9,7 @@ jobs: build: runs-on: ubuntu-latest permissions: - contents: read + contents: write outputs: vsixFile: ${{ steps.package.outputs.vsixFile }} steps: @@ -20,6 +20,8 @@ jobs: node-version: 22.21.1 package-manager-cache: false + - uses: oven-sh/setup-bun@v2 + - run: npm ci - name: Get version from tag @@ -43,9 +45,16 @@ jobs: id: package run: | npm run extension:package + VERSION="${{ steps.get_version.outputs.version }}" VSIX_PATH="$(ls vscode-extension/*.vsix | head -n 1)" - VSIX_FILE="$(basename "$VSIX_PATH")" - echo "vsixPath=$VSIX_PATH" >> "$GITHUB_OUTPUT" + VSIX_FILE="feather-vscode-${VERSION}.vsix" + mkdir -p release-assets + cp "$VSIX_PATH" "release-assets/$VSIX_FILE" + cp cli/bin/feather "release-assets/feather-cli-darwin-arm64-${VERSION}.bin" + cp cli/bin/feather-darwin-x64 "release-assets/feather-cli-darwin-x64-${VERSION}.bin" + cp cli/bin/feather-linux-x64 "release-assets/feather-cli-linux-x64-${VERSION}.bin" + cp cli/bin/feather-win-x64.exe "release-assets/feather-cli-windows-x64-${VERSION}.exe" + echo "vsixPath=release-assets/$VSIX_FILE" >> "$GITHUB_OUTPUT" echo "vsixFile=$VSIX_FILE" >> "$GITHUB_OUTPUT" - name: Upload VSIX @@ -54,6 +63,12 @@ jobs: name: feather-vscode path: ${{ steps.package.outputs.vsixPath }} + - name: Attach VSIX and CLI binaries to GitHub Release + uses: ncipollo/release-action@v1 + with: + artifacts: release-assets/* + allowUpdates: true + publish-vs-marketplace: runs-on: ubuntu-latest needs: build diff --git a/cli/README.md b/cli/README.md index 426f769d..ea5f947b 100644 --- a/cli/README.md +++ b/cli/README.md @@ -76,6 +76,8 @@ feather init # configure current directory feather init path/to/my-game # configure a specific directory feather init --no-plugins # feather core only, no plugins feather init --plugins screenshots,profiler +feather init --plugins hot-reload --hot-reload-allow game.player,game.systems.combat +feather init --session-name "My Game" --app-id feather-app-... feather init --remote --branch v0.7.0 # use a specific runtime release feather init --local-src ../feather/src-lua # use a local source tree feather init --install-dir lib/feather # configure like FEATHER_DIR=lib/feather @@ -94,6 +96,8 @@ By default, `feather init` opens an interactive terminal picker powered by Ink w | `auto` | Advanced embedded mode. Copies core/plugins and patches `main.lua` with a guarded `require("feather.auto")`. | | `manual` | Advanced embedded mode. Copies core/plugins, creates `feather.debugger.lua`, and loads it from `main.lua` when enabled. | +CLI mode writes a development config with `debug = true`, `autoRegisterErrorHandler = true`, and `managed = "cli"`. Unless you pass `--plugins` or `--no-plugins`, it includes `particle-system-playground` and `shader-graph` so the creative tooling is ready immediately. Opt-in or dangerous plugins, such as Console and Hot Reload, still require an explicit include. + Install source priority: 1. `--local-src ` copies from a local `src-lua` style tree. @@ -147,7 +151,7 @@ The interactive flow asks for: - Git branch or tag when using GitHub download, matching `FEATHER_BRANCH` - whether to install built-in plugins, matching `FEATHER_PLUGINS` - optional plugins to force-enable, such as Console, Physics Debug, and Timer Inspector -- plugins to skip/exclude, matching `FEATHER_SKIP_PLUGINS`; Console, HUMP Signal, and Lua State Machine start preselected like the shell installer defaults +- plugins to skip/exclude, matching `FEATHER_SKIP_PLUGINS`; Console, Hot Reload, HUMP Signal, and Lua State Machine start preselected like the shell installer defaults - advanced connection/runtime options from `feather.config.lua`, including host/port, socket vs disk mode, observers, logging, debugger, asset previews, capabilities, and binary threshold - a strong API key when Console is included @@ -190,16 +194,19 @@ return DEBUGGER **Options:** -| Option | Description | -| ---------------------- | ------------------------------------------------------------------------------ | -| `--remote` | Download from GitHub instead of copying the local/bundled Lua runtime. | -| `--branch ` | GitHub branch or tag to download from when using `--remote` (default: `main`). | -| `--local-src ` | Copy from a local `src-lua` style directory. | -| `--install-dir ` | Install directory for auto/manual modes (default: `feather`). | -| `--no-plugins` | Skip plugin installation. | -| `--plugins ` | Comma-separated list of plugin IDs to install (default: all). | -| `--mode ` | Setup mode: `cli`, `auto`, or `manual`. | -| `-y, --yes` | Skip confirmation prompts. | +| Option | Description | +| ---------------------------- | ---------------------------------------------------------------------------------------------------- | +| `--remote` | Download from GitHub instead of copying the local/bundled Lua runtime. | +| `--branch ` | GitHub branch or tag to download from when using `--remote` (default: `main`). | +| `--local-src ` | Copy from a local `src-lua` style directory. | +| `--install-dir ` | Install directory for auto/manual modes (default: `feather`). | +| `--no-plugins` | Skip plugin installation and omit the CLI-mode default include list. | +| `--plugins ` | Comma-separated plugin IDs. In CLI mode this overrides the default creative plugins. | +| `--hot-reload-allow ` | Comma-separated Lua module names allowed for Hot Reload; also includes the `hot-reload` plugin. | +| `--session-name ` | Session name shown in the Feather desktop app. | +| `--app-id ` | Desktop App ID allowed to send commands to this game. | +| `--mode ` | Setup mode: `cli`, `auto`, or `manual`. | +| `-y, --yes` | Skip confirmation prompts. | --- @@ -777,6 +784,24 @@ feather config plugins --include profiler --exclude runtime-snapshot --dir path/ | `--exclude ` | Comma-separated plugin IDs to add to `exclude`. | | `--dir ` | Project directory (default: current directory). | +#### `feather config hot-reload` + +Enable the opt-in `hot-reload` plugin and write a narrow `debugger.hotReload.allow` list. + +```bash +feather config hot-reload --allow game.player,game.systems.combat +feather config hot-reload --allow game.player --dir path/to/my-game +``` + +This also writes `debug = true`, `autoRegisterErrorHandler = true`, `include = { "hot-reload", ... }`, the required `filesystem` capability, and safe defaults such as `deny = { "main", "conf", "feather.*" }` and `persistToDisk = false`. + +**Options:** + +| Option | Description | +| ----------------- | ------------------------------------------------------- | +| `--allow ` | Comma-separated Lua module names Hot Reload may update. | +| `--dir ` | Project directory (default: current directory). | + #### `feather config managed ` Change the `managed` field that controls how Feather detects the integration mode. Useful when you want to override what `feather init` wrote without re-initialising the project. diff --git a/cli/src/commands/config.ts b/cli/src/commands/config.ts index 42e8ea06..933dafcb 100644 --- a/cli/src/commands/config.ts +++ b/cli/src/commands/config.ts @@ -18,6 +18,11 @@ export type ConfigManagedOptions = { dir?: string; }; +export type ConfigHotReloadOptions = { + dir?: string; + allow?: string; +}; + const VALID_MANAGED_MODES = ['cli', 'auto', 'manual'] as const; const knownPluginIds = new Set(pluginCatalog.map((plugin) => plugin.id)); @@ -51,8 +56,87 @@ function setArray(config: Record, key: 'include' | 'exclude', v } } +function hotReloadDebuggerConfig(allow: string[]): Record { + return { + enabled: true, + hotReload: { + enabled: true, + allow, + deny: ['main', 'conf', 'feather.*'], + persistToDisk: false, + clearOnBoot: false, + requireLocalNetwork: true, + }, + }; +} + +function valueEnd(source: string, start: number): number { + let i = start; + while (i < source.length && /\s/.test(source[i])) i++; + + if (source[i] !== '{') { + const lineEnd = source.indexOf('\n', i); + return lineEnd === -1 ? source.length : lineEnd; + } + + let depth = 0; + let quote: '"' | "'" | null = null; + while (i < source.length) { + const ch = source[i]; + const next = source[i + 1]; + + if (quote) { + if (ch === '\\' && next) { + i += 2; + continue; + } + if (ch === quote) quote = null; + i++; + continue; + } + + if (ch === '"' || ch === "'") { + quote = ch; + i++; + continue; + } + + if (ch === '-' && next === '-') { + while (i < source.length && source[i] !== '\n') i++; + continue; + } + + if (ch === '{') depth++; + if (ch === '}') { + depth--; + if (depth === 0) { + i++; + while (i < source.length && /[ \t]/.test(source[i])) i++; + if (source[i] === ',') i++; + return i; + } + } + i++; + } + + return source.length; +} + +function replaceTopLevelAssignment(source: string, key: string, rendered: string | undefined): string | undefined { + const assignment = new RegExp(`^\\s*${key}\\s*=`, 'm'); + const match = assignment.exec(source); + if (!match) return undefined; + + const equals = source.indexOf('=', match.index); + const end = valueEnd(source, equals + 1); + return `${source.slice(0, match.index)}${rendered ?? ''}${source.slice(end)}`; +} + function upsertTopLevelValue(source: string, key: string, value: unknown): string { const rendered = value === undefined ? undefined : ` ${key} = ${luaValue(value, 2)},`; + const replaced = replaceTopLevelAssignment(source, key, rendered); + if (replaced !== undefined) return replaced; + const assignment = new RegExp( `^\\s*${key}\\s*=\\s*(?:\\{[^\\n]*\\}|"[^"]*"|'[^']*'|true|false|-?\\d+(?:\\.\\d+)?),?\\s*$`, 'm', @@ -171,3 +255,60 @@ export async function configManagedCommand(mode: string, opts: ConfigManagedOpti writeFileSync(configPath, next); printLine(`${icon.success} Updated managed mode to "${mode}"`); } + +export async function configHotReloadCommand(opts: ConfigHotReloadOptions = {}): Promise { + const projectDir = findConfigDir(opts.dir ? resolve(opts.dir) : process.cwd()); + const allow = parseIds(opts.allow); + + if (allow.length === 0) { + fail('No hot reload allowlist requested.', { + details: ['Pass --allow .'], + }); + } + + let configPath: string; + try { + configPath = assertSafeProjectTarget(projectDir, 'feather.config.lua', 'Config update target'); + } catch (err) { + fail((err as Error).message); + } + + if (!existsSync(configPath)) { + fail(`No feather.config.lua found in ${projectDir}.`, { + details: ['Run `feather init` first.'], + }); + } + + const loaded = loadConfig(projectDir); + if (!loaded) { + fail(`Failed to load ${join(projectDir, 'feather.config.lua')}.`); + } + + const config = { ...(loaded as FeatherConfig) } as Record; + const include = new Set( + Array.isArray(config.include) ? config.include.filter((id): id is string => typeof id === 'string') : [], + ); + const exclude = new Set( + Array.isArray(config.exclude) ? config.exclude.filter((id): id is string => typeof id === 'string') : [], + ); + include.add('hot-reload'); + exclude.delete('hot-reload'); + setArray(config, 'include', include); + setArray(config, 'exclude', exclude); + + const mergedCapabilities = mergeCapabilities(config.capabilities as string[] | 'all' | undefined, include); + if (mergedCapabilities && mergedCapabilities !== 'all') { + config.capabilities = mergedCapabilities; + } + + let nextSource = readFileSync(configPath, 'utf8'); + nextSource = upsertTopLevelValue(nextSource, 'debug', true); + nextSource = upsertTopLevelValue(nextSource, 'autoRegisterErrorHandler', true); + nextSource = upsertTopLevelValue(nextSource, 'include', config.include); + nextSource = upsertTopLevelValue(nextSource, 'exclude', config.exclude); + nextSource = upsertTopLevelValue(nextSource, 'capabilities', config.capabilities); + nextSource = upsertTopLevelValue(nextSource, 'debugger', hotReloadDebuggerConfig(allow)); + + writeFileSync(configPath, nextSource); + printLine(`${icon.success} Updated hot reload allowlist`); +} diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index e0c75c2e..cf0dc569 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -13,6 +13,7 @@ import { import { configTemplate, luaKey, luaValue } from '../lib/config.js'; import { chooseInitMode, type InitMode, type InitSetup } from '../ui/init/index.js'; import { mergeCapabilities } from '../ui/init/config.js'; +import { defaultIncludedPluginIds } from '../ui/init/model.js'; import { pluginCatalog } from '../generated/plugin-catalog.js'; import { resolveLocalLuaRoot } from '../lib/paths.js'; import { fail } from '../lib/command.js'; @@ -29,6 +30,9 @@ export interface InitOptions { yes?: boolean; mode?: InitMode; allowInsecureConnection?: boolean; + appId?: string; + sessionName?: string; + hotReloadAllow?: string[]; } const knownPlugins = pluginCatalog.map((plugin) => plugin.id); @@ -51,6 +55,30 @@ function addIncludedPlugins(config: Record, pluginIds: Iterable } } +function addCliDebugDefaults(config: Record): void { + if (config.debug === undefined) config.debug = true; + if (config.autoRegisterErrorHandler === undefined) config.autoRegisterErrorHandler = true; +} + +function hotReloadDebuggerConfig(allow: string[]): Record { + return { + enabled: true, + hotReload: { + enabled: true, + allow, + deny: ['main', 'conf', 'feather.*'], + persistToDisk: false, + clearOnBoot: false, + requireLocalNetwork: true, + }, + }; +} + +function addHotReloadConfig(config: Record, allow: string[]): void { + addCliDebugDefaults(config); + config.debugger = hotReloadDebuggerConfig(allow); +} + const toLocalName = (id: string) => id .split(/[-.]/) @@ -118,7 +146,14 @@ export async function initCommand(dir: string, opts: InitOptions): Promise branch: opts.branch ?? 'main', installDir: opts.installDir ?? 'feather', installPlugins: opts.noPlugins ? false : true, - config: opts.allowInsecureConnection ? { __DANGEROUS_INSECURE_CONNECTION__: true } : {}, + config: { + ...(opts.sessionName?.trim() ? { sessionName: opts.sessionName.trim() } : {}), + ...(opts.appId?.trim() + ? { appId: opts.appId.trim() } + : opts.allowInsecureConnection + ? { __DANGEROUS_INSECURE_CONNECTION__: true } + : {}), + }, exclude: [], }; const mode = setup.mode; @@ -138,9 +173,17 @@ export async function initCommand(dir: string, opts: InitOptions): Promise setup.config.managed = mode; if (mode === 'cli') { - const pluginIds = pluginsDisabled ? [] : (opts.plugins ?? []).filter((id) => !setup.exclude.includes(id)); + addCliDebugDefaults(setup.config); + const requestedPlugins = opts.plugins ?? defaultIncludedPluginIds; + const pluginIds = pluginsDisabled ? [] : requestedPlugins.filter((id) => !setup.exclude.includes(id)); + if (!pluginsDisabled && opts.hotReloadAllow && opts.hotReloadAllow.length > 0 && !pluginIds.includes('hot-reload')) { + pluginIds.push('hot-reload'); + } addIncludedPlugins(setup.config, pluginIds); addPluginCapabilities(setup.config, pluginIds); + if (!pluginsDisabled && (pluginIds.includes('hot-reload') || (opts.hotReloadAllow && opts.hotReloadAllow.length > 0))) { + addHotReloadConfig(setup.config, opts.hotReloadAllow ?? []); + } writeConfig(target, setup.config, { mode, installDir, diff --git a/cli/src/index.ts b/cli/src/index.ts index 8c8e22e1..8a9f17db 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -13,7 +13,7 @@ import { watchCommand } from './commands/watch.js'; import { buildVendorAddCommand, buildVendorListCommand } from './commands/build-vendor.js'; import { buildTargets } from './lib/build/config.js'; import { uploadCommand } from './commands/upload.js'; -import { configManagedCommand, configPluginsCommand } from './commands/config.js'; +import { configHotReloadCommand, configManagedCommand, configPluginsCommand } from './commands/config.js'; import { pluginListCommand, pluginInstallCommand, @@ -43,6 +43,14 @@ function parseInitMode(value: string): InitMode { return value as InitMode; } +function parseCommaList(value: unknown): string[] | undefined { + if (typeof value !== 'string' || !value) return undefined; + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + export function createProgram(): Command { const program = new Command(); @@ -110,6 +118,9 @@ export function createProgram(): Command { .option('--install-dir ', 'Install directory for auto/manual modes', 'feather') .option('--no-plugins', 'Skip plugin installation') .option('--plugins ', 'Comma-separated list of plugins to install') + .option('--hot-reload-allow ', 'Comma-separated Lua module names to allow for hot reload') + .option('--session-name ', 'Session name shown in the Feather desktop app') + .option('--app-id ', 'Desktop App ID allowed to send commands to this game') .option('--mode ', 'Setup mode: cli, auto, or manual', parseInitMode) .option('-y, --yes', 'Skip confirmation prompts') .option( @@ -124,13 +135,13 @@ export function createProgram(): Command { 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: parseCommaList(opts.plugins as string | undefined), + hotReloadAllow: parseCommaList(opts.hotReloadAllow as string | undefined), mode: opts.mode as InitMode | undefined, yes: opts.yes as boolean, allowInsecureConnection: opts.allowInsecureConnection as boolean | undefined, + appId: opts.appId as string | undefined, + sessionName: opts.sessionName as string | undefined, }), ), ); @@ -218,6 +229,20 @@ export function createProgram(): Command { ), ); + config + .command('hot-reload') + .description('Enable hot reload config and set its module allowlist') + .option('--dir ', 'Project directory (default: current directory)') + .option('--allow ', 'Comma-separated Lua module names to allow') + .action((opts) => + runCliAction(() => + configHotReloadCommand({ + dir: opts.dir as string | undefined, + allow: opts.allow as string | undefined, + }), + ), + ); + const build = program .command('build') .description('Build a LÖVE game package, web bundle, mobile dev app, or desktop installer'); diff --git a/cli/src/ui/init/model.ts b/cli/src/ui/init/model.ts index 607932de..391127a0 100644 --- a/cli/src/ui/init/model.ts +++ b/cli/src/ui/init/model.ts @@ -77,6 +77,9 @@ export const toggleTone = (value: string): Tone | undefined => { return undefined; }; +export const defaultIncludedPluginIds = ["particle-system-playground", "shader-graph"]; +export const defaultIncludedPlugins = new Set(defaultIncludedPluginIds); + export const modes: Option[] = [ { value: "cli", diff --git a/cli/src/ui/init/workflow.tsx b/cli/src/ui/init/workflow.tsx index 798525e5..c5c75058 100644 --- a/cli/src/ui/init/workflow.tsx +++ b/cli/src/ui/init/workflow.tsx @@ -5,6 +5,7 @@ import { buildInitSetup } from "./config.js"; import { configToggles, dangerousInsecureConnection, + defaultIncludedPlugins, defaultSkippedPlugins, installSources, isStrongApiKey, @@ -56,7 +57,7 @@ function InitSetupPrompt({ const [includeCursor, setIncludeCursor] = useState(0); const [excludeCursor, setExcludeCursor] = useState(0); const [toggleCursor, setToggleCursor] = useState(0); - const [include, setInclude] = useState>(new Set()); + const [include, setInclude] = useState>(new Set(defaultIncludedPlugins)); const [exclude, setExclude] = useState>(new Set(defaultSkippedPlugins)); const [advanced, setAdvanced] = useState(false); const [host, setHost] = useState("127.0.0.1"); diff --git a/cli/test/commands/config.test.mjs b/cli/test/commands/config.test.mjs index 0500e137..75729d82 100644 --- a/cli/test/commands/config.test.mjs +++ b/cli/test/commands/config.test.mjs @@ -113,6 +113,33 @@ test('config managed: updates an existing managed field', () => { assert.doesNotMatch(config, /managed\s*=\s*"auto"/); }); +test('config hot-reload: enables plugin and writes debugger allowlist', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + managed = "cli", + include = { "shader-graph" }, + capabilities = { "draw" }, +} +`, + ); + + const result = run(['config', 'hot-reload', '--dir', dir, '--allow', 'game.player,game.systems.combat']); + assert.equal(result.exitCode, 0, outputOf(result)); + + const config = readFileSync(join(dir, 'feather.config.lua'), 'utf8'); + assert.match(config, /debug\s*=\s*true/); + assert.match(config, /autoRegisterErrorHandler\s*=\s*true/); + assert.match(config, /include\s*=\s*\{\s*"hot-reload",\s*"shader-graph"\s*\}/); + assert.match(config, /capabilities\s*=\s*\{\s*"draw",\s*"filesystem"\s*\}/); + assert.match(config, /debugger\s*=\s*\{/); + assert.match(config, /hotReload\s*=\s*\{/); + assert.match(config, /allow\s*=\s*\{\s*"game\.player",\s*"game\.systems\.combat"\s*\}/); + assert.match(config, /deny\s*=\s*\{\s*"main",\s*"conf",\s*"feather\.\*"\s*\}/); +}); + test('config managed: rejects invalid mode', () => { const dir = makeTmp(); writeGame(dir); diff --git a/cli/test/commands/init.test.mjs b/cli/test/commands/init.test.mjs index 5c7a9792..8bb4e522 100644 --- a/cli/test/commands/init.test.mjs +++ b/cli/test/commands/init.test.mjs @@ -350,6 +350,104 @@ test('init e2e: cli mode records selected plugins in generated config', () => { } }); +test('init e2e: cli mode includes default creative plugins', () => { + const workspace = makeTmp(); + const project = join(workspace, 'cli-default-plugins-game'); + + try { + writeE2eGame(project); + + runOk([ + 'init', + project, + '--mode', + 'cli', + '--yes', + '--allow-insecure-connection', + ]); + + const config = readFileSync(join(project, 'feather.config.lua'), 'utf8'); + assert.match(config, /include\s*=\s*\{\s*"particle-system-playground",\s*"shader-graph"\s*\}/); + assert.match(config, /capabilities\s*=\s*\{\s*"draw",\s*"filesystem"\s*\}/); + assert.match(config, /debug\s*=\s*true/); + assert.match(config, /autoRegisterErrorHandler\s*=\s*true/); + assert.doesNotMatch(config, /^\s*hotReload\s*=/m); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: cli mode hot reload writes debugger allowlist config', () => { + const workspace = makeTmp(); + const project = join(workspace, 'cli-hot-reload-game'); + + try { + writeE2eGame(project); + + runOk([ + 'init', + project, + '--mode', + 'cli', + '--plugins', + 'hot-reload', + '--hot-reload-allow', + 'game.player,game.systems.combat', + '--yes', + '--allow-insecure-connection', + ]); + + const config = readFileSync(join(project, 'feather.config.lua'), 'utf8'); + assert.match(config, /include\s*=\s*\{\s*"hot-reload"\s*\}/); + assert.match(config, /debug\s*=\s*true/); + assert.match(config, /autoRegisterErrorHandler\s*=\s*true/); + assert.match(config, /debugger\s*=\s*\{/); + assert.match(config, /enabled\s*=\s*true/); + assert.match(config, /hotReload\s*=\s*\{/); + assert.match(config, /allow\s*=\s*\{\s*"game\.player",\s*"game\.systems\.combat"\s*\}/); + assert.match(config, /deny\s*=\s*\{\s*"main",\s*"conf",\s*"feather\.\*"\s*\}/); + assert.match(config, /persistToDisk\s*=\s*false/); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: cli mode writes session name and app id from flags', () => { + const workspace = makeTmp(); + const project = join(workspace, 'cli-custom-config-game'); + + try { + writeE2eGame(project); + + runOk([ + 'init', + project, + '--mode', + 'cli', + '--session-name', + 'Custom Session', + '--app-id', + 'feather-app-test', + '--no-plugins', + '--yes', + ]); + + const config = readFileSync(join(project, 'feather.config.lua'), 'utf8'); + assert.match(config, /sessionName\s*=\s*"Custom Session"/); + assert.match(config, /appId\s*=\s*"feather-app-test"/); + assert.doesNotMatch(config, /^\s*__DANGEROUS_INSECURE_CONNECTION__\s*=\s*true/m); + assert.doesNotMatch(config, /^\s*include\s*=/m); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + test('init --yes without --allow-insecure-connection: config omits __DANGEROUS_INSECURE_CONNECTION__ and doctor fails on missing appId', () => { const workspace = makeTmp(); const project = join(workspace, 'secure-game'); diff --git a/docs/configuration.md b/docs/configuration.md index 673f707a..f938936a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -40,6 +40,31 @@ > [!WARNING] > `captureScreenshot` can affect performance because it captures the current frame when errors are handled. Enable it only when you need visual error context. +## Generated CLI Config + +`feather init` defaults to CLI-managed mode. In that mode Feather creates `feather.config.lua` without patching game code, and `feather run` injects the runtime when you launch the game. + +The generated CLI config includes: + +```lua +return { + managed = "cli", + debug = true, + autoRegisterErrorHandler = true, + include = { "particle-system-playground", "shader-graph" }, + capabilities = { "draw", "filesystem" }, +} +``` + +Those default plugins are normal bundled plugins, not special cases. Other plugins are enabled by adding their IDs to `include`, either manually or with the CLI: + +```bash +feather config plugins --include profiler,input-replay +feather config plugins --exclude shader-graph +``` + +Plugins marked `optIn = true` or `disabled = true` in their `manifest.lua` only become active when included. This is how development-only tools such as Console and Hot Reload stay off unless you ask for them. + ## Hot Reload Hot reload is configured under `debugger.hotReload`, but the command handler only exists when the opt-in `hot-reload` plugin is installed and included: @@ -62,6 +87,13 @@ return { } ``` +The CLI can write the same shape for you: + +```bash +feather init path/to/my-game --plugins hot-reload --hot-reload-allow game.player,game.systems.combat --yes +feather config hot-reload --allow game.player,game.systems.combat --dir path/to/my-game +``` + See [Hot Reload](hot-reload.md) for the full workflow. > [!WARNING] diff --git a/docs/index.md b/docs/index.md index 172253fe..246d725a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,6 +46,13 @@ feather run path/to/my-game Feather is injected by the CLI for dev runs and debug builds, so your game code does not need a manual `require` for any target. +By default, CLI init enables error capture and includes the creative plugins `particle-system-playground` and `shader-graph`. Other plugins are controlled through `feather.config.lua`: + +```bash +feather config plugins --include profiler,input-replay --dir path/to/my-game +feather config hot-reload --allow game.player --dir path/to/my-game +``` + > [!CAUTION] > `feather run` is for development. Do not publish builds created from a run session; create user-facing builds with `feather build --release` so Feather debugging tools are not included. diff --git a/docs/installation.md b/docs/installation.md index 7cca95c2..61b8a26e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -15,6 +15,18 @@ A new session tab appears in the Feather desktop app automatically. No `require` > [!NOTE] > Use plain `feather run` for local desktop iteration where the CLI launches LÖVE directly. Use `feather run --target web|android|ios` when you want the CLI to build, serve, install, or launch a configured platform target. +`feather init` defaults to CLI-managed mode. It creates `feather.config.lua` with development defaults, enables error capture, and includes `particle-system-playground` plus `shader-graph`. Other plugins can be enabled later: + +```bash +feather config plugins --include profiler,input-replay --dir path/to/my-game +``` + +Hot Reload is development-only remote code execution, so it uses a separate allowlist command: + +```bash +feather config hot-reload --allow game.player,game.systems.combat --dir path/to/my-game +``` + ### Vendors and Platform Runs Add local LÖVE runtimes/templates once per project, then run or build those targets: @@ -85,6 +97,8 @@ curl -sSf https://raw.githubusercontent.com/Kyonru/feather/main/scripts/install- This creates a `feather/` directory (core library) and a `plugins/` directory (all built-in plugins) in your current folder. +The script installs normal built-in plugins by default, including `particle-system-playground` and `shader-graph`. It skips Console, Hot Reload, HUMP Signal, and Lua State Machine unless you opt in or override `FEATHER_SKIP_PLUGINS`. + **Customize with environment variables:** ```bash diff --git a/docs/usage.md b/docs/usage.md index eb825220..eda350d8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -17,6 +17,13 @@ The direct Lua API is still available for unusual projects that intentionally ve > [!WARNING] > Manual setup can leave Feather code, remote debugging hooks, or powerful plugins such as Console in places you did not intend to ship. Use it only if you understand the security consequences of accidental or unintended use. Prefer the CLI-managed workflow for normal development and releases. +CLI init creates a `feather.config.lua` with `debug = true`, automatic error capture enabled, and the default creative plugins `particle-system-playground` and `shader-graph` included. Add or remove plugins with: + +```bash +feather config plugins --include profiler,input-replay --dir path/to/my-game +feather config plugins --exclude shader-graph --dir path/to/my-game +``` + ```lua local FeatherDebugger = require "feather" local FeatherPluginManager = require "feather.plugin_manager" diff --git a/package.json b/package.json index c15ff632..630f34f9 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,9 @@ "test:lua:e2e": "node scripts/lua-e2e.mjs", "test:app:e2e": "playwright test", "test:tauri:e2e": "cargo test --manifest-path src-tauri/Cargo.toml ws_server", - "extension:prepare": "node vscode-extension/scripts/prepare.mjs", + "extension:prepare": "npm run cli:build && npm run bundle:lua --workspace=cli && node vscode-extension/scripts/prepare.mjs", "extension:build": "npm run extension:prepare && tsc -p vscode-extension/tsconfig.json", - "extension:binary": "npm run cli:build && npm run build:binary --workspace=cli", + "extension:binary": "npm run cli:build && npm run bundle:lua --workspace=cli && npm run build:binary --workspace=cli", "extension:test": "npm run extension:build && npm run test --workspace=vscode-extension", "extension:test:integration": "npm run extension:build && npm run test:integration --workspace=vscode-extension", "extension:package": "npm run extension:binary && npm run extension:build && cd vscode-extension && npx @vscode/vsce package", diff --git a/src-lua/feather/core/logger.lua b/src-lua/feather/core/logger.lua index 5da76213..1203a4be 100644 --- a/src-lua/feather/core/logger.lua +++ b/src-lua/feather/core/logger.lua @@ -140,7 +140,7 @@ function FeatherLogger:__countOnRepeat(type, ...) end end ----@alias LogType "output" | "trace" | "error" | "feather:finish" | "feather:start" | "fatal" +---@alias LogType "output" | "trace" | "warn" | "error" | "feather:finish" | "feather:start" | "fatal" ---@class FeatherLine ---@field type LogType ---@field str? string diff --git a/src-lua/feather/plugin_manager.lua b/src-lua/feather/plugin_manager.lua index 7a3da4a3..56d1cfb0 100644 --- a/src-lua/feather/plugin_manager.lua +++ b/src-lua/feather/plugin_manager.lua @@ -103,6 +103,14 @@ local function describeApiCompatibility(compatibility, currentApi) return "Requires a different Feather plugin API. Desktop API is " .. tostring(currentApi) .. "." end +local function isCallable(value) + if type(value) == "function" then + return true + end + local meta = type(value) == "table" and getmetatable(value) or nil + return type(meta) == "table" and type(meta.__call) == "function" +end + ---@param feather Feather ---@param logger FeatherLogger ---@param observer FeatherObserver @@ -147,7 +155,18 @@ function FeatherPluginManager:init(feather, logger, observer) callbackDisposers = {}, } - if not isApiCompatible(compatibility, feather.version) then + if not isCallable(plugin.plugin) then + pluginRecord.disabled = true + table.insert(self.plugins, pluginRecord) + self.logger:log({ + type = "warn", + str = "Plugin <" + .. tostring(plugin.identifier) + .. "> is disabled: expected a callable plugin module, got " + .. type(plugin.plugin) + .. ".", + }) + elseif not isApiCompatible(compatibility, feather.version) then local message = describeApiCompatibility(compatibility, feather.version) pluginRecord.disabled = true pluginRecord.incompatible = true diff --git a/src-lua/plugins/README.md b/src-lua/plugins/README.md index 571cc40b..2d75f2fe 100644 --- a/src-lua/plugins/README.md +++ b/src-lua/plugins/README.md @@ -116,6 +116,23 @@ return { | `optIn` | `boolean` | If `true`, the plugin is not registered at all unless its ID appears in `config.include`. | | `disabled` | `boolean` | If `true`, the plugin registers and appears in the UI but starts inactive. Users can enable it from the desktop, or via `config.include`. | +### Loading and enabling plugins + +`feather.auto` scans the bundled or installed plugin directory and reads each `manifest.lua`. + +- Plugins with `optIn = false` and `disabled = false` load automatically unless their ID is in `config.exclude`. +- Plugins with `optIn = true` are skipped unless their ID is in `config.include`. +- Plugins with `disabled = true` are registered but inactive unless their ID is in `config.include`. + +CLI-managed projects use `feather.config.lua` as the source of truth for plugin selection. Use the CLI helpers to edit that list and keep capabilities in sync: + +```bash +feather config plugins --include profiler,input-replay +feather config plugins --exclude shader-graph +``` + +`feather init` includes `particle-system-playground` and `shader-graph` by default in CLI mode. Development-only plugins such as `console` and `hot-reload` remain explicit opt-ins. + ### Love-event hooks Instead of patching `love.*` callbacks inside `init()`, use Feather's callback bus or override the corresponding `on*` method. `FeatherPluginManager` patches each love callback once and dispatches through a shared bus — this prevents conflicts when multiple plugins hook the same callback. diff --git a/src-lua/plugins/hot-reload/README.md b/src-lua/plugins/hot-reload/README.md index c3102714..9ad6f65b 100644 --- a/src-lua/plugins/hot-reload/README.md +++ b/src-lua/plugins/hot-reload/README.md @@ -21,7 +21,21 @@ Only enable hot reload when all of this is true: ## Enable It -Configure it from `feather.config.lua`: +For CLI-managed projects, let Feather write the allowlist config: + +```bash +feather init path/to/my-game --plugins hot-reload --hot-reload-allow game.player,game.enemy --yes +``` + +For an already initialized project: + +```bash +feather config hot-reload --allow game.player,game.enemy --dir path/to/my-game +``` + +The VS Code extension uses the same CLI path. When you select the `hot-reload` plugin during init or plugin install, it prompts for Lua files and converts project paths like `game/player.lua` into module names like `game.player`. + +Or configure it manually from `feather.config.lua`: ```lua return { diff --git a/vscode-extension/scripts/prepare.mjs b/vscode-extension/scripts/prepare.mjs index 5b3a1629..2788ce35 100644 --- a/vscode-extension/scripts/prepare.mjs +++ b/vscode-extension/scripts/prepare.mjs @@ -1,4 +1,4 @@ -import { cpSync, existsSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -8,33 +8,46 @@ const repoRoot = join(extensionRoot, '..'); const cliBin = join(repoRoot, 'cli', 'bin'); const cliLua = join(repoRoot, 'cli', 'lua'); +const sourceLua = join(repoRoot, 'src-lua'); const cliGenerated = join(repoRoot, 'cli', 'dist', 'generated'); const bundledBin = join(extensionRoot, 'bundled-bin'); -if (!existsSync(cliBin)) { - throw new Error('Missing cli/bin/. Run `npm run build:binary --workspace=cli` first.'); -} - -if (!existsSync(join(cliLua, 'feather', 'init.lua'))) { - throw new Error('Missing cli/lua/feather/init.lua. Run `npm run bundle:lua --workspace=cli` first.'); +const luaSource = existsSync(join(cliLua, 'feather', 'init.lua')) ? cliLua : sourceLua; +if (!existsSync(join(luaSource, 'feather', 'init.lua'))) { + throw new Error('Missing Lua runtime. Run `npm run bundle:lua --workspace=cli` first.'); } rmSync(bundledBin, { recursive: true, force: true }); - -// Copy platform binaries -cpSync(cliBin, bundledBin, { recursive: true }); +mkdirSync(bundledBin, { recursive: true }); + +let bundleMode; +if (existsSync(cliBin)) { + // Copy platform binaries for packaged extension builds. + cpSync(cliBin, bundledBin, { recursive: true }); + bundleMode = 'platform binaries'; +} else { + // Local extension development does not require Bun-compiled binaries. The + // extension detects this file and launches the compiled CLI with Node. + writeFileSync( + join(bundledBin, 'feather-dev.mjs'), + `#!/usr/bin/env node +import { runCli } from '../../cli/dist/index.js'; + +process.exitCode = await runCli(); +`, + ); + bundleMode = 'dev CLI launcher'; +} // Copy Lua runtime next to the binaries (bundledLuaRoot() checks process.execPath + '/lua') -cpSync(cliLua, join(bundledBin, 'lua'), { recursive: true }); +cpSync(luaSource, join(bundledBin, 'lua'), { recursive: true }); // Copy registry.json for the packages catalog cpSync(join(cliGenerated, 'registry.json'), join(bundledBin, 'registry.json')); // Generate plugin-catalog.json from the ESM plugin-catalog.js const { pluginCatalog } = await import(join(cliGenerated, 'plugin-catalog.js')); -writeFileSync( - join(bundledBin, 'plugin-catalog.json'), - JSON.stringify(pluginCatalog, null, 2) + '\n', -); +writeFileSync(join(bundledBin, 'plugin-catalog.json'), JSON.stringify(pluginCatalog, null, 2) + '\n'); -console.log('Prepared bundled-bin/ with platform binaries, lua/, registry.json, and plugin-catalog.json'); +// eslint-disable-next-line no-undef +console.log(`Prepared bundled-bin/ with ${bundleMode}, lua/, registry.json, and plugin-catalog.json`); diff --git a/vscode-extension/src/cli.ts b/vscode-extension/src/cli.ts index 5ad69167..39fa22dc 100644 --- a/vscode-extension/src/cli.ts +++ b/vscode-extension/src/cli.ts @@ -1,17 +1,28 @@ import * as vscode from 'vscode'; import { spawn, type ChildProcess } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { join } from 'node:path'; import * as os from 'node:os'; import { shellQuote } from './command'; -function getBinaryPath(context: vscode.ExtensionContext): string { +interface CliInvocation { + command: string; + argsPrefix: string[]; +} + +function getCliInvocation(context: vscode.ExtensionContext): CliInvocation { + const devLauncher = join(context.extensionPath, 'bundled-bin', 'feather-dev.mjs'); + if (existsSync(devLauncher)) { + return { command: 'node', argsPrefix: [devLauncher] }; + } + const p = os.platform(); const a = os.arch(); const name = p === 'darwin' && a === 'arm64' ? 'feather' : p === 'darwin' ? 'feather-darwin-x64' : p === 'win32' ? 'feather-win-x64.exe' : 'feather-linux-x64'; - return join(context.extensionPath, 'bundled-bin', name); + return { command: join(context.extensionPath, 'bundled-bin', name), argsPrefix: [] }; } export function runInTerminal( @@ -29,12 +40,12 @@ export function runCommandsInTerminal( commands: string[][], cwd: string, ): vscode.Terminal { - const bin = getBinaryPath(context); + const cli = getCliInvocation(context); const existing = vscode.window.terminals.find((t) => t.name === name); existing?.dispose(); const terminal = vscode.window.createTerminal({ name, cwd }); for (const args of commands) { - terminal.sendText([bin, ...args].map(shellQuote).join(' ')); + terminal.sendText([cli.command, ...cli.argsPrefix, ...args].map(shellQuote).join(' ')); } terminal.show(); return terminal; @@ -45,5 +56,6 @@ export function spawnFeather( args: string[], cwd: string, ): ChildProcess { - return spawn(getBinaryPath(context), args, { cwd, shell: false }); + const cli = getCliInvocation(context); + return spawn(cli.command, [...cli.argsPrefix, ...args], { cwd, shell: false }); } diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index bd37c5de..06982586 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, relative } from 'node:path'; import { loadPackageCatalog, loadPluginCatalog, readInstalledPackageIds, type PackageEntry, type PluginEntry } from './catalog'; import { runCommandsInTerminal, runInTerminal } from './cli'; import { FeatherProjectProvider } from './featherPanel'; @@ -11,6 +11,17 @@ import { ALL_RUN_TARGETS, vendorPresent, vendorLabel, vendorArg, targetIcon, typ type Pick = vscode.QuickPickItem & { value: T }; type PluginPick = vscode.QuickPickItem & { plugin: PluginEntry }; type PackagePick = vscode.QuickPickItem & { pkg: PackageEntry }; +type InitModeValue = 'cli' | 'auto' | 'manual'; +type InitSecurityMode = 'appId' | 'insecure' | 'unset'; +type InitSourceMode = 'bundled' | 'remote' | 'local'; +type InitPluginMode = 'default' | 'select' | 'none'; + +interface InitCommandPlan { + args: string[]; +} + +const DEFAULT_INIT_PLUGIN_IDS = new Set(['particle-system-playground', 'shader-graph']); +const DEFAULT_INIT_PLUGIN_ID_LIST = [...DEFAULT_INIT_PLUGIN_IDS]; function workspaceRoots(): string[] { return vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath) ?? []; @@ -52,12 +63,33 @@ function savedTargets(): RunTarget[] { return (featherConfig().get('runTargets') ?? []) as RunTarget[]; } -function pluginPick(plugin: PluginEntry): PluginPick { +function refreshProjectUi(provider: FeatherProjectProvider, updateStatus: () => void): void { + updateStatus(); + provider.refresh(); +} + +function registerRefreshWatcher( + context: vscode.ExtensionContext, + pattern: string, + provider: FeatherProjectProvider, + updateStatus: () => void, +): void { + const watcher = vscode.workspace.createFileSystemWatcher(pattern); + context.subscriptions.push( + watcher, + watcher.onDidCreate(() => refreshProjectUi(provider, updateStatus)), + watcher.onDidChange(() => refreshProjectUi(provider, updateStatus)), + watcher.onDidDelete(() => refreshProjectUi(provider, updateStatus)), + ); +} + +function pluginPick(plugin: PluginEntry, picked = false): PluginPick { const caps = plugin.capabilities.length > 0 ? ` · ${plugin.capabilities.join(', ')}` : ''; return { label: plugin.name, description: plugin.id, detail: `${plugin.description}${caps}`, + picked, plugin, }; } @@ -71,9 +103,13 @@ function packagePick(pkg: PackageEntry): PackagePick { }; } -async function pickPlugins(context: vscode.ExtensionContext, placeHolder: string): Promise { +async function pickPlugins( + context: vscode.ExtensionContext, + placeHolder: string, + defaultPickedIds: ReadonlySet = new Set(), +): Promise { const catalog = await loadPluginCatalog(context); - const picked = await vscode.window.showQuickPick(catalog.map(pluginPick), { + const picked = await vscode.window.showQuickPick(catalog.map((plugin) => pluginPick(plugin, defaultPickedIds.has(plugin.id))), { canPickMany: true, matchOnDescription: true, matchOnDetail: true, @@ -82,6 +118,169 @@ async function pickPlugins(context: vscode.ExtensionContext, placeHolder: string return picked?.map((item) => item.plugin); } +function hotReloadModuleName(root: string, filePath: string): string | undefined { + const rel = relative(root, filePath).replace(/\\/g, '/'); + if (!rel || rel.startsWith('../') || rel === '..' || rel.startsWith('/')) return undefined; + if (!rel.endsWith('.lua')) return undefined; + const withoutExt = rel.slice(0, -'.lua'.length); + const modulePath = withoutExt.endsWith('/init') ? withoutExt.slice(0, -'/init'.length) : withoutExt; + return modulePath.split('/').filter(Boolean).join('.'); +} + +async function pickHotReloadAllowlist(root: string): Promise { + const uris = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: true, + defaultUri: vscode.Uri.file(root), + filters: { Lua: ['lua'] }, + openLabel: 'Allow hot reload', + title: 'Feather: Select Lua files hot reload may update', + }); + if (!uris) return undefined; + + const modules = [...new Set(uris.map((uri) => hotReloadModuleName(root, uri.fsPath)).filter((id): id is string => Boolean(id)))]; + if (modules.length === 0) { + vscode.window.showWarningMessage('Select Lua files inside the project folder for hot reload.'); + return undefined; + } + return modules; +} + +function nonEmptyInput(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +async function pickInitMode(): Promise { + const mode = await vscode.window.showQuickPick>( + [ + { label: 'CLI mode', description: 'No game-code changes. Use Feather: Run Project.', value: 'cli' }, + { label: 'Auto mode', description: 'Patch main.lua with guarded feather.auto loader.', value: 'auto' }, + { label: 'Manual mode', description: 'Create feather.debugger.lua and guarded loader.', value: 'manual' }, + ], + { placeHolder: 'Select init mode' }, + ); + return mode?.value; +} + +async function buildInitCommandPlan( + context: vscode.ExtensionContext, + root: string, + mode: InitModeValue, +): Promise { + const flow = await vscode.window.showQuickPick>( + [ + { label: 'Quick init', description: 'Default plugins, bundled runtime, local dev connection.', value: 'quick' }, + { label: 'Customize init', description: 'Choose session name, security, source, install dir, and plugins.', value: 'custom' }, + ], + { placeHolder: 'Choose init setup' }, + ); + if (!flow) return undefined; + + if (flow.value === 'quick') { + return { + args: [ + 'init', + root, + '--mode', + mode, + '--yes', + '--allow-insecure-connection', + '--plugins', + DEFAULT_INIT_PLUGIN_ID_LIST.join(','), + ], + }; + } + + const args = ['init', root, '--mode', mode, '--yes']; + + const sessionName = nonEmptyInput(await vscode.window.showInputBox({ + prompt: 'Session name shown in Feather (optional)', + value: '', + placeHolder: 'My Game', + })); + if (sessionName) args.push('--session-name', sessionName); + + const security = await vscode.window.showQuickPick>( + [ + { label: 'Desktop App ID', description: 'Restrict commands to your Feather desktop app.', value: 'appId' }, + { label: 'Insecure local dev', description: 'Allow any desktop connection. Use only for trusted development.', value: 'insecure' }, + { label: 'Leave unset', description: 'Doctor will warn until appId or insecure mode is configured.', value: 'unset' }, + ], + { placeHolder: 'Select connection security' }, + ); + if (!security) return undefined; + if (security.value === 'appId') { + const appId = nonEmptyInput(await vscode.window.showInputBox({ + prompt: 'Desktop App ID from Feather Settings > Security', + placeHolder: 'feather-app-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + validateInput: (value) => value.trim() ? undefined : 'App ID cannot be empty', + })); + if (!appId) return undefined; + args.push('--app-id', appId); + } else if (security.value === 'insecure') { + args.push('--allow-insecure-connection'); + } + + const source = await vscode.window.showQuickPick>( + [ + { label: 'Bundled runtime', description: 'Use the runtime packaged with this extension/CLI.', value: 'bundled' }, + { label: 'GitHub branch or tag', description: 'Download runtime files from GitHub.', value: 'remote' }, + { label: 'Local src-lua folder', description: 'Copy runtime files from a local source checkout.', value: 'local' }, + ], + { placeHolder: 'Select runtime source' }, + ); + if (!source) return undefined; + if (source.value === 'remote') { + const branch = nonEmptyInput(await vscode.window.showInputBox({ + prompt: 'GitHub branch or tag', + value: 'main', + validateInput: (value) => value.trim() ? undefined : 'Branch cannot be empty', + })); + if (!branch) return undefined; + args.push('--remote', '--branch', branch); + } else if (source.value === 'local') { + const uris = await vscode.window.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + defaultUri: vscode.Uri.file(root), + openLabel: 'Use src-lua', + title: 'Feather: Select local src-lua folder', + }); + const localSrc = uris?.[0]?.fsPath; + if (!localSrc) return undefined; + args.push('--local-src', localSrc); + } + + const pluginMode = await vscode.window.showQuickPick>( + [ + { label: 'Default creative plugins', description: DEFAULT_INIT_PLUGIN_ID_LIST.join(', '), value: 'default' }, + { label: 'Select plugins', description: 'Choose exactly which plugins init should include.', value: 'select' }, + { label: 'No plugins', description: 'Create Feather config/runtime only.', value: 'none' }, + ], + { placeHolder: 'Select plugin setup' }, + ); + if (!pluginMode) return undefined; + + let pluginIds = pluginMode.value === 'default' ? DEFAULT_INIT_PLUGIN_ID_LIST : []; + if (pluginMode.value === 'none') { + args.push('--no-plugins'); + } else if (pluginMode.value === 'select') { + const plugins = await pickPlugins(context, 'Select plugins to install now', DEFAULT_INIT_PLUGIN_IDS); + if (!plugins) return undefined; + pluginIds = plugins.map((plugin) => plugin.id); + } + + const hotReloadAllow = pluginIds.includes('hot-reload') ? await pickHotReloadAllowlist(root) : undefined; + if (pluginIds.includes('hot-reload') && !hotReloadAllow) return undefined; + if (pluginIds.length > 0) args.push('--plugins', pluginIds.join(',')); + if (hotReloadAllow && hotReloadAllow.length > 0) args.push('--hot-reload-allow', hotReloadAllow.join(',')); + + return { args }; +} + async function pickTargets(currentTargets: RunTarget[], watchMode: boolean): Promise { const available = watchMode ? ALL_RUN_TARGETS.filter((t) => t !== 'web') @@ -313,24 +512,15 @@ async function registerCommands( const root = requireProjectDir(); if (!root) return; - const mode = await vscode.window.showQuickPick>( - [ - { label: 'CLI mode', description: 'No game-code changes. Use Feather: Run Project.', value: 'cli' }, - { label: 'Auto mode', description: 'Patch main.lua with guarded feather.auto loader.', value: 'auto' }, - { label: 'Manual mode', description: 'Create feather.debugger.lua and guarded loader.', value: 'manual' }, - ], - { placeHolder: 'Select init mode' }, - ); + const mode = await pickInitMode(); if (!mode) return; - const plugins = await pickPlugins(context, 'Select plugins to install now'); - if (!plugins) return; + const plan = await buildInitCommandPlan(context, root, mode); + if (!plan) return; - const args = ['init', root, '--mode', mode.value, '--yes', '--allow-insecure-connection']; - if (plugins.length > 0) args.push('--plugins', plugins.map((plugin) => plugin.id).join(',')); - runInTerminal(context, 'Feather: Init', args, root); - provider.refresh(); - setTimeout(() => provider.refresh(), 1500); + runInTerminal(context, 'Feather: Init', plan.args, root); + refreshProjectUi(provider, updateStatus); + setTimeout(() => refreshProjectUi(provider, updateStatus), 1500); }), ); @@ -362,11 +552,18 @@ async function registerCommands( const plugins = await pickPlugins(context, `Select plugins to ${action.value}`); if (!plugins || plugins.length === 0) return; const ids = plugins.map((plugin) => plugin.id); + const hotReloadAllow = action.value === 'install' && ids.includes('hot-reload') + ? await pickHotReloadAllowlist(root) + : undefined; + if (action.value === 'install' && ids.includes('hot-reload') && !hotReloadAllow) return; const commands = action.value === 'install' ? [ ['plugin', 'install', ...ids, '--dir', root], ['config', 'plugins', '--dir', root, '--include', ids.join(',')], + ...(hotReloadAllow && hotReloadAllow.length > 0 + ? [['config', 'hot-reload', '--dir', root, '--allow', hotReloadAllow.join(',')]] + : []), ] : action.value === 'remove' ? [ @@ -376,8 +573,8 @@ async function registerCommands( : ids.map((id) => ['plugin', 'update', id, '--dir', root, '--yes']); runCommandsInTerminal(context, `Feather: Plugins ${action.value}`, commands, root); - provider.refresh(); - setTimeout(() => provider.refresh(), 1500); + refreshProjectUi(provider, updateStatus); + setTimeout(() => refreshProjectUi(provider, updateStatus), 1500); }), ); @@ -539,8 +736,8 @@ async function registerCommands( const confirmed = await vscode.window.showWarningMessage('Remove Feather from this project?', { modal: true }, 'Remove'); if (confirmed !== 'Remove') return; runInTerminal(context, 'Feather: Remove', ['remove', root, '--yes'], root); - provider.refresh(); - setTimeout(() => provider.refresh(), 1500); + refreshProjectUi(provider, updateStatus); + setTimeout(() => refreshProjectUi(provider, updateStatus), 1500); }), ); @@ -550,8 +747,8 @@ async function registerCommands( const root = requireProjectDir(); if (!root) return; runInTerminal(context, 'Feather: Update', ['update', root, '--yes'], root); - provider.refresh(); - setTimeout(() => provider.refresh(), 1500); + refreshProjectUi(provider, updateStatus); + setTimeout(() => refreshProjectUi(provider, updateStatus), 1500); }), ); @@ -591,12 +788,15 @@ export async function activate(context: vscode.ExtensionContext): Promise }; updateStatus(); statusItem.show(); + registerRefreshWatcher(context, '**/feather.config.lua', provider, updateStatus); + registerRefreshWatcher(context, '**/.featherrc.lua', provider, updateStatus); + registerRefreshWatcher(context, '**/feather.lock.json', provider, updateStatus); + registerRefreshWatcher(context, '**/feather/plugins/**', provider, updateStatus); context.subscriptions.push( statusItem, vscode.workspace.onDidChangeWorkspaceFolders(() => { - updateStatus(); - provider.refresh(); + refreshProjectUi(provider, updateStatus); }), vscode.workspace.onDidChangeConfiguration((event) => { if ( @@ -604,8 +804,7 @@ export async function activate(context: vscode.ExtensionContext): Promise event.affectsConfiguration('feather.watchMode') || event.affectsConfiguration('feather.runTargets') ) { - updateStatus(); - provider.refresh(); + refreshProjectUi(provider, updateStatus); } }), ); diff --git a/vscode-extension/src/project.ts b/vscode-extension/src/project.ts index ad857a1c..3d6b6ac6 100644 --- a/vscode-extension/src/project.ts +++ b/vscode-extension/src/project.ts @@ -42,6 +42,37 @@ function packageCount(root: string): number { } } +function configArrayValues(source: string, key: 'include' | 'exclude'): string[] { + const withoutLineComments = source + .split('\n') + .map((line) => line.replace(/--.*$/, '')) + .join('\n'); + const match = new RegExp(`\\b${key}\\s*=\\s*\\{([\\s\\S]*?)\\}`).exec(withoutLineComments); + if (!match) return []; + return [...match[1].matchAll(/["']([^"']+)["']/g)].map((item) => item[1]); +} + +function managedMode(source: string): string | undefined { + return /\bmanaged\s*=\s*["']([^"']+)["']/.exec(source)?.[1]; +} + +function cliManagedPluginCount(root: string, hasRuntime: boolean): number | undefined { + const configPath = join(root, 'feather.config.lua'); + if (!existsSync(configPath)) return undefined; + + try { + const source = readFileSync(configPath, 'utf8'); + const mode = managedMode(source); + if (mode !== 'cli' && hasRuntime) return undefined; + + const excluded = new Set(configArrayValues(source, 'exclude')); + const included = new Set(configArrayValues(source, 'include').filter((id) => !excluded.has(id))); + return included.size; + } catch { + return undefined; + } +} + export function getProjectStatus(root: string | undefined): ProjectStatus { if (!root) { return { @@ -55,13 +86,15 @@ export function getProjectStatus(root: string | undefined): ProjectStatus { }; } + const hasRuntime = existsSync(join(root, 'feather', 'init.lua')); + return { root, hasWorkspace: true, hasMain: existsSync(join(root, 'main.lua')), hasConfig: existsSync(join(root, 'feather.config.lua')), - hasRuntime: existsSync(join(root, 'feather', 'init.lua')), - pluginCount: countManifestFiles(join(root, 'feather', 'plugins')), + hasRuntime, + pluginCount: cliManagedPluginCount(root, hasRuntime) ?? countManifestFiles(join(root, 'feather', 'plugins')), packageCount: packageCount(root), }; } diff --git a/vscode-extension/test/helpers.test.mjs b/vscode-extension/test/helpers.test.mjs index c564eb91..b8185cd5 100644 --- a/vscode-extension/test/helpers.test.mjs +++ b/vscode-extension/test/helpers.test.mjs @@ -49,3 +49,28 @@ test('project status reports config, runtime, plugins, and packages', () => { rmSync(root, { recursive: true, force: true }); } }); + +test('project status counts CLI-managed included plugins from config', () => { + const root = mkdtempSync(join(tmpdir(), 'feather-vscode-test-')); + try { + writeFileSync(join(root, 'main.lua'), ''); + writeFileSync( + join(root, 'feather.config.lua'), + `return { + managed = "cli", + include = { "console", "hot-reload", "profiler" }, + exclude = { "profiler" }, + + -- include = { "commented-out" }, +} +`, + ); + + const status = getProjectStatus(root); + assert.equal(status.hasConfig, true); + assert.equal(status.hasRuntime, false); + assert.equal(status.pluginCount, 2); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); From 9524c0cb3d298ee4b04280552deb452f65f7f3b7 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 20 May 2026 10:07:43 -0400 Subject: [PATCH 12/28] feather: improve upload process --- cli/README.md | 7 +- cli/src/commands/doctor/index.ts | 131 +++++++++++++++-------- cli/src/commands/upload.ts | 44 +++++++- cli/src/index.ts | 6 +- cli/src/lib/build/build.ts | 3 +- cli/src/lib/build/upload-safety.ts | 119 +++++++++++++++++--- cli/src/lib/build/upload.ts | 8 +- cli/test/commands/doctor.test.mjs | 66 ++++++++++++ cli/test/commands/upload-safety.test.mjs | 82 ++++++++++++++ cli/test/commands/upload.test.mjs | 104 ++++++++++++++++++ scripts/lua-e2e.mjs | 4 + src-lua/example/e2e/main.lua | 5 + src-lua/feather/init.lua | 26 ++++- src-lua/plugins/shader-graph/init.lua | 7 ++ vscode-extension/src/extension.ts | 57 ++++++++-- 15 files changed, 587 insertions(+), 82 deletions(-) create mode 100644 cli/test/commands/upload-safety.test.mjs diff --git a/cli/README.md b/cli/README.md index ea5f947b..9244220d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -384,7 +384,7 @@ Doctor checks: - installed plugin manifests - missing, unknown, malformed, or development-only plugins - package lockfile integrity, version drift, and source provenance -- build/upload dependencies when `--build-target` or `--upload-target` is provided +- build dependencies when `--build-target` is provided, plus upload readiness when an upload target is requested or `feather.build.json` is present - `USE_DEBUGGER` guards and `FEATHER-INIT` markers - risky settings such as hot reload, screenshot capture, and Console API keys - Feather desktop WebSocket reachability @@ -621,6 +621,7 @@ Upload a built artifact. V1 supports Itch through `butler`; Steam is registered ```bash feather upload itch web --dir path/to/my-game +feather upload itch android --dir path/to/my-game --build feather upload itch web --channel html5 --if-changed feather upload itch web --dry-run --json feather upload steam linux @@ -634,6 +635,8 @@ butler push : --userversion The Itch project and default channels come from `feather.build.json`. Use `--channel` or `--user-version` to override them in CI. +When `--build` is used, upload always performs a production-safe build first. Android and iOS upload builds force release mode, debugger embedding is disabled, `--allow-unsafe` and `--allow-feather-runtime` are rejected, and generated inspectable artifacts are checked for Feather runtime/debug files before upload. `--allow-feather-runtime` only applies when uploading an existing artifact you built yourself. + **Options:** | Option | Description | @@ -646,6 +649,8 @@ The Itch project and default channels come from `feather.build.json`. Use `--cha | `--dry-run` | Show the upload command without running it. | | `--if-changed` | Pass `--if-changed` to supported uploaders. | | `--hidden` | Pass `--hidden` to supported uploaders. | +| `--build` | Build a production-safe artifact before uploading. | +| `--allow-feather-runtime` | Override safety checks only for existing artifacts. | | `--json` | Print machine-readable output only. | Run `feather doctor --upload-target itch` to check for `butler`, Itch project config, and CI auth hints. Use `BUTLER_API_KEY` in CI or `butler login` locally. diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index 85c084fd..fa174251 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -168,27 +168,42 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) hasProjectDir ? undefined : 'Pass the game directory to `feather doctor `.', ); - if (hasProjectDir && (opts.buildTarget || opts.uploadTarget)) { + if (hasProjectDir) { try { const buildConfig = loadBuildConfig({ projectDir }); const configExists = existsSync(buildConfig.configPath); - add( - checks, - 'Build', - 'feather.build.json', - configExists ? 'pass' : 'warn', - configExists ? buildConfig.configPath : 'missing', - configExists ? undefined : 'Create feather.build.json to share local and CI build settings.', - ); - const writable = outDirWritableDetail(buildConfig.outDir); - add( - checks, - 'Build', - 'Build output directory', - writable.ok ? 'pass' : 'fail', - writable.detail, - writable.ok ? undefined : 'Choose a writable outDir in feather.build.json or pass --out-dir.', - ); + const discoveredUploadTargets: SupportedUploadDoctorTarget[] = []; + if (!opts.uploadTarget && buildConfig.upload.itch) discoveredUploadTargets.push('itch'); + if (!opts.uploadTarget && !buildConfig.upload.itch && configExists) discoveredUploadTargets.push('itch'); + const uploadTargetsToCheck: SupportedUploadDoctorTarget[] = opts.uploadTarget === 'itch' ? ['itch'] : discoveredUploadTargets; + const shouldCheckBuildConfig = Boolean(opts.buildTarget || opts.uploadTarget || buildConfig.upload.itch); + if (shouldCheckBuildConfig) { + add( + checks, + 'Build', + 'feather.build.json', + configExists ? 'pass' : 'warn', + configExists ? buildConfig.configPath : 'missing', + configExists ? undefined : 'Create feather.build.json to share local and CI build settings.', + ); + const writable = outDirWritableDetail(buildConfig.outDir); + add( + checks, + 'Build', + 'Build output directory', + writable.ok ? 'pass' : 'fail', + writable.detail, + writable.ok ? undefined : 'Choose a writable outDir in feather.build.json or pass --out-dir.', + ); + } else if (uploadTargetsToCheck.length > 0) { + add( + checks, + 'Build', + 'feather.build.json', + 'pass', + buildConfig.configPath, + ); + } if (opts.buildTarget && isDoctorBuildTarget(opts.buildTarget)) { const fromAll = opts.buildTarget === 'all'; @@ -200,33 +215,10 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) } } - if (opts.uploadTarget === 'itch') { - const itchProject = buildConfig.upload.itch?.project; - add( - checks, - 'Upload', - 'Itch project', - itchProject ? 'pass' : 'fail', - itchProject ?? 'missing', - 'Set upload.itch.project in feather.build.json, for example "user/game".', - ); - const butler = commandVersion('butler', ['--version']); - add( - checks, - 'Upload', - 'butler', - butler ? 'pass' : 'fail', - butler ? butler : 'not found', - butler ? undefined : 'Install butler from https://itch.io/docs/butler/ and make sure it is on PATH.', - ); - add( - checks, - 'Upload', - 'BUTLER_API_KEY', - process.env.BUTLER_API_KEY ? 'pass' : 'warn', - process.env.BUTLER_API_KEY ? 'configured' : 'missing', - process.env.BUTLER_API_KEY ? undefined : 'Set BUTLER_API_KEY in CI or run `butler login` locally.', - ); + for (const uploadTarget of uploadTargetsToCheck) { + addUploadTargetChecks(checks, buildConfig, uploadTarget, { + required: Boolean(opts.uploadTarget || buildConfig.upload[uploadTarget]), + }); } } catch (err) { add(checks, 'Build', 'feather.build.json', 'fail', (err as Error).message, 'Fix feather.build.json before building or uploading.'); @@ -342,6 +334,15 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) } const projectDirArg = shellArg(projectDir); const installDirArg = shellArg(effectiveInstallDir); + if (configOnlyMode && activeIncludedPluginIds.some((id) => knownPluginIds.has(id))) { + add( + checks, + 'Plugins', + 'CLI-managed plugins', + 'pass', + `${activeIncludedPluginIds.filter((id) => knownPluginIds.has(id)).length} included from bundled runtime`, + ); + } if (hasRuntime) { add( checks, @@ -414,6 +415,8 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) continue; } + if (configOnlyMode) continue; + if (!installedPlugins.has(id)) { missingIncludedPlugins.push(id); add( @@ -999,11 +1002,49 @@ function androidReleaseSigningSeverity(buildConfig: ReturnType, + target: SupportedUploadDoctorTarget, + options: { required?: boolean } = {}, +): void { + if (target !== 'itch') return; + const required = Boolean(options.required); + const itchProject = buildConfig.upload.itch?.project; + add( + checks, + 'Upload', + 'Itch project', + itchProject ? 'pass' : required ? 'fail' : 'info', + itchProject ?? 'missing', + 'Set upload.itch.project in feather.build.json, for example "user/game".', + ); + const butler = commandVersion('butler', ['--version']); + add( + checks, + 'Upload', + 'butler', + butler ? 'pass' : required ? 'fail' : 'info', + butler ? butler : 'not found', + butler ? undefined : 'Install butler from https://itch.io/docs/butler/ and make sure it is on PATH.', + ); + add( + checks, + 'Upload', + 'BUTLER_API_KEY', + process.env.BUTLER_API_KEY ? 'pass' : required ? 'warn' : 'info', + process.env.BUTLER_API_KEY ? 'configured' : 'missing', + process.env.BUTLER_API_KEY ? undefined : 'Set BUTLER_API_KEY in CI or run `butler login` locally.', + ); +} + async function addDesktopBuildChecks( checks: DoctorCheck[], projectDir: string, diff --git a/cli/src/commands/upload.ts b/cli/src/commands/upload.ts index 4d8ab08c..69d5c58e 100644 --- a/cli/src/commands/upload.ts +++ b/cli/src/commands/upload.ts @@ -13,8 +13,9 @@ import { import { buildTargets, isBuildTarget, isUploadTarget, uploadTargets, type BuildTarget, type UploadTarget } from '../lib/build/config.js'; import { runBuild } from '../lib/build/build.js'; import { runUpload } from '../lib/build/upload.js'; +import { inspectUploadArtifact, type UploadSafetyResult } from '../lib/build/upload-safety.js'; import { chooseUploadWorkflow, type UploadWorkflowResult } from '../ui/upload-workflow.js'; -import type { UploadSafetyResult } from '../lib/build/upload-safety.js'; +import type { BuildResult } from '../lib/build/build.js'; export type UploadCommandOptions = { dir?: string; @@ -63,6 +64,28 @@ function printSafetyWarning(safety: UploadSafetyResult, warning: string): void { printDanger('Review this upload before sharing it with players.'); } +function uploadBuildReleaseMode(target: string): boolean { + return target === 'android' || target === 'ios'; +} + +function assertUploadBuildArtifactsSafe(result: BuildResult): void { + if (!result.ok) return; + + const inspected = result.artifacts + .map((artifact) => inspectUploadArtifact(artifact.path)) + .filter((safety) => safety.status !== 'unknown'); + const unsafe = inspected.find((safety) => safety.status === 'unsafe'); + if (unsafe) { + throw new Error( + `Upload build blocked because Feather runtime/debugging files were detected in ${unsafe.artifact}.\n${unsafe.detectedFiles.join('\n')}`, + ); + } + + if (inspected.length === 0) { + throw new Error('Upload build blocked because no generated artifact could be inspected for Feather runtime/debugging files.'); + } +} + async function resolveUploadInput( targetValue: string | undefined, buildTarget: string | undefined, @@ -125,6 +148,12 @@ export async function uploadCommand(targetValue: string | undefined, buildTarget if (!buildTarget || !isBuildTarget(buildTarget)) { fail('A build target is required with --build. Example: feather upload itch web --build'); } + if (opts.allowUnsafe) { + fail('Upload builds always run production safety checks; remove --allow-unsafe.'); + } + if (opts.allowFeatherRuntime) { + fail('Upload builds must be Feather-free; remove --allow-feather-runtime.'); + } const verbose = Boolean(opts.verbose && !opts.json && !opts.dryRun); const buildSpinner = opts.json || opts.dryRun || verbose ? null : createSpinner(`Building ${buildTarget}…`).start(); const buildResult = runBuild({ @@ -134,9 +163,11 @@ export async function uploadCommand(targetValue: string | undefined, buildTarget outDir: opts.outDir, clean: opts.clean, dryRun: false, - allowUnsafe: opts.allowUnsafe, - release: opts.release, + allowUnsafe: false, + release: uploadBuildReleaseMode(buildTarget), noCache: opts.noCache, + debugger: false, + skipProductionConfigPreflight: true, verbose, log: verbose ? printMuted : undefined, }); @@ -144,6 +175,12 @@ export async function uploadCommand(targetValue: string | undefined, buildTarget buildSpinner?.fail(buildResult.error); fail(buildResult.error, { silent: Boolean(buildSpinner) }); } + try { + assertUploadBuildArtifactsSafe(buildResult); + } catch (err) { + buildSpinner?.fail((err as Error).message); + fail((err as Error).message, { silent: Boolean(buildSpinner) }); + } buildSpinner?.succeed(`Built ${buildTarget}`); } const spinner = opts.json || opts.dryRun ? null : createSpinner(`Uploading to ${target}…`).start(); @@ -160,6 +197,7 @@ export async function uploadCommand(targetValue: string | undefined, buildTarget ifChanged: opts.ifChanged, hidden: opts.hidden, allowFeatherRuntime: opts.allowFeatherRuntime || resolved.confirmedUnsafe, + trustedFeatherFreeBuild: Boolean(opts.build), }); if (!result.ok) { diff --git a/cli/src/index.ts b/cli/src/index.ts index 8a9f17db..d6440b84 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -389,12 +389,12 @@ export function createProgram(): Command { .option('-y, --yes', 'Skip upload confirmation in non-interactive mode') .option('--build', 'Build the selected target before uploading') .option('--out-dir ', 'Build output directory when used with --build') - .option('--release', 'Build signed/store-oriented mobile release artifacts when used with --build') - .option('--allow-unsafe', 'Allow production-unsafe Feather config during --build') + .option('--release', 'Deprecated for upload builds; mobile upload builds always use release mode') + .option('--allow-unsafe', 'Rejected with --build; upload builds always run production safety checks') .option('--clean', 'Remove the output directory before --build') .option('--no-cache', 'Disable Android/iOS dev native build cache during --build') .option('--verbose', 'Show build command output when used with --build') - .option('--allow-feather-runtime', 'Allow uploading artifacts that include or may include Feather runtime files') + .option('--allow-feather-runtime', 'Allow uploading existing artifacts that include or may include Feather runtime files') .action((target: string, buildTarget: string | undefined, opts) => runCliAction(() => uploadCommand(target, buildTarget, { diff --git a/cli/src/lib/build/build.ts b/cli/src/lib/build/build.ts index 2d6d5885..14c1b138 100644 --- a/cli/src/lib/build/build.ts +++ b/cli/src/lib/build/build.ts @@ -42,6 +42,7 @@ export type BuildOptions = LoadBuildConfigOptions & { noPlugins?: boolean; featherOverride?: string; pluginsOverride?: string; + skipProductionConfigPreflight?: boolean; verbose?: boolean; log?: NativeBuildLogger; }; @@ -118,7 +119,7 @@ export function runBuild(options: BuildOptions): BuildResult { assertReleaseTargetSupported(options.target, Boolean(options.release)); const config = loadBuildConfig(options); assertBuildConfigValidForTarget(config, options.target, Boolean(options.release)); - assertProductionBuildSafe(config, options.allowUnsafe); + assertProductionBuildSafe(config, options.allowUnsafe || options.skipProductionConfigPreflight); assertNoSymlinkEscape(config.projectDir, config.outDir, 'Build output directory'); if (options.dryRun) return planBuild(options); diff --git a/cli/src/lib/build/upload-safety.ts b/cli/src/lib/build/upload-safety.ts index 93865b11..51e35a34 100644 --- a/cli/src/lib/build/upload-safety.ts +++ b/cli/src/lib/build/upload-safety.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { extname, join, relative } from 'node:path'; +import { inflateRawSync } from 'node:zlib'; export type UploadSafetyStatus = 'safe' | 'unsafe' | 'unknown'; @@ -11,17 +12,33 @@ export type UploadSafetyResult = { }; const FEATHER_PATTERNS = [ - /^feather(?:\/|$)/, - /^plugins(?:\/|$)/, - /^\.feather-main\.lua$/, - /^feather\.config\.lua$/, - /^feather\.debugger\.lua$/, - /^feather-build-manifest\.json$/, + /(?:^|\/)feather(?:\/|$)/, + /(?:^|\/)plugins(?:\/|$)/, + /(?:^|\/)\.feather-main\.lua$/, + /(?:^|\/)feather\.config\.lua$/, + /(?:^|\/)feather\.debugger\.lua$/, + /(?:^|\/)feather-build-manifest\.json$/, ]; +const ZIP_LIKE_EXTENSIONS = new Set(['.aab', '.apk', '.ipa', '.love', '.zip']); +const MAX_NESTED_ARCHIVE_DEPTH = 6; + +type ZipEntry = { + name: string; + compressedSize: number; + compressionMethod: number; + data?: Buffer; +}; function isFeatherPath(path: string): boolean { const normalized = path.replace(/\\/g, '/').replace(/^\/+/, ''); - return FEATHER_PATTERNS.some((pattern) => pattern.test(normalized)); + return normalized.split('!').some((segment) => { + const archivePath = segment.replace(/^\/+/, ''); + return FEATHER_PATTERNS.some((pattern) => pattern.test(archivePath)); + }); +} + +function isZipLikePath(path: string): boolean { + return ZIP_LIKE_EXTENSIONS.has(extname(path).toLowerCase()); } function inspectNames(artifact: string, names: string[]): UploadSafetyResult { @@ -33,7 +50,7 @@ function inspectNames(artifact: string, names: string[]): UploadSafetyResult { }; } -function listDirectoryNames(root: string): string[] { +function listInspectableDirectoryNames(root: string): string[] { const names: string[] = []; const visit = (dir: string) => { for (const entry of readdirSync(dir, { withFileTypes: true })) { @@ -41,18 +58,43 @@ function listDirectoryNames(root: string): string[] { const rel = relative(root, abs).replace(/\\/g, '/'); names.push(entry.isDirectory() ? `${rel}/` : rel); if (entry.isDirectory()) visit(abs); + else if (isZipLikePath(entry.name)) { + names.push(...zipEntryNames(readFileSync(abs), rel, 1)); + } } }; visit(root); return names; } -function zipEntryNames(zip: Buffer): string[] { +function zipEntryNames(zip: Buffer, prefix = '', depth = 0): string[] { + if (depth > MAX_NESTED_ARCHIVE_DEPTH) { + throw new Error(`nested archive depth exceeded ${MAX_NESTED_ARCHIVE_DEPTH}`); + } const names: string[] = []; + for (const entry of zipEntries(zip)) { + const archiveName = prefix ? `${prefix}!${entry.name}` : entry.name; + names.push(archiveName); + if (!entry.name.endsWith('/') && isZipLikePath(entry.name)) { + if (!entry.data) { + throw new Error( + `could not inspect nested archive ${archiveName}: unsupported compression method ${entry.compressionMethod}`, + ); + } + names.push(...zipEntryNames(entry.data, archiveName, depth + 1)); + } + } + return names; +} + +function zipEntries(zip: Buffer): ZipEntry[] { + const entries: ZipEntry[] = []; let offset = centralDirectoryOffset(zip); while (offset + 46 <= zip.length) { const signature = zip.readUInt32LE(offset); if (signature !== 0x02014b50) break; + const flags = zip.readUInt16LE(offset + 8); + const compressionMethod = zip.readUInt16LE(offset + 10); const compressedSize = zip.readUInt32LE(offset + 20); const uncompressedSize = zip.readUInt32LE(offset + 24); const nameLength = zip.readUInt16LE(offset + 28); @@ -63,10 +105,52 @@ function zipEntryNames(zip: Buffer): string[] { throw new Error('ZIP64 archives are not supported for upload safety inspection.'); } const nameStart = offset + 46; - names.push(zip.subarray(nameStart, nameStart + nameLength).toString('utf8')); + const name = zip.subarray(nameStart, nameStart + nameLength).toString('utf8'); + const shouldReadData = !name.endsWith('/') && isZipLikePath(name); + entries.push({ + name, + compressedSize, + compressionMethod, + data: shouldReadData + ? readZipEntryData(zip, { + compressedSize, + compressionMethod, + encrypted: (flags & 0x0001) !== 0, + localHeaderOffset, + uncompressedSize, + }) + : undefined, + }); offset = nameStart + nameLength + extraLength + commentLength; } - return names; + return entries; +} + +function readZipEntryData( + zip: Buffer, + entry: { + compressedSize: number; + compressionMethod: number; + encrypted: boolean; + localHeaderOffset: number; + uncompressedSize: number; + }, +): Buffer | undefined { + if (entry.encrypted || (entry.compressionMethod !== 0 && entry.compressionMethod !== 8)) return undefined; + if (entry.localHeaderOffset + 30 > zip.length) throw new Error('invalid ZIP local header offset'); + const signature = zip.readUInt32LE(entry.localHeaderOffset); + if (signature !== 0x04034b50) throw new Error('invalid ZIP local header'); + const nameLength = zip.readUInt16LE(entry.localHeaderOffset + 26); + const extraLength = zip.readUInt16LE(entry.localHeaderOffset + 28); + const dataStart = entry.localHeaderOffset + 30 + nameLength + extraLength; + const dataEnd = dataStart + entry.compressedSize; + if (dataEnd > zip.length) throw new Error('ZIP entry data extends past archive end'); + const compressed = zip.subarray(dataStart, dataEnd); + const data = entry.compressionMethod === 8 ? inflateRawSync(compressed) : Buffer.from(compressed); + if (data.length !== entry.uncompressedSize) { + throw new Error('ZIP entry size mismatch'); + } + return data; } function centralDirectoryOffset(zip: Buffer): number { @@ -91,11 +175,20 @@ export function inspectUploadArtifact(artifact: string): UploadSafetyResult { const stat = statSync(artifact); if (stat.isDirectory()) { - return inspectNames(artifact, listDirectoryNames(artifact)); + try { + return inspectNames(artifact, listInspectableDirectoryNames(artifact)); + } catch (error) { + return { + status: 'unknown', + artifact, + detectedFiles: [], + reason: `could not inspect directory archive contents: ${(error as Error).message}`, + }; + } } const ext = extname(artifact).toLowerCase(); - if (ext === '.love' || ext === '.zip') { + if (ZIP_LIKE_EXTENSIONS.has(ext)) { try { return inspectNames(artifact, zipEntryNames(readFileSync(artifact))); } catch (error) { diff --git a/cli/src/lib/build/upload.ts b/cli/src/lib/build/upload.ts index 3de6ab57..d4de6bcd 100644 --- a/cli/src/lib/build/upload.ts +++ b/cli/src/lib/build/upload.ts @@ -16,6 +16,7 @@ export type UploadOptions = LoadBuildConfigOptions & { ifChanged?: boolean; hidden?: boolean; allowFeatherRuntime?: boolean; + trustedFeatherFreeBuild?: boolean; }; export type UploadResult = { @@ -57,8 +58,11 @@ export function runUpload(options: UploadOptions): UploadResult { const channel = options.channel ?? itch.channels?.[buildTarget] ?? buildTarget; const userVersion = options.userVersion ?? config.version; const safety = inspectUploadArtifact(resolveArtifactPath(artifact.path)); - const warning = uploadSafetyWarning(safety) ?? undefined; - if (!options.dryRun && safety.status !== 'safe' && !options.allowFeatherRuntime) { + const warning = options.trustedFeatherFreeBuild && safety.status === 'unknown' + ? undefined + : uploadSafetyWarning(safety) ?? undefined; + const blockedBySafety = safety.status === 'unsafe' || (safety.status === 'unknown' && !options.trustedFeatherFreeBuild); + if (!options.dryRun && blockedBySafety && !options.allowFeatherRuntime) { return { ok: false, error: diff --git a/cli/test/commands/doctor.test.mjs b/cli/test/commands/doctor.test.mjs index a981b326..98a854d6 100644 --- a/cli/test/commands/doctor.test.mjs +++ b/cli/test/commands/doctor.test.mjs @@ -81,6 +81,30 @@ test('doctor --json remains decoration-free and reports missing plugin directory assert.equal(labels.get('Plugin directory').detail, 'not installed'); }); +test('doctor --json treats included plugins as bundled in cli mode', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `-- mode: cli +-- installDir: feather +return { + appId = "feather-app-test-1234567890", + managed = "cli", + include = { "console", "shader-graph" }, +} +`, + ); + + const parsed = parseDoctorJson(dir); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Embedded Feather runtime').severity, 'info'); + assert.equal(labels.get('CLI-managed plugins').severity, 'pass'); + assert.equal(labels.get('CLI-managed plugins').detail, '2 included from bundled runtime'); + assert.equal(labels.has('Plugin console'), false); + assert.equal(labels.has('Plugin shader-graph'), false); +}); + test('doctor --json reports malformed plugin manifests with recovery text', () => { const dir = makeTmp(); writeGame(dir); @@ -270,6 +294,48 @@ test('doctor: build and upload target checks report missing and configured depen assert.equal(labels.get('BUTLER_API_KEY')?.severity, 'pass'); }); +test('doctor: discovers upload dependency checks from feather.build.json', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + writeBuildConfig(dir, { + name: 'Doctor Upload Game', + version: '1.0.0', + upload: { itch: { project: 'tester/doctor-upload-game' } }, + }); + const { binDir } = writeFakeCommand(dir, 'butler', `console.log('butler test'); process.exit(0);`); + + const result = run(['doctor', dir, '--json'], { + env: envWithPath(binDir, { BUTLER_API_KEY: 'test-key' }), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('feather.build.json')?.severity, 'pass'); + assert.equal(labels.get('Itch project')?.severity, 'pass'); + assert.equal(labels.get('butler')?.severity, 'pass'); + assert.equal(labels.get('BUTLER_API_KEY')?.severity, 'pass'); +}); + +test('doctor: reports optional upload readiness when build config has no upload block', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + writeBuildConfig(dir, { + name: 'Doctor Optional Upload Game', + version: '1.0.0', + }); + + const result = run(['doctor', dir, '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('feather.build.json')?.severity, 'pass'); + assert.equal(labels.get('Itch project')?.severity, 'info'); + assert.equal(labels.get('butler')?.severity, labels.get('butler')?.detail === 'not found' ? 'info' : 'pass'); + assert.equal(labels.get('BUTLER_API_KEY')?.severity, process.env.BUTLER_API_KEY ? 'pass' : 'info'); +}); + test('doctor: desktop build targets report runtime vendors and packaging tools', () => { const dir = makeTmp(); writeGame(dir); diff --git a/cli/test/commands/upload-safety.test.mjs b/cli/test/commands/upload-safety.test.mjs new file mode 100644 index 00000000..57f01580 --- /dev/null +++ b/cli/test/commands/upload-safety.test.mjs @@ -0,0 +1,82 @@ +/* eslint-disable no-undef */ +import { assert, join, makeTmp, mkdirSync, test, writeFileSync } from './helpers.mjs'; +import { createZipBuffer } from '../../dist/lib/build/archive.js'; +import { inspectUploadArtifact } from '../../dist/lib/build/upload-safety.js'; + +function zip(entries) { + return createZipBuffer( + entries.map(([name, data]) => ({ + name, + data: Buffer.isBuffer(data) ? data : Buffer.from(data), + })), + ); +} + +function unsafeLove() { + return zip([ + ['main.lua', 'function love.draw() end\n'], + ['feather/init.lua', 'return {}\n'], + ]); +} + +test('upload safety detects Feather inside nested APK love archive', () => { + const dir = makeTmp(); + const artifact = join(dir, 'game.apk'); + writeFileSync( + artifact, + zip([ + ['AndroidManifest.xml', ''], + ['assets/game.love', unsafeLove()], + ]), + ); + + const safety = inspectUploadArtifact(artifact); + assert.equal(safety.status, 'unsafe'); + assert.ok(safety.detectedFiles.includes('assets/game.love!feather/init.lua')); +}); + +test('upload safety inspects AAB containers', () => { + const dir = makeTmp(); + const artifact = join(dir, 'game.aab'); + writeFileSync( + artifact, + zip([ + ['base/manifest/AndroidManifest.xml', ''], + ['base/assets/game.love', zip([['main.lua', 'function love.draw() end\n']])], + ]), + ); + + const safety = inspectUploadArtifact(artifact); + assert.equal(safety.status, 'safe'); + assert.deepEqual(safety.detectedFiles, []); +}); + +test('upload safety detects Feather inside IPA app love archive', () => { + const dir = makeTmp(); + const artifact = join(dir, 'game.ipa'); + writeFileSync( + artifact, + zip([ + ['Payload/Game.app/Info.plist', ''], + ['Payload/Game.app/game.love', unsafeLove()], + ]), + ); + + const safety = inspectUploadArtifact(artifact); + assert.equal(safety.status, 'unsafe'); + assert.ok(safety.detectedFiles.includes('Payload/Game.app/game.love!feather/init.lua')); +}); + +test('upload safety detects Feather inside .app bundle love archive', () => { + const dir = makeTmp(); + const artifact = join(dir, 'Game.app'); + mkdirSync(join(artifact, 'Contents', 'Resources', 'feather'), { recursive: true }); + writeFileSync(join(artifact, 'Info.plist'), ''); + writeFileSync(join(artifact, 'game.love'), unsafeLove()); + writeFileSync(join(artifact, 'Contents', 'Resources', 'feather', 'init.lua'), 'return {}\n'); + + const safety = inspectUploadArtifact(artifact); + assert.equal(safety.status, 'unsafe'); + assert.ok(safety.detectedFiles.includes('Contents/Resources/feather/init.lua')); + assert.ok(safety.detectedFiles.includes('game.love!feather/init.lua')); +}); diff --git a/cli/test/commands/upload.test.mjs b/cli/test/commands/upload.test.mjs index d697d153..d689068e 100644 --- a/cli/test/commands/upload.test.mjs +++ b/cli/test/commands/upload.test.mjs @@ -12,9 +12,11 @@ import { test, writeBuildConfig, writeFakeCommand, + writeFakeLoveAndroid, writeFakeLoveJs, writeFileSync, writeGame, + readStoredZipEntries, } from './helpers.mjs'; function writeUnsafeUploadManifest( @@ -213,6 +215,108 @@ test('upload itch: --build --dry-run builds an artifact and returns upload JSON assert.equal(parsed.safety.status, 'safe'); }); +test('upload itch: --build creates a clean artifact even when dev Feather config is unsafe', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + __DANGEROUS_INSECURE_CONNECTION__ = true, + writeToDisk = true, + include = { "console" }, + debugger = { + enabled = true, + hotReload = { + enabled = true, + allow = { "game.*" }, + persistToDisk = true, + }, + }, +} +`, + ); + writeBuildConfig(dir, { + name: 'Clean Upload', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/clean-upload', channels: { web: 'html5' } } }, + }); + + const result = run(['upload', 'itch', 'web', '--dir', dir, '--build', '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.safety.status, 'safe'); +}); + +test('upload itch: --build android forces release mode without Feather runtime', () => { + const dir = makeTmp(); + writeGame(dir); + const { recordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Android Upload', + version: '1.0.0', + productId: 'com.example.uploadandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + upload: { itch: { project: 'tester/android-upload', channels: { android: 'android' } } }, + }); + + const result = run(['upload', 'itch', 'android', '--dir', dir, '--build', '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.artifact.endsWith('android-upload-1.0.0-android.aab'), true); + assert.equal(parsed.warning, undefined); + const loveEntries = readStoredZipEntries(join(dir, 'builds', 'android-upload-1.0.0.love')); + assert.equal(loveEntries.has('.feather-main.lua'), false); + assert.equal(loveEntries.has('feather/auto.lua'), false); + assert.equal(loveEntries.has('feather.config.lua'), false); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(record.records[0].argv.some((arg) => /bundle/i.test(arg))); + assert.ok(record.records[0].argv.some((arg) => /release/i.test(arg))); +}); + +test('upload itch: --build rejects unsafe overrides', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Guarded Upload', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/guarded-upload', channels: { web: 'html5' } } }, + }); + + const allowUnsafe = run(['upload', 'itch', 'web', '--dir', dir, '--build', '--allow-unsafe', '--dry-run', '--json']); + assert.equal(allowUnsafe.exitCode, 1); + assert.ok(outputOf(allowUnsafe).includes('remove --allow-unsafe')); + + const allowRuntime = run(['upload', 'itch', 'web', '--dir', dir, '--build', '--allow-feather-runtime', '--dry-run', '--json']); + assert.equal(allowRuntime.exitCode, 1); + assert.ok(outputOf(allowRuntime).includes('remove --allow-feather-runtime')); +}); + +test('upload itch: --build blocks generated artifacts containing Feather runtime', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + mkdirSync(join(dir, 'feather'), { recursive: true }); + writeFileSync(join(dir, 'feather', 'init.lua'), 'return {}\n'); + writeBuildConfig(dir, { + name: 'Embedded Runtime Upload', + version: '1.0.0', + includeRuntime: true, + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/embedded-runtime-upload', channels: { web: 'html5' } } }, + }); + + const result = run(['upload', 'itch', 'web', '--dir', dir, '--build', '--dry-run', '--json']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Upload build blocked')); + assert.ok(outputOf(result).includes('feather/')); +}); + test('upload itch: missing project reports a clear headless error', () => { const dir = makeTmp(); writeGame(dir); diff --git a/scripts/lua-e2e.mjs b/scripts/lua-e2e.mjs index 56b42881..6bebcec1 100644 --- a/scripts/lua-e2e.mjs +++ b/scripts/lua-e2e.mjs @@ -47,6 +47,10 @@ if (process.platform === 'linux' && !process.env.DISPLAY) { console.log(`lua-e2e: ${command} ${commandArgs.join(' ')}`); const result = spawnSync(command, commandArgs, { cwd: root, + env: { + ...process.env, + FEATHER_GAME_PATH: gamePath, + }, encoding: 'utf8', timeout: 15000, }); diff --git a/src-lua/example/e2e/main.lua b/src-lua/example/e2e/main.lua index 819e041d..0ffb9c95 100644 --- a/src-lua/example/e2e/main.lua +++ b/src-lua/example/e2e/main.lua @@ -166,6 +166,11 @@ local function run() assertTruthy(feather, "feather creates debugger instance") assertTruthy(feather.hotReloader, "hot reloader is available") local helloConfig = feather:__getConfig() + local envGamePath = os.getenv("FEATHER_GAME_PATH") + if envGamePath and #envGamePath > 0 then + assertEqual(helloConfig.root_path, envGamePath, "CLI run config root_path uses real game path") + assertEqual(helloConfig.sourceDir, envGamePath, "CLI run config sourceDir uses real game path") + end assertEqual(helloConfig.debugger.hotReload.enabled, true, "hello config includes hot reload state") assertEqual(helloConfig.plugins["api-compatible"].incompatible, false, "compatible plugin remains available") assertEqual(helloConfig.plugins["api-compatible"].disabled, false, "compatible plugin remains enabled") diff --git a/src-lua/feather/init.lua b/src-lua/feather/init.lua index 0632d6c6..c92e09e1 100644 --- a/src-lua/feather/init.lua +++ b/src-lua/feather/init.lua @@ -29,6 +29,24 @@ local FEATHER_VERSION = { api = FEATHER_API, } +local function is_absolute_path(path) + return path:sub(1, 1) == "/" or path:match("^%a:[/\\]") ~= nil +end + +local function join_path(base, child) + if not child or #child == 0 then + return base + end + if is_absolute_path(child) then + return child + end + local suffix = base:sub(-1) + if suffix == "/" or suffix == "\\" then + return base .. child + end + return base .. "/" .. child +end + local CALLBACK_NAMES = { "draw", "keypressed", @@ -338,13 +356,15 @@ function Feather:__sendHello() end function Feather:__getConfig() - local root_path = get_current_dir() + local cliGamePath = os.getenv("FEATHER_GAME_PATH") + local hasCliGamePath = type(cliGamePath) == "string" and #cliGamePath > 0 + local root_path = hasCliGamePath and cliGamePath or get_current_dir() if #self.baseDir > 0 then - root_path = root_path .. "/" .. self.baseDir + root_path = join_path(root_path, self.baseDir) end local sourceDir = root_path ---@diagnostic disable-next-line: undefined-field - if love.filesystem.getSourceDirectory then + if not hasCliGamePath and love.filesystem.getSourceDirectory then ---@diagnostic disable-next-line: undefined-field sourceDir = love.filesystem.getSourceDirectory() end diff --git a/src-lua/plugins/shader-graph/init.lua b/src-lua/plugins/shader-graph/init.lua index 8e4adc05..4b4fb1f6 100644 --- a/src-lua/plugins/shader-graph/init.lua +++ b/src-lua/plugins/shader-graph/init.lua @@ -48,4 +48,11 @@ function ShaderGraphPlugin:handleActionRequest(request) return { status = "error", pixelError = "Unknown shader graph action: " .. tostring(action) } end +function ShaderGraphPlugin:getConfig() + return { + type = "shader-graph", + icon = "blend", + } +end + return ShaderGraphPlugin diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 06982586..ae4fdb92 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -152,14 +152,24 @@ function nonEmptyInput(value: string | undefined): string | undefined { return trimmed ? trimmed : undefined; } +function projectManagedMode(root: string): InitModeValue | undefined { + const configPath = join(root, 'feather.config.lua'); + if (!existsSync(configPath)) return undefined; + const source = readFileSync(configPath, 'utf8'); + const configMode = /\bmanaged\s*=\s*["'](cli|auto|manual)["']/.exec(source)?.[1]; + if (configMode === 'cli' || configMode === 'auto' || configMode === 'manual') return configMode; + const metadataMode = /^--\s*mode:\s*(cli|auto|manual)\s*$/m.exec(source)?.[1]; + return metadataMode === 'cli' || metadataMode === 'auto' || metadataMode === 'manual' ? metadataMode : undefined; +} + async function pickInitMode(): Promise { const mode = await vscode.window.showQuickPick>( [ - { label: 'CLI mode', description: 'No game-code changes. Use Feather: Run Project.', value: 'cli' }, - { label: 'Auto mode', description: 'Patch main.lua with guarded feather.auto loader.', value: 'auto' }, - { label: 'Manual mode', description: 'Create feather.debugger.lua and guarded loader.', value: 'manual' }, + { label: '$(terminal) CLI mode', description: 'Recommended. No game-code changes; use Feather: Run Project.', value: 'cli' }, + { label: '$(tools) Auto embedded mode', description: 'Advanced. Patch main.lua with guarded feather.auto loader.', value: 'auto' }, + { label: '$(file-code) Manual embedded mode', description: 'Advanced. Create feather.debugger.lua and guarded loader.', value: 'manual' }, ], - { placeHolder: 'Select init mode' }, + { placeHolder: 'Select setup mode' }, ); return mode?.value; } @@ -171,8 +181,8 @@ async function buildInitCommandPlan( ): Promise { const flow = await vscode.window.showQuickPick>( [ - { label: 'Quick init', description: 'Default plugins, bundled runtime, local dev connection.', value: 'quick' }, - { label: 'Customize init', description: 'Choose session name, security, source, install dir, and plugins.', value: 'custom' }, + { label: 'Recommended setup', description: 'CLI-style defaults: bundled runtime and default creative plugins.', value: 'quick' }, + { label: 'Customize setup', description: 'Choose session name, security, runtime source, plugins, and hot reload.', value: 'custom' }, ], { placeHolder: 'Choose init setup' }, ); @@ -186,7 +196,6 @@ async function buildInitCommandPlan( '--mode', mode, '--yes', - '--allow-insecure-connection', '--plugins', DEFAULT_INIT_PLUGIN_ID_LIST.join(','), ], @@ -539,15 +548,32 @@ async function registerCommands( vscode.commands.registerCommand('feather.plugins', async () => { const root = requireProjectDir(); if (!root) return; + const cliManaged = projectManagedMode(root) === 'cli'; const action = await vscode.window.showQuickPick>( [ - { label: 'Install plugins', description: 'Copy plugins into the project and include them in config.', value: 'install' }, - { label: 'Update plugins', description: 'Refresh installed plugin files from the bundled runtime.', value: 'update' }, - { label: 'Remove plugins', description: 'Delete plugin folders and exclude them in config.', value: 'remove' }, + { + label: 'Install plugins', + description: cliManaged ? 'Add bundled plugins to feather.config.lua include list.' : 'Copy plugins into the project and include them in config.', + value: 'install', + }, + { + label: 'Update plugins', + description: cliManaged ? 'CLI-managed plugin code is bundled with Feather.' : 'Refresh installed plugin files from the bundled runtime.', + value: 'update', + }, + { + label: 'Remove plugins', + description: cliManaged ? 'Remove plugins from feather.config.lua include list.' : 'Delete plugin folders and exclude them in config.', + value: 'remove', + }, ], { placeHolder: 'Plugin action' }, ); if (!action) return; + if (cliManaged && action.value === 'update') { + vscode.window.showInformationMessage('CLI-managed plugin code is bundled with Feather. Update the extension or CLI to update plugin files.'); + return; + } const plugins = await pickPlugins(context, `Select plugins to ${action.value}`); if (!plugins || plugins.length === 0) return; @@ -557,7 +583,14 @@ async function registerCommands( : undefined; if (action.value === 'install' && ids.includes('hot-reload') && !hotReloadAllow) return; const commands = - action.value === 'install' + action.value === 'install' && cliManaged + ? [ + ['plugin', 'install', ...ids, '--managed', 'cli', '--dir', root], + ...(hotReloadAllow && hotReloadAllow.length > 0 + ? [['config', 'hot-reload', '--dir', root, '--allow', hotReloadAllow.join(',')]] + : []), + ] + : action.value === 'install' ? [ ['plugin', 'install', ...ids, '--dir', root], ['config', 'plugins', '--dir', root, '--include', ids.join(',')], @@ -565,6 +598,8 @@ async function registerCommands( ? [['config', 'hot-reload', '--dir', root, '--allow', hotReloadAllow.join(',')]] : []), ] + : action.value === 'remove' && cliManaged + ? ids.map((id) => ['plugin', 'remove', id, '--managed', 'cli', '--dir', root, '--yes']) : action.value === 'remove' ? [ ...ids.map((id) => ['plugin', 'remove', id, '--dir', root, '--yes']), From 4d5c1ca2b78490392b89763e2ab19bf6f6caf1bf Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 20 May 2026 10:07:48 -0400 Subject: [PATCH 13/28] plugin: fix input replay --- src-lua/e2e/plugins/input_replay.lua | 40 ++++++ src-lua/plugins/input-replay/init.lua | 178 ++++++++++++++++++++++++++ 2 files changed, 218 insertions(+) diff --git a/src-lua/e2e/plugins/input_replay.lua b/src-lua/e2e/plugins/input_replay.lua index e283a6d6..aaef7e5c 100644 --- a/src-lua/e2e/plugins/input_replay.lua +++ b/src-lua/e2e/plugins/input_replay.lua @@ -11,6 +11,12 @@ function InputReplaySuite.run(assertEqual, assertTruthy) local preReplayRecordCount = 0 local lateRecordOverrideCount = 0 local lateReplayOverrideCount = 0 + local originalKeyboardIsDown = love.keyboard and love.keyboard.isDown + local originalKeyboardIsScancodeDown = love.keyboard and love.keyboard.isScancodeDown + local originalMouseIsDown = love.mouse and love.mouse.isDown + local originalMouseGetPosition = love.mouse and love.mouse.getPosition + local originalMouseGetX = love.mouse and love.mouse.getX + local originalMouseGetY = love.mouse and love.mouse.getY love.keypressed = function() preReplayRecordCount = preReplayRecordCount + 1 @@ -74,9 +80,43 @@ function InputReplaySuite.run(assertEqual, assertTruthy) inputReplayFeather:update(0) assertEqual(lateReplayOverrideCount, 2, "input replay replays through late keypressed override") assertEqual(replay.replaying, false, "input replay stops after queued replay events finish") + + replay.events = { + { time = 0, type = "keypressed", args = { "right", "d", false } }, + { time = 999, type = "keyreleased", args = { "right", "d" } }, + } + replay:startReplay() + inputReplayFeather:update(0) + assertEqual(love.keyboard.isDown("right"), true, "input replay exposes held keys to love.keyboard.isDown") + assertEqual(love.keyboard.isScancodeDown("d"), true, "input replay exposes held scancodes to love.keyboard.isScancodeDown") + replay:stopReplay() + + replay.events = { + { time = 0, type = "mousepressed", args = { 42, 64, 1, false, 1 } }, + { time = 999, type = "mousereleased", args = { 42, 64, 1, false, 1 } }, + } + replay:startReplay() + inputReplayFeather:update(0) + local mouseX, mouseY = love.mouse.getPosition() + assertEqual(love.mouse.isDown(1), true, "input replay exposes held mouse buttons to love.mouse.isDown") + assertEqual(mouseX, 42, "input replay exposes replay mouse x position") + assertEqual(mouseY, 64, "input replay exposes replay mouse y position") + assertEqual(love.mouse.getX(), 42, "input replay exposes replay mouse x getter") + assertEqual(love.mouse.getY(), 64, "input replay exposes replay mouse y getter") + replay:stopReplay() end, debug.traceback) love.keypressed = originalReplayKeypressed + if love.keyboard then + love.keyboard.isDown = originalKeyboardIsDown + love.keyboard.isScancodeDown = originalKeyboardIsScancodeDown + end + if love.mouse then + love.mouse.isDown = originalMouseIsDown + love.mouse.getPosition = originalMouseGetPosition + love.mouse.getX = originalMouseGetX + love.mouse.getY = originalMouseGetY + end inputReplayFeather:finish() if not ok then diff --git a/src-lua/plugins/input-replay/init.lua b/src-lua/plugins/input-replay/init.lua index 8bcf01d9..87ab637f 100644 --- a/src-lua/plugins/input-replay/init.lua +++ b/src-lua/plugins/input-replay/init.lua @@ -36,8 +36,11 @@ end ---@field captureJoystickAxis boolean ---@field _originals table Original love callbacks saved for restoration ---@field _wrappers table Stable love callback wrappers owned by the plugin +---@field _pollOriginals table Original polling functions saved for restoration +---@field _virtualInput table Replay-time input state for polling APIs ---@field _callbackDisposers function[] ---@field _hooked boolean +---@field _pollHooked boolean local InputReplayPlugin = Class({ __includes = Base, init = function(self, config) @@ -61,8 +64,17 @@ local InputReplayPlugin = Class({ self.captureJoystickAxis = self.options.captureJoystickAxis == true -- off by default (noisy) self._originals = {} self._wrappers = {} + self._pollOriginals = {} + self._virtualInput = { + keys = {}, + scancodes = {}, + mouseButtons = {}, + mouseX = nil, + mouseY = nil, + } self._callbackDisposers = {} self._hooked = false + self._pollHooked = false if config.callbacks then self:_installCallbackBusHooks(config.callbacks) @@ -300,6 +312,165 @@ function InputReplayPlugin:stopRecording() end end +function InputReplayPlugin:_resetVirtualInput() + self._virtualInput = { + keys = {}, + scancodes = {}, + mouseButtons = {}, + mouseX = nil, + mouseY = nil, + } +end + +function InputReplayPlugin:_setVirtualKey(key, scancode, down) + if key ~= nil then + self._virtualInput.keys[key] = down and true or nil + end + if scancode ~= nil then + self._virtualInput.scancodes[scancode] = down and true or nil + end +end + +function InputReplayPlugin:_setVirtualMouse(button, down, x, y) + if button ~= nil then + self._virtualInput.mouseButtons[button] = down and true or nil + end + if x ~= nil then + self._virtualInput.mouseX = x + end + if y ~= nil then + self._virtualInput.mouseY = y + end +end + +function InputReplayPlugin:_installPollingHooks() + if self._pollHooked then + return + end + self._pollHooked = true + + if love.keyboard then + if love.keyboard.isDown then + self._pollOriginals.keyboardIsDown = love.keyboard.isDown + love.keyboard.isDown = function(...) + local keys = { ... } + for _, key in ipairs(keys) do + if self._virtualInput.keys[key] then + return true + end + end + return self._pollOriginals.keyboardIsDown(...) + end + end + + if love.keyboard.isScancodeDown then + self._pollOriginals.keyboardIsScancodeDown = love.keyboard.isScancodeDown + love.keyboard.isScancodeDown = function(...) + local scancodes = { ... } + for _, scancode in ipairs(scancodes) do + if self._virtualInput.scancodes[scancode] then + return true + end + end + return self._pollOriginals.keyboardIsScancodeDown(...) + end + end + end + + if love.mouse then + if love.mouse.isDown then + self._pollOriginals.mouseIsDown = love.mouse.isDown + love.mouse.isDown = function(...) + local buttons = { ... } + for _, button in ipairs(buttons) do + if self._virtualInput.mouseButtons[button] then + return true + end + end + return self._pollOriginals.mouseIsDown(...) + end + end + + if love.mouse.getPosition then + self._pollOriginals.mouseGetPosition = love.mouse.getPosition + love.mouse.getPosition = function() + if self._virtualInput.mouseX ~= nil and self._virtualInput.mouseY ~= nil then + return self._virtualInput.mouseX, self._virtualInput.mouseY + end + return self._pollOriginals.mouseGetPosition() + end + end + + if love.mouse.getX then + self._pollOriginals.mouseGetX = love.mouse.getX + love.mouse.getX = function() + if self._virtualInput.mouseX ~= nil then + return self._virtualInput.mouseX + end + return self._pollOriginals.mouseGetX() + end + end + + if love.mouse.getY then + self._pollOriginals.mouseGetY = love.mouse.getY + love.mouse.getY = function() + if self._virtualInput.mouseY ~= nil then + return self._virtualInput.mouseY + end + return self._pollOriginals.mouseGetY() + end + end + end +end + +function InputReplayPlugin:_removePollingHooks() + if not self._pollHooked then + return + end + + if love.keyboard then + if self._pollOriginals.keyboardIsDown then + love.keyboard.isDown = self._pollOriginals.keyboardIsDown + end + if self._pollOriginals.keyboardIsScancodeDown then + love.keyboard.isScancodeDown = self._pollOriginals.keyboardIsScancodeDown + end + end + + if love.mouse then + if self._pollOriginals.mouseIsDown then + love.mouse.isDown = self._pollOriginals.mouseIsDown + end + if self._pollOriginals.mouseGetPosition then + love.mouse.getPosition = self._pollOriginals.mouseGetPosition + end + if self._pollOriginals.mouseGetX then + love.mouse.getX = self._pollOriginals.mouseGetX + end + if self._pollOriginals.mouseGetY then + love.mouse.getY = self._pollOriginals.mouseGetY + end + end + + self._pollOriginals = {} + self._pollHooked = false +end + +function InputReplayPlugin:_applyVirtualInput(event) + local args = event.args or {} + if event.type == "keypressed" then + self:_setVirtualKey(args[1], args[2], true) + elseif event.type == "keyreleased" then + self:_setVirtualKey(args[1], args[2], false) + elseif event.type == "mousepressed" then + self:_setVirtualMouse(args[3], true, args[1], args[2]) + elseif event.type == "mousereleased" then + self:_setVirtualMouse(args[3], false, args[1], args[2]) + elseif event.type == "mousemoved" then + self:_setVirtualMouse(nil, nil, args[1], args[2]) + end +end + --- Start replaying recorded events. Calls the original love callbacks at the recorded timestamps. function InputReplayPlugin:startReplay() if self.recording then @@ -309,6 +480,8 @@ function InputReplayPlugin:startReplay() return end self:_installHooks() + self:_resetVirtualInput() + self:_installPollingHooks() self.replaying = true self.replayStart = gettime() self.replayIndex = 1 @@ -320,6 +493,8 @@ end --- Stop replay. function InputReplayPlugin:stopReplay() self.replaying = false + self:_resetVirtualInput() + self:_removePollingHooks() if self.logger and self.logger.log then self.logger:log({ type = "trace", str = "[InputReplay] Replay stopped" }) end @@ -374,6 +549,8 @@ local REPLAY_RESOLVERS = { } function InputReplayPlugin:_dispatchReplayEvent(event) + self:_applyVirtualInput(event) + local resolver = REPLAY_RESOLVERS[event.type] local replayArgs = resolver and resolver(event.args) or event.args if not replayArgs then @@ -669,6 +846,7 @@ function InputReplayPlugin:handleParamsUpdate(request, _feather) end function InputReplayPlugin:finish(_feather) + self:_removePollingHooks() self:_removeHooks() for _, dispose in ipairs(self._callbackDisposers) do pcall(dispose) From d4b43b24adc912f2ad99d8c7ab88142e19f532f8 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 20 May 2026 10:07:55 -0400 Subject: [PATCH 14/28] plugin: optimistic plugin update --- src-lua/feather/init.lua | 1 + src/hooks/use-plugin.ts | 12 ++++--- src/pages/plugins/content.tsx | 65 ++++++++++++++++++++++++++--------- src/pages/plugins/index.tsx | 37 ++++++++++++-------- 4 files changed, 80 insertions(+), 35 deletions(-) diff --git a/src-lua/feather/init.lua b/src-lua/feather/init.lua index c92e09e1..8aad527e 100644 --- a/src-lua/feather/init.lua +++ b/src-lua/feather/init.lua @@ -523,6 +523,7 @@ function Feather:__handleCommand(msg) local path = msg.plugin:find("^/plugins/") and msg.plugin or ("/plugins/" .. msg.plugin) local request = { method = "PUT", path = path, params = msg.params or {}, headers = {} } self.pluginManager:handleParamsUpdate(request, self) + self:__sendHello() elseif msg.type == "cmd:plugin:set_enabled" and msg.plugin then local enabled = msg.enabled == true local ok = false diff --git a/src/hooks/use-plugin.ts b/src/hooks/use-plugin.ts index 7e891791..3ab49310 100644 --- a/src/hooks/use-plugin.ts +++ b/src/hooks/use-plugin.ts @@ -9,6 +9,8 @@ import { sessionQueryKey } from './use-ws-connection'; import { toast } from 'sonner'; import { isWeb } from '@/utils/platform'; +export type PluginParamValue = string | boolean; + /** Strip the /plugins/ prefix so the ID matches what Lua uses as plugin.identifier */ function normalizePluginId(pluginId: string): string { return pluginId.replace(/^\/plugins\//, ''); @@ -208,7 +210,7 @@ export type PluginContentProps = export const usePluginAction = (pluginId: string) => { const sessionId = useSessionStore((state) => state.sessionId); const normalized = normalizePluginId(pluginId); - const [params, setParams] = useState>({}); + const [params, setParams] = useState>({}); const paramsRef = useRef(params); paramsRef.current = params; @@ -258,8 +260,10 @@ export const usePluginAction = (pluginId: string) => { [sessionId, normalized], ); - const onActionChange = (action: string, value: string | boolean) => { - setParams((prev) => ({ ...prev, [action]: value })); + const onActionChange = (action: string, value: PluginParamValue) => { + const next = { ...paramsRef.current, [action]: value }; + paramsRef.current = next; + setParams(next); updateOptions(); }; @@ -267,7 +271,7 @@ export const usePluginAction = (pluginId: string) => { sendCommand({ type: 'cmd:plugin:toggle', plugin: normalized }); }; - return { onAction, onFileAction, onCancel, onActionChange, onToggle }; + return { onAction, onFileAction, onCancel, onActionChange, onToggle, pendingParams: params }; }; export function usePlugin(pluginId: string) { diff --git a/src/pages/plugins/content.tsx b/src/pages/plugins/content.tsx index 749b1d81..86c27a2a 100644 --- a/src/pages/plugins/content.tsx +++ b/src/pages/plugins/content.tsx @@ -23,6 +23,7 @@ import { PluginContentImageType, PluginContentProps, PluginDataType, + PluginParamValue, PluginTableCellValue, PluginTableColumn, PluginTableRow, @@ -373,6 +374,7 @@ export function PluginContentTree({ total, shown, onParamsChange, + pendingParams, }: { nodes: PluginTreeNode[]; sources: string[]; @@ -382,8 +384,11 @@ export function PluginContentTree({ total?: number; shown?: number; onParamsChange?: (params: Record) => void; + pendingParams?: Record; }) { const [expandedNodes, setExpandedNodes] = useState>(new Set()); + const selectedSourceValue = Number(pendingString(pendingParams?.selectedSource) ?? selectedSource); + const searchFilterValue = pendingString(pendingParams?.searchFilter) ?? searchFilter; const toggleNode = useCallback((path: string) => { setExpandedNodes((prev) => { @@ -403,7 +408,7 @@ export function PluginContentTree({ {sources.length > 1 && ( onParamsChange?.({ searchFilter: e.target.value })} /> {total != null && shown != null && ( @@ -563,6 +568,9 @@ const renderUiScalar = (value: PluginTableCellValue) => { const getUiControlName = (node: PluginUiNode) => node.name ?? node.id ?? node.action; +const pendingString = (value: PluginParamValue | undefined) => value === undefined ? undefined : String(value); +const pendingBool = (value: PluginParamValue | undefined) => value === true || value === 'true'; + const renderUiLabel = (node: PluginUiNode, control: ReactNode) => { const label = node.label ?? node.title; if (!label && !node.description) return control; @@ -579,16 +587,15 @@ const renderUiLabel = (node: PluginUiNode, control: ReactNode) => { function PluginUiCheckbox({ node, onParamsChange, + pendingParams, }: { node: PluginUiNode; onParamsChange?: (params: Record) => void; + pendingParams?: Record; }) { const name = getUiControlName(node); - const [checked, setChecked] = useState(node.checked === true); - - useEffect(() => { - setChecked(node.checked === true); - }, [node.checked]); + const pending = name ? pendingParams?.[name] : undefined; + const checked = pending === undefined ? node.checked === true : pendingBool(pending); return (
@@ -601,7 +608,6 @@ function PluginUiCheckbox({ onClick={() => { if (!name) return; const nextChecked = !checked; - setChecked(nextChecked); onParamsChange?.({ [name]: nextChecked ? 'true' : 'false' }); }} className={cn( @@ -622,10 +628,12 @@ function PluginUiRenderer({ node, onAction, onParamsChange, + pendingParams, }: { node: PluginUiNode; onAction?: (action: string) => void; onParamsChange?: (params: Record) => void; + pendingParams?: Record; }) { const children = node.children?.map((child, index) => ( )); @@ -684,11 +693,12 @@ function PluginUiRenderer({ if (node.type === 'input') { const name = getUiControlName(node); + const value = pendingString(name ? pendingParams?.[name] : undefined) ?? renderUiScalar(node.value) ?? ''; return renderUiLabel( node, name && onParamsChange?.({ [name]: event.target.value })} @@ -698,10 +708,11 @@ function PluginUiRenderer({ if (node.type === 'textarea') { const name = getUiControlName(node); + const value = pendingString(name ? pendingParams?.[name] : undefined) ?? renderUiScalar(node.value) ?? ''; return renderUiLabel( node,